From 7a380c40ed3a7f29521f599843c5907817e5feeb Mon Sep 17 00:00:00 2001 From: Brendan Greenlee Date: Sat, 1 Aug 2026 15:13:34 +0000 Subject: [PATCH] Import official OpenClaw Android app (apps/android @ 71a59512ba476df3328cf485d84748121cf341f2) Pristine fork source for PROJ-0088. v1 will turn this into a WebView web shell; the WebSocket node infrastructure stays intact for v2 heartbeat. --- .editorconfig | 16 + .gitignore | 5 + AGENTS.md | 26 + CHANGELOG.md | 59 + CLAUDE.md | 1 + Config/ReleaseSigning.json | 15 + Config/Version.properties | 6 + README.md | 386 + .../openclaw/licenses/AndroidX Compose.txt | 16 + .../openclaw/licenses/AndroidX Media3.txt | 18 + .../openclaw/licenses/AndroidX Room.txt | 182 + .../openclaw/licenses/AndroidX Wear.txt | 19 + .../licenses/Bouncy Castle Provider.txt | 19 + .../openclaw/licenses/Coil.txt | 19 + .../openclaw/licenses/CommonMark Java.txt | 32 + .../openclaw/licenses/KaTeX.txt | 110 + .../openclaw/licenses/Kotlin Libraries.txt | 184 + .../openclaw/licenses/Manrope.txt | 93 + .../openclaw/licenses/OkHttp and Okio.txt | 184 + .../openclaw/licenses/SLF4J API.txt | 25 + .../openclaw/licenses/dnsjava.txt | 34 + .../openclaw/licenses/nibor autolink.txt | 23 + VERSIONING.md | 115 + app/build.gradle.kts | 478 + app/lint.xml | 13 + app/proguard-rules.pro | 8 + .../1.json | 273 + .../1.json | 146 + .../2.json | 177 + .../app/ui/CanvasHostLifecycleTest.kt | 274 + app/src/debug/AndroidManifest.xml | 32 + .../java/ai/openclaw/app/VoiceE2eReceiver.kt | 242 + .../app/ui/CanvasLifecycleTestActivity.kt | 129 + app/src/main/AndroidManifest.xml | 160 + app/src/main/assets/katex/README.md | 37 + .../katex/fonts/KaTeX_AMS-Regular.woff2 | Bin 0 -> 28076 bytes .../katex/fonts/KaTeX_Caligraphic-Bold.woff2 | Bin 0 -> 6912 bytes .../fonts/KaTeX_Caligraphic-Regular.woff2 | Bin 0 -> 6908 bytes .../katex/fonts/KaTeX_Fraktur-Bold.woff2 | Bin 0 -> 11348 bytes .../katex/fonts/KaTeX_Fraktur-Regular.woff2 | Bin 0 -> 11316 bytes .../assets/katex/fonts/KaTeX_Main-Bold.woff2 | Bin 0 -> 25324 bytes .../katex/fonts/KaTeX_Main-BoldItalic.woff2 | Bin 0 -> 16780 bytes .../katex/fonts/KaTeX_Main-Italic.woff2 | Bin 0 -> 16988 bytes .../katex/fonts/KaTeX_Main-Regular.woff2 | Bin 0 -> 26272 bytes .../katex/fonts/KaTeX_Math-BoldItalic.woff2 | Bin 0 -> 16400 bytes .../katex/fonts/KaTeX_Math-Italic.woff2 | Bin 0 -> 16440 bytes .../katex/fonts/KaTeX_SansSerif-Bold.woff2 | Bin 0 -> 12216 bytes .../katex/fonts/KaTeX_SansSerif-Italic.woff2 | Bin 0 -> 12028 bytes .../katex/fonts/KaTeX_SansSerif-Regular.woff2 | Bin 0 -> 10344 bytes .../katex/fonts/KaTeX_Script-Regular.woff2 | Bin 0 -> 9644 bytes .../katex/fonts/KaTeX_Size1-Regular.woff2 | Bin 0 -> 5468 bytes .../katex/fonts/KaTeX_Size2-Regular.woff2 | Bin 0 -> 5208 bytes .../katex/fonts/KaTeX_Size3-Regular.woff2 | Bin 0 -> 3624 bytes .../katex/fonts/KaTeX_Size4-Regular.woff2 | Bin 0 -> 4928 bytes .../fonts/KaTeX_Typewriter-Regular.woff2 | Bin 0 -> 13568 bytes app/src/main/assets/katex/index.html | 20 + app/src/main/assets/katex/katex.min.css | 1 + app/src/main/assets/katex/katex.min.js | 1 + app/src/main/assets/katex/renderer.js | 34 + .../ai/openclaw/app/AndroidLicenseNotices.kt | 40 + .../openclaw/app/AndroidScreenshotFixture.kt | 436 + .../ai/openclaw/app/AndroidScreenshotMode.kt | 33 + .../main/java/ai/openclaw/app/AppLanguage.kt | 98 + .../ai/openclaw/app/AppearanceThemeMode.kt | 25 + .../java/ai/openclaw/app/AssistantLaunch.kt | 223 + .../java/ai/openclaw/app/CameraHudState.kt | 16 + .../java/ai/openclaw/app/CronJobDetail.kt | 316 + .../java/ai/openclaw/app/CronJobManagement.kt | 671 ++ .../main/java/ai/openclaw/app/DeviceNames.kt | 28 + .../ai/openclaw/app/GatewayAgentSummary.kt | 41 + .../ai/openclaw/app/GatewayExecApprovals.kt | 587 ++ .../openclaw/app/GatewayTalkSetupReadiness.kt | 237 + .../main/java/ai/openclaw/app/LocationMode.kt | 34 + .../main/java/ai/openclaw/app/MainActivity.kt | 382 + .../openclaw/app/MainActivityPendingIntent.kt | 22 + .../java/ai/openclaw/app/MainViewModel.kt | 1967 ++++ app/src/main/java/ai/openclaw/app/NodeApp.kt | 123 + .../ai/openclaw/app/NodeForegroundService.kt | 469 + .../main/java/ai/openclaw/app/NodeRuntime.kt | 9054 +++++++++++++++++ .../app/NotificationForwardingPolicy.kt | 138 + .../ai/openclaw/app/PermissionRequester.kt | 517 + .../java/ai/openclaw/app/PhotoPermissions.kt | 23 + .../main/java/ai/openclaw/app/SecurePrefs.kt | 802 ++ .../main/java/ai/openclaw/app/SessionKey.kt | 37 + .../java/ai/openclaw/app/SkillManagement.kt | 238 + .../main/java/ai/openclaw/app/Utf16Text.kt | 31 + .../java/ai/openclaw/app/VoiceCaptureMode.kt | 10 + .../java/ai/openclaw/app/WorkspaceFiles.kt | 81 + .../ai/openclaw/app/chat/BackgroundTask.kt | 112 + .../ai/openclaw/app/chat/ChatCommandOutbox.kt | 1703 ++++ .../ai/openclaw/app/chat/ChatComposerOwner.kt | 65 + .../ai/openclaw/app/chat/ChatController.kt | 7320 +++++++++++++ .../java/ai/openclaw/app/chat/ChatModels.kt | 309 + .../java/ai/openclaw/app/chat/ChatQuestion.kt | 111 + .../ai/openclaw/app/chat/ChatSwarmProgress.kt | 295 + .../openclaw/app/chat/ChatTranscriptCache.kt | 535 + .../app/chat/ChatWidgetUrlResolver.kt | 193 + .../ai/openclaw/app/chat/ClientDatabases.kt | 772 ++ .../app/chat/MessageSpeechController.kt | 303 + .../app/chat/VoiceNoteRecorderController.kt | 317 + .../ai/openclaw/app/gateway/BonjourEscapes.kt | 40 + .../ai/openclaw/app/gateway/ChatSendAck.kt | 46 + .../openclaw/app/gateway/DeviceAuthPayload.kt | 57 + .../openclaw/app/gateway/DeviceAuthStore.kt | 155 + .../app/gateway/DeviceIdentityStore.kt | 238 + .../app/gateway/GatewayCustomHeaders.kt | 43 + .../openclaw/app/gateway/GatewayDiscovery.kt | 704 ++ .../openclaw/app/gateway/GatewayEndpoint.kt | 32 + .../app/gateway/GatewayHostSecurity.kt | 148 + .../openclaw/app/gateway/GatewayProtocol.kt | 558 + .../openclaw/app/gateway/GatewayRegistry.kt | 245 + .../ai/openclaw/app/gateway/GatewaySession.kt | 2266 +++++ .../app/gateway/GatewayStoreMigration.kt | 103 + .../ai/openclaw/app/gateway/GatewayTls.kt | 541 + .../openclaw/app/gateway/InvokeErrorParser.kt | 47 + .../ai/openclaw/app/gateway/NetworkMonitor.kt | 110 + .../app/i18n/NativeStringResources.kt | 1639 +++ .../ai/openclaw/app/i18n/NativeStrings.kt | 336 + .../java/ai/openclaw/app/node/A2UIHandler.kt | 149 + .../app/node/AndroidPermissionSnapshot.kt | 91 + .../ai/openclaw/app/node/CalendarHandler.kt | 459 + .../openclaw/app/node/CameraCaptureManager.kt | 482 + .../ai/openclaw/app/node/CameraHandler.kt | 184 + .../ai/openclaw/app/node/CanvasActionTrust.kt | 21 + .../ai/openclaw/app/node/CanvasController.kt | 388 + .../app/node/CanvasNavigationPolicy.kt | 196 + .../ai/openclaw/app/node/ConnectionManager.kt | 248 + .../ai/openclaw/app/node/ContactsHandler.kt | 489 + .../java/ai/openclaw/app/node/DebugHandler.kt | 157 + .../ai/openclaw/app/node/DeviceHandler.kt | 679 ++ .../node/DeviceNotificationListenerService.kt | 518 + .../app/node/InvokeCommandRegistry.kt | 313 + .../ai/openclaw/app/node/InvokeDispatcher.kt | 416 + .../ai/openclaw/app/node/JpegSizeLimiter.kt | 70 + .../app/node/LocationCaptureManager.kt | 123 + .../ai/openclaw/app/node/LocationHandler.kt | 196 + .../ai/openclaw/app/node/MotionHandler.kt | 423 + .../app/node/NodePresenceAliveBeacon.kt | 115 + .../java/ai/openclaw/app/node/NodeUtils.kt | 111 + .../openclaw/app/node/NotificationsHandler.kt | 180 + .../ai/openclaw/app/node/PhotosHandler.kt | 317 + .../ai/openclaw/app/node/SystemHandler.kt | 200 + .../app/protocol/OpenClawCanvasA2UIAction.kt | 76 + .../app/protocol/OpenClawProtocolConstants.kt | 208 + .../systemagent/SystemAgentChatController.kt | 453 + .../java/ai/openclaw/app/tools/ToolDisplay.kt | 253 + .../ai/openclaw/app/ui/AboutBuildIdentity.kt | 261 + .../java/ai/openclaw/app/ui/CanvasScreen.kt | 319 + .../openclaw/app/ui/CanvasSettingsScreen.kt | 127 + .../openclaw/app/ui/ChannelsSettingsScreen.kt | 165 + .../java/ai/openclaw/app/ui/CommandPalette.kt | 392 + .../ai/openclaw/app/ui/ControlUiWebView.kt | 197 + .../openclaw/app/ui/CronJobManagementPanel.kt | 692 ++ .../openclaw/app/ui/DreamingSettingsScreen.kt | 192 + .../openclaw/app/ui/GatewayConfigResolver.kt | 514 + .../ai/openclaw/app/ui/GatewayDiagnostics.kt | 205 + .../app/ui/HealthLogsSettingsScreen.kt | 303 + .../java/ai/openclaw/app/ui/MobileUiTokens.kt | 205 + .../app/ui/NodesDevicesSettingsScreen.kt | 608 ++ .../openclaw/app/ui/NotificationAppPicker.kt | 82 + .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 3355 ++++++ .../java/ai/openclaw/app/ui/OpenClawTheme.kt | 52 + .../openclaw/app/ui/ProvidersModelsScreen.kt | 455 + .../java/ai/openclaw/app/ui/RootScreen.kt | 21 + .../openclaw/app/ui/SessionDashboardScreen.kt | 120 + .../java/ai/openclaw/app/ui/SessionsScreen.kt | 1036 ++ .../ai/openclaw/app/ui/SettingsScreens.kt | 3180 ++++++ .../ai/openclaw/app/ui/ShellNavigation.kt | 112 + .../java/ai/openclaw/app/ui/ShellScreen.kt | 2237 ++++ .../ai/openclaw/app/ui/SidebarComponents.kt | 302 + .../java/ai/openclaw/app/ui/SidebarContent.kt | 384 + .../java/ai/openclaw/app/ui/SidebarShell.kt | 170 + .../app/ui/SkillWorkshopSettingsScreen.kt | 718 ++ .../openclaw/app/ui/SkillsSettingsScreen.kt | 899 ++ .../app/ui/SystemAgentSettingsScreen.kt | 319 + .../ai/openclaw/app/ui/SystemAnimations.kt | 42 + .../openclaw/app/ui/TerminalSettingsScreen.kt | 77 + .../ai/openclaw/app/ui/UnifiedChatScreen.kt | 50 + .../java/ai/openclaw/app/ui/VoiceScreen.kt | 1280 +++ .../openclaw/app/ui/WorkspaceFilesScreen.kt | 445 + .../app/ui/chat/BackgroundTasksSheet.kt | 322 + .../openclaw/app/ui/chat/Base64ImageState.kt | 41 + .../openclaw/app/ui/chat/ChatCodeHighlight.kt | 506 + .../app/ui/chat/ChatCommandControls.kt | 65 + .../ai/openclaw/app/ui/chat/ChatComposer.kt | 538 + .../app/ui/chat/ChatComposerStateStore.kt | 394 + .../ai/openclaw/app/ui/chat/ChatDictation.kt | 464 + .../openclaw/app/ui/chat/ChatHardwareKey.kt | 134 + .../ai/openclaw/app/ui/chat/ChatImageCodec.kt | 316 + .../app/ui/chat/ChatInlineWidgetView.kt | 549 + .../openclaw/app/ui/chat/ChatLinkPreview.kt | 258 + .../ai/openclaw/app/ui/chat/ChatMarkdown.kt | 1220 +++ .../openclaw/app/ui/chat/ChatMathRenderer.kt | 751 ++ .../openclaw/app/ui/chat/ChatMathSegmenter.kt | 206 + .../openclaw/app/ui/chat/ChatMediaPlayer.kt | 838 ++ .../app/ui/chat/ChatMessageActions.kt | 180 + .../openclaw/app/ui/chat/ChatMessageViews.kt | 812 ++ .../openclaw/app/ui/chat/ChatModelPicker.kt | 43 + .../openclaw/app/ui/chat/ChatQuestionCard.kt | 246 + .../app/ui/chat/ChatReaderScrollController.kt | 304 + .../openclaw/app/ui/chat/ChatRealtimeTalk.kt | 70 + .../ai/openclaw/app/ui/chat/ChatScreen.kt | 3027 ++++++ .../app/ui/chat/ChatSwarmProgressView.kt | 176 + .../ai/openclaw/app/ui/chat/ChatTimeline.kt | 295 + .../ai/openclaw/app/ui/chat/ChatTurnRecap.kt | 284 + .../openclaw/app/ui/chat/ChatWidgetExport.kt | 273 + .../app/ui/chat/ChatWorkingIndicator.kt | 460 + .../openclaw/app/ui/chat/PendingAttachment.kt | 232 + .../ai/openclaw/app/ui/chat/SessionFilters.kt | 161 + .../openclaw/app/ui/chat/VoiceNoteComposer.kt | 250 + .../ai/openclaw/app/ui/design/AgentAvatar.kt | 204 + .../openclaw/app/ui/design/ClawComponents.kt | 660 ++ .../openclaw/app/ui/design/ClawNavigation.kt | 178 + .../ai/openclaw/app/ui/design/ClawPreview.kt | 17 + .../ai/openclaw/app/ui/design/ClawSurfaces.kt | 82 + .../ai/openclaw/app/ui/design/ClawTheme.kt | 293 + .../openclaw/app/ui/design/MascotAnimator.kt | 515 + .../ai/openclaw/app/ui/design/MascotPose.kt | 127 + .../openclaw/app/ui/design/OpenClawMascot.kt | 414 + .../ai/openclaw/app/ui/design/TalkWaveform.kt | 261 + .../openclaw/app/ui/image/SafeRemoteImage.kt | 409 + .../app/voice/AndroidAudioInputSession.kt | 425 + .../ai/openclaw/app/voice/ChatEventText.kt | 49 + .../openclaw/app/voice/MicCaptureManager.kt | 897 ++ .../app/voice/PushToTalkRecognitionLadder.kt | 25 + .../app/voice/PushToTalkTranscriptMerger.kt | 39 + .../app/voice/RealtimeAgentCoordinator.kt | 530 + .../ai/openclaw/app/voice/TalkAudioLevel.kt | 52 + .../ai/openclaw/app/voice/TalkAudioPlayer.kt | 269 + .../ai/openclaw/app/voice/TalkDefaults.kt | 6 + .../openclaw/app/voice/TalkDirectiveParser.kt | 236 + .../app/voice/TalkModeGatewayConfig.kt | 111 + .../ai/openclaw/app/voice/TalkModeManager.kt | 3346 ++++++ .../ai/openclaw/app/voice/TalkSpeakClient.kt | 175 + .../ai/openclaw/app/voice/VoiceWakeManager.kt | 545 + .../app/voice/VoiceWakePreferences.kt | 172 + .../ai/openclaw/app/wear/WearProxyBridge.kt | 633 ++ .../openclaw/app/wear/WearProxyController.kt | 666 ++ .../app/wear/WearProxyListenerService.kt | 28 + .../app/wear/WearRealtimeChannelRegistry.kt | 594 ++ .../app/wear/WearRealtimeTalkController.kt | 955 ++ app/src/main/res/font/manrope_400_regular.ttf | Bin 0 -> 96832 bytes app/src/main/res/font/manrope_500_medium.ttf | Bin 0 -> 96904 bytes .../main/res/font/manrope_600_semibold.ttf | Bin 0 -> 96936 bytes app/src/main/res/font/manrope_700_bold.ttf | Bin 0 -> 96800 bytes .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4070 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 12694 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2585 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 7486 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 5806 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 17954 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 9604 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 29869 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 14309 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 46094 bytes .../res/raw/ai_openclaw_app_phone_keep.xml | 3 + app/src/main/res/resources.properties | 1 + app/src/main/res/values-ar/assistant.xml | 7 + app/src/main/res/values-ar/strings.xml | 1652 +++ app/src/main/res/values-de/assistant.xml | 7 + app/src/main/res/values-de/strings.xml | 1652 +++ app/src/main/res/values-es/assistant.xml | 7 + app/src/main/res/values-es/strings.xml | 1652 +++ app/src/main/res/values-fa/assistant.xml | 7 + app/src/main/res/values-fa/strings.xml | 1652 +++ app/src/main/res/values-fr/assistant.xml | 7 + app/src/main/res/values-fr/strings.xml | 1652 +++ app/src/main/res/values-hi/assistant.xml | 7 + app/src/main/res/values-hi/strings.xml | 1652 +++ app/src/main/res/values-in/assistant.xml | 7 + app/src/main/res/values-in/strings.xml | 1652 +++ app/src/main/res/values-it/assistant.xml | 7 + app/src/main/res/values-it/strings.xml | 1652 +++ app/src/main/res/values-ja/assistant.xml | 7 + app/src/main/res/values-ja/strings.xml | 1652 +++ app/src/main/res/values-ko/assistant.xml | 7 + app/src/main/res/values-ko/strings.xml | 1652 +++ app/src/main/res/values-night/themes.xml | 8 + app/src/main/res/values-nl/assistant.xml | 7 + app/src/main/res/values-nl/strings.xml | 1652 +++ app/src/main/res/values-pl/assistant.xml | 7 + app/src/main/res/values-pl/strings.xml | 1652 +++ app/src/main/res/values-pt-rBR/assistant.xml | 7 + app/src/main/res/values-pt-rBR/strings.xml | 1652 +++ app/src/main/res/values-ru/assistant.xml | 7 + app/src/main/res/values-ru/strings.xml | 1652 +++ app/src/main/res/values-sv/assistant.xml | 7 + app/src/main/res/values-sv/strings.xml | 1652 +++ app/src/main/res/values-th/assistant.xml | 7 + app/src/main/res/values-th/strings.xml | 1652 +++ app/src/main/res/values-tr/assistant.xml | 7 + app/src/main/res/values-tr/strings.xml | 1652 +++ app/src/main/res/values-uk/assistant.xml | 7 + app/src/main/res/values-uk/strings.xml | 1652 +++ app/src/main/res/values-vi/assistant.xml | 7 + app/src/main/res/values-vi/strings.xml | 1652 +++ app/src/main/res/values-zh-rCN/assistant.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 1652 +++ app/src/main/res/values-zh-rTW/assistant.xml | 7 + app/src/main/res/values-zh-rTW/strings.xml | 1652 +++ app/src/main/res/values/assistant.xml | 7 + app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 1652 +++ app/src/main/res/values/themes.xml | 7 + app/src/main/res/values/wear.xml | 7 + app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + app/src/main/res/xml/file_paths.xml | 6 + .../main/res/xml/network_security_config.xml | 9 + app/src/main/res/xml/shortcuts.xml | 17 + .../ai/openclaw/app/SensitiveFeatureConfig.kt | 9 + .../ai/openclaw/app/node/CallLogHandler.kt | 54 + .../ai/openclaw/app/node/MobileUiHandler.kt | 26 + .../java/ai/openclaw/app/node/SmsHandler.kt | 39 + .../java/ai/openclaw/app/node/SmsManager.kt | 69 + .../app/ui/FlavorPhoneCapabilitiesSettings.kt | 9 + .../openclaw/app/AndroidLicenseNoticesTest.kt | 65 + .../app/AndroidScreenshotFixtureTest.kt | 190 + .../openclaw/app/AndroidScreenshotModeTest.kt | 84 + .../java/ai/openclaw/app/AppLanguageTest.kt | 185 + .../ai/openclaw/app/AssistantLaunchTest.kt | 42 + .../java/ai/openclaw/app/BuildMetadataTest.kt | 17 + .../openclaw/app/ClawHubSkillRuntimeTest.kt | 181 + .../java/ai/openclaw/app/CronJobDetailTest.kt | 159 + .../ai/openclaw/app/CronJobManagementTest.kt | 555 + .../openclaw/app/CronJobStatusParsingTest.kt | 40 + .../ai/openclaw/app/CronRuntimeGuardTest.kt | 371 + .../ai/openclaw/app/DreamingRuntimeTest.kt | 25 + .../openclaw/app/GatewayAgentSummaryTest.kt | 79 + .../openclaw/app/GatewayBootstrapAuthTest.kt | 1605 +++ .../app/GatewayConnectionDisplayTest.kt | 56 + .../app/GatewayDevicePairingRuntimeTest.kt | 188 + .../openclaw/app/GatewayDevicePairingTest.kt | 159 + .../app/GatewayExecApprovalParsingTest.kt | 570 ++ .../app/GatewayExecApprovalRuntimeTest.kt | 1347 +++ .../openclaw/app/GatewayFleetSelectionTest.kt | 109 + .../ai/openclaw/app/GatewayLogTextTest.kt | 46 + .../app/GatewayNodeApprovalStateTest.kt | 194 + .../app/GatewayTalkSetupReadinessTest.kt | 297 + .../openclaw/app/MainActivityLifecycleTest.kt | 344 + .../java/ai/openclaw/app/MainViewModelTest.kt | 514 + .../openclaw/app/NodeForegroundServiceTest.kt | 295 + .../app/NodeRuntimeAgentSelectionTest.kt | 30 + .../app/NodeRuntimeWearRealtimeTalkTest.kt | 72 + .../app/NotificationForwardingPolicyTest.kt | 247 + .../app/NotificationNodeEventOutboxTest.kt | 346 + .../openclaw/app/PermissionRequesterTest.kt | 446 + .../ai/openclaw/app/PhotoPermissionsTest.kt | 48 + .../app/ProviderModelCatalogRequestTest.kt | 60 + .../SecurePrefsNotificationForwardingTest.kt | 193 + .../java/ai/openclaw/app/SecurePrefsTest.kt | 426 + .../java/ai/openclaw/app/SessionKeyTest.kt | 45 + .../app/SessionObserverVisibilityTest.kt | 129 + .../java/ai/openclaw/app/ShareLaunchTest.kt | 399 + .../ai/openclaw/app/SkillManagementTest.kt | 224 + .../app/SkillWorkshopAgentScopeRuntimeTest.kt | 285 + .../java/ai/openclaw/app/Utf16TextTest.kt | 53 + .../ai/openclaw/app/VoiceWakeRuntimeTest.kt | 285 + .../ai/openclaw/app/WorkspaceFilesTest.kt | 85 + .../openclaw/app/chat/BackgroundTaskTest.kt | 166 + .../ChatControllerBranchCoordinationTest.kt | 884 ++ .../chat/ChatControllerCommandControlsTest.kt | 928 ++ .../chat/ChatControllerMessageIdentityTest.kt | 397 + .../chat/ChatControllerModelSelectionTest.kt | 1363 +++ .../app/chat/ChatControllerOutboxTest.kt | 3353 ++++++ .../app/chat/ChatControllerPlanStreamTest.kt | 158 + .../ChatControllerReconnectRestoreTest.kt | 1857 ++++ .../chat/ChatControllerSessionActionsTest.kt | 203 + .../chat/ChatControllerSessionPolicyTest.kt | 251 + .../chat/ChatControllerSessionSearchTest.kt | 177 + .../chat/ChatControllerStreamReplayTest.kt | 534 + .../chat/ChatControllerSwarmProgressTest.kt | 206 + .../app/chat/ChatControllerTerminalAckTest.kt | 422 + .../chat/ChatControllerTranscriptCacheTest.kt | 1385 +++ .../app/chat/ChatControllerUsageStreamTest.kt | 228 + .../app/chat/ChatMessageContentParsingTest.kt | 441 + .../ai/openclaw/app/chat/ChatQuestionTest.kt | 1057 ++ .../ai/openclaw/app/chat/ChatReplayHarness.kt | 268 + .../app/chat/ChatSwarmProgressTest.kt | 244 + .../app/chat/ChatVoiceNoteAttachmentTest.kt | 74 + .../openclaw/app/chat/ClientDatabasesTest.kt | 508 + .../app/chat/MessageSpeechControllerTest.kt | 222 + .../app/chat/RoomChatCommandOutboxTest.kt | 1080 ++ .../app/chat/RoomChatTranscriptCacheTest.kt | 444 + .../chat/VoiceNoteRecorderControllerTest.kt | 320 + .../app/gateway/BonjourEscapesTest.kt | 19 + .../openclaw/app/gateway/ChatSendAckTest.kt | 85 + .../app/gateway/DeviceAuthPayloadTest.kt | 35 + .../app/gateway/DeviceAuthStoreTest.kt | 66 + .../app/gateway/DeviceIdentityStoreTest.kt | 80 + .../app/gateway/DeviceIdentityTestSupport.kt | 16 + .../app/gateway/GatewayDiscoveryTest.kt | 68 + .../app/gateway/GatewayErrorDetailsTest.kt | 52 + .../gateway/GatewayProtocolGeneratedTest.kt | 59 + .../app/gateway/GatewayRegistryStoreTest.kt | 199 + .../GatewaySessionCustomHeadersTest.kt | 446 + .../app/gateway/GatewaySessionInvokeTest.kt | 1592 +++ .../GatewaySessionInvokeTimeoutTest.kt | 54 + .../gateway/GatewaySessionReconnectTest.kt | 1191 +++ .../app/gateway/GatewayStoreMigrationTest.kt | 172 + .../ai/openclaw/app/gateway/GatewayTlsTest.kt | 372 + .../app/gateway/InvokeErrorParserTest.kt | 48 + .../app/gateway/NetworkMonitorTest.kt | 42 + .../ai/openclaw/app/i18n/NativeStringsTest.kt | 155 + .../app/node/AndroidPermissionSnapshotTest.kt | 150 + .../openclaw/app/node/CalendarHandlerTest.kt | 181 + .../app/node/CameraFacingPreferenceTest.kt | 18 + .../ai/openclaw/app/node/CameraHandlerTest.kt | 172 + .../app/node/CanvasActionTrustTest.kt | 63 + .../node/CanvasControllerPresentationTest.kt | 38 + .../CanvasControllerSnapshotParamsTest.kt | 43 + .../app/node/CanvasNavigationPolicyTest.kt | 99 + .../app/node/ConnectionManagerTest.kt | 722 ++ .../openclaw/app/node/ContactsHandlerTest.kt | 134 + .../ai/openclaw/app/node/DebugHandlerTest.kt | 41 + .../ai/openclaw/app/node/DeviceHandlerTest.kt | 554 + .../DeviceNotificationListenerServiceTest.kt | 131 + .../app/node/InvokeCommandRegistryTest.kt | 316 + .../openclaw/app/node/InvokeDispatcherTest.kt | 556 + .../openclaw/app/node/JpegSizeLimiterTest.kt | 70 + .../openclaw/app/node/LocationHandlerTest.kt | 271 + .../ai/openclaw/app/node/MotionHandlerTest.kt | 185 + .../app/node/NodeHandlerRobolectricTest.kt | 11 + .../app/node/NodePresenceAliveBeaconTest.kt | 116 + .../ai/openclaw/app/node/NodeUtilsTest.kt | 65 + .../app/node/NotificationsHandlerTest.kt | 333 + .../ai/openclaw/app/node/PhotosHandlerTest.kt | 75 + .../ai/openclaw/app/node/SystemHandlerTest.kt | 143 + .../protocol/OpenClawCanvasA2UIActionTest.kt | 63 + .../protocol/OpenClawProtocolConstantsTest.kt | 39 + .../SystemAgentChatControllerTest.kt | 501 + .../app/tools/ToolDisplayRegistryTest.kt | 41 + .../app/ui/CanvasA2UIActionBridgeTest.kt | 50 + .../app/ui/CommandPaletteLogicTest.kt | 84 + .../openclaw/app/ui/ControlUiWebViewTest.kt | 56 + .../app/ui/CronJobManagementPanelTest.kt | 48 + .../app/ui/GatewayConfigResolverTest.kt | 1251 +++ .../openclaw/app/ui/GatewayDiagnosticsTest.kt | 96 + .../app/ui/HealthLogsSettingsScreenTest.kt | 47 + .../app/ui/InitialOnboardingLayoutTest.kt | 198 + .../app/ui/NodesDevicesSettingsScreenTest.kt | 94 + .../app/ui/OnboardingFlowLogicTest.kt | 1294 +++ .../app/ui/ProviderModelStatusTest.kt | 240 + .../app/ui/SessionDashboardScreenTest.kt | 48 + .../app/ui/SessionObserverDigestTest.kt | 340 + .../app/ui/SessionsScreenGroupingTest.kt | 141 + .../app/ui/SessionsScreenSearchTest.kt | 18 + .../ui/SettingsScreensNotificationAppsTest.kt | 77 + .../ai/openclaw/app/ui/SettingsScreensTest.kt | 444 + .../openclaw/app/ui/ShellScreenLogicTest.kt | 901 ++ .../openclaw/app/ui/SidebarShellLogicTest.kt | 185 + .../app/ui/SkillsSettingsScreenTest.kt | 25 + .../openclaw/app/ui/SystemAnimationsTest.kt | 116 + .../openclaw/app/ui/VoiceScreenLogicTest.kt | 155 + .../app/ui/chat/ChatCommandControlsTest.kt | 159 + .../app/ui/chat/ChatComposerDraftTest.kt | 1038 ++ .../app/ui/chat/ChatContextMeterTest.kt | 159 + .../ui/chat/ChatDictationControllerTest.kt | 308 + .../ChatDurationFormatterRobolectricTest.kt | 23 + .../openclaw/app/ui/chat/ChatErrorTextTest.kt | 22 + .../app/ui/chat/ChatHardwareKeyTest.kt | 212 + .../app/ui/chat/ChatImageCodecTest.kt | 25 + .../app/ui/chat/ChatLinkPreviewTest.kt | 599 ++ .../openclaw/app/ui/chat/ChatMarkdownTest.kt | 592 ++ .../app/ui/chat/ChatMathAssetsTest.kt | 20 + .../app/ui/chat/ChatMathRendererTest.kt | 234 + .../app/ui/chat/ChatMediaPlayerTest.kt | 170 + .../app/ui/chat/ChatMessageActionsTest.kt | 33 + .../app/ui/chat/ChatMessageViewsTest.kt | 56 + .../app/ui/chat/ChatModelPickerTest.kt | 69 + .../ui/chat/ChatReaderScrollControllerTest.kt | 393 + .../ai/openclaw/app/ui/chat/ChatScreenTest.kt | 186 + .../openclaw/app/ui/chat/ChatTimelineTest.kt | 305 + .../app/ui/chat/ChatTurnRecapResolverTest.kt | 314 + .../app/ui/chat/ChatWidgetExportTest.kt | 87 + .../app/ui/chat/ChatWorkingIndicatorTest.kt | 175 + .../app/ui/chat/SessionFiltersTest.kt | 138 + .../openclaw/app/ui/design/AgentAvatarTest.kt | 82 + .../app/ui/design/ClawComponentsTest.kt | 46 + .../app/ui/design/MascotAnimatorTest.kt | 194 + .../app/ui/design/TalkWaveformMathTest.kt | 81 + .../app/voice/AndroidAudioInputSessionTest.kt | 246 + .../ai/openclaw/app/voice/AudioLevelsTest.kt | 72 + .../openclaw/app/voice/ChatEventTextTest.kt | 106 + .../app/voice/MicCaptureManagerTest.kt | 453 + .../voice/PushToTalkRecognitionLadderTest.kt | 56 + .../voice/PushToTalkTranscriptMergerTest.kt | 90 + .../app/voice/RealtimeAgentCoordinatorTest.kt | 546 + .../openclaw/app/voice/TalkAudioPlayerTest.kt | 44 + .../app/voice/TalkDirectiveParserTest.kt | 73 + .../app/voice/TalkModeConfigParsingTest.kt | 137 + .../openclaw/app/voice/TalkModeManagerTest.kt | 1385 +++ .../openclaw/app/voice/TalkSpeakClientTest.kt | 149 + .../app/voice/VoiceWakeManagerTest.kt | 279 + .../app/voice/VoiceWakePreferencesTest.kt | 60 + .../openclaw/app/wear/WearProxyBridgeTest.kt | 960 ++ .../app/wear/WearProxyControllerTest.kt | 903 ++ .../app/wear/WearProxyListenerManifestTest.kt | 96 + .../wear/WearRealtimeChannelRegistryTest.kt | 1073 ++ .../wear/WearRealtimeTalkControllerTest.kt | 1065 ++ .../resources/chat/markdown_stream_fixture.md | 49 + .../ai/openclaw/app/VoiceE2eReceiverTest.kt | 91 + .../AccessibilityComponentControllerTest.kt | 55 + .../AccessibilitySnapshotterTest.kt | 500 + .../openclaw/app/node/CallLogHandlerTest.kt | 299 + .../openclaw/app/node/MobileUiHandlerTest.kt | 105 + .../ai/openclaw/app/node/SmsManagerTest.kt | 1089 ++ app/src/thirdParty/AndroidManifest.xml | 41 + .../ai/openclaw/app/SensitiveFeatureConfig.kt | 9 + .../AccessibilityActionExecutor.kt | 456 + .../AccessibilityComponentController.kt | 34 + .../accessibility/AccessibilityDevActivity.kt | 500 + .../accessibility/AccessibilitySnapshotter.kt | 280 + .../app/accessibility/MobileUiSnapshot.kt | 27 + .../OpenClawAccessibilityService.kt | 103 + .../ai/openclaw/app/node/CallLogHandler.kt | 276 + .../ai/openclaw/app/node/MobileUiHandler.kt | 200 + .../java/ai/openclaw/app/node/SmsHandler.kt | 39 + .../java/ai/openclaw/app/node/SmsManager.kt | 1154 +++ .../ui/SensitivePhoneCapabilitiesSettings.kt | 104 + .../res/values-ar/accessibility_strings.xml | 6 + .../res/values-de/accessibility_strings.xml | 6 + .../res/values-es/accessibility_strings.xml | 6 + .../res/values-fa/accessibility_strings.xml | 6 + .../res/values-fr/accessibility_strings.xml | 6 + .../res/values-hi/accessibility_strings.xml | 6 + .../res/values-in/accessibility_strings.xml | 6 + .../res/values-it/accessibility_strings.xml | 6 + .../res/values-ja/accessibility_strings.xml | 6 + .../res/values-ko/accessibility_strings.xml | 6 + .../res/values-nl/accessibility_strings.xml | 6 + .../res/values-pl/accessibility_strings.xml | 6 + .../values-pt-rBR/accessibility_strings.xml | 6 + .../res/values-ru/accessibility_strings.xml | 6 + .../res/values-sv/accessibility_strings.xml | 6 + .../res/values-th/accessibility_strings.xml | 6 + .../res/values-tr/accessibility_strings.xml | 6 + .../res/values-uk/accessibility_strings.xml | 6 + .../res/values-vi/accessibility_strings.xml | 6 + .../values-zh-rCN/accessibility_strings.xml | 6 + .../values-zh-rTW/accessibility_strings.xml | 6 + .../res/values/accessibility_strings.xml | 8 + .../res/xml/accessibility_service_config.xml | 11 + benchmark/build.gradle.kts | 47 + .../app/benchmark/CronJobNavigationTest.kt | 80 + .../app/benchmark/StartupMacrobenchmark.kt | 76 + build.gradle.kts | 8 + fastlane/.env.example | 20 + fastlane/Appfile | 3 + fastlane/Fastfile | 614 ++ fastlane/SETUP.md | 130 + .../android/en-US/changelogs/2026060201.txt | 3 + .../android/en-US/changelogs/2026060901.txt | 3 + .../android/en-US/changelogs/2026070302.txt | 7 + .../android/en-US/changelogs/2026070352.txt | 7 + .../android/en-US/changelogs/2026070401.txt | 7 + .../android/en-US/changelogs/2026070451.txt | 7 + .../android/en-US/full_description.txt | 18 + .../metadata/android/en-US/release_notes.txt | 7 + .../android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + gradle.properties | 10 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 108 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 11 + gradlew | 248 + gradlew.bat | 82 + scripts/build-release-artifacts.ts | 447 + scripts/perf-online-benchmark.sh | 429 + scripts/perf-startup-benchmark.sh | 124 + scripts/perf-startup-hotspots.sh | 154 + scripts/voice-e2e.sh | 230 + settings.gradle.kts | 21 + style.md | 113 + version.json | 4 + wear-shared/build.gradle.kts | 44 + wear-shared/src/main/AndroidManifest.xml | 1 + .../ai/openclaw/wear/shared/WearProtocol.kt | 363 + .../openclaw/wear/shared/WearRealtimeTalk.kt | 121 + .../openclaw/wear/shared/WearProtocolTest.kt | 276 + .../wear/shared/WearRealtimeTalkTest.kt | 35 + wear/BEHAVIOR.md | 15 + wear/build.gradle.kts | 126 + wear/proguard-rules.pro | 2 + wear/src/main/AndroidManifest.xml | 82 + .../java/ai/openclaw/wear/MainActivity.kt | 574 ++ .../ai/openclaw/wear/OpenClawTileService.kt | 104 + .../java/ai/openclaw/wear/WearApplication.kt | 41 + .../openclaw/wear/WearAudioFocusController.kt | 52 + .../ai/openclaw/wear/WearCompanionUiModels.kt | 124 + .../ai/openclaw/wear/WearGatewayRepository.kt | 520 + .../java/ai/openclaw/wear/WearLocaleText.kt | 13 + .../java/ai/openclaw/wear/WearProxyClient.kt | 573 ++ .../openclaw/wear/WearProxyListenerService.kt | 46 + .../openclaw/wear/WearRealtimeTalkClient.kt | 613 ++ .../ai/openclaw/wear/WearReplyNotifier.kt | 286 + .../java/ai/openclaw/wear/WearReplySpeaker.kt | 117 + .../main/java/ai/openclaw/wear/WearScreens.kt | 2066 ++++ .../ai/openclaw/wear/WearScreenshotMode.kt | 124 + .../java/ai/openclaw/wear/WearTalkAvatar.kt | 606 ++ .../main/java/ai/openclaw/wear/WearTheme.kt | 179 + .../java/ai/openclaw/wear/WearViewModel.kt | 1424 +++ .../main/res/drawable-round/tile_preview.xml | 37 + .../src/main/res/drawable/ic_notification.xml | 10 + wear/src/main/res/drawable/tile_preview.xml | 38 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 12694 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 7486 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 17954 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 29869 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 46094 bytes .../res/raw/ai_openclaw_app_wear_keep.xml | 3 + wear/src/main/res/values-ar/strings.xml | 89 + wear/src/main/res/values-de/strings.xml | 89 + wear/src/main/res/values-es/strings.xml | 89 + wear/src/main/res/values-fa/strings.xml | 89 + wear/src/main/res/values-fr/strings.xml | 89 + wear/src/main/res/values-hi/strings.xml | 89 + wear/src/main/res/values-in/strings.xml | 89 + wear/src/main/res/values-it/strings.xml | 89 + wear/src/main/res/values-ja/strings.xml | 89 + wear/src/main/res/values-ko/strings.xml | 89 + wear/src/main/res/values-nl/strings.xml | 89 + wear/src/main/res/values-pl/strings.xml | 89 + wear/src/main/res/values-pt-rBR/strings.xml | 89 + wear/src/main/res/values-ru/strings.xml | 89 + wear/src/main/res/values-sv/strings.xml | 89 + wear/src/main/res/values-th/strings.xml | 89 + wear/src/main/res/values-tr/strings.xml | 89 + wear/src/main/res/values-uk/strings.xml | 89 + wear/src/main/res/values-vi/strings.xml | 89 + wear/src/main/res/values-zh-rCN/strings.xml | 89 + wear/src/main/res/values-zh-rTW/strings.xml | 89 + wear/src/main/res/values/colors.xml | 4 + wear/src/main/res/values/strings.xml | 91 + wear/src/main/res/values/themes.xml | 9 + wear/src/main/res/values/wear.xml | 6 + wear/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + .../java/ai/openclaw/wear/MainActivityTest.kt | 261 + .../wear/WearGatewayRepositoryTest.kt | 439 + .../ai/openclaw/wear/WearLaunchIntentTest.kt | 219 + .../java/ai/openclaw/wear/WearLayoutTest.kt | 39 + .../ai/openclaw/wear/WearLocaleTextTest.kt | 12 + .../ai/openclaw/wear/WearProxyClientTest.kt | 937 ++ .../ai/openclaw/wear/WearReplyNotifierTest.kt | 90 + .../openclaw/wear/WearScreenshotModeTest.kt | 59 + .../ai/openclaw/wear/WearSessionScopeTest.kt | 585 ++ .../ai/openclaw/wear/WearSettingsStoreTest.kt | 60 + .../ai/openclaw/wear/WearTalkAvatarTest.kt | 556 + .../java/ai/openclaw/wear/WearThemeTest.kt | 115 + .../wear/WearViewModelLifecycleTest.kt | 57 + 656 files changed, 209982 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 120000 CLAUDE.md create mode 100644 Config/ReleaseSigning.json create mode 100644 Config/Version.properties create mode 100644 README.md create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Compose.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Media3.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Room.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Wear.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/Bouncy Castle Provider.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/CommonMark Java.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/KaTeX.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/Kotlin Libraries.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/Manrope.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/OkHttp and Okio.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/SLF4J API.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/dnsjava.txt create mode 100644 THIRD_PARTY_LICENSES/openclaw/licenses/nibor autolink.txt create mode 100644 VERSIONING.md create mode 100644 app/build.gradle.kts create mode 100644 app/lint.xml create mode 100644 app/proguard-rules.pro create mode 100644 app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json create mode 100644 app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json create mode 100644 app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json create mode 100644 app/src/androidTest/java/ai/openclaw/app/ui/CanvasHostLifecycleTest.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt create mode 100644 app/src/debug/java/ai/openclaw/app/ui/CanvasLifecycleTestActivity.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/assets/katex/README.md create mode 100644 app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Main-Bold.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Size2-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Size3-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2 create mode 100644 app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 create mode 100644 app/src/main/assets/katex/index.html create mode 100644 app/src/main/assets/katex/katex.min.css create mode 100644 app/src/main/assets/katex/katex.min.js create mode 100644 app/src/main/assets/katex/renderer.js create mode 100644 app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt create mode 100644 app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt create mode 100644 app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt create mode 100644 app/src/main/java/ai/openclaw/app/AppLanguage.kt create mode 100644 app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt create mode 100644 app/src/main/java/ai/openclaw/app/AssistantLaunch.kt create mode 100644 app/src/main/java/ai/openclaw/app/CameraHudState.kt create mode 100644 app/src/main/java/ai/openclaw/app/CronJobDetail.kt create mode 100644 app/src/main/java/ai/openclaw/app/CronJobManagement.kt create mode 100644 app/src/main/java/ai/openclaw/app/DeviceNames.kt create mode 100644 app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt create mode 100644 app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt create mode 100644 app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt create mode 100644 app/src/main/java/ai/openclaw/app/LocationMode.kt create mode 100644 app/src/main/java/ai/openclaw/app/MainActivity.kt create mode 100644 app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt create mode 100644 app/src/main/java/ai/openclaw/app/MainViewModel.kt create mode 100644 app/src/main/java/ai/openclaw/app/NodeApp.kt create mode 100644 app/src/main/java/ai/openclaw/app/NodeForegroundService.kt create mode 100644 app/src/main/java/ai/openclaw/app/NodeRuntime.kt create mode 100644 app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt create mode 100644 app/src/main/java/ai/openclaw/app/PermissionRequester.kt create mode 100644 app/src/main/java/ai/openclaw/app/PhotoPermissions.kt create mode 100644 app/src/main/java/ai/openclaw/app/SecurePrefs.kt create mode 100644 app/src/main/java/ai/openclaw/app/SessionKey.kt create mode 100644 app/src/main/java/ai/openclaw/app/SkillManagement.kt create mode 100644 app/src/main/java/ai/openclaw/app/Utf16Text.kt create mode 100644 app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt create mode 100644 app/src/main/java/ai/openclaw/app/WorkspaceFiles.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/BackgroundTask.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatComposerOwner.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatController.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatModels.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatSwarmProgress.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ChatWidgetUrlResolver.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/MessageSpeechController.kt create mode 100644 app/src/main/java/ai/openclaw/app/chat/VoiceNoteRecorderController.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/ChatSendAck.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/DeviceAuthPayload.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayCustomHeaders.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayStoreMigration.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/InvokeErrorParser.kt create mode 100644 app/src/main/java/ai/openclaw/app/gateway/NetworkMonitor.kt create mode 100644 app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt create mode 100644 app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/AndroidPermissionSnapshot.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/CameraHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/CanvasController.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/CanvasNavigationPolicy.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/ContactsHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/DebugHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/JpegSizeLimiter.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/LocationCaptureManager.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/LocationHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/MotionHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/NodePresenceAliveBeacon.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/NodeUtils.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/NotificationsHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/PhotosHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/node/SystemHandler.kt create mode 100644 app/src/main/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIAction.kt create mode 100644 app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt create mode 100644 app/src/main/java/ai/openclaw/app/systemagent/SystemAgentChatController.kt create mode 100644 app/src/main/java/ai/openclaw/app/tools/ToolDisplay.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/AboutBuildIdentity.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/CanvasSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/ControlUiWebView.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/NotificationAppPicker.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/RootScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SessionDashboardScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/ShellNavigation.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SystemAgentSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/SystemAnimations.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/UnifiedChatScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/BackgroundTasksSheet.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatCodeHighlight.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatComposerStateStore.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatHardwareKey.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatInlineWidgetView.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatMathRenderer.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatMathSegmenter.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatModelPicker.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatReaderScrollController.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatRealtimeTalk.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatSwarmProgressView.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatTurnRecap.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatWidgetExport.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/PendingAttachment.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/AgentAvatar.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/ClawNavigation.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/ClawPreview.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/ClawSurfaces.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/MascotAnimator.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/MascotPose.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/OpenClawMascot.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/design/TalkWaveform.kt create mode 100644 app/src/main/java/ai/openclaw/app/ui/image/SafeRemoteImage.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/AndroidAudioInputSession.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/ChatEventText.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/PushToTalkRecognitionLadder.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/PushToTalkTranscriptMerger.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/RealtimeAgentCoordinator.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkAudioLevel.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkDirectiveParser.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/VoiceWakeManager.kt create mode 100644 app/src/main/java/ai/openclaw/app/voice/VoiceWakePreferences.kt create mode 100644 app/src/main/java/ai/openclaw/app/wear/WearProxyBridge.kt create mode 100644 app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt create mode 100644 app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt create mode 100644 app/src/main/java/ai/openclaw/app/wear/WearRealtimeChannelRegistry.kt create mode 100644 app/src/main/java/ai/openclaw/app/wear/WearRealtimeTalkController.kt create mode 100644 app/src/main/res/font/manrope_400_regular.ttf create mode 100644 app/src/main/res/font/manrope_500_medium.ttf create mode 100644 app/src/main/res/font/manrope_600_semibold.ttf create mode 100644 app/src/main/res/font/manrope_700_bold.ttf create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/raw/ai_openclaw_app_phone_keep.xml create mode 100644 app/src/main/res/resources.properties create mode 100644 app/src/main/res/values-ar/assistant.xml create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values-de/assistant.xml create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-es/assistant.xml create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 app/src/main/res/values-fa/assistant.xml create mode 100644 app/src/main/res/values-fa/strings.xml create mode 100644 app/src/main/res/values-fr/assistant.xml create mode 100644 app/src/main/res/values-fr/strings.xml create mode 100644 app/src/main/res/values-hi/assistant.xml create mode 100644 app/src/main/res/values-hi/strings.xml create mode 100644 app/src/main/res/values-in/assistant.xml create mode 100644 app/src/main/res/values-in/strings.xml create mode 100644 app/src/main/res/values-it/assistant.xml create mode 100644 app/src/main/res/values-it/strings.xml create mode 100644 app/src/main/res/values-ja/assistant.xml create mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-ko/assistant.xml create mode 100644 app/src/main/res/values-ko/strings.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values-nl/assistant.xml create mode 100644 app/src/main/res/values-nl/strings.xml create mode 100644 app/src/main/res/values-pl/assistant.xml create mode 100644 app/src/main/res/values-pl/strings.xml create mode 100644 app/src/main/res/values-pt-rBR/assistant.xml create mode 100644 app/src/main/res/values-pt-rBR/strings.xml create mode 100644 app/src/main/res/values-ru/assistant.xml create mode 100644 app/src/main/res/values-ru/strings.xml create mode 100644 app/src/main/res/values-sv/assistant.xml create mode 100644 app/src/main/res/values-sv/strings.xml create mode 100644 app/src/main/res/values-th/assistant.xml create mode 100644 app/src/main/res/values-th/strings.xml create mode 100644 app/src/main/res/values-tr/assistant.xml create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 app/src/main/res/values-uk/assistant.xml create mode 100644 app/src/main/res/values-uk/strings.xml create mode 100644 app/src/main/res/values-vi/assistant.xml create mode 100644 app/src/main/res/values-vi/strings.xml create mode 100644 app/src/main/res/values-zh-rCN/assistant.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values-zh-rTW/assistant.xml create mode 100644 app/src/main/res/values-zh-rTW/strings.xml create mode 100644 app/src/main/res/values/assistant.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/values/wear.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/main/res/xml/shortcuts.xml create mode 100644 app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt create mode 100644 app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt create mode 100644 app/src/play/java/ai/openclaw/app/node/MobileUiHandler.kt create mode 100644 app/src/play/java/ai/openclaw/app/node/SmsHandler.kt create mode 100644 app/src/play/java/ai/openclaw/app/node/SmsManager.kt create mode 100644 app/src/play/java/ai/openclaw/app/ui/FlavorPhoneCapabilitiesSettings.kt create mode 100644 app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/AppLanguageTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/AssistantLaunchTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ClawHubSkillRuntimeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/CronJobStatusParsingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/DreamingRuntimeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayConnectionDisplayTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayDevicePairingRuntimeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayDevicePairingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/GatewayTalkSetupReadinessTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/MainViewModelTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/NodeRuntimeAgentSelectionTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/NotificationNodeEventOutboxTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/PhotoPermissionsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ProviderModelCatalogRequestTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/SessionKeyTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/SkillManagementTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/Utf16TextTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/WorkspaceFilesTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/BackgroundTaskTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerSwarmProgressTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatControllerUsageStreamTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatSwarmProgressTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ChatVoiceNoteAttachmentTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/MessageSpeechControllerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/ChatSendAckTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityStoreTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityTestSupport.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewayStoreMigrationTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/GatewayTlsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/gateway/NetworkMonitorTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CameraFacingPreferenceTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CanvasControllerPresentationTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/CanvasNavigationPolicyTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/DebugHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/NodePresenceAliveBeaconTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/systemagent/SystemAgentChatControllerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/tools/ToolDisplayRegistryTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/CanvasA2UIActionBridgeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/ControlUiWebViewTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/CronJobManagementPanelTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/GatewayDiagnosticsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/HealthLogsSettingsScreenTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/InitialOnboardingLayoutTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/NodesDevicesSettingsScreenTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SessionDashboardScreenTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SessionsScreenSearchTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SettingsScreensNotificationAppsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SkillsSettingsScreenTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/SystemAnimationsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatCommandControlsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatDictationControllerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatDurationFormatterRobolectricTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatErrorTextTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatHardwareKeyTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatMarkdownTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatMathAssetsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatMathRendererTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageViewsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatWidgetExportTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/design/ClawComponentsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/AndroidAudioInputSessionTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/ChatEventTextTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/MicCaptureManagerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/PushToTalkRecognitionLadderTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/PushToTalkTranscriptMergerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/RealtimeAgentCoordinatorTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/VoiceWakeManagerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/voice/VoiceWakePreferencesTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt create mode 100644 app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt create mode 100644 app/src/test/resources/chat/markdown_stream_fixture.md create mode 100644 app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt create mode 100644 app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentControllerTest.kt create mode 100644 app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotterTest.kt create mode 100644 app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt create mode 100644 app/src/testThirdParty/java/ai/openclaw/app/node/MobileUiHandlerTest.kt create mode 100644 app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt create mode 100644 app/src/thirdParty/AndroidManifest.xml create mode 100644 app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityActionExecutor.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentController.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotter.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/accessibility/MobileUiSnapshot.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/accessibility/OpenClawAccessibilityService.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/node/MobileUiHandler.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt create mode 100644 app/src/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt create mode 100644 app/src/thirdParty/res/values-ar/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-de/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-es/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-fa/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-fr/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-hi/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-in/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-it/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-ja/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-ko/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-nl/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-pl/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-pt-rBR/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-ru/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-sv/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-th/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-tr/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-uk/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-vi/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-zh-rCN/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values-zh-rTW/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/values/accessibility_strings.xml create mode 100644 app/src/thirdParty/res/xml/accessibility_service_config.xml create mode 100644 benchmark/build.gradle.kts create mode 100644 benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt create mode 100644 benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt create mode 100644 build.gradle.kts create mode 100644 fastlane/.env.example create mode 100644 fastlane/Appfile create mode 100644 fastlane/Fastfile create mode 100644 fastlane/SETUP.md create mode 100644 fastlane/metadata/android/en-US/changelogs/2026060201.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/2026060901.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/2026070302.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/2026070352.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/2026070401.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/2026070451.txt create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/release_notes.txt create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/en-US/title.txt create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 scripts/build-release-artifacts.ts create mode 100755 scripts/perf-online-benchmark.sh create mode 100755 scripts/perf-startup-benchmark.sh create mode 100755 scripts/perf-startup-hotspots.sh create mode 100755 scripts/voice-e2e.sh create mode 100644 settings.gradle.kts create mode 100644 style.md create mode 100644 version.json create mode 100644 wear-shared/build.gradle.kts create mode 100644 wear-shared/src/main/AndroidManifest.xml create mode 100644 wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt create mode 100644 wear-shared/src/main/java/ai/openclaw/wear/shared/WearRealtimeTalk.kt create mode 100644 wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt create mode 100644 wear-shared/src/test/java/ai/openclaw/wear/shared/WearRealtimeTalkTest.kt create mode 100644 wear/BEHAVIOR.md create mode 100644 wear/build.gradle.kts create mode 100644 wear/proguard-rules.pro create mode 100644 wear/src/main/AndroidManifest.xml create mode 100644 wear/src/main/java/ai/openclaw/wear/MainActivity.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/OpenClawTileService.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearApplication.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearAudioFocusController.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearLocaleText.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearProxyListenerService.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearReplyNotifier.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearReplySpeaker.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearScreens.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearTheme.kt create mode 100644 wear/src/main/java/ai/openclaw/wear/WearViewModel.kt create mode 100644 wear/src/main/res/drawable-round/tile_preview.xml create mode 100644 wear/src/main/res/drawable/ic_notification.xml create mode 100644 wear/src/main/res/drawable/tile_preview.xml create mode 100644 wear/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 wear/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 wear/src/main/res/raw/ai_openclaw_app_wear_keep.xml create mode 100644 wear/src/main/res/values-ar/strings.xml create mode 100644 wear/src/main/res/values-de/strings.xml create mode 100644 wear/src/main/res/values-es/strings.xml create mode 100644 wear/src/main/res/values-fa/strings.xml create mode 100644 wear/src/main/res/values-fr/strings.xml create mode 100644 wear/src/main/res/values-hi/strings.xml create mode 100644 wear/src/main/res/values-in/strings.xml create mode 100644 wear/src/main/res/values-it/strings.xml create mode 100644 wear/src/main/res/values-ja/strings.xml create mode 100644 wear/src/main/res/values-ko/strings.xml create mode 100644 wear/src/main/res/values-nl/strings.xml create mode 100644 wear/src/main/res/values-pl/strings.xml create mode 100644 wear/src/main/res/values-pt-rBR/strings.xml create mode 100644 wear/src/main/res/values-ru/strings.xml create mode 100644 wear/src/main/res/values-sv/strings.xml create mode 100644 wear/src/main/res/values-th/strings.xml create mode 100644 wear/src/main/res/values-tr/strings.xml create mode 100644 wear/src/main/res/values-uk/strings.xml create mode 100644 wear/src/main/res/values-vi/strings.xml create mode 100644 wear/src/main/res/values-zh-rCN/strings.xml create mode 100644 wear/src/main/res/values-zh-rTW/strings.xml create mode 100644 wear/src/main/res/values/colors.xml create mode 100644 wear/src/main/res/values/strings.xml create mode 100644 wear/src/main/res/values/themes.xml create mode 100644 wear/src/main/res/values/wear.xml create mode 100644 wear/src/main/res/xml/backup_rules.xml create mode 100644 wear/src/main/res/xml/data_extraction_rules.xml create mode 100644 wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearLaunchIntentTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearLayoutTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearLocaleTextTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearProxyClientTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearReplyNotifierTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearScreenshotModeTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearSettingsStoreTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearThemeTest.kt create mode 100644 wear/src/test/java/ai/openclaw/wear/WearViewModelLifecycleTest.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f829249 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_style = space +indent_size = 2 +max_line_length = off +ktlint_standard_filename = disabled +ktlint_standard_function-naming = disabled +ktlint_standard_max-line-length = disabled +ktlint_standard_property-naming = disabled diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bfc09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +**/build/ +local.properties +.idea/ +**/*.iml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cba8924 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Android Release Agent Policy + +Root rules still apply. This file adds the Android release guardrails. + +## Google Play Releases + +- Agent-driven Google Play uploads must use only `pnpm android:release:upload`. +- If `pnpm android:release:upload` exits non-zero, stop immediately and report the failing step. +- After a failed `pnpm android:release:upload`, do not continue with `pnpm android:release:archive`, `pnpm android:release:metadata`, `fastlane android play_store`, `fastlane android metadata`, direct Gradle release artifacts plus Google Play upload commands, Google Play API mutation commands, or mobile release ref recording. +- Do not promote an Android release to production. Production promotion stays manual in Google Play Console unless the user explicitly asks to promote a specific already-prepared release after the failed state has been reported. +- `pnpm android:release:archive` is for local archive validation only. It is not a fallback release path after screenshot, metadata, signing, validation, or upload-lane failure. + +## Licenses Screen + +- Maintain the Settings-tab Licenses screen when Android app dependencies change. +- Bundled license files live in `apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/`. +- License files must be UTF-8 `.txt` files. Do not add Markdown, HTML, RTF, JSON, XML, or generated notice bundles for this screen. +- The Licenses screen discovers bundled `.txt` files at runtime through `AndroidLicenseNotices`; do not hardcode individual license rows in Compose. +- License rows are ordered alphabetically in code by derived display title, case-insensitive, with filename as the tiebreaker. Do not use numeric filename prefixes for ordering. +- The display title is the license filename without the `.txt` extension. +- Filenames should be plain dependency names, for example `Manrope.txt`; the filename is the row title and must not be shown as a row subtitle. +- Do not add OpenClaw, OpenClaw Foundation, or other first-party/self-owned license entries. The screen is for third-party/open-source dependency acknowledgements. +- When adding, removing, or upgrading Android dependencies, audit whether `apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/` needs updates. Exclude dependencies owned by OpenClaw Foundation from the published license list. +- Keep license detail bodies rendered as verbatim monospace text. +- Keep the Settings `Licenses` section at the bottom of Settings, after `Account`, with a single `Licenses` row and no row subtitle unless product direction changes. +- When changing license loading or presentation, update `apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt`, then run focused Android validation. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f2e2f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# OpenClaw Android Changelog + +## Unreleased + +## 2026.7.4 - 2026-07-30 + +Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk. + +Improves the working claw, collapsible details, Skill Workshop flows, and generated images. + +Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability. + +Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo. + +## 2026.7.3 - 2026-07-20 + +Adds a Wear OS companion for sessions, transcripts, text and voice replies, realtime Talk, Gateway controls, notifications, settings, and a launch Tile. + +Adds foreground, on-device Voice Wake with editable Gateway-synced wake words, plus copy and save-as-PNG actions for rendered chat widgets. + +Fixes composer media leaking across chats and malformed agent or profile initials when display names begin with emoji. + +Thanks @sibbl, @IWhatsskill, and @Leon-SK668. + +## 2026.7.2 - 2026-07-13 + +Adds Automations and Skills management with search, filters, editing, run tracking, install safety, and ClawHub risk review. + +Improves chat with per-device history, durable approval status, session search, sharing, and agent avatars. + +Adds provider model details, build identity, safer permission recovery, fresh Installed Apps consent, and Gateway protocol v3/v4 support. + +Thanks @snowzlmbot, @IWhatsskill, @NianJiuZst, and @guarismo. + +## 2026.7.1 - 2026-07-08 + +Adds multi-gateway switching with isolated credentials, history, queues, and notification routing. + +Upgrades chat with offline recovery, session search and groups, model and agent pickers, voice notes, actions, link previews, code and math rendering. + +Adds workspace files, Cron details, terminal access, and Listen playback. + +Improves onboarding, reconnects, keyboards, notification filtering, location, canvas safety, and voice reliability. + +Thanks @IWhatsskill, @ioridev, and @narcissus0702. + +## 2026.6.11 - 2026-07-01 + +Improves Android gateway setup with localized onboarding, QR pairing fixes, and support for local mDNS gateway hosts. + +Adds clearer recovery guidance for TLS fingerprint timeouts, mobile protocol mismatches, and gateway auth states. + +Refreshes native Android localization coverage, including Swedish app naming and localized gateway trust flows. + +## 2026.6.2 - 2026-06-02 + +OpenClaw is now available on Android. + +Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Config/ReleaseSigning.json b/Config/ReleaseSigning.json new file mode 100644 index 0000000..7b32162 --- /dev/null +++ b/Config/ReleaseSigning.json @@ -0,0 +1,15 @@ +{ + "signingRepo": "git@github.com:openclaw/apps-signing.git", + "signingBranch": "main", + "assetPath": "android/openclaw", + "uploadKeystoreEncryptedFile": "upload-keystore.jks.enc", + "gradlePropertiesEncryptedFile": "gradle.properties.enc", + "apkCertificateSha256": "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef29ff0550468b9a63", + "materializedRoot": "apps/android/build/release-signing", + "gradlePropertyNames": [ + "OPENCLAW_ANDROID_STORE_FILE", + "OPENCLAW_ANDROID_STORE_PASSWORD", + "OPENCLAW_ANDROID_KEY_ALIAS", + "OPENCLAW_ANDROID_KEY_PASSWORD" + ] +} diff --git a/Config/Version.properties b/Config/Version.properties new file mode 100644 index 0000000..0e42f22 --- /dev/null +++ b/Config/Version.properties @@ -0,0 +1,6 @@ +# Shared Android version defaults. +# Source of truth: apps/android/version.json +# Generated by scripts/android-sync-versioning.ts. + +OPENCLAW_ANDROID_VERSION_NAME=2026.7.4 +OPENCLAW_ANDROID_VERSION_CODE=2026070401 diff --git a/README.md b/README.md new file mode 100644 index 0000000..55c30a1 --- /dev/null +++ b/README.md @@ -0,0 +1,386 @@ +## OpenClaw Android App + +OpenClaw Android is the officially released Google Play app. It connects to an OpenClaw Gateway as a companion node for chat, voice, approvals, screen, and device-aware automation. + +### Current App Surface + +- [x] New 4-step onboarding flow +- [x] Connect tab with `Setup Code` + `Manual` modes +- [x] Encrypted persistence for gateway setup/auth state +- [x] Chat UI restyled +- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect) +- [x] QR code scanning in onboarding +- [x] Performance improvements +- [x] Streaming support in chat UI +- [x] Dedicated per-device Android chat session created/adopted on connect without resetting history +- [x] Request camera/location and other permissions in onboarding/settings flow +- [x] Push notifications for gateway/chat status updates +- [x] Security hardening (biometric lock, token handling, safer defaults) +- [x] Authenticated background presence beacons +- [x] Voice tab full functionality +- [x] Foreground on-device Voice Wake with Gateway-synced wake words +- [x] Screen tab full functionality +- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs +- [x] Skills settings can search installed skills, enable or disable them, and install Gateway-verified ClawHub releases +- [x] Per-app language selection for translated resources follows Android system settings and persistence +- [x] Cron job settings support details, run history, run now, edits, enable/disable, and deletion with admin-scoped Gateway access +- [x] Wear OS companion proxies sessions, transcripts, replies, aborts, and realtime Talk through the paired phone without storing Gateway credentials on the watch + +## Open in Android Studio + +- Open the folder `apps/android`. + +## Wear OS companion + +The `wear` app is a paired-phone companion with the same application ID and signing identity as the phone app. The watch discovers the phone through Wear OS Data Layer, then uses the phone's existing authenticated operator session. It never receives or stores Gateway tokens, passwords, TLS pins, or device-signing identity. + +The watch supports agent and session selection, bounded text-only transcript history, streaming reply state, text and voice replies, abort, realtime Talk within the selected session, paired-phone Gateway controls, local reply notifications, theme and automatic-speech settings, and a launch Tile. Realtime Talk streams watch microphone and playback audio over a temporary Wear OS Data Layer channel; it still uses the phone's authenticated Gateway session and closes when the selected phone or Gateway connection changes. A missing Data Layer event sequence or changed phone-process epoch triggers a fresh history request instead of applying uncertain deltas. Agent and Gateway controls are capability-negotiated so an older paired phone remains usable during staggered updates. + +```bash +cd apps/android +./gradlew :wear:testDebugUnitTest :wear:assembleDebug :wear:lintDebug :wear:ktlintCheck +``` + +## Build / Run + +```bash +cd apps/android +./gradlew :app:assemblePlayDebug +./gradlew :app:installPlayDebug +./gradlew :app:testPlayDebugUnitTest +cd ../.. +pnpm android:release:archive +``` + +Third-party debug flavor: + +```bash +cd apps/android +./gradlew :app:assembleThirdPartyDebug +./gradlew :app:installThirdPartyDebug +./gradlew :app:testThirdPartyDebugUnitTest +``` + +Repository-backed debug Gradle invocations, including `pnpm android:run` and +`pnpm android:screenshots`, stamp the full checkout commit and capture one UTC +build timestamp shared by every debug variant in that invocation. Release +tasks still require explicit `openclawBuildCommit` and +`openclawBuildTimestamp` properties so signed artifacts remain reproducible. + +Android release archives use the pinned version in `apps/android/version.json`. Update it with: + +```bash +pnpm android:version +pnpm android:version:check +pnpm android:version:pin -- --from-gateway +pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501 +``` + +Release-owner signing sync: + +```bash +pnpm android:release:signing:plan +MATCH_PASSWORD= pnpm android:release:signing:sync:pull +MATCH_PASSWORD= pnpm android:release:signing:check +``` + +The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`. +Standalone release APK verification also requires that key's public certificate SHA-256 fingerprint to match `Config/ReleaseSigning.json`. + +Generate phone and Wear OS Google Play screenshots: + +```bash +pnpm android:screenshots +``` + +The screenshot script captures both form factors with retained +`OpenClaw_Screenshots_API36` (Pixel 2) and +`OpenClaw_Wear_Screenshots_API34` (Wear OS Large Round) AVDs. It creates a +missing AVD, boots it headlessly, waits for Android to finish booting, disables +animations, captures the screenshots, then shuts down the emulator it started. +Install the API 36 Google APIs and API 34 Wear OS system images in the local +Android SDK. Use `--form-factor phone|wear` with `--avd` or `--device` to +explicitly capture one form factor from another emulator. + +`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files: + +- Play build: `openclaw--play-release.aab` +- Wear build: `openclaw--wear-release.aab` +- Third-party build: `openclaw--third-party-release.apk` + +`pnpm android:bundle:release` is an alias for the same Fastlane archive lane. + +Regular final and correction OpenClaw releases publish the signed third-party APK as `OpenClaw-Android.apk` with a checksum manifest and GitHub Actions provenance. `.github/workflows/android-release.yml` is the only automated GitHub Release upload path; `OpenClaw Release Publish` dispatches it while the canonical release is still a draft and blocks publication until the uploaded asset contract verifies. + +The protected `android-release` environment supplies `MATCH_PASSWORD`; the repository's read-only GitHub App token checks out encrypted material from `openclaw/apps-signing`. The workflow builds the exact release tag, refuses to replace different existing bytes, and re-downloads the APK for checksum, certificate, and provenance verification. + +`pnpm android:release:archive` is for local archive validation only. It is not a +fallback upload path after `pnpm android:release:upload` fails. + +Agent-driven Google Play uploads must use `pnpm android:release:upload` as the +only release path. If that command fails, stop and fix the failing screenshot, +metadata, signing, validation, archive, or upload step before trying again. Do +not upload archived artifacts through direct Fastlane lanes, Gradle artifacts, +Google Play API commands, or Play Console mutation commands. + +The release lane uploads the phone and Wear bundles in one atomic Google Play +edit. It publishes the phone bundle to `GOOGLE_PLAY_TRACK` and maps the Wear +bundle to the corresponding form-factor track (`wear:`), so the default +internal channel publishes to `internal` and `wear:internal`. + +See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow. + +Prefer `pnpm android:release:archive`, which stamps and validates the full Git commit and one UTC build timestamp before signing. Flavor-specific direct Gradle release tasks must pass the same metadata explicitly: + +```bash +cd apps/android +commit="$(git -C ../.. rev-parse HEAD)" +built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundlePlayRelease +./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :wear:bundleRelease +./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundleThirdPartyRelease +``` + +## Kotlin Lint + Format + +```bash +pnpm android:lint +pnpm android:format +``` + +Android framework/resource lint (separate pass): + +```bash +pnpm android:lint:android +``` + +Direct Gradle tasks: + +```bash +cd apps/android +./gradlew :app:ktlintCheck :benchmark:ktlintCheck :wear:ktlintCheck :wear-shared:ktlintCheck +./gradlew :app:ktlintFormat :benchmark:ktlintFormat :wear:ktlintFormat :wear-shared:ktlintFormat +./gradlew :app:lintPlayDebug :app:lintThirdPartyDebug :wear:lintDebug :wear-shared:lintDebug +``` + +`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. + +## Macrobenchmark (Startup + Frame Timing) + +```bash +cd apps/android +./gradlew :benchmark:connectedDebugAndroidTest +``` + +Reports are written under: + +- `apps/android/benchmark/build/reports/androidTests/connected/` + +## Perf CLI (low-noise) + +Deterministic startup measurement + hotspot extraction with compact CLI output: + +```bash +cd apps/android +./scripts/perf-startup-benchmark.sh +./scripts/perf-startup-hotspots.sh +``` + +Benchmark script behavior: + +- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations). +- Prints median/min/max/COV in one line. +- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`. +- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline `). + +Hotspot script behavior: + +- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`. +- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView). +- Writes raw `perf.data` path for deeper follow-up if needed. + +## Run on a Real Android Phone (USB) + +1) On phone, enable **Developer options** + **USB debugging**. +2) Connect by USB and accept the debugging trust prompt on phone. +3) Verify ADB can see the device: + +```bash +adb devices -l +``` + +4) Install + launch debug build: + +```bash +pnpm android:install +pnpm android:run +``` + +If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again. + +### USB-only gateway testing (no LAN dependency) + +Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`. + +Terminal A (gateway): + +```bash +pnpm openclaw gateway --port 18789 --verbose +``` + +Terminal B (USB tunnel): + +```bash +adb reverse tcp:18789 tcp:18789 +``` + +Then in app **Connect → Manual**: + +- Host: `127.0.0.1` +- Port: `18789` +- TLS: off + +## Hot Reload / Fast Iteration + +This app is native Kotlin + Jetpack Compose. + +- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement). +- For many non-structural code/resource changes: use Android Studio **Apply Changes**. +- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`). +- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`). + +## Connect / Pair + +1) Start the gateway (on your main machine): + +```bash +pnpm openclaw gateway --port 18789 --verbose +``` + +2) In the Android app: + +- Open the **Connect** tab. +- Use **Setup Code** or **Manual** mode to connect. + +3) Approve pairing (on the gateway machine): + +```bash +openclaw devices list +openclaw devices approve +``` + +More details: `docs/platforms/android.md`. + +## Permissions + +- Discovery: + - Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES` + - Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning) +- Location: + - Both flavors: `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` for foreground checks. + - Third-party flavor only: `ACCESS_BACKGROUND_LOCATION` plus `FOREGROUND_SERVICE_LOCATION` for user-enabled `Always` checks. +- Foreground service notification (Android 13+): `POST_NOTIFICATIONS` +- Camera: + - `CAMERA` for `camera.snap` and `camera.clip` + - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` + +## Google Play Restricted Permissions + +As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app: + +- `READ_SMS` +- `SEND_SMS` +- `READ_CALL_LOG` + +Why these matter: + +- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception. +- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console. +- The Play build removes these behind the `play` flavor. +- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`. + +Current OpenClaw Android implication: + +- APK / sideload build can keep SMS, Call Log, and recent-photo features. +- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception. +- The repo now ships this split as Android product flavors: + - `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, `READ_EXTERNAL_STORAGE`, and background location; hides SMS, Call Log, Photos, and `Always` location surfaces. + - Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`. + - `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality, and offers explicit `Always` location opt-in through Android settings. + +Policy links: + +- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en) +- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241) +- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers) + +Other Play-restricted surfaces to watch if added later: + +- `ACCESS_BACKGROUND_LOCATION` +- `MANAGE_EXTERNAL_STORAGE` +- `QUERY_ALL_PACKAGES` +- `REQUEST_INSTALL_PACKAGES` +- `AccessibilityService` + +Reference links: + +- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150) +- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB) +- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990) + +## Integration Capability Test (Preconditioned) + +This suite assumes setup is already done manually. It does **not** install/run/pair automatically. + +Pre-req checklist: + +1) Gateway is running and reachable from the Android app. +2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected. +3) App stays unlocked and in foreground for the whole run. +4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there). +5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.). +6) No interactive system dialogs should be pending before test start. +7) Canvas host is enabled and reachable from the device for remote Canvas checks (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`). +8) Local operator test client pairing is approved. If first run fails with `pairing required`, preview the latest pending request, approve the printed request ID, then rerun: +9) For A2UI checks, keep the app on **Screen** tab; the node uses its bundled app-owned A2UI page for message application. + +```bash +openclaw devices list +openclaw devices approve --latest # preview only; copy the requestId from output +openclaw devices approve +``` + +Run: + +```bash +pnpm android:test:integration +``` + +Optional overrides: + +- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config) +- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...` +- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...` +- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...` + +What it does: + +- Reads `node.describe` command list from the selected Android node. +- Invokes advertised non-interactive commands. +- Skips `screen.record` and `talk.ptt.*` in this suite because they require + interactive capture. Use `apps/android/scripts/voice-e2e.sh` for microphone + and voice-path proof. +- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`). + +Common failure quick-fixes: + +- `pairing required` before tests start: + - list pending requests (`openclaw devices list`), then approve with the exact ID (`openclaw devices approve `) and rerun. +- `A2UI host not reachable` / `A2UI_HOST_UNAVAILABLE`: + - keep the app foregrounded on the **Screen** tab and rerun. A2UI commands use the bundled app-owned A2UI page; the Gateway Canvas host is still needed for remote Canvas checks, but not for A2UI message application. +- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`: + - app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active. + +## Contributions + +Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Compose.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Compose.txt new file mode 100644 index 0000000..7bb4974 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Compose.txt @@ -0,0 +1,16 @@ +AndroidX Compose +Artifacts: +- androidx.compose.material3:material3:1.4.0 +- androidx.compose.material3.adaptive:adaptive:1.2.0 +- androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0 + +Copyright 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0. +You may obtain a copy of the License at: + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Media3.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Media3.txt new file mode 100644 index 0000000..2a292a6 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Media3.txt @@ -0,0 +1,18 @@ +AndroidX Media3 +Artifacts: +- androidx.media3:media3-datasource-okhttp:1.10.1 +- androidx.media3:media3-exoplayer:1.10.1 +- androidx.media3:media3-ui:1.10.1 + +Copyright 2016 The Android Open Source Project + +Licensed under the Apache License, Version 2.0. +You may obtain a copy of the License at: + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Room.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Room.txt new file mode 100644 index 0000000..d1b89a1 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Room.txt @@ -0,0 +1,182 @@ +AndroidX Room +Artifacts: +- androidx.room:room-common +- androidx.room:room-runtime +License: Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Wear.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Wear.txt new file mode 100644 index 0000000..b6ce82f --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Wear.txt @@ -0,0 +1,19 @@ +AndroidX Wear +Artifacts: +- androidx.wear.compose:compose-foundation:1.6.2 +- androidx.wear.compose:compose-material3:1.6.2 +- androidx.wear:wear-input:1.2.0 +- androidx.wear.tiles:tiles:1.6.1 +- androidx.wear.protolayout:protolayout:1.4.1 +- androidx.wear.protolayout:protolayout-material3:1.4.1 + +Copyright 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0. +You may obtain a copy of the License at: + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/Bouncy Castle Provider.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/Bouncy Castle Provider.txt new file mode 100644 index 0000000..d9c322a --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/Bouncy Castle Provider.txt @@ -0,0 +1,19 @@ +Bouncy Castle Provider +Artifact: org.bouncycastle:bcprov-jdk18on +License: Bouncy Castle Licence + +Bouncy Castle License + +Copyright (c) 2000 - 2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt new file mode 100644 index 0000000..eaa188c --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt @@ -0,0 +1,19 @@ +Coil +Artifacts: +- io.coil-kt.coil3:coil-compose +- io.coil-kt.coil3:coil-svg +License: Apache License 2.0 + +Copyright 2026 Coil Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/CommonMark Java.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/CommonMark Java.txt new file mode 100644 index 0000000..4f74bd9 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/CommonMark Java.txt @@ -0,0 +1,32 @@ +CommonMark Java +Artifacts: +- org.commonmark:commonmark +- org.commonmark:commonmark-ext-autolink +- org.commonmark:commonmark-ext-gfm-strikethrough +- org.commonmark:commonmark-ext-gfm-tables +- org.commonmark:commonmark-ext-task-list-items +License: BSD 2-Clause + +Copyright (c) 2015, Atlassian Pty Ltd +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/KaTeX.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/KaTeX.txt new file mode 100644 index 0000000..3972d22 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/KaTeX.txt @@ -0,0 +1,110 @@ +KaTeX is licensed under the MIT License. KaTeX bundles fonts under the SIL Open Font License. + +The MIT License (MIT) + +Copyright (c) 2013-2020 Khan Academy and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/Kotlin Libraries.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/Kotlin Libraries.txt new file mode 100644 index 0000000..6f543cc --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/Kotlin Libraries.txt @@ -0,0 +1,184 @@ +Kotlin libraries +Artifacts: +- org.jetbrains.kotlin:kotlin-stdlib +- org.jetbrains.kotlinx:kotlinx-coroutines-android +- org.jetbrains.kotlinx:kotlinx-coroutines-core +- org.jetbrains.kotlinx:kotlinx-serialization-json +License: Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/Manrope.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/Manrope.txt new file mode 100644 index 0000000..472064a --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/Manrope.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/OkHttp and Okio.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/OkHttp and Okio.txt new file mode 100644 index 0000000..b2de802 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/OkHttp and Okio.txt @@ -0,0 +1,184 @@ +OkHttp and Okio +Artifacts: +- com.squareup.okhttp3:okhttp +- com.squareup.okhttp3:okhttp-android +- com.squareup.okio:okio +- com.squareup.okio:okio-jvm +License: Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/SLF4J API.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/SLF4J API.txt new file mode 100644 index 0000000..e8bdd97 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/SLF4J API.txt @@ -0,0 +1,25 @@ +SLF4J API +Artifact: org.slf4j:slf4j-api +License: MIT License + +Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland) +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/dnsjava.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/dnsjava.txt new file mode 100644 index 0000000..f8b55a0 --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/dnsjava.txt @@ -0,0 +1,34 @@ +dnsjava +Artifact: dnsjava:dnsjava +License: BSD 3-Clause + +Copyright (c) 1998-2019, Brian Wellington +Copyright (c) 2005 VeriSign. All rights reserved. +Copyright (c) 2019-2023, dnsjava authors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/THIRD_PARTY_LICENSES/openclaw/licenses/nibor autolink.txt b/THIRD_PARTY_LICENSES/openclaw/licenses/nibor autolink.txt new file mode 100644 index 0000000..3f7c9ee --- /dev/null +++ b/THIRD_PARTY_LICENSES/openclaw/licenses/nibor autolink.txt @@ -0,0 +1,23 @@ +nibor autolink +Artifact: org.nibor.autolink:autolink +License: MIT License + +Copyright (c) 2015 Robin Stocker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..84e5444 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,115 @@ +# OpenClaw Android Versioning + +Android release builds use pinned app metadata instead of auto-bumping `build.gradle.kts`. + +## Version model + +- `apps/android/version.json` is the source of truth. +- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`. +- `versionCode` uses `YYYYMMDDNN`, where phone build number `NN` is `01` through `49`. +- The matching Wear APK reserves `51` through `99` by adding `50` to the pinned phone `versionCode`; Play requires a unique code per form factor under the shared application ID. +- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle. +- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source. +- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog. + +Examples: + +- `version = 2026.6.2` +- `versionCode = 2026060201` +- matching Wear `versionCode = 2026060251` +- another upload on the same release train: `versionCode = 2026060202` + +## Commands + +```bash +pnpm android:version +pnpm android:version:check +pnpm android:version:sync +pnpm android:version:pin -- --from-gateway +pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501 +pnpm android:release:signing:plan +MATCH_PASSWORD= pnpm android:release:signing:sync:pull +pnpm android:release:preflight +``` + +## Release-note resolution order + +When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order: + +1. exact pinned version, for example `## 2026.6.2` +2. `## Unreleased` + +Recommended workflow: + +- while iterating on a Google Play release train, keep pending notes under `## Unreleased` +- before the production release, move or copy the final notes under `## ` and run sync again + +## Release Workflow + +1. Pin Android to the intended release version. +2. Run `pnpm android:version:sync`. +3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed. +4. Run `MATCH_PASSWORD= pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`. +5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes. +6. Run `pnpm android:screenshots` to refresh phone and Wear OS Google Play + screenshots with the script-managed Pixel 2 and Wear OS Large Round + emulators. +7. Run `pnpm android:release:archive` to produce the signed phone Play AAB, Wear AAB, and third-party APK. +8. Run `pnpm android:release:upload` to upload metadata, screenshots, the phone AAB, and the Wear AAB to their phone and `wear:` tracks in one atomic Google Play edit. +9. For a regular final or correction OpenClaw release, let `OpenClaw Release Publish` dispatch the protected `Android Release` workflow. It builds the signed third-party APK from the exact tag and attaches the verified APK, checksum manifest, and GitHub provenance before the release draft can publish. Before tagging a correction with its own package version, increment the pinned `versionCode`; the workflow verifies it is higher than the preceding final or correction APK. A same-commit fallback correction reuses the base release's verified APK and adds provenance for the correction tag. +10. Complete production rollout manually in Google Play Console when needed. + +If `pnpm android:release:upload` fails, stop at that failure. Do not continue by +uploading archived artifacts through `pnpm android:release:archive`, +`pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts, +Google Play API mutation commands, or Play Console mutation commands. Fix the +failing release-lane step, then rerun `pnpm android:release:upload`. + +The third-party flavor is archived as a signed APK for non-Play distribution. The Play release lane never uploads it. Official GitHub distribution is owned only by `.github/workflows/android-release.yml`, which publishes regular final and correction tags through the protected `android-release` environment as `OpenClaw-Android.apk`. + +## Release SHA tracking + +Successful Play build uploads create a non-tag Git ref that records the source +commit for the uploaded store build: + +```text +refs/openclaw/mobile-releases/android/- +``` + +Example: + +```text +refs/openclaw/mobile-releases/android/2026.6.10-2026061008 +``` + +These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do +not appear on GitHub release or tag pages, and they do not participate in the +core OpenClaw release machinery. + +`pnpm android:release:upload` checks the ref before uploading the Play build and +records it only after the atomic phone and Wear Play edit commits. Existing refs are +immutable: the same ref at the same SHA is accepted, while the same ref at a +different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does +not record it because no Play build is published. + +Do not create this ref after a manual fallback upload. The ref is release-lane +evidence, not a repair mechanism for a failed `pnpm android:release:upload` run. + +Useful direct commands: + +```bash +pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008 +pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008 +``` + +## Signing model + +`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`. + +`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command. + +If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks. + +Agent-driven releases must not use those lower-level signing and upload surfaces +to bypass a failed `pnpm android:release:upload` attempt. Report the failing +step and wait for maintainer direction instead. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..496dda5 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,478 @@ +import com.android.build.api.variant.impl.VariantOutputImpl +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Properties + +val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider" +val openClawAndroidVersionFile = rootProject.file("Config/Version.properties") +val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES") +val openClawAndroidVersionProperties = + Properties().apply { + if (!openClawAndroidVersionFile.isFile) { + error("Missing Android version properties. Run `pnpm android:version:sync`.") + } + openClawAndroidVersionFile.inputStream().use(::load) + } + +fun requireOpenClawAndroidVersionProperty(name: String): String = + openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() } + ?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.") + +val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME") +val openClawAndroidVersionCode = + requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull() + ?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.") + +fun optionalOpenClawBuildProperty(name: String): String? = + providers + .gradleProperty(name) + .orNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + +val fullGitCommitPattern = Regex("^[a-f0-9]{40}$") +val buildTimestampFormatter = + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC) +val explicitOpenClawBuildCommit = + optionalOpenClawBuildProperty("openclawBuildCommit") + ?.lowercase() + ?.also { commit -> + if (!fullGitCommitPattern.matches(commit)) { + error("openclawBuildCommit must be a full 40-character hexadecimal Git commit.") + } + } + +val explicitOpenClawBuildTimestamp = + optionalOpenClawBuildProperty("openclawBuildTimestamp") + ?.let { timestamp -> + if (!Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$").matches(timestamp)) { + error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") + } + val instant = + runCatching { Instant.parse(timestamp) } + .getOrElse { error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") } + buildTimestampFormatter.format(instant) + } + +val repositoryBuildCommit = + if (explicitOpenClawBuildCommit == null) { + runCatching { + providers + .exec { + workingDir(rootProject.projectDir) + commandLine("git", "rev-parse", "HEAD") + }.standardOutput + .asText + .get() + .trim() + .lowercase() + .takeIf(fullGitCommitPattern::matches) + }.getOrNull() + } else { + null + } + +val openClawBuildCommit = explicitOpenClawBuildCommit ?: repositoryBuildCommit ?: "unknown" +// Keep every variant generated by one Gradle invocation on the same build instant. +val invocationBuildTimestamp = + providers.provider { buildTimestampFormatter.format(Instant.now()) }.get() +val openClawBuildTimestamp = explicitOpenClawBuildTimestamp ?: invocationBuildTimestamp + +val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() } +val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() } +val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() } +val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() } +val resolvedAndroidStoreFile = + androidStoreFile?.let { storeFilePath -> + if (storeFilePath.startsWith("~/")) { + "${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}" + } else { + storeFilePath + } + } + +val hasAndroidReleaseSigning = + listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null } + +val wantsAndroidReleaseBuild = + gradle.startParameter.taskNames.any { taskName -> + taskName.contains("Release", ignoreCase = true) || + Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName) + } +val missingAndroidBuildMetadata = + explicitOpenClawBuildCommit == null || explicitOpenClawBuildTimestamp == null + +if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) { + error( + "Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " + + "OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " + + "OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.", + ) +} + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +android { + namespace = "ai.openclaw.app" + // AndroidX Core 1.19 and Lifecycle 2.11 require API 37 compilation. + // targetSdk stays separate so runtime behavior changes remain an explicit migration. + compileSdk = 37 + + // Release signing is local-only; keep the keystore path and passwords out of the repo. + signingConfigs { + if (hasAndroidReleaseSigning) { + create("release") { + storeFile = project.file(checkNotNull(resolvedAndroidStoreFile)) + storePassword = checkNotNull(androidStorePassword) + keyAlias = checkNotNull(androidKeyAlias) + keyPassword = checkNotNull(androidKeyPassword) + } + } + } + + sourceSets { + getByName("main") { + assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources") + assets.directories.add(thirdPartyLicensesDir.path) + } + } + + defaultConfig { + applicationId = "ai.openclaw.app" + minSdk = 31 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + versionCode = openClawAndroidVersionCode + versionName = openClawAndroidVersionName + buildConfigField("String", "GIT_COMMIT", "\"$openClawBuildCommit\"") + buildConfigField("String", "BUILD_TIMESTAMP", "\"$openClawBuildTimestamp\"") + ndk { + // Support all major ABIs — native libs are tiny (~47 KB per ABI) + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + flavorDimensions += "store" + + productFlavors { + create("play") { + dimension = "store" + manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone" + } + create("thirdParty") { + dimension = "store" + manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone|location" + } + } + + buildTypes { + release { + if (hasAndroidReleaseSigning) { + signingConfig = signingConfigs.getByName("release") + } + isMinifyEnabled = true + isShrinkResources = true + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + debug { + isMinifyEnabled = false + } + } + + bundle { + language { + // The in-app picker can select a locale outside the device language list. + // Without a Play Core download path, every translated resource must stay installed. + enableSplit = false + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + androidResources { + generateLocaleConfig = true + localeFilters += + listOf( + "ar", + "de", + "en", + "es", + "fa", + "fr", + "hi", + "in", + "it", + "ja", + "ko", + "nl", + "pl", + "pt-rBR", + "ru", + "sv", + "th", + "tr", + "uk", + "vi", + "zh-rCN", + "zh-rTW", + ) + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += + setOf( + "/META-INF/{AL2.0,LGPL2.1}", + "/META-INF/*.version", + "/META-INF/LICENSE*.txt", + "DebugProbesKt.bin", + "kotlin-tooling-metadata.json", + "org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties", + "org/bouncycastle/x509/CertPathReviewerMessages*.properties", + ) + } + } + + lint { + lintConfig = file("lint.xml") + warningsAsErrors = true + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +androidComponents { + onVariants { variant -> + variant.outputs + .filterIsInstance() + .forEach { output -> + val versionName = output.versionName.orNull ?: "0" + val buildType = variant.buildType + val flavorName = variant.flavorName?.takeIf { it.isNotBlank() } + val outputFileName = + if (flavorName == null) { + "openclaw-$versionName-$buildType.apk" + } else { + "openclaw-$versionName-$flavorName-$buildType.apk" + } + output.outputFileName = outputFileName + } + } +} +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +ktlint { + android.set(true) + ignoreFailures.set(false) + filter { + exclude("**/build/**") + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(project(":wear-shared")) + implementation(libs.play.services.wearable) + + implementation(libs.androidx.core.ktx) + // AppCompat owns per-app locale persistence and Activity recreation on API 31-32. + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.webkit) + + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive.navigation.suite) + // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. + // R8 will tree-shake unused icons when minify is enabled on release builds. + implementation(libs.androidx.compose.material.icons.extended) + + debugImplementation(libs.androidx.compose.ui.tooling) + + // Material Components (XML theme + resources) + implementation(libs.material) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.security.crypto) + // Room owns separate disposable gateway cache and durable client-state databases. + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.exifinterface) + implementation(libs.okhttp) + implementation(libs.media3.datasource.okhttp) + implementation(libs.media3.exoplayer) + implementation(libs.media3.session) + implementation(libs.media3.ui) + implementation(libs.bcprov) + implementation(libs.coil.compose) + implementation(libs.coil.svg) + implementation(libs.commonmark) + implementation(libs.commonmark.ext.autolink) + implementation(libs.commonmark.ext.gfm.strikethrough) + implementation(libs.commonmark.ext.gfm.tables) + implementation(libs.commonmark.ext.task.list.items) + + // CameraX (for node.invoke camera.* parity) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.video) + implementation(libs.barcode.scanning) + + // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. + implementation(libs.dnsjava) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.mockwebserver) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + testRuntimeOnly(libs.junit.vintage.engine) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.uiautomator) +} + +tasks.withType().configureEach { + useJUnitPlatform() + testLogging { + events("failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + +val validateOpenClawReleaseBuildMetadata = + tasks.register("validateOpenClawReleaseBuildMetadata") { + doLast { + if (missingAndroidBuildMetadata) { + error( + "Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " + + "Use the repository Android release helper.", + ) + } + } + } + +val validateThirdPartyLicenseAssets = + tasks.register("validateThirdPartyLicenseAssets") { + inputs.dir(thirdPartyLicensesDir) + doLast { + if (!thirdPartyLicensesDir.isDirectory) { + error("Missing Android third-party license directory: ${thirdPartyLicensesDir.relativeTo(rootProject.projectDir)}") + } + val invalidFiles = + thirdPartyLicensesDir + .walkTopDown() + .filter { file -> file.isFile && file.extension.lowercase() != "txt" } + .map { file -> file.relativeTo(thirdPartyLicensesDir).path } + .toList() + + if (invalidFiles.isNotEmpty()) { + error( + "Android third-party license assets must be .txt files:\n" + + invalidFiles.joinToString(separator = "\n") { path -> "- $path" }, + ) + } + } + } + +tasks.matching { task -> task.name == "preBuild" }.configureEach { + dependsOn(validateThirdPartyLicenseAssets) +} + +androidComponents { + onVariants(selector().withBuildType("release")) { variant -> + val variantName = variant.name + val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase) + val preBuildTaskName = "pre${variantNameCapitalized}Build" + val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor" + val mergeTaskName = "merge${variantNameCapitalized}JavaResource" + val minifyTaskName = "minify${variantNameCapitalized}WithR8" + val mergedJar = + layout.buildDirectory.file( + "intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar", + ) + + tasks.matching { task -> task.name == preBuildTaskName }.configureEach { + dependsOn(validateOpenClawReleaseBuildMetadata) + } + + val stripTask = + tasks.register(stripTaskName) { + inputs.file(mergedJar) + outputs.file(mergedJar) + + doLast { + val jarFile = mergedJar.get().asFile + if (!jarFile.exists()) { + return@doLast + } + + val unpackDir = temporaryDir.resolve("merged-java-res") + delete(unpackDir) + copy { + from(zipTree(jarFile)) + into(unpackDir) + exclude(dnsjavaInetAddressResolverService) + } + delete(jarFile) + ant.invokeMethod( + "zip", + mapOf( + "destfile" to jarFile.absolutePath, + "basedir" to unpackDir.absolutePath, + ), + ) + } + } + + tasks.matching { it.name == mergeTaskName }.configureEach { + finalizedBy(stripTask) + } + tasks.matching { it.name == minifyTaskName }.configureEach { + dependsOn(stripTask) + } + } +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..e0fee5d --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..7c04b96 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,8 @@ +-dontwarn org.bouncycastle.** +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn com.sun.jna.** +-dontwarn javax.naming.** +-dontwarn lombok.Generated +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn sun.net.spi.nameservice.NameServiceDescriptor diff --git a/app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json b/app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json new file mode 100644 index 0000000..69d18b1 --- /dev/null +++ b/app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json @@ -0,0 +1,273 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "924cec9afdb455dced2592399a08f5da", + "entities": [ + { + "tableName": "outbox_commands", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, `gatedEpoch` INTEGER, `ownerAgentId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thinkingLevel", + "columnName": "thinkingLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryCount", + "columnName": "retryCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "gatedEpoch", + "columnName": "gatedEpoch", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerAgentId", + "columnName": "ownerAgentId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "outbox_attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, `position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, `durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "byteLength", + "columnName": "byteLength", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_outbox_attachments_commandId", + "unique": false, + "columnNames": [ + "commandId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `${TABLE_NAME}` (`commandId`)" + } + ] + }, + { + "tableName": "outbox_attachment_chunks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attachmentId` TEXT NOT NULL, `chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))", + "fields": [ + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkIndex", + "columnName": "chunkIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "attachmentId", + "chunkIndex" + ] + } + }, + { + "tableName": "composer_send_admissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerAgentId", + "columnName": "ownerAgentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "client_state_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "gateway_removals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '924cec9afdb455dced2592399a08f5da')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json b/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json new file mode 100644 index 0000000..68bad9d --- /dev/null +++ b/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json @@ -0,0 +1,146 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "b40806fd03e11cc1094f31253e9432fb", + "entities": [ + { + "tableName": "cached_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey" + ] + } + }, + { + "tableName": "cached_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textPartsJson", + "columnName": "textPartsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampMs", + "columnName": "timestampMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey", + "rowOrder" + ] + } + }, + { + "tableName": "cached_gateway_owners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b40806fd03e11cc1094f31253e9432fb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json b/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json new file mode 100644 index 0000000..074e0e3 --- /dev/null +++ b/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json @@ -0,0 +1,177 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "6faa4088ccfb9dd4dd8bc3e6619de163", + "entities": [ + { + "tableName": "cached_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `status` TEXT, `startedAt` INTEGER, `endedAt` INTEGER, `runtimeMs` INTEGER, `outputTokens` INTEGER, `hasRunMetadata` INTEGER NOT NULL, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "runtimeMs", + "columnName": "runtimeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasRunMetadata", + "columnName": "hasRunMetadata", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey" + ] + } + }, + { + "tableName": "cached_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textPartsJson", + "columnName": "textPartsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampMs", + "columnName": "timestampMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey", + "rowOrder" + ] + } + }, + { + "tableName": "cached_gateway_owners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6faa4088ccfb9dd4dd8bc3e6619de163')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/ai/openclaw/app/ui/CanvasHostLifecycleTest.kt b/app/src/androidTest/java/ai/openclaw/app/ui/CanvasHostLifecycleTest.kt new file mode 100644 index 0000000..8833c63 --- /dev/null +++ b/app/src/androidTest/java/ai/openclaw/app/ui/CanvasHostLifecycleTest.kt @@ -0,0 +1,274 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.node.CanvasController +import ai.openclaw.app.ui.chat.ChatWidgetExportDestination +import ai.openclaw.app.ui.chat.exportChatWidgetImage +import ai.openclaw.app.ui.chat.widgetExportFileName +import android.content.ClipboardManager +import android.content.ContentUris +import android.content.pm.ActivityInfo +import android.graphics.BitmapFactory +import android.os.SystemClock +import android.provider.MediaStore +import android.view.View +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class CanvasHostLifecycleTest { + @get:Rule + val activityRule = ActivityScenarioRule(CanvasLifecycleTestActivity::class.java) + + @Before + fun resetMetrics() { + CanvasLifecycleTestMetrics.reset() + } + + @Test + fun hiddenHostRetainsOneWebViewWithoutBlockingShellInput() { + activityRule.scenario.onActivity { activity -> + assertEquals(CanvasController.PresentationState.Unmounted, activity.controller.presentationState.value) + assertNull(activity.host) + } + + val presentElapsedMs = activityRule.scenario.readActivity { activity -> activity.presentSlowPage() } + + assertTrue( + "present waited for the remote page: ${presentElapsedMs}ms", + presentElapsedMs < canvasLifecycleSlowPageDelayMs / 2, + ) + assertTrue("slow page never finished", activityRule.scenario.waitForPageFinished()) + + val firstWebView = + activityRule.scenario.readActivity { activity -> + val host = checkNotNull(activity.host) + assertEquals(1, host.childCount) + assertEquals(CanvasController.PresentationState.Visible, activity.controller.presentationState.value) + val webView = checkNotNull(activity.currentWebView()) + activity.hideCanvas() + assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value) + webView + } + assertTrue( + "hidden host remained visible", + activityRule.scenario.waitUntilActivity { activity -> activity.host?.visibility == View.INVISIBLE }, + ) + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + assertTrue(device.click(device.displayWidth / 2, device.displayHeight / 2)) + device.waitForIdle() + + activityRule.scenario.onActivity { activity -> + assertEquals(1, activity.underlayClickCount) + repeat(3) { + activity.presentFastPage() + activity.hideCanvas() + } + assertEquals(1, activity.host?.childCount) + assertTrue(firstWebView === activity.currentWebView()) + } + } + + @Test + fun stalePageCompletionCannotReshowCanvasAfterHide() { + activityRule.scenario.onActivity { activity -> activity.presentSlowPage() } + assertTrue( + "Canvas host was not attached", + activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() != null }, + ) + activityRule.scenario.onActivity { activity -> activity.hideCanvas() } + assertTrue( + "hidden host remained visible", + activityRule.scenario.waitUntilActivity { activity -> activity.host?.visibility == View.INVISIBLE }, + ) + + assertTrue("slow page never finished", activityRule.scenario.waitForPageFinished()) + activityRule.scenario.onActivity { activity -> + assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value) + assertEquals(View.INVISIBLE, checkNotNull(activity.host).visibility) + assertNotNull(activity.currentWebView()) + } + } + + @Test + fun rendererTerminationForgetsFailedPageAndNextShowRecreatesIt() { + activityRule.scenario.onActivity { activity -> activity.presentFastPage() } + assertTrue("initial page never finished", activityRule.scenario.waitForPageFinished()) + + val firstWebView = + activityRule.scenario.readActivity { activity -> + assertNotNull(activity.controller.currentUrl()) + checkNotNull(activity.currentWebView()) + } + val terminated = + activityRule.scenario.readActivity { activity -> + activity.currentWebView()?.webViewRenderProcess?.terminate() == true + } + assertTrue("WebView renderer did not terminate", terminated) + assertTrue( + "renderer loss did not clear the invalid WebView", + activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() == null }, + ) + + activityRule.scenario.onActivity { activity -> + assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value) + assertNull(activity.controller.currentUrl()) + assertEquals(0, activity.host?.childCount) + activity.showCanvas() + } + assertTrue( + "next show did not create a replacement WebView", + activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() != null }, + ) + assertTrue("replacement scaffold never finished", activityRule.scenario.waitForPageFinished()) + activityRule.scenario.onActivity { activity -> + assertEquals(CanvasController.PresentationState.Visible, activity.controller.presentationState.value) + assertEquals(1, activity.host?.childCount) + assertNotEquals(firstWebView, activity.currentWebView()) + } + } + + @Test + fun configurationChangesKeepTheSameHostAndWebView() { + activityRule.scenario.onActivity { activity -> activity.presentFastPage() } + assertTrue("initial page never finished", activityRule.scenario.waitForPageFinished()) + val firstHost = activityRule.scenario.readActivity { activity -> checkNotNull(activity.host) } + val firstWebView = activityRule.scenario.readActivity { activity -> checkNotNull(activity.currentWebView()) } + + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).waitForIdle() + + activityRule.scenario.onActivity { activity -> + assertTrue(firstHost === activity.host) + assertTrue(firstWebView === activity.currentWebView()) + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } + + @Test + fun renderedWebViewExportsPngToClipboardAndDownloads() { + activityRule.scenario.onActivity { activity -> activity.presentFastPage() } + assertTrue("widget proof page never finished", activityRule.scenario.waitForPageFinished()) + val activity = activityRule.scenario.readActivity { it } + val webView = activityRule.scenario.readActivity { checkNotNull(it.currentWebView()) } + val title = "Widget export proof ${UUID.randomUUID()}" + val fileName = widgetExportFileName(title) + val clipboard = activity.getSystemService(ClipboardManager::class.java) + var downloadsUri: android.net.Uri? = null + + try { + runBlocking { + exportChatWidgetImage(activity, webView, title, ChatWidgetExportDestination.Clipboard) + } + val clipboardUri = checkNotNull(clipboard.primaryClip?.getItemAt(0)?.uri) + assertEquals("image/png", activity.contentResolver.getType(clipboardUri)) + activity.contentResolver.openInputStream(clipboardUri).use { input -> + val bitmap = checkNotNull(BitmapFactory.decodeStream(input)) + assertTrue(bitmap.width > 0) + assertTrue(bitmap.height > 0) + bitmap.recycle() + } + + runBlocking { + exportChatWidgetImage(activity, webView, title, ChatWidgetExportDestination.Downloads) + } + downloadsUri = + activity.contentResolver + .query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(fileName), + null, + ).use { cursor -> + checkNotNull(cursor) + assertTrue(cursor.moveToFirst()) + ContentUris.withAppendedId( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)), + ) + } + assertEquals("image/png", activity.contentResolver.getType(downloadsUri)) + activity.contentResolver.openInputStream(downloadsUri).use { input -> + val bitmap = checkNotNull(BitmapFactory.decodeStream(input)) + assertTrue(bitmap.width > 0) + assertTrue(bitmap.height > 0) + bitmap.recycle() + } + } finally { + downloadsUri?.let { activity.contentResolver.delete(it, null, null) } + clipboard.clearPrimaryClip() + File(activity.cacheDir, "exports") + .walkTopDown() + .firstOrNull { it.isFile && it.name == fileName } + ?.parentFile + ?.deleteRecursively() + } + } +} + +@RunWith(AndroidJUnit4::class) +class CanvasHostReleaseTest { + @Before + fun resetMetrics() { + CanvasLifecycleTestMetrics.reset() + } + + @Test + fun activityTeardownReleasesTheHostAndWebView() { + ActivityScenario.launch(CanvasLifecycleTestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> activity.presentFastPage() } + } + + assertTrue( + "AndroidView onRelease was not called", + waitUntil { CanvasLifecycleTestMetrics.hostReleaseCount.get() == 1 }, + ) + assertEquals(1, CanvasLifecycleTestMetrics.webViewDestroyCount.get()) + } +} + +private inline fun ActivityScenario.readActivity(crossinline block: (CanvasLifecycleTestActivity) -> T): T { + var result: Result? = null + onActivity { activity -> result = runCatching { block(activity) } } + return checkNotNull(result).getOrThrow() +} + +private fun ActivityScenario.waitForPageFinished(): Boolean = waitUntilActivity { activity -> activity.currentWebView()?.progress == 100 } + +private inline fun ActivityScenario.waitUntilActivity( + timeoutMs: Long = 5_000L, + crossinline predicate: (CanvasLifecycleTestActivity) -> Boolean, +): Boolean = + waitUntil(timeoutMs) { + readActivity(predicate) + } + +private fun waitUntil( + timeoutMs: Long = 5_000L, + predicate: () -> Boolean, +): Boolean { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (SystemClock.elapsedRealtime() < deadline) { + if (predicate()) return true + SystemClock.sleep(20) + } + return predicate() +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..e9ad827 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt b/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt new file mode 100644 index 0000000..eeeb3ff --- /dev/null +++ b/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt @@ -0,0 +1,242 @@ +package ai.openclaw.app + +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import java.io.File + +private const val tag = "VoiceE2E" +private const val resultFileName = "voice_e2e_result.json" + +class VoiceE2eReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + context.startService( + Intent(context, VoiceE2eService::class.java) + .putExtras(intent), + ) + } +} + +class VoiceE2eService : Service() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + val command = intent ?: return START_NOT_STICKY + serviceScope.launch { + try { + runCommand(command) + } finally { + stopSelf(startId) + } + } + return START_NOT_STICKY + } + + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } + + private suspend fun runCommand(intent: Intent) { + try { + val app = applicationContext as NodeApp + val runtime = app.ensureRuntime() + val mode = + intent + .getDecodedStringExtra("mode") + ?.trim() + .orEmpty() + .ifEmpty { "both" } + if (mode == "stop") { + runtime.cancelMicCapture() + runtime.setTalkModeEnabled(false) + writeResult("""{"ok":true,"mode":"stop"}""") + return + } + + val connect = !intent.getBooleanExtra("noConnect", false) + val connectTimeoutMs = intent.getLongExtra("connectTimeoutMs", 20_000L) + if (connect) { + configureGateway(runtime = runtime, intent = intent) + } + if (connect || !runtime.isConnected.value) { + awaitGateway(runtime = runtime, timeoutMs = connectTimeoutMs) + } + + startActivity( + Intent(actionOpenVoiceE2e) + .setClass(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), + ) + + if (mode == "connect") { + val resultJson = """{"ok":true,"mode":"connect","connected":true}""" + writeResult(resultJson) + Log.i(tag, "PASS $resultJson") + return + } + + val transcript = + intent + .getDecodedStringExtra("transcript") + ?.trim() + .orEmpty() + .ifEmpty { "Reply exactly: Android voice e2e normal path ok." } + val realtimeReply = + intent + .getDecodedStringExtra("realtimeAssistant") + ?.trim() + .orEmpty() + .ifEmpty { "Android realtime voice e2e relay path ok." } + val timeoutMs = intent.getLongExtra("timeoutMs", 60_000L) + val result = + runtime.runVoiceE2e( + mode = mode, + transcript = transcript, + realtimeAssistantText = realtimeReply, + timeoutMs = timeoutMs, + ) + val resultJson = encodeResult(result) + writeResult(resultJson) + Log.i(tag, "PASS $resultJson") + } catch (err: Throwable) { + val resultJson = + buildJsonObject { + put("ok", JsonPrimitive(false)) + put("error", JsonPrimitive(err.message ?: err::class.java.simpleName)) + }.toString() + writeResult(resultJson) + Log.e(tag, "FAIL $resultJson", err) + } + } + + private fun configureGateway( + runtime: NodeRuntime, + intent: Intent, + ) { + val host = + intent + .getDecodedStringExtra("host") + ?.trim() + .orEmpty() + .ifEmpty { "127.0.0.1" } + val port = intent.getIntExtra("port", 18789) + runtime.setManualEnabled(true) + runtime.setManualHost(host) + runtime.setManualPort(port) + runtime.setManualTls(intent.getBooleanExtra("tls", false)) + runtime.setOnboardingCompleted(true) + runtime.connect( + ai.openclaw.app.gateway.GatewayEndpoint + .manual(host, port), + NodeRuntime.GatewayConnectAuth( + token = intent.getDecodedStringExtra("token"), + bootstrapToken = intent.getDecodedStringExtra("bootstrapToken"), + password = intent.getDecodedStringExtra("password"), + ), + ) + } + + private suspend fun awaitGateway( + runtime: NodeRuntime, + timeoutMs: Long, + ) { + try { + withTimeout(timeoutMs) { + while (!runtime.isConnected.value) { + voiceE2eTerminalGatewayFailure(runtime.gatewayConnectionProblem.value)?.let { error(it) } + delay(100L) + } + } + } catch (err: TimeoutCancellationException) { + throw IllegalStateException( + voiceE2eGatewayTimeoutMessage( + timeoutMs = timeoutMs, + statusText = runtime.statusText.value, + problem = runtime.gatewayConnectionProblem.value, + ), + err, + ) + } + } + + private fun encodeResult(result: NodeRuntime.VoiceE2eResult): String = + buildJsonObject { + put("ok", JsonPrimitive(true)) + put("normal", result.normal?.let(::encodeSlice) ?: JsonNull) + put("realtime", result.realtime?.let(::encodeSlice) ?: JsonNull) + }.toString() + + private fun encodeSlice(slice: NodeRuntime.VoiceE2eSliceResult) = + buildJsonObject { + put("mode", JsonPrimitive(slice.mode)) + put("status", JsonPrimitive(slice.status)) + put("userText", slice.userText?.let(::JsonPrimitive) ?: JsonNull) + put("assistantText", slice.assistantText?.let(::JsonPrimitive) ?: JsonNull) + } + + private fun writeResult(json: String) { + File(cacheDir, resultFileName).writeText(json) + } +} + +private fun Intent.getDecodedStringExtra(name: String): String? { + val encoded = getStringExtra("${name}Base64") + if (!encoded.isNullOrBlank()) { + return String(Base64.decode(encoded, Base64.NO_WRAP), Charsets.UTF_8) + } + return getStringExtra(name) +} + +internal fun voiceE2eTerminalGatewayFailure(problem: GatewayConnectionProblem?): String? = + problem + ?.takeIf { it.pauseReconnect && !it.canAutoRetry } + ?.message + ?.trim() + ?.takeIf { it.isNotEmpty() } + +internal fun voiceE2eGatewayTimeoutMessage( + timeoutMs: Long, + statusText: String, + problem: GatewayConnectionProblem?, +): String { + val detail = + problem + ?.message + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: statusText.trim().takeIf { it.isNotEmpty() } + return buildString { + append("Gateway connection timed out after ") + append(timeoutMs) + append(" ms") + if (detail != null) { + append(": ") + append(detail) + } + } +} diff --git a/app/src/debug/java/ai/openclaw/app/ui/CanvasLifecycleTestActivity.kt b/app/src/debug/java/ai/openclaw/app/ui/CanvasLifecycleTestActivity.kt new file mode 100644 index 0000000..4dfa386 --- /dev/null +++ b/app/src/debug/java/ai/openclaw/app/ui/CanvasLifecycleTestActivity.kt @@ -0,0 +1,129 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.node.CanvasController +import android.os.Bundle +import android.os.SystemClock +import android.util.Base64 +import android.webkit.WebView +import android.widget.Button +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import java.util.concurrent.atomic.AtomicInteger + +const val canvasLifecycleSlowPageDelayMs = 2_000L + +class CanvasLifecycleTestActivity : ComponentActivity() { + val controller = CanvasController() + + internal var host: CanvasHostView? = null + internal set + + var underlayClickCount: Int = 0 + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + CanvasLifecycleTestContent( + activity = this, + onUnderlayClick = { underlayClickCount += 1 }, + ) + } + } + + fun presentSlowPage(): Long = presentHtml(slowPageHtml) + + fun presentFastPage(): Long = presentHtml("ready") + + fun hideCanvas() { + controller.hide() + } + + fun showCanvas() { + controller.show() + } + + fun currentWebView(): WebView? = host?.currentWebView + + private fun presentHtml(html: String): Long { + val startedAt = SystemClock.elapsedRealtime() + val encoded = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + controller.navigate("data:text/html;base64,$encoded") + controller.show() + return SystemClock.elapsedRealtime() - startedAt + } +} + +@Composable +private fun CanvasLifecycleTestContent( + activity: CanvasLifecycleTestActivity, + onUnderlayClick: () -> Unit, +) { + val state by activity.controller.presentationState.collectAsState() + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + factory = { context -> + Button(context).apply { + setOnClickListener { onUnderlayClick() } + } + }, + modifier = Modifier.fillMaxSize(), + ) + if (state != CanvasController.PresentationState.Unmounted) { + AndroidView( + factory = { context -> + CanvasHostView( + context = context, + controller = activity.controller, + isTrustedPage = { false }, + onA2uiMessage = {}, + ).also { host -> + activity.host = host + host.updateVisible(state == CanvasController.PresentationState.Visible) + } + }, + update = { host -> + host.updateVisible(state == CanvasController.PresentationState.Visible) + }, + modifier = Modifier.fillMaxSize(), + onRelease = { host -> + val hadWebView = host.currentWebView != null + host.release() + if (activity.host === host) activity.host = null + CanvasLifecycleTestMetrics.hostReleaseCount.incrementAndGet() + if (hadWebView) CanvasLifecycleTestMetrics.webViewDestroyCount.incrementAndGet() + }, + ) + } + } +} + +object CanvasLifecycleTestMetrics { + val hostReleaseCount = AtomicInteger() + val webViewDestroyCount = AtomicInteger() + + fun reset() { + hostReleaseCount.set(0) + webViewDestroyCount.set(0) + } +} + +private val slowPageHtml = + """ + + + + ready + + + """.trimIndent() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2b6c953 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/katex/README.md b/app/src/main/assets/katex/README.md new file mode 100644 index 0000000..aff4d4d --- /dev/null +++ b/app/src/main/assets/katex/README.md @@ -0,0 +1,37 @@ +# KaTeX Android assets + +- Version: KaTeX 0.17.0 (`v0.17.0`, released 2026-05-22) +- Source: https://github.com/KaTeX/KaTeX/releases/tag/v0.17.0 +- Release archive: https://github.com/KaTeX/KaTeX/releases/download/v0.17.0/katex.tar.gz +- Published and verified archive SHA-256: `f77cf2555a40e879a4784e43e229d41867f106125bc05b15161de3e761b64b88` + +Only the minified runtime, its stylesheet, and the WOFF2 fonts referenced by that stylesheet are copied from the release archive. `index.html` and `renderer.js` are OpenClaw's local rendering shell. No auto-render extension, mhchem extension, source map, WOFF, or TTF file is bundled. + +## SHA-256 + +```text +0cdd387c9590a1a9f9794560022dbb59654a7d86f187aa0c81495ad42d3a7308 fonts/KaTeX_AMS-Regular.woff2 +de7701e42cf1f4cf0b766c03fb27977207eee2f4fd5d76fa82188406da43ea4c fonts/KaTeX_Caligraphic-Bold.woff2 +5d53e70ad607c2352162dec9e0923fb54ecdafaccbf604cd8dcf7d00facb989b fonts/KaTeX_Caligraphic-Regular.woff2 +74444efd593c005e3f4573b44524704c0af0a937fe911cca9e94068d0d140d3f fonts/KaTeX_Fraktur-Bold.woff2 +51814d270d06ff0255dba0799994fa4d8c84d11f09951d47595f4abb1f3602dc fonts/KaTeX_Fraktur-Regular.woff2 +0f60d1b897938ec918c8ce073092411baf9438f6739465693ff18b0f9d20b021 fonts/KaTeX_Main-Bold.woff2 +99cd42a3c072d918f2f44984a807cf7aa16e13545fd0875fc07c6c65f99e715b fonts/KaTeX_Main-BoldItalic.woff2 +97479ca6cce906abc961ecac96faa5f9ca2e61b8e7670d475826bcdee9a7c267 fonts/KaTeX_Main-Italic.woff2 +c2342cd8b869e01752a9321dc17213fc40d4d04c79688c1d43f2cf316abd7866 fonts/KaTeX_Main-Regular.woff2 +dc47344dbb6cb5b655c8460d561f4df5f501b90c804ad3c6cec65fe322351ab1 fonts/KaTeX_Math-BoldItalic.woff2 +7af58c5ec8f132a2ddde9027c6d7814decce4d3b822a11192a42a20e2e973264 fonts/KaTeX_Math-Italic.woff2 +e99ae51144bf1232efcc1bfe5add36262c6866b0faab24fa75740e1b98577a62 fonts/KaTeX_SansSerif-Bold.woff2 +00b26ac825e2095056396e0553b8ac26d3f8ad158c3826e28b4c45b385c4714a fonts/KaTeX_SansSerif-Italic.woff2 +68e8c73ef42afd3ccec58bf0fba302cce448938e7fc020a5e31f8a952eee1342 fonts/KaTeX_SansSerif-Regular.woff2 +036d4e95149b69ff9bcc0cd55771efeb25ffa3947293e69acd78d5ac328c684b fonts/KaTeX_Script-Regular.woff2 +6b47c40166b6dbe21a5dfca7718413f2147fd2399be1ba605d8ad39cedf25dfe fonts/KaTeX_Size1-Regular.woff2 +d04c54219f9eaec6d4d4fd42dfb28785975a4794d6b2fc71e566b9cd6db842dd fonts/KaTeX_Size2-Regular.woff2 +73d591271b1604960cb10bb90fee021670af7297017e0e98480b332d11f51995 fonts/KaTeX_Size3-Regular.woff2 +a4af7d414440a1c1790825cfb700cf9cf43b0f2c4b04f0ebc523011ad9853ec0 fonts/KaTeX_Size4-Regular.woff2 +71d517d67827787cfabdf186914cc3358eda539e37931941f2b2fd4a21f68c0b fonts/KaTeX_Typewriter-Regular.woff2 +e73bea6d899a3f89595d37f5713cb5ddabc4084fc428a5931782628ee14799e2 index.html +a34ad8fc188e8f5a3af7ceaa2a58d7210c6c9171335a15bff2b48ebcd6a6f5b0 katex.min.css +45fbe318fea878fdc0a111913dc1f87894b2c439360d0228c086ef313f213efc katex.min.js +3cefe6fba565e189311b29265eb842484a637e1a80b0b5e766ab05bfc9ad452f renderer.js +``` diff --git a/app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0acaaff03d4bb7606de02a827aeee338e5a86910 GIT binary patch literal 28076 zcmV)4K+3;&Pew8T0RR910Bx)Q4gdfE0Qryr0ButM0RR9100000000000000000000 z00006U;u_x2rvnp3=s$lgQIMM!gK*P0we>6dJBXK00bZfh;RpzAq;^h8yChW*tQI) zf474tf9UWmvjer;At_qJJ4ObAjRSzte{IG8|DTss#?U6Pq$r5$-28t~$dN6wErwJo za~1SqW}?_^GLyD_B})qv!-NCu+2=w|xZXP?WH@?W-qc{t=*Dc@7G{&*Rr|f2PJS1C zhC(0s6eQ>iMjQ6NMr%a(8W(NUg-6j?jOV&o6a!>CRL6BUiA-uV3!83tjRD8w9Q zTS)(|WV)+(idwaDgvnbaZjk7gd`Q54BYKt#$^sjr>VY-r-3%|Gm46yDaW9 zA*>`MVXTA%2t!Ch7$IRKA?zg}h>8dZvc$1L!HHv{b?xdd&bo@Vt*u>ZTiaS|hyA~G z{@0vZsQ;#>ocmS+q4P+Q6bJ==`li~vx<@m2JRmS77FvoOGC`1MckSwYimL)UDdBE= zU(y{*T007`?KlPI+1(^67zzMC`>m=oco?9F7&)oE+s{ZQpTPk8{JE5yXE%chKZB_X8HRih-qey z+?Q-qv53jN4{v&CO1eskfOCJa3iT;f#6SE4=USD}rard`&95=?zssa(BF1FNtXLQ1 zZ~TM@OYAGf@a}&8C9fbbx97ge(q^cIwlr8&Knje!sSE&n4+)%A=~R~^uDx$0UY7!KfcrV?PMq?9a+|xdk4sNTo`xT10ZSpv)=wBog^+? zNVtS)ZhL_W7i(KX_NCm#VEfLsy7t$Ty`QJ}p`|<%v{So>8SwJ~C zVK#U35`M*$l6LT#61}{p@LooR$I7G?Dbu5I6a`IQ*PrM2%Vs~gE%8~3WQvFrG9l=GIBt*Od}N}61FZQE zW6Mf!kslWpsbCTqTnlB6*K#9)4p5JHZFH&`%3(OTE6|h<2UbL>qb*@ zdi((~nNq)2{fN5qp6w(l(`U|}JCzK7tnN9WM5dL+$_%{~I)_r%rEhNQi6GO2QuU|q zeCl;wSf6R{mi}5F*{a2Ew{h$Ct$E8+)>QbX{}q~VpXSif8urVbHvX((@}GE29{i8L zdCj)1>qpnEU9o)e&|rUG`^nIk^FgQGs+6Mq7+)?5!iR%5FP^Z$K>>>T{oB_sI_aRj z=9+1$iKKyw1w6$4+{2v=0HnltxENCns)G`v`tJa?H5C^c{juAGRGbNd1U~z~&9i35 zPX9k@-dqCC`5V$MzXfWS>31JT$j&<=o~|&#q+%#X&U=D9f&}Tb07^pC z8A4D}Ml(bpUi=JEpgBQj?p@Q0JR(Ld$V{b0(M=-!GzM9T2&>ePayD*}t}aHUw0`1U zqAh3k`sNdyBBCu%ryXEL5@d#BYlYf%ScoEm1_cZV79k;{9@e1&FV>h?{?_{GD7(Wh zY1_fC_`40h2NZQV*O+^9i~e{hP2`(RmzukYLXF#SsKVb3koS} zGo%7tkm9K+i*(iji%E%L;JlwSijC1)9V3dU&^wAc&}hpw0=5-5{wk5$_LeV+$da!^ z8b#IXq~ya8YnKKV#JowMzYH67;%Gnw>#XGHksliuD1 z4sf2#;qa0o2PoYrWJNAO?TE>sT z(}xekn~&2z=l3sY6JDxL>F`|BeZ8tw6Rv1#*+3OHNX< z6Jb%r3)h9~LdqRcRT&Wfvm>kue;~LdmM3h6LKGkfF^IU8yo`jrf;@Q@`SKnV$Px-= z8AY;!Vp&Crj0UxsKu8w4l2+b)3W8a}=W_;cvxDj&lQ4Yr2Pb9t{F(&UxJI&j!s=|A z<1R_0NRVOpV8}5P7)lIZ3_lEii~y|Wp%7rZ-=ff1q-#NSB&_OKTwxOwuB*af#BQ|f zM??*vkDP{**5&fvK8-pFP?$Oi3#V_p?0Qk%E>xZEhIvbsX2u8>zi?VTqAUP95iv1Z-#B z=N-iKV>YNunx63yVCj{mUVk1=D0bUi8Rgqcrq|mFgUCL9zVxEZ%afMIYo2;A`#8NO_<8}^*$kwG$g0S*nh%*GK&lT^8}ewM5-i*4~PGo@f> zQ|k56T$}Ui2}bS8DNA0<8BIMu8^0zw&=xd4=Co{hrlVawYC0<=E|wNC)NWt_+csNN zIy2>Yd&9>MT)nU{K-+%zI01}~!&aNXn8=b73hfeR-9NCa#96A=SYpGWNUbctpU67Y z7J#K8lOvdw^(gTq6h@CLI^DB(i+(9XVsJIP3jUo<&yY*F$chz@DY6b+v_FGDRQ zy(J{GB{=zc3(j-n&Ty}Y_Pdh0y#)opnLCVBN>(uHh0=;ZxGnJ@^m0Zr-cbtrHMS^? zNh(@23`?3Er0)Zf3>h_v5-VE(Y6BoSvdJz^&>)f|Z%vTDFGLE~pdncXIU=Aj2&7~U znnsprIfEI^0gwtAEr}8*R{&ZAK!m#T20JKi7ISYQ2W{gW>o46 zflKhulrmUm$h6DSOL}awKG4ZM+dIT|p`by_jEb^GApmv6KB2nvQHeZ)Bec)KjUew6 z96^GE+JOPt)+pLSTRO>XsgQHp+4~%Em#xTZYp-nt7~) zx>HM4mn5}Jn?yBpa1fmen=5abpF<0#|07r1x*O`frFy%cL+Gimn`I)c4HKN#m zIKP%|dFF3UwR1vwX))!j>Nu3_PfWXtKLY38%rwbGl%u1PA>WCOBNV-~J@vg!lslo^ zYZ`v&sQQ0TM(3S7?nAqSA7gcey?MoKbXm86K8X*vv$vTW^zOCGmqfT^j!2N>PZqZfU)eC3Hb=u8e zO(~5mfdl(i5Kvx$-1BDNYtAtCNL=20#}ueqcbJhU~P*IcLl; z_D~AMFpw4E&FV%7kVH&Sk>@9*V4hMowiiV^D{Vaf<0(?tMI z!^6Y$H6U*loW&SHRI80w+*uN#o0TldfGdFDIh(u^5M-9+S(fEm791Xq1en<(E`WZ6 zY39v5wG>wsT>%2gf>|(4v}JCy!t}XDU!K8qg~_%fowg_lAny~xe&#M$xPO-}y=1?? zl>_t&c4JmZy-T#|)&oQ%RCGob^~BW&0fsh&y1&k{YJq4JVCR?|L58Ww7K?n)UERVA z%`4e&0A?&QXtKa8#S;_8R7T)_Ea$uiq=H)v0Jx!8LPoOm1m;~rE!qOoj*j3OJJdj+ z05v90+M(b?$=H(9nX4=8K}=AQA2w0?3q(E3p48wbMsRExq6(SBe!I&9u)Lb1a43Q-6}sEG!ZVxyG*+ll5axyIqi^b^#xIg-4M!a8D~7gc)W`%hsSj`=6n#R z2nNeT2BXREw+j#eH={#a3@`KtE{I8(Jkdjpaiww8X_6=iaLKnWS3VPbG`C3}A|VmX z+Aq!x2@T`sJKJVXV_Yga8fN@u9SGcCj^nP)J}#;q#Jq%rK>)A&Wg6zXGD!u#KIjuD zB>XhDF{W@f(MJLSmc!m7-|fYj-rD)`h10aRICwFz08JX)*Or>@iG};P;bsK z(jq_Zaxq2`?3gT@0pj~5(adkYJ|UWb=E@!D5U?e_c3wX3#SVwz5qc2jBK}6b>ja5} z{(nLRYH-nvzS1}&c!f!a)lr6cfl)SvzegRtip%46O`#a^@;Aeo1xf$@nZhAKK;9|V$kRhc(i4W4rk&j=S-bD3~YSEZpd z&mnxiE6#B(4E}^+Pkq1_K1!kyP!*p=FmbV?sG#^7M)ajCIHM7gQ7C$u5C)UI%5@dmt5!KkyX@MMhBbKDvLxX`695gPgE3LGx@MYKA6bkf+6Xu$acWM7t=Ij!ylQ3qP;rEJ zx_s%uS38Y>gG!in0FosChn+Qb$GdqOFA!kPUI#H=sVFFVF6DPFHBF5SD^v+E9*(If zLTg_->iw;naC?0xk_55eZhYD5FrIHQ{7kBFn=x*w{Dh8`wktpnH)O}X;?U(3V!^b=q;!l^% z<>sZ7$q@#b_Co1k-HVn&0^PKjU_qOrxFZtqY!x&1Pst~6%H!ur@c|VasfMCHS^ZIX zQey%IW}(33o2;{wHGH%~htcTvASztNZo;%dd&x=Z6UUCB3VQ+>VF+Pwaxa0R9LfP( zjDJTatKub0J~rX<$%x|0hU&+RE%;g)E$ulF)PxHVWrgF%i5fd^{7BzN2Z3RB{jyt) z+#WoqSS@m~OQuj|oU=!epU@V`D>FG~Lc{R*%_0O?tPL9Qn=B#k_daZGk0W_hMhgI` zVtW+%+0P%LHDvrIi{4<^w9}TR;a~qzML7oUuWEo&>+D36`9&~p=tRvbsScY`y=itX^5edpPEjaOB{VPKhoX^^yT_NbSpi961y^v z75v621(PDv+Ajhy6ePLGKw8^|S#$#^5E_R zZF-Pi1Qe{>@HB-z${K|-j}jdu4GG?C%p;gUQ2Z=qm(q=@wn(ey1lUXP@Qf3$BeegO zg_3>vteALF12*~I(NIxcE>Y$3!Dh7_88cZ3!wWX-Ayouf9Dqp_^59!dG}DrfX_wul zBV5W@s1XEPoNwMfkCS0O>SQCN+kGtX@=Npz$LfJiHh;9cfz7JUZL_t{$y_p~L7Mui zG=(Yim3hR8*Gce~gJXc|WP=GSB)F)G!H}pI%kkxr2(mGu6#7K!{JMs69JL7FR|m1t zr2Q&Z!h8wC69E8|8n*PJdCbFrvf;BzZk+#2^kX6wKV|<;PxLA`{k>XT43WLeoUwHk z67mboKunnX-BRpz4ZmH{CV0>o zA~@vboi2WP90`@UIuS{(VG9hRR{}nRtNLg)dfNp5v6gl$*Bb9_?XVS`kY0tPr)S(NtH+wJ!g5QUlgDUEZKrtZjMk4+JEuJ+HGJR5r zbS#dVZHBH1Z2+h4VOHgRc`C~6TImqW>^MPP?`$ZWMrTPGzF}j_gBy{Epj_ohbrGsK z!vU3sneup*>`z%PTVmr8Dt^08m)c3oBfkDnDWG=m#vFTq3M^~AQV+m}GzxenP@FA$ z39x0}3idwGqahrl;Ee2}+1%{Jd^N=iL)?9D3WOz1ij4QNGBX0-0Kp_$m{Une52HFD zs}L0br;yY5{`zwPwF8#GCQfu^yjM_L^b_d_Hag!~x=pwUtKPSSUV>A|V#tN1E3_@d z)DjTH)>iqi%^DyB&RN~ zd>&`gIGQR}aPvopY1UbqUj&d$3QnNofF4W_6aa!#Jp?J&1rm9REVXWxp3dASFW76CuhjO} zhSI!56VvR{lb1<}RDt$Qc?&QzMg~xRhm3BS#QvkpW*}xJUX#le^0*z%+SYx`F~jIp zhixpJN8UBf*B`&Wnyz~+=a@Ry1lx&7BBB=v=cDd>?`|tgyWh?J2bW>yKlkxbV05{Y z+>Gn=7tyRV!_H$bYUc@X41pLJg^CUuK``255lAx&;D~D3e<6S{u)bN?< zT}6dXn0R_6tb{4Fuh^K7vM{*9yh?_gz$8!F;dl-cO-*;)X^UNLz!*5WdQdpV1ST7- zvIRN^qi#Eq2%T7&yG-B#Drx1U{@OehANOBAjLBLP$V9u<#_?*!3V1eF!Zd|c1E@cA zz%7gsd4SpQaBo>WQdL01Vv%3&B-4)bMvbBBt?p`%o(q6$6^soh^4Wzrt?t_-+unv1 z%&JV>Tcg9Z_N5|EZ5AAABnqNyv_CeMl&Q3ZW0b@CZ=`v(;c#&@O{^5>d)e)k)0kk@ zj>A57T%OcJmeqQ%-->Zbp#48b|6q{D+7}Dzswks6t;de`%Zf`x{u)3M7 z_nAQiL3kd;Yb#i<){4}srT>dS*cRAS8gp^PvP%M07Ru~j;L@GTc{6IhsD-WT>zVpI zc`HMcZo9K^R~<;yA&cGuOWZ=oV{ZtY_=$FVWr+b?=WGb#tsA5Qj!6;!1i`V`leUjo zSH~U2SLdBxCQfV2SGRF%!fC?`Wyl``6Y0Y3JebJ5dFruCi-Os<&|R`=TDcWZAR80< znFxee=5V@Ks(g8kjUb{Ve_`|ty88K8t~QV)D;N%E>!}Gl<|eIG-;{z z9_~T@3^MF*U#a<1!AyItjaSOp^7|YV(Edu-v&iBa;;gP{Gp225p%jvw0G+9bn#yJ< zDi|)T1+mw_D?&#Yb~i2QPZ=nu2G8xcWtSm`src%&gMzCB?eG8#BXcH}Y7a+~SlpaD zoQ%}Qj8ihBRJ){>JiLN>rKhxOn#Hj7gVBb`e>`|5<65>Bj5R`<4NLu@5>1kMQz^+< zz;mwP4iktg(%~h0o&$D|e3dZB<+0-gsK z%6{kt&mo$1K9sfk^l@qA=9TYEpi9PYLc@gF6Ji-O4Bm7hl5MqA$k~y3#}=~;tnu$w z0w`q;>47{Vg~{ZuTgiV2jpF%#MIyG>owW#0 z)VVIDrHCHIPhnIknv*@IAyKW&Z$@7sl=F}ABLjYBkF*cPt`A8U^MO5OCg)KFOx%* zcJw#xI>tLYELSjpU*^q3A67}vVwbr%p?ZemwaY)HGV-KG zF7<-UiIv6IV7kgqno~qI+RbunKTLT7%h?+|EynV^w|p*aGQ8(Dd==Vzug}(KKi~kN zZFC>9cL`=R)%uN`7*1&y%9j80>!7l!Hlr1tBUun9c7r{CgoNb87C+4noXH+edK4eX zKGgS(!KG2;Xy*To+51xU7S6PIeFpPZ08zO7?7Hpo1)?QQKxq(Uu~qZRbL*GtTkQ7M zfDWI+i@2l3SYF2tK*KJJq0+`9t@D_XmYWUd#lsx02k$9ej_n2Zb=eZ9NRxJSZ7f*6Rc+->2g3_7A?CcgP=NnL zqsT#3du#KdNUNGer&VpfJav%R=AEditkuKy2Q=X3QpuiE9N9|-|5GE6M#2an{y|z+ zGLg!&HsUyP^GE5PBQ?aY4eL3cQBXzJ4@2-uYxy>|&e#5iBXWMAJXt=cBcGuCn1P;W z^ovAfAGQ~SQfXTiaBC_+>@rGGX}r0jw>VC5Af9LBcyQ?TmTGEy1*t7GNurL$I#yCS zdDfY3;+KlEJC2I>GGVcAy)#R-Mk=s%btQB-sWMNILas6C-?FM4CmNeIp;!YPMJ}eV zH>!Qpg=3$hs=Ifn_pOJ?Ti^lAtv88@)S}s*Q^wmhS=NiunoH;RY5czhEPeLVW8A-Tr(q=sQd3qtnm605pU_t@>npbbUe7ry zHvwStEvghqUsx(>WtMlyw;=Ezp?iCRW9C2G(aV-A6w#!NwJ#r{5PI_~KKBHCeQ|Tr zlbqsENO;YdvO~xG*4GizyUF-JR|75DM}RJmtfrShDtA2l&~8E2&4#=0Hm@kMwBR{+ z|MSwZ@4ow{+9Kn8`XyM5F}AP{ljYS9^`cs=Mumni(-CtRNll)~cs;IuV)d3 zBl)=N(*0(j`PKCtGkiC~YkZ3N?cBUd4P>C4NOp}O;hBpi{3=s~$Za*6K z_FSNto>>KgDIdhV@wf~}(Ok`t09KxT8|$UeqWb4kCxOu+E?A%SA^W+u?Q%dV8BaM( zUVw^yT4X;_@eMkYOuJmAZGE+YH#tc~WiIot?Qn3)Jt-YQAEH!)?LUvyL ziyBQ!zizfU(ZPWVXjq2$C~2k(+rbF*@b1-J*rWl27 zjI=J|-2ncP<(I_YCuk$#6@pX~0H`;RuR}h1G5nuj3yOl>?lo#37fd>)l%9sYOI>qU ztJo0{OYH<``2Y&9)Usj`P6LTmks%qged!X0m@{m4w^AgHp9Tq#9`AR-bX5m2cp3Q^ zcSMgN%LYZAFtHu=T7E;!;xG&_TsdU>}4_-wPn{)QAGQ%}SF9IBGt zlxHky@I(|6#FPZWXk;c_zOx5B-~&BdKNH#K4o^U?^>(>D@bo$@MKf_%34PGRKRGEV znxXHnPy1R{HM-{40f29HSIl)@9Lyf(;5d@GAdUc1H)GK&Zf!m1>?kp6vYVO5cA(gb6rSz{o*nyoPdbyr zh23@5qDlD&>5kN|AYJv3@@fZuTg#;WIP(48@ow#bu`y~3?b;;mMB-(AICtnfzT>#B zeGzIL&7sHpTAqve)wq(X4jmC41$2QyOU&Rn>+cDw-xPM|V{7g_aEP*(l(I-FINtB5uJjH>5+fMZC zujOyP(p$jmN%f3hbaj5}CM?p2;=EOt{>BaP*xq!Ps}|l6Sh)Z<<43{-V}ZsVZ7LJJ zyyI4Wtyv9<)CDuplSa9U6;13xX68;I7yW@3OqJn*g}OpqLBrV&(#9A)3o^`v!fPNF zm8UczpVvIYtsFQdlH*G3@Oa^-4}$QqT2S`~Yz5!o*39jbdLo(2J6VTL@UxNxeU`vpX>8_9E;kOtP3Zg;w` zsfy9lzhyM)a#inf2f*yh<{%-NG{$F*kZtt7Xwb;s=0mU!^BmMx!p{M9nsbVt7%qqs5yPr?B>1^3?@!Ci1%buN;eI@> z-3q|HVmO&008!m_8E!Mw7Crww9+`Ck8=A{Str5^Y@wwp9uxz)ZunfJjkWf1m-M?s# zjBzJkK-9t#!3{3<*AE_xsE0ahl0puQIBQ(?a$}1|sw4`FS7ImNv|-f6lE$>wjNC$NY(BWR>)kgK(A9ScNj6zs-eP>6BE(VFQhYa+i&|Xo2o%I zKO^{>NmA2I#3j&7^4vPPB$dd#XTP!BF%M>dHO_y5Nw3{kBYV}VIA-gYTA6qUMiCWp zE?(Ms$!y!-LXLqMz+={EW0qZ2Bjqx%zE5WWgmXTkgJZ{Wjt+>JnMp0Ze9neplA|Y8 z!#_{9yAINCDte;t0%yUE=br1zk{6WJq2Y?38;+^%Tv2W(ht*LEwjeJU-v1ISHzy;p z&peZcAL*)Z*p8)}_7pf z3*8MaLDCtQZ8y-ccFL984f;RW`Joakxgasl_5&9R;lNF~_iX$fV~f)z6>@)1r0!GU zE9!})=fyYtblFKRXijR}8tJ3YI;#|0#>X2nrf$a@DyT4)kPZ15(V&{Ahz^T#_+saP0D0lf(*g8Ytax z3J?E<*7z~>u_|V=FwgXL0V9iJU8soR@})KkX3ToUN)1HGLG5p)Q(OU zSV?GU=Dh82Q$#J_$7kKd2w~8GVdt)gal=L7wo#z|UDw~T(sI&I0Sk7jCA^a^=9#P& zPF|imA@!XfY@_u*r)?_dN2_R_pFEW*{1(qshy9>6$^4z4UiR))#+yMyOVir=TtQgJ zei6~)8p+nZnSagKraJ!#7`G}YFnekCnba$VT3p2Db^Wn%`!Wf0YjvV3wLL)RD*N3* z=X@YwI_PR8C<3ELIx^j;Z(kvV+m1*UL5dOscR^WMxY z@7U^9{ZLkA+R%WMBgquwAm2N$27^96|L8vGTVfaX}n~e zh*#&$0Gzg%xc0|Qd{)0YogI2mi#vd+o;@`-(}s0~tv^(?S*w%rG5ci;g{r_7`foD^ z-E$`j(sj)Kuc3qe@Uz>T3h&S&6&(h(5q~;rLfG(&kZFVHG2Q^-hlCQg=f4nl67gm zvVkr80D-OD$@V@=7p*|cGm~h_T~toC4=?>fwo{rTHoUK}cO9^eFOQjv@ih16oZ{d? z8kpqH{E|%!HwVh=(g@$&Z9Ok(C)>B``(V_t$-?)k{hf&GM_o-Tf(u}@Wq1CRq|Wka zj~};*%<2vNW-ooc(?X}&luxqmrm&G*oeao;Fw$6fM!V`9gSrz?<2QySUfAU(Ct|QZ zr`OxVzD-xfeWtykzNAqN&3`0vch7gdyy#$DW4Vwg{+|Tb5r1{ujirL zftA-mV$YvnVq+;I)VWAC<%c_;kH~DunfC*wo|lg3gtJAj0}{EEOZ0fqhSu9H&=T0Z z($vS19blLK?7{4qe&d#YXE8nX4t5lXXcy(yLhA5eR{ums@urK+X!y>78sLMyQ&zia zTve{Phx{HasWft{YlZwRK3Cq+?$2G=D}23RkGcP~dNTS#p68Nkd|s;v{qA8`T3`SG0n;V{8;M6Wa8n?f+&2mvaP`*v zPby$$WY67>g+?fOvBc+MeyX#w5AzA^FH+O`$D`>9onaCW?WToO_oT1=G!5(T-ysC@ zK2ice3NlEDh6YNM0!tG+6H}NknCjn%r0l2^x-3hf0g>HS$1h;A>~@i*Kk(g#EW4{@ zUg0G47A)~{FtceGtJC?6&(YEz;SWhCAlErHBiv-aTork+$j#{{c-gWz^tOzvIspV( zcGFvTA3$Ivv>li9r?(|oXD7psKspBK#fP9|r)D7^HOS?1-0Q(BWyAl==3~YBZn$w` zzOnR2l&rORr%HThtffMg9vMGHb@R%}`~n5qHgDlq}0`}VgYrcF+G?4@CZ0W zTxKy(K>9efWzHZ0B@w{jusVPtQUc|vD`_Z|SqhJ^nZ4Hn5xYlO4o~R-gW() zJbUo^>@r8e5c@tAzNYD3ey3o2v#`A!jR~_mFq4KeB#6G5lN-@2begj9P9D|zt4}n7wl;PR)hp?oM95|8cpKL9bWCng=D#IoW*=DKW;&q`)*jvE z3_N?Uk0hzRyAzvDd(6xSM z4Z;o zqPvRdqaQ{t;u&81q+5IR@KWK1KBKNwm&vpWlqwKXQH54krd~;Xh6+Hm-`bry!Z`JT zp6-N;J2U#APj##rNj?ioX$e`@tOS}AvQ>yJhy+H84;Uk**uXyN_Fg?LAFdRHLbdJ> zPwAiMo!rdlh^p#E-m~M#MRcZb01^dEZ$PMj3{{8NCx`0)Qe9#T*R|jREQv0592G6bVF#A50kF`WYS6!>RO|bl~T|w?`HK@ zrGLyy&{to*aPSL&ii2iJ3HCN(e#JeliB9t5?OipMKP6=)J4cW2e|mpB?6dm!>iUVD zFM2)j+|CS0pll}79~MNJToGhnMVhV9B*=j40D1GR+>c9TH-1H1M?u{$0s3&%a9h_d zF_3 zx;AU-!wr7v62r{!=*#am; z1j?0QvIQdY0!huN%U0DXBJza1_rn0yhhWiSU+_nen>kKH3-mi=IpR+$d4}}*GxMqS^0^cJ_756I=NoX|0=y|HZwUu`I{U-P(E6^Rz9}_%@H?s2K%4_B4~qv!9BxsKzQLt+xaIT(ISMA5qI5A zZ;kXn4+a;yXTX1V*9U3P((wXZ$QeAmU} zue^rZVoEbc^K0l5dx5=lW-7c03ol)kyXZgMcKSXZc0GjO@XV<)xt)5L6UDRVxJf_g z9GgSK^upXpbf_nbb#L>ZLgMN+UyFFb#Oio5R4)Wo@L5&{4FlO)U7JsTMnmYZr zh|>)18@*g1=8|-iwlt-H_|90z;J(t$h;C599NYcWiOaC`%aSh?bvRZBYUPdLR$M^e zi?Oy7|Nq(e);VKU7l<4#i4kbmzm8+LF1MTh4!!DA?8Hv`% zfgKun;HTFW%K20SwLiZNnorgF6|oQ)pI+2rVq{QprmxQs;2I4`_`JITwL}FSBJvH3 z_g^Zb^7D&G7ruf-zd!{CF6kQBdFx4`&l8ejNxY~^t*hPrDfg(W|8qJm$m>Co5lj=B zWS=l(w}vEM@Qzu_ppVfJ3QRH(>&Mi?Owui$6c#Nzocp|~DI4|R7m@gSI%BG?-cjA? zd+F{s*B3X$CAS`8dVkKtHqaSs)Wajhwvi5sp#R%g+v0nD*KXWqVm(X#+5Nx5C6|4T zNeR$f3IRl+E}V8-7We;winUQ$*+W0E|M2MpggG?L*0g4=iAG;fC;t{!ZcUv#6U_00 zyr97zUb_b7wNY3z4gBWnnhwf}Ggr1vU8sAF_T<#oy|vG3_X@%wqc?8x9(?Q@%@!TY zg3T@=cNkPS=Rq5{0#wjpj6aG*=@8UE2GT)81GoOGTr$iDZe~n>LtRIqyWa!!VZu*M z>-L#jrHo1h$Mwvdlu{oTRxxJB>^y~C`i8jXfpj#=V73!nGBX+~7>UW}SB|)QKtTf9 z21%CyJ3K5stKD2}NIBuZn~-RhK+uIi1XS%kn8a3)q#H?dOK={zQj;T_9mf`Sk@UTE z=CJyv&}u*2O-A?aXzBoIQ0hkCKxb_uHmdEu$fJiybG6A&z#PZ1F~Xr~HWw2+ne43c z@>~y?S(V!~m%q39TQ=RP8Fw}kJG)AJ{CtshRG0xen?Oefq^?8q5ncA5)j}Z>!M`~< zZN9UlJ+l%5qoJzv#Y2Fx(KlTkZtzDIRMz%jn-4z(zn>FrTEGb5mbS|%VadUB>;0bTgVRDRF(~JP6c53;71>AV zAuj2Z9X^Gl$f(p1oA=rbvM0jxyu0S(cMds(fRL2p9Flc8)xz_A@J*;N#4-Xyg5i;E zTaN^!U`sz72vGOT<{ax&m43b{)k6?cI!=3x*&zw=|I$RVYaJTSgCg*rAv414! z2__vhy?2iP?2RtP$?iNKPh!!v%ZrJ_GU?%&tU~ighs^n$nVvp8_hh0{pINnlx^UZv z+b};4FB6R9tw_=wJ(S7g`1LJ!Tubwd4UiCm=5LoLRD3u87~6R8FkfQDt6XQ{Zi{u# z-6;}DF_SdBM=N4f-{F`7P`n~jk!-1kt~s(V`O-XvVYN_7aitP^K)KR_+gK1EH4ayXY0Zl{6hjKDluYkIRmm7xF{bfEPTOYyt{<*GPo9a z+Zt&I*NQ@VgS!YJyPfI5dJy1X^EtXRs-)L`ZoXa$VnfJWRzipB8+r7hmz8KVK37;ayl*S+rHP5;$-fx zC7J?t3h|4b@xKlG5loOP@i+fHq`cVu%5pZtr6Ia7EXBnlzVblP^=Y@^c+2)D3nmxR zR@-NMUB!>IOjTMCeuL%y^*+>LC}qLeoa&Vh4O0xAY3K*FiVnwjWha)5_yO}0#3FS#T3Ra6)DBcA*bHo82HTKY4%|0r75iW zzFeXHOoL>>?-AN2yn*gu&dlo&zQsu{!E1AN_IQTkbowL>~vK2zpmi0c)(BGo&S+40{w5dSaBprlCFaw!xt zFHa+de*4BebNyQA33Simx>-4Xr7h}}0&jYPUyDyoPqhaF%JnIEP6#BUsM5eC3B&7{7`73etK>!#q#P@E`Hj+RPtDXwVD0M^_fK z7B|YI;7*!&>UHE6)_CJ6f6vF@{*-uX(EByuy<<@2$sBH`;m04Qo}j_|AKU}i?q-r9 zgmBkiOU)JLmOJ;r_4An+fY9B|J{6B@D+#q57+a)S!HD2(=ZzN|)XVCz1&Ue&L~fI_ z)N|(i&7{4Vqakdy^>+(vzQ1)alNyK=vx)dQIktvI(2@q)7K-2Wv7m(<;^7%V$u6Fe zGrksaEammn(6=AoH6kj^{_H9E5GWPObtnE7{=MNF*|)0#%!e|hRf}1LcpT0uc!So( zwaEW=$|7w@TX%`*ej_Fl6~HMl+AI6!hlww+8o zWqMDooGi&`$*SenX0>FLkn-A|=_xpKr^Lfk+G-7`aD+T|ee4JUw~hi2S9`_vRxgDw z0r0IAYU_|lV7*a&&#DITTFSdtgMr2CEsMtB28fYA!xs?oi|Lg5?3d8kcMYMlK zap()yixRb8S#-rkSDadQ{{8#3t;~ZDGYOQjQv7FZ!Sk!&YS;*fe8-;Jewzs|8{VHU zrQxpk5>oxjO4RnSFa)6_j1;T<%Tp8XxiTo_cYXoNBI6y}X$4Rq&=M`q457<*)DI~GHNeSr0!^TDsD6ix9wN@PL=Se=9Nh5+fg+(oUS2(oB&y;; z7`ateT^~;pbq4P;(Zg(Iso?9UXmnV8FrZ(D!92iz6j4w*C=o&AyLzKf1=0ubvCr}y z^3;mL?94oiF(a9&0e3Bk(zF5%Y!o-b$7S;WpGvx$sBdplv(<`{9DyaZ=dG&h^$}Ox zNR4+ji(p=G*vNLtc(3_qV+%Az#Q)^9OHjfqd^Db%3)N71Wh zpnF$6&9^orN^I<^>8z<%&l;AT%e0SGFPf{G*}Hyy`;hasWO$ak+QRN~s)`CZk+<2X zERPASZ<%saqT0ZfnY7llu;BsK@F+4eDj66Kv!-cHGOj_LXnNU(MWvR&Vo-E+(a3(@ zh6Q?6QIxWpJHa32u3rKo*s(^sSx?blN-huh03ZX2_Xuu*YXO%+`FEnDmkL9y9;Ph} zEDZd24~j&}n(DYPGAU5(<+@f zx@`M{R^c_d@{>BjrX8#nv5V}}<5XNkW15a#PD?86#%K*8#pMCllGx-rVUibRAA?aB zpRF>kwq?Zyztcgxx+lQz&L7=%vd7Ky901%C202Y^I-md ze+^Q-57~IP>Z864&xV!EV$UE?PHVb-_Tyw9TiAa^9$mxC8d@}skyA35d&qhba*wwc{Zi>5J)8dha^_IHaL|y8CPH z|IYOA^SYJjS2ypPH($I7K3e z;3KDo=6CZfVhayU?w!s*cI=8)-SdY|jo=6riC*OH0_XR}aM-CmtKHmxIxwpTcO0@O z2;*+pjL`)Fc3?ny-1WHh#n^b38`lR-FN+Q{7U=w{MIz))-=_8b1H?lY)`)swaM7~K zdvd7ZFmRyiW8z~t=zh6V#F;-KB9YW_F?y#=eKREsibP1!Oy2eSMT3Ln4z|lfVxWKh zrallYJ^qBrSgRf!T=d#q&-0T*{)mVEnfJp-y_UhA8UO?D@8z{3A<{(0-kl@)k$#oD zUf;Yd&B)HZi4JK9w<7P}d!QfL#28=78XY|Fo&rUpN{OM7uMIS31boc-I3pm)Y>ug} z_Z5jC^{f5sMp;Y8S&g7?U{v+QY_OLbo~TAa#1_^|2D+0ei1IBD9q0$o*(4u!gb(F@ zJa_$Ty}|c;_A{FIGe%WU4CQu%`H5r-UH<2g+_RHngw7?U5 zGi^en^mGp`Ngh92p(4kCff@gyj_mD_|Cr_Pl909=JYbAg7KNZG|q}Rw`srEbe-(0rvI@EtA)y+1M>QL?DEd-cD@Ch^#`Z z#+S0-42ERB$A`RSS4KuMycV|20k)M3+uGo^Nm1$wuwtQC#?T}Xna`f8k)(TD$A~i+ z>XGD?4EY1$jT|YWD-vh@L?I}A8hyd}Iy;MxiFSWW^^RT!aJN%z=BJAn17l#-#6Iw7 zIgJ|~XbGN$83Q61Q^61>^QuH)h)fop{q)M*U3WXOzmAs4kT6jdRB*Wf22U|q?^4>M z)2&g1EiLMuY}O8SwUfd0Se>Ok2WsmxKtp@AySD{ z5JPaei06<1iPWuAj`H^mfC0p3OvmO|@gpLq7UayKNY{GIM`2c0OYIS_WesGyN{#gN z_*WhuiU$O$u+$8aUJSmT)Hf;*`|~<|C5=uf=U_! zvUfHlaH>=Re-I>}@KLHt7?P5h+#K+T%}YLxEE}N<0qnQ=xBY(hd&(1h;dVnj6|ezp z*od>6!UG<^fbd3fV_kBfU_CZLr%B5LH=$Y@_8Eq%C86U87u;71UDbI(hc_Sfuk_to z5~Rv_kYTJ1E7?(d*(61q)bV_FH($$s*}^#$E7s*Fwkwte}-A+VSM%0<6WxqRlVa-%fLjzC{jmUB*) zgZe@Q^y&u~*aVLB29eU|0y!oZ9Lt_)x?uClDn=TQep3V~rv(Pk!525~avY7=4L1MS z#AYl7?(T7CPQ3zQv^AxVG1eG!7#v*6U@qMZHpQ)>;}bU<8Di21V)r;PRzC01LtZ`$ zbDF^JUEtR|7Cr`c?FObA?qJc2b8#lqr>5ro`Q}DqgS*e(QWI3{EQSb_DM{v3&+lDK zCko5zhn;UqZ3u=QK4wnwVj>{ci=|>$Sy+A`&OUUPxx1;{TqSPe-#0|LbKTuYvD+JM zJP^K)!SAk}@(x7oOLsKxi`}KsbB3{BljEUL&^GR`G0Yirw zFI5sCyKh6W35==$%0e{RDf=f-it)zOTVn>zxt2VMjl$*Ad0kjktay(Pl9W>Z^sTUR zLF5PGsje5UFS1%JL2xF5$}=ds z?{E(m$4j4@b#|4|EvuXYgDin*aP3-!fK7<1dTz81Gn&DWA|RRTgxZ{Xe+TR>}*j{lW<@eoOk5+LVq^@*AB~ zRivSmvV&6OUnp2oHhm!{Aw9!L=Xf=nYb+VhS~+Wf8Long%65CeJ&0d+XrY#`7r2tZ z@s6678M?<^n)YL2u>8s7Tw-_}pPm}P3SY8fePh;q}|S3rcTi+%6umz;6{HUxxZ@ zjXmrU`ft8IeoagImwplZGR4|as?eAI40od7!q*fIRgr%#nbc5@wvkn0`3frQ&)Usg zxQRsKe)?d(&is0D^}C??=8XPgL-GAY6|gBKL)+74Xcy|e7itw$E=dapN{7fw7UOtp zAT9nH^JT)H;^&D|?8$Xu<~s)aIj}#aEu~}fAdKU7-XzIP9pZ|yVGq1Bc$-@U!zpIRU8{#lFJCn!vUL1CYqwRk_* zr}m$|x9^C=5BZileD+MM4!AD9*GUS4VAenJu_a!I+|Pw#!2a- zsFvs{u=+G@Q#gE7O;qwLWi1B)IsboT1e@fdbq|O8%KuD}(g>2}Buj&f0|T=^3oX_) zY_)8&l2sUOGaXMDL(<36H<00PDrO&S2+fc0N|p6YOOp1%JsDv30r>t}#4(#mjr!L> z$uusavm-6CAa3ZJzT9{+d-`h2ZC1V0FC_|&C>FFaNc5U(wl9Z73QzuwEHxxa!GaH) zqL*vC0ldBInaPPU*V;b$RIFDPkkxeTscY0yBs@aBlZ81o(y(c9>$b>qA?%7?5UaWS z3atDP!t$SB6dOB@QK1#{aqd5-o*ed7|V0m}h3^$jfAv{~Pg37uME+b7I4qh4*%lExMnA(vtw=2CVY{aTbtO8|__yrW1>+jR%O>k50cwFUl}Q8OWd z=CN9kLGC?sV85VhvhpKM1cUw=hC+VP>B8fX7CahF^hlEX2nsfV$s}oco+a`%@!zEA z3SF{v8PURmOe&wpF+++7b$q3%JL-QKly^1Q%IRU?5~P?!Zk1&=9lJ%GYlg^o3j%_2 zzjBEEXA@^|YNmYr^Qdo=bv~=)MthzlO@>Wi6rwL#GJSrGsaHBM|5`smT1g<+2T*uD ziEagqOi;5xJXLo#xcO`P&UlGxFxF zC*h6nfTKV>HMYI)@2Ajw2uWpY5=(u{6uC%(BS+_1u{FdeiE#9FIEjJMKyQn;6<)oD zWKws)T{%>Zro>ZSUa4LdfD{)$XEP^jt3mlsHR`sF5Lpv+taRhL69K%UZwkKzh%5&h zmDxIBL7k~ikdqPN0FJ!2@l7+CkoU|t%yq+?MVrBHfPm6WUSk6*gYGV-Z?=?9=UmgO z7J)7OwsdS$X(c||%`Hsg?q@%zhs3FD2sVMyxN@(MHZZrQ&^;tr?a9E7z_}%%O^sj@ z*lW5&^X-$9gj6`Tpn~4Kag6N2Y>BQ926>MCVyk*!()icE=cblz^5*iqH>H+N4>?XT zx*1G9BBEINy}^cJXR&3R;Nn-!U?!D9YQ67M(H}q)Ug+rfL>VzhO$);3L2m<%6OD$& zfD7W^iKiON+XLFm8!fZEvcJs&ZrY2He$7>!G=nphKPx;XoG4FBv82~?9r9pZk#ONE zqU6?Y>rR{6Cnnmf^|rSsGWFH-uIOsj2ai7$^X?B#EOHmSFFv~`Q<=Hv>|*71o}Ku# zIB=bPyJCVa4BX@pp z&I^_NLXNRrrf|4aa^~2vCvQfmN9c0`P4;p%<{~3FL&fkPqVuIWBtp7wt|Y<9btXvW zu2mo9ut4(Bm{ee{t>|8-T*KcJ2lx#hTn~!}>EUbgNza;)4`7E>lZAD9Ip`{H zU)Nr)9pafN?6L6^=U>0OOd+Fk45XrWp?2S|i>hm2-w?fVrt?hS;{L&Yz~}?O&*58U zDT{xr<+{;icTmh}9A|A=8$#ecK5xFdom+p-&l%`^wd=z9c|bFc0FM+rkdtY?*v;CkDnJ!PYzfLhH&glf2Fg`S)K{(lejl5D_cL! zV5w?#b76sM5V5nH%~<*$`2XnYDry2LlysxPQC5KMO&VUhYRNDddDUcpKPPJ(=QM%N zuBtLs4Q`ybH=HwvTWEk;Mlg1c{nx97jtp5H*T%U1ahpMSKY$~6cJs^`cK6(5hCeN$?!~|8QL3!AvEnj08QxnmwIT_no-cZjKh* zpKi8KbDQ&-KI&wtV45R&*bN|Q>9OF8TzVP;))lMtMoqw(0D&N2Vw+76k~WkHrX7!r zSbqigH~?^_H5GgsyW4Q#!;yh;ru*j>U?*cl=l z7#20Xlv`%MwQPw3)gRsZn~DGP$qUyPAmTJ*YKlbT9=&^gIE>0jB4@pA{hemuu=2sf zGY<-q7}zkIY^H26v$#mmR3-X>1X2__i9FLvUO zEUKu8{q8b`NrKrPT~-Z0csbQJT!G6Wvc^Wu{xy+jf+lc5Fk3XA{phGhT{;g%b#)DZ zauEt1ik%}lli2fpm*rOfm*oVJ8~yKK%rOw<&{_o$f!ODC%migRZq}MD*Ew&_R!swqXraaPGqa5JASn9$E@s2ax zXyFT5-X&-(y1RXW!j}EkvP5qV%af?y=gUN`S@%n;--NYv)c5{8Q~RH6){D+5U=QYr z=&FYDAu1`Gbp+JN>2yAs zK-y4NK39SM5Ia9^K^t*|%M%Njt3o4g-^URc6x4+1U!8PU(M3G&k!)5}lCy#Hn+!PK z*$&T?%Q9In{r(z53uhc9mY*jo(-ra?IPZQfjUioGue z*`uT0xe*$Ep(H|H;^t>x*D0gBlg#`g%B{)OY;og(#cb=ge*;wsx*XAg1C8Rwi6zX` z&W6rZ=8_4J?qn{93%UwbN$CTz1u@s!Ty+iv^RT;KrNb+;H2A$ZHZBhbhKFy(K1lB5ogW6gg`){=#i^+0T29*ST#KD|0;EITWiCXVs2~v&N8N!+L!QF=Dn48n-)G0Qu*|Y4b*-#?(h$ zxLn--5t$Gg&MQBLedOKBd>OhHA$7JM$8TXO<$dD_lTj%PeuVHyPQT>w+2sF~deAHH zWPpA^)s$mralQY;FwUy*e}rQb81vfOi;d1207W3(G+PN*n}$D~ySB z9>JCQ!BBO~P!}T2-a-U&@%Oz2zUTby|b zI$$coBSODG3L%ID`eE-Kl)Mk4*Q@aIAp4^pfq)WOd-(94=P^kt|2ra+eXr_%)i!>FP9@eat z-F<~r?uIaWL3AH<5@(3gPq$ltZ{o>$7Ub!j*6=$~JyEAy2AXC>=^&!_N|$E`rYSGy z=lbXQ!-9{wB&Zih8NHSmiUJ|T14Fu)WB8C73R@$VIx*a-zFM>;HEKabw@Jyu_7S1= zgR|jQD~)a8k()#^calY=KmxQye^|kufBdOLW0yO8EffE`9L_>eMgA=aUAnu>#nPzhOszZ^aS z;QZ*`X_~vQ;Klq8^ZaJ27m_9hk6>8tE;9&9hO1p!FkQR+f;hF@w#4MU-J1Uv!ga~{ zv0r}P)1T{ryw!&`Nyl5KA=h#%L*c8tvaysE37KUcX$Q#K)ad+x*~hMYTTfv@HCmmQ zC>=?x2!S4H9_dk=VCrCFLC|J%E@^mb{CVPBqej`_+n|EpIY0eGyImg!*ChjMJAM$1^daevVkgl z^ed&_9C->OxwOXti37z}&LbcBBb&>rMzH%TVb}92B_pf7D?}!9ws*QLtEW3ln&z41 zw0JtDJ>9Y_@AT|15BJYAi;g}$)!cOYR80d-MOn)DGp-lMM~23EdG))K&LtPJ2@ODT{O_-H%+ObAKO&ldS{wF+>l$E==@{0NLDjDohGW9 z;IN&v_-s?Muf|`zzu@}*`quNY=^){#^ym@wPS>64-Me=8(=paufK63QQ(jWe}O7sZgmz2feB|9TzB~00|MY! zTJjjcxHzm@fN59vJ(qS|?zx$hLZPN)_uNv1QZ+|?qiWpBj-b;buDwV=mL+v0wqvM| zrTC}^?Gv{E3q+tFIx~uR_yf3niQ+uyq@YL`*-D&h!0wW$M7Kqnvwr(f*r7cpP_MG} zmzS{~3Q;n=SH5gT7SS)2qaBG-S0~w46ky$CnDEfq?QfL6Iu7ai;|tJMcYoII#ChV} z1GGsx!W?L8|%w`tQDlq7iG`!j^o_a9auBH9-Pf1>8`@GyvnBGvft|!$eqTM19?-sFHPAyYf?@MPMNS)JpO0q zOYxV##F23nNOgJr+6?w|`}wxx{n|$3l4N$u}kH&(tirc0S0y!S4BTC46~TC z%A+184~eG|pNpR-vd{eQz&YUCqa^yieGMD0lEpp3NG@v!5Fwyy9y>-#;~vVYaP}H| z)O{81b}7Ox(k_rYKmmIyF;Ah56v*nEHjp@#yp^D06U~!laY-!hk*t!z8ir(*XWcvu z!p>v#s`;X#d4kS3VN>Do;)axFaYmbSF4b5am+Di3AavL#JTzfb-@^>6?X7?2_xffi zii7&&ta8zRm0BJP5TIm?Qoii z(>PUPkm!fMk&(g5Yr7J$Gf)1xt)fd8Nr1y-EIK#nKJ zF9h0ySDNO=v|_al#r9!z$Xl_+1{^hU*ZW3yf?emK4c|{ol78-ErQHrD8Mxe>>bzY$ zQ>4S?{{tGnd_5fNIqTV(c3`9+&?le8%;N?Jxme2J1TSfG_GAat{JPh$^@ABn zO-$@_Iz)uZ*u(E#&HpKUbyqV#X09%HAbY``gQW+mRO~*M#Xru@!5Wy|8I z%#t)V_SDtro?+EFTiWzlhU(8E zpgI&1D7GJC?zFu(#1UH}#*y}@&S)8VYoGpmE3|ygozR^7?^mRRhd|gNS=bp39BlE_ zE@@h+f0P-bC%#J*RaWv6wubm5a|`5)K`o5~Z@LU5T}sgQ?12InCy@kkSF*Qv)88}R z!R0F?VQ!9sQPb!daCVZ(n7jh6N-a_={Qmpr;^$A_dL@vFIQ<4j_cxCy1W0Tsa*uwJ zRGAeqr+)SY2on+nnU}LIkx8>^GMKc+zf=K!XI&{zt~Rb0jZo`QDAl`|?B`YGqm`hF zDt-%?skGS!cE~*h4)OU0Bb9y*qb%gZi7D~aeN12T_xkl?%1<*r^9 zFDtxwiF2eI;AY(DOYozZ$9=5|)#_MreorwDb@V7x$fJ?|Ka0eML=zv-G%N7_3B?vT zyE@8k2T!QNC#J+x*LgWt>gPEnHU!&;(@3bzfB@2Iw2a!ojqMy` zGo`M~(ld$+9QM>W6+#IM)N@uYS=c*!dS!{-><(#d!pXwyv;=P#)Ierz+c2`QV@4_@ zD`agPTe)KKqWLpJXw>rGqjDxl| zRuoTJi;qY_O+}%@YKjQ*Wc?^(O>A4cdhtL{gE!=NnE9Rcxz3DG%AsWbxb;{I)xBz>e>LR!$- zK5Is4h=_65-{!k<(Bsd0bwr)Cfa5CHtZ2}UT$$2~ob-hTw!qgMg%z&{`ijbR$} z4*_`q2xJ4mD;uSS&p|4R&L{&Yi6k5VeE1g71J{+{fgS>+nkh-?5NrMT@#Jzu1f)NiYkT;}6A<~VRe_!gu>wlsUZ zO;FmoE-P(lO484c+DbF!NJWB*BDZ_*Z|JoTS~Bz~IfBtBPtY5nFnN0ovf+Z1kiUT= z=!~EkG^HnAqJ{%q0Iykgl}=(lou1Dk&YH-HL4d)xg`*jvC1<+}ttWf%1CbrYeLvStRbah;WfPd%&S>%x+{elZ@bsa0*xsqn#81fUD18 z*}_tlaWh?8%~?5o8*m)N^?e+IH0N>bb_wds<e>Z7g+DSZCZ)`-lfj{- zasb1m%scBU(kxgxj^ETbHF*_o6UKr$SryQ&Rzp0~_0hkdOT~GqSIhsXb zaNK;^*n(p|<0(T}OevbdoL8ZlGbP561vrH4IGNY|prMAIr{k6Cl-^&2ae?*T0S1$^ zb8vET^YHTV3kVj>@2(M1F>wh=DQOv5IeCM)vesfh2I^DCuU9FQDz!$d(;JK?Gs) z*&R-o+vD~5JuQS_1QLbDU~zZ?kwm6YX>Sq-Is^$n6ap)Msb-*0qd5#mMINy` z%@|D%*bzb=+96ysvTsf%%ECVgez2m5=9h12ja#q5->$P9sZ?wxAgr{B%>qc7R5mV~ zFrkbKskE_iIjLfDp-l4xxF~;bMzF2o+TY_rqI}Z-4={Lgn+qg|*QirRAxykg{oa$H zy(ng|=~N01>848ylAnkPE5eGC(S0<1ztqA+@oc z^>Ps~@wikMeP4;%2S>EA+y)_)Ha0E?Ai{()E~K(?xd18SLMmOJ37;qUy|n*L8zF?$ z{9WM+m89h{d4*Sa7$I5HTrLDM=~mC{G%?(|00|>mg8saiNWkO9V(67xKT_YG649 zChfV0AzYq!2)?}d7tMzO-FO5*5HP89tUU)fhQXiDn&+xjRPP8XO`gq zOM*5=2<9KQRTU_BMxzlGwv~WzSli+^Rdx{muj4olHX5bgJ*Oipw;IuWU-<$htl`jl zoclDNi72q66eA>=9iF!N?~LU|NW7k|L#vPF^*=UOKS~Cu~XrK zRb*R@Hu1ju=H7nn?yCzNgTGUzuf|lKFqwC5#%?l!k5GaXfH&C#Rd_yiB^On~3Vh{< zckBQiIHaXRkb=^!Z;Seh+FkYJV+-Brk$)|>=?e@D@O{8nNN{}I# z`4+R|t9N|?9J=m<0r1UrCji@ep>Guf29FyF&z}L{2hz9S`4$zIp-$k%IEpZxt1(e0 z8DM8CVwJ#m05;bP?MX?ep@-X04oNT#Td!<%^x8EI^X2-lAL%tNn|g!0pz9s=VE<4I zIKS=+FRTKn@%Ex#QvxcUc3eI zu=Cpw^_r$$skqjpclXKFtjc`}l2wvwOx4ly7;`9x11x4_EX|hm1{@g;#n>p0hGj!` z5JMO_1F*y62oU#xk_TyJVJb_>r<|oLQbv~Nxx!>=2z3fT5dshh-yt%p3k4XYFQA@k zfyFHk%N&F`V{HJc1vu_}fmo4QV<$#bwrk3uvwEE03E0TGrcP;?|ErUc9a9dPw|(3) zX(xCMHVEE3zbHeGlhUyYSb)t=3t+y1$g<6;0FI|6;PDvfJAgG>BQ_-Kf`FqdRF;aT z6mJct-Pk*wjDwcFEP=jzZ7T@4>sOS^^LBnH6c7OQDE&s;q(_tn zsP4X?x;#*Gh@$s$!0xi}8Oe!2+bSTwzw<*VqAE=k{whAmk7- z*Ub&EwkcemH3M)%dq4y%X`z%}u9*}Q8C>=}lsV}mFbCg&s*`vr-<=fE#El8(91$S7 zWT2KMv%%KR!IMxRLk7}L0o^kQra7JPn{KHL3E*lx zrdcpu8t-U0M;S|7eg8Iqbu)0SW?@3@q{NPZBBzb-r$BZFHih0doy(bN z3-V#fhEy_y5dZ@83o6J#d8aDKy(R(TXl$Yz85Y?yDKP?Qhi2Jwvt?*(MG}8xmhVJ! zZEi|iH(%G@JOE_Smxub(Ha~Udi61UI$Bo@YswOwRME;PJemmes(Qp{m2t3azcPo=O6 z$4(3~1t&4vOKj|-8iaG>Db>D|O09YQNlAV!)X>9S+-~_dOoPphHoYU7vf6KZK5P-3 zSAM)NQ^$8rt^+SLPGoX^YMOq_>;x}WD6=DNc0w=qy?V!N?cDEUlN~>I0OUpBY!Ku} z!|c>*huGv^(*w>D$0UThK-Q*i7GPC^XAT3Z)OA%VDRnMRK8(!ixx02t*Y>Ys*vtft z*4f7^oiny=hHc0fBJ)6Aha4Fd`95s*jzF!41s1u|{`Xrj=;DT5%^tmy;$u3rzCAa z#{k?LAoL8BZ_i)>gM|zhF;pBI4@>9kXNtRMxY1!2X|b$(c*!5S^r=&;5B zYYef*2y2Y7YbTi&lX|N4V9lJNpyue?C*+G48Md%2!B~|5>)ABkabpf{&2e{^ki#B< z%silA9+AUoHrX$pP2w(3c<|xe|Pu!Iv3)o57Ex;9COxN?7=Bqq)Cu zGgood6AB9#zR;>w>V^it>H>JrCb0OB6tyx3Gx51s@t z1v@)uC1@wGW_|So1n3N`IyVlgy0U&aTCDX(5_QE+dg*YBuO_Q)v~rM(anV!m$qm@W z-vD>MGbbZ{B#Ey|BRyix@brgG3zArX{Bv_7cuVXJTdvoU`o37I##rdb#Dt=HI6KfI zl7R2Qx@$erM+gzTz@CvzmaQ{ne6!zXXL)42?`WYg4tBK=plGL0ej^0nW4tR6;KgUI zGffQe9KT#Dp+(=!su3V;q><0FW`+@60DAcY2rgjSFG=Qw-s87p3tJU$#RxHrETgK@l1%n%?KaIYc%GB+f5rr5} z`BJoV1~u^{oKoGh1GMATkf%W%&24hdpoaLYGyzs0U1ylLAUtZikxX(cxO`}&%r>e5 zKl0SpVr-7>O}GHdD_w!ZO_yVdqDk^R3Q@XN__>}G=NWym$vWyGz9YSdid4EIKwiOM zPp6vuAC)YsLtD_S-p=$b>PNJAGEF2mWoZDgqie;}2<~54@J5}D=K!_!+3JFoeV(Q2 z(zt-2Jff_)iBW^Nk*0*=Jiwniwh5|71A8kz7Ds9eKS>%skT5#8N+jhRj%OGb*Yr7| zh3!hd(?{*-vg&T%9mmqHrmjb1AWfHtQAAHaw57jDM$JA^9Mci_w)(U@Y8R)8=CAf~ zn8y@t(=3^DvDp0 zWg)MR#wS{x=}S{|f%DbcOR71eB^9|lU>!m>higMTP`oITM$XDs+Q^3r*WUzp+Nyd( z_*CWimSS5Txp|Gl!w{`A+*{NNJ8Ob-5F6A4d?bxbxoI%xyW*gH?+DfbmFcGv+KWR2=8-=iN-z&Ul`gm~fJG!4kq1+-A1%K2Z^pP)_ zHUbX71n2%LslLEe7(zv(Z=^3Yppb~BAXIp4$fW}pW8-ig%^{OKEJ6QiyDj~r<6c2( zn*b&TAuzgM9MR2g#Fqm};^q0pW-ZASz6Ubx@HX818S(#HQatXppSj_ItJY1i(C3!N z)gC#=0{OGb*2244XT~o)D+7AfbF+FMsjhaW3Uv``D&sT!dg1gI2?E1XDep=mKSQ_YsJxZ#RW(`q;cD4g+% z#`RbT)=c>SX(7hnj9{_0sux-iW{$~wOTTaoBepsD{zNy|S8b1=?cBRWYh|qcAMF*q+-!U#*aEG(GzoG#h_IHx!#~k7f`bI^FBJU0H&7NmLYoEol zA6_W1$X2XzVO26YD-An%}e)5@#EP9ywUg?C)&y#Sv7F=Mv!}PUHxdVKe5r$j?a*RCRIkWq& z$yXxDJWlSuHy?wKBD{GjX-47|gvqiy2HEJUJ7&0luvO1K985_D?w5DciK^YZK<-lW z)LnJ7jaHR3Vw`4V1A(BzuPS#E`47-kDkn^4bZPndFU_=$6Zneb}J;rmg^G2j;gOa9_{<~v7Fe}4N_o&2N!}fh`1sy~?)i<$jFhwhv zjCOB(;2Vi^cgp8ZyEyLG7G0A07^O^t&)n2273z$M!f>QkxI!!*@aBHuEkq%F;Bzi+ z*f;TqbAA1XymvTkL!1&-6=Z$xH>A=OqWGY?BDdbUk_82TQV|BQOY~N`wIaJ^BzkV> zP42D+^TsQP2m|mai~h3xgY__W&qQ&FOI~*$p}9vTBA?CJ87t)+)z}_ip3)%lDEcR= zT*oxNz4_kzpP%;z@CpLRJ<**eK0W)#WF=QFz%HYb-wqhv8>Wm&L2aolO-A84>)=D5 zz7#_iu+<3LR+H{F7rpa6euztz-+jO}ob!EuD9cOAUMiLxCUVNM)L4bXFX{&8b(r{B zQ)B#A-Gb-PdnnC$ir_A=dv=$?%-{d8huV0!c*1A_XQ7i=@qnND;;(bkhJdG@KTE?ck#klS)pZ7t(s7UkSHe z_p6mMiDpl^dm2%HaoP@Z5xiB=-3u>&)e#5nx23jRd7=2~KQ9`k>G+>ag|b2xfg!j1 zOSbrE-nyeoNL9f1;w2~twpg>9&i)-u!*hO?i%`1j6K^EBgjoecQinA!>DIRh*6K$p z9}j^L_xg}>z;e}BzPTH8&)=m{QV9K6TX0L&(TBmG^Hv_&c|K3(%XOEgJ)qzD>{d&C z6??-QZ_4l|)?itvt1holj-{k}_ZknPo==^x;0Wk``e;Re3n4I@Fu; zUxHje8~s`>kegmQTG4GcHXEAF7X&GV{VVco&E>iLSW+~hR9*l7w;43vkvts#lRr1- zpEXH2{sc`em3FE&`EO0GJaIZ?{Ygar)-#$LZxpjX8`2VyymgRgQR+yR40o6pwbj)_Z9Hq>*r=v6knII z>hYRdF)4gQN_rMSzj{AZc=nffc0M^n_~P_`sZsl&WxKaVI~TekbhBS=6km;v z=HT`%BD3&%7Soe=i|B6Fwoi|zvX<3I3dHV9jZYeDZ@BSAFd!)R!|*$Xm9RBXp0d*< z*K4&Qd7K|aiSv?s)dQaAGhe(H00cq3p>!?R6@NL)Z!TXlS^bVXojK+`pSM3OJ}%Ip zk0h&Bi|*y(H{Vyuk&AG{vp0QrKChHWpnP<;$$z9eX5Dp%ZpjYdr=Q{!a$>puBPMbl$D#uNcTCT|*ctzLx%^mh$jTgFEr znv3$5nUCH6lXESrdCB9LNGN-Y$azmmkzMbU(*gXKWa&>KUVVE>))v>wO|{dd^IRD6 z;vb@>i7IjT+O|qvk+r@#))-x#p@~SklKjeuhF%eMsCi#-Fj!LBm;KkdQH^$25o?v9 zUiIbOGini@Gh6$_vKRm7Oiz|o5PdkmZEUKwu%Wo5=lWDZu%ax0va;}d$RrVdc8Wtu zI2iOJR>jiH1O2@M@#ZMPWi4#A^WV{Asq(2^IsSIjV|@$X3}qRM|6WE|hhMYGDMZ?K z`sVF9OQf^0lf`PkshsuOmm7bQidg#fwNF%zuEsx4(WU#=P0CPMEO{{Yl%|RMS-^ll ztyZQAuK)Pvgn=)R_C)5Y@)nivosp!N{_fX>WU+$Nw3sdIdb6ZtRh_jp(?={HK{@iJ z`$IM;NrXBv`q@w>&#vIsUDGH(`}pRTAEwM}AF~uRjg%X^GiQC=k!6D!%6E0qDrFB| z@Ek3|P2yPBlH-2JEZBiSB#to(MwoCs?0TA}%Qd0>Ju<(J zl8fmXbwnH(z8#7^``M~;%(SQHtt{MVbWus`V%Aa?NfqW8lfs))BiYxzx-K>Quv1Rf zmS)`hse2@M`}y;qM+_=jL^F|LiET!=_uDeEf7N)`{bS)dAH(=_CHkPEBOb5bvu;}Q zapu7H&GrI=ebChOeJ3R$g>Kv#Q-~!G(#xb3s6A98S-cK3L&^I_;(fEP>RD+nO0G>_ zCAx=8xC7+{DeE1N|NmNdO{q=EqO$WE;`w4$S7;QMx5{JLCg;|cLh{`#yE0jz>AAml zVq4o`a{z%lAi5~i#e+@*7~b!0ev|pkE&XU>V^;S&okk8TeK)OBYoey5ypNp4d1NXl z=4daw{><%x=pBzG_UG}R%6rtX7Kh%v0e|(Aj}Ig;iC%z_#m7@S{l|2~-8hjh6UqO& z)SORnuZ}sNx(M^vqfpdbpDV0INh=?Rr(zC$@=>Ltgry4P9ISm2gGA?{hPyQEgj6jT zOQx7&&QZOtV?cjm4N*bmusL{X`gkC@7L|PBBZV2@o(?fv<(Jc?roUpI7sp?(hEUv# zMXT47=auZaDm>!~;eG3oO*f6K+uYvb8@ff96)C)w!O{##1mV+*52*=ee_>!@xEd1+iEC_~tFxMW zpaCB$T#FXd3L@i39|tGpByPkXYKx6>6v+>w3SHnQL?+^0u4?IQtzl3u2Id~;!E{2C z!Xguk@<4TL$H?Qm+Fyp%rug9XjoGO*iKR(Pcdo7!JmfKdiza8^%3Dx~xDP&O-aRrq zJeU3<&c}<^HfD7AeVg8?gK+==xV6@aaL+;U*GxH1J0 z0H6E*aQruEo3P+FLWq2s*MQaf8yC-yaqY8i#)?`=qQJk(G#t6i%>^14OGDNFU$nFS zW<{#Mxl|3>!{1XxZW-%aPIZxFHA%J6$BwM?TzLn7UbFpK2*^qgb0o}*r3^XOUna|w zG?H8}o%hkYi=s9#)HD5iJu>EQia6!gA9QiC`x^jICby4*?X%nDwl7kycwjS`Z8-!q z*%gjEx@i!NB@p_7&m zS)oM2>c{G}3Ftw;yx!JfRQ8?A{YDJV$#8$iuyMIOs=Fd;d;T9a596_Id)RU=vNo=l zlVgm8PIfNy1v!4m?pZle^oV(PGE+zFInsi6x*r!s*Yn+E887DbfWjc$;B&3w1$g8w-^4TQ*$WK=;EauvU zZC>+Q&!wIE-_lo2N6)~>#4L@4m5p6`3w_@%88T(bmLr#2o_qxg2h5td>T@`J4p8y| zo{aki2-ZkpRvv* G2<`xUL{2yW literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..75344a1f98e37e2c631e178065854c3a81fb842f GIT binary patch literal 6908 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 CFgb+) literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..395f28beac23c7b0f7f3a1e714bd8dac253dd3bc GIT binary patch literal 11348 zcmV-aEUVLZPew8T0RR9104!7h4gdfE08HQj04x0f0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b50920e138807f385d0b0359f4f0f09891f18406 GIT binary patch literal 16988 zcmV(>K-j-`Pew8T0RR91076^<4gdfE0E4su073x(0RR9100000000000000000000 z00006U;u(d2wDl83=s$lfzV`upmYH?0we>33=4t?00bZfh;j#m7Yuo}gkBMuFlG6J5B*sHHKd(*=umo3RRA1q&Aq{Qq;*?z?Zs zS6lWBvpA{|4kRGzglV7W)AM`dl?u#krjN&WNtdj+pK9tmbDj6g11qm=IR>q4=|=`? zti%rTtj4WAvC1G_rIr^=2^+WshA@nFohl_hT*y>e+7AVqh%8x7!MALuOl3;G|JvJS zZ2pf6{GYmVua&&rfSf~>Q|VHyoWtv{ooO}gpZNn4!G}Ns2Wky}~; z-+Rx%Qf?d6zTgLFWNq{L)|&XtUDJ@rBvM+z<#qC}{~v8;7xR!-65^qpmB9aR)86*I z(Fb`#+6{RXz>gL8A*j+OT~ahDXWkvbdrxCqZx*DH?W|_}L8Ap}LZi^ z0IlqWBQJkKu7V{2mMO|~b$%JDQZ#*va?6C3FLd5So^>i7j8{2goP1iH=I;vx?RqZ+f%D!E1Q}Uf z{0KzZ#6dL*1rA#A#nlOe2*^SaCA87WYSuH!F-~xf7kHOX_>w>4Ow>GI^i|*Yqu$(o zy|edpvIl#l$1ki=Wz?DEGei2WNuq=@I_Sp34KMx$U-n%;?B1Oo?y(DFR2sE^JKR2X z-8G;1*ayx#?E#1FbCY3f%;g&TKkL8!pWgZVe&=t0G8VL%TMb-GT|7;&|I;&j zkM`FvLW;i-j(9}~p?4@p##%xxg#6NNA;2G8NdOw#s3Z)rVoM@GbAqhjDO`sP5rWI` zddKCYp`S4K#-PLrvlAPlH{%u_3>X|uvq!cmzm;uF_#UBueexp|=;6wEg#<-aPj zO2>wF3fYv914sg zp$!>z%#4E66NKBGCU$09PCSu}|0gCgJH|;w%eD_&Chn*gwF-LfJu|~jXh6f26o5i5 zv=E$ZMC1zH2?(VfMZ%L2!B2vMv)L2^K6_*wUZT#}mw<#y zTcCP5%QzGnTzj6hJM<`XN2wET4&g$%Jpob0t-?9S17aH!^vo`#aofV)Go>6J8R8Zm zNFf2FlwhQi5Tuq+q>(VBm2jk!1V}Ft$RG)O(;y-=CEix|yr3fZoHGY4ncikgezV^v z&Dhem+25PYh=OYd+egsPPDGUiiA~su&DgL78@6J@N!YLr8&1ZC?bvV%Hk^vh&IIbb zMOpcQ%7%^xp@$fX^vESsxkHx!*` z8PkoPf1#mrca-J;XlDa&{qM;^p%zk!O@j2Oa-#+Dr;zq^zsiT4tz5uwl3bw1AczDZ zzuk*U=ApV*m(1^wCg8AZU;#2L{1hrR30daMp37-`;FlBOkIdRT&|RCaVB_{Yt6Oig zA|hGb64DR0Ku%f~);#TPQv;Nt5n_yusik-{%))wC)-f3cBRNI-@q?L75&Lhq3=ygJ zHDJp`QK_?#k|Y)}E8Es2T81J8Me@*kIve5cTC$iCirP4=sD#uX8n!GkC;~8+9 zc9a5OOd8*czk`^sP>VH@6N7g;+AfZVSF`*cjF!rZB_EQEdFFSNJwSrcm4$b6%8opo zXvYIV#if$1T0y^McGRQRDm#>2h&;LXd3Vg#!hHx;yS>VOurT}04S+?Nj4LU${h0DQ zD4{efI>u1YfcPSf75)>El0}OExlJpmQOO4qcL-TD3fFONXZCN!pp;2qWHo!)T0R(C zG~=v#izt_SQQ^)Ft$4~h&dQF2R1yhXjd7D-w9q_{-m3aTsZUF7aD6c&urUlf>Bb_X ze^7HG;!7xiehPCYT8nudXHB8*?l189t@>n0~k5)@!|=BAippP zplJt~MfMzQ;DzI*fma55O-#_6u@TV#NM}<(DohV0rU9_d;k+YYeqJPW05NhTH576H zDIGwK{I$i5iqm*>+n1Rs4YJ#e{jA8{*82y5vJ1i~ko!X=*mzljpCu#jie z1<%8NmGYRSJY^}*S<1^&dM(gf!SfDR86R23XO{7mWqdECp91|BxFq`zr;gvhJ?-;{U?B*Z z4Z#qHcQ1Sa31vZA4qiwVYhxt^5N%)GEmGIal1(-4o$PUW>&S}Umx6InD){m5;8B#5 z==BSTLIuUFlk4@yXqthNP@Kv&e^zBp4j)Kn*#cT3kr`rS6LJc z)s=K~)i&A0Qc9A%TjpT+MFEP+l+uNR$})y3(km#Q)=DUejpMv5!LzvyDQQ`WK*wB( zWJ4!Qs`MI-UT?Ge$sV_3kv(dT_za4xDG(N`BCyc+A$=}b1I-}IgtK{n7Gn*xfI_L3 zNdmaU5Jm;qQ2V#1CMHhgK#2sZW*Ww_y7MwE~SKKEVnJI8Ww; znjb!eLwzJTZyZxWxFqgs%z9QNU&UCXGWi%Z5t)O8Q7CA7;V*x2X@GzKJFXoQ?#okB zYN;mQ3Wh!~v{_uzD3yR0g)$+y?<1}HbzVXAfrKzy!UzXuVL#zxm!qn_hMJF6Pnl2C zWm2r-n}N>Z{^PX6NPJlB{^*bjVrWemY`lpPGuxe$q$CQc!soke)SQK2htF3_%SI|; zn3A4|T>#AVR@=W1I?{+V3@6Pr1xLDI3jdNyE#k!zv&n9=Pqv4|zNkB_as*j}S{WFWVj27}?Uoq5_GUyfl@>s_i3333Q$g(#pRCdm}jY~Pb(!!8lh4c!(ZF8nFP;8Ng@P7I_q-Ss^i!zr*bYe_~-*Q5tk z0W=4Ot^I&-u@pu$ph|5KiH5q5Tp$x65Y$PMwchEbTzLgF(9O1!)gycS^Mtk$EPhJZ z6mdCS& zm=bOoVVI_~*z?)u3X(_`CNY3dp;5vcCi`l=v6_d{WKCO4-3EiD7|gKqS$Q@BEfoFT z2%4!aGXYYljWUSeLJx&BA*^Gj$p!gDw~z@XLpDU4YQ1M8x~w#qi$pnm)WFPoxEpJI zjYPy|F~f2~oNe!7tiDDcg2G0`sFAaq-tZGzDi!|rrke<5jghzSDfEQ{bg%;m<6A*_ zO*V>8!30%mfsGQ+xb`L^%p^aMK^}Fcg4|q~f5=j?k+9fG!ZHOe1ry`WE>1p+Y$yG{ zKyGViW8u51|3$HUlCQ=ym4%8#J?!uIB7^#%ECceKCW!4Mni#H>q3)#MM{oe=er;XN zi7p1eLHLuzKoZu7(B+}JQ}l6gL87nxa*~3qB;2DlQrX)8Sw=Y^mkCO=400?>Z^h%J zQQQaFr_Io*kQ5XN9D1Hi(NL_rwYf)}w50n{8^wowkkZHp1<2}ePc8FZyq1A6FPHs) z>5Y| zOhwWFb?E03?7JUsxSywBb-h2ohNxl$yZq8*>AbbZQ%Do?(nQZxi){Azd?5k_RuCG@ zJd_t;toAhjapE3ALbr=GvD?kuFj}Jo#i<#MdMwPq-K=G{cNM`vxuB@ucxDTE$rE8y zBWtURlAc8@r+pvaAlnsZQ95sLmvq4v@lxzebAQyHA@>)@B{6|6uuY_TwG4RK4}#c< zV}U|i;i5Fgsu;X!1+ia!)2$>jNV!LMyG94CG|1pU-0mKo;;CjZEY)dBDA<0IRDQH8 zJ1^;{h9O3+4v?4B=Tbfrk|0bwJm}WSIdLBuP z4}c=2^8m=LPia-5c_hC2hIhl3F1P@;`22sL&&2;L$v=>tJJR131;fPc_=|~;Oc2n+ zK4H}N$4-Tf2E!)U1^RjKln;TVO=7ICOAU9nH2R~OkNizE414K<<2WVf^SA(X%Z^d0 zrHswC@7NcPVy7rk>^LFRVgO6QdXHptyM?4Oy(5w-I9_H^kB}#+`ER46swU%=myOVs zX_#gRD=##!N;5O*0m>JVb7m~al0I7LaEOW^s*qYnJDZCjB?Q>=Auj5E%VPqsomB4; zOe)2ZA6RA(Lm}E7K4^k8ZKT7tPwsMU;&ry#)1;AP>)Vyqr_m3(Zgnols_GXe$a}@E z*(SMf5pM^@^m@oSTw8I@7jbG$CKgK`buz*r+zZWxlMO{wtwClawh`xaXhMm9;4wvL z8LD!Um)v4mY>CnN$oZiBZL(P}&c-Pi67b1v$SDFXb4q+n7%UMK-BM8`+|O9Ws=RSo z)2Hc<9-7Bz>X|SI(NC>Nzg9FGOzHWKC@-EMVVKXPVh|wLJkgKI!5>b6kiXj+&M@Hi zLCcUEF#VT(qcCSQ4Ckw#jE_2s^k|B-Z<_oDw^Etu3#d@bV81I>RS;hj8OR6{ ze&!MkQV6Zp8Z+^KL5HxkyGH**DXiTM%c(_jFQgZ3wmXa*)9L?qZF%E;n5MFHgi+1} zh60(WFk#!#PEijF8nsLozR4%7f(D*rV+kAQ&?$#*81C;=4ic%~ zY{z}7Wya0e-i7x(+m7WKFz9sPhq6MEem$_Vh4@_wM(_9hmn|5I4H%elfE1o{>!1ql z9T}`xW8)?+hN>9@$_RW7glTTMh2KrA{jtU8H||DM0T+q;7_*HeLHZ`p&$Ip}p#jva zrG@7`E70}2E!8LNRg5JDzs^270W$GaD2%``ES5hHZsM3Q>2-XIt?ZcD&m|H7RK%@# z&BSx(c7z6)>wUXM&RcSb(<$&11+6IM+*@Q`Nt z=fNCl9nCAyLnK<0sR3m?+Tn0unRJN+v$qjnd^>`+(ecP*B54m{XO=k}Tl-;KoHI4o zQ%MpF>o4*@vmspqbRSoH5ycJZ5_plc3SMDiIkOR~NI}q-N4JGUEG`U*WIQlS_I061 z*Qf=TO;J-am?i)le|x+{*t9KSd`eM2O~{rYm|3jMHR*21IkR%Ri0p+$w~vL>aklU7 zcOYRthz_w4-`tktH6CuL`bLPYCp(~a!Io?;9Ji4(=Nl#%nr#O zq%sM)EzGBt$albx;6$6v);tH$ySZcuLpFV@$Gpq<;`N1d(BpJ~8mVz@o1hU>*Ru}u zU+YYfx#8y$5&NbQs64Wq%lVF6uxD1g)9H;tcWK755GNbgNfJu1ar4O9WBp87F;YsL zu6T2zd5Gx5Ibny)ci#1cV6EyUmT=ouxW!K~(tGQn`Di}MStlr5NBRe9e0+EqC0KiW zIgL=|x{a*w=U!z5ZjhsbeiD0mdSa~Jxh^%#LSvvaq*6LMC`E?**JI0(00U47!RX+oxB;Pp#FnIo}hyI zx#D@6^+kjo`3d1YQZf37YPDoSf7)wF&kSrxvF^QBCzlI!k(L-3ubX!0c5c+m8Z9j* z1f~^HX8ZSRPK=41W=O8ly$QN+qOUO<*`A(k%4=iKHo!U&>FQ+s6S}dF{~O_UqV^g*40Z^~E-_9ncFKgXFlvjoqcD zM8VQVE+q#@Vn7T}#D&C=v*6F_3D9ngb6udG$m6L@(+jQDTLWW|Ae;2)zY*Vm~#%|ApE!2^5 z2Za=xhHCVAzCzjhJHs=9dLSCxYG~Rmc;#)aJcMX(nBg4zqNA(zQVtUqpLF zX*2H@6E4&Xb_&M1)IEnWJ9!O4%G)4ae?NskC^uWIuwU&)>j&~3+w7of)=LbJNvj!= zaa;JJ6G}cy9!u-Zt>)sPq#!ZXsXT{Sph@C9_tq>jX^4oJB_^_055b}v4^mWV^}`qz z$r(Dk_j?iY6_zt9(_Ir<+oP1*EY>+nM{^?eozL?T#M|Ufek=L9HoqQee-XjzRQ{`? zgr%828U129Trd;QC#xeW$n^5jVCH!V&r#6-?AkN_DB`2N8PjdOekfKM*%nk}Xw0g<00!xi68(;S`l|-<= zzo#FoImC1FlCBCn&NH*b^U@@A5y?n5!RV$loIcwTChg@FdbqG zCD`qX$PB{>f|?4(C9qy8kCW7(PNhXYj%h6s0mL{XZ7vAXbU&k&pbdO^gO-wYu++)0 zmmKMj{d4$TCQu(U`CpQeD;_7235QN)%D50d)nE2^zWH?2oy!c12zSi0FZp0Eiv!)f zhE|*4O#=$MvL$(gJX}_6y?9^sROCySfR6|rK2gWI(?^+Nvugp-ppvR3l z@cnFohB^^-5kQorM+kDh}%64gs)d#H*+jUS3F_c_n>h}J-qnced#N8idT5` zM>_62At+WH{$okvyE7?PxRNr zN!3YVFgsy-L@GIBTD+*{p2+^Vka&_nyqjiB!9g&5WFkNa-d_A3$y%fi}whS?v!KfJ-pJ`-7{=I|Yn#ddZ}Z8h}ehmReGzyAZCX!&GNrCk4O zPH>j8t4Hdsc->JC3tkZ-fUDh9wU+YZ#N!0aS=AxV3-&?|_kCZ{b;&iEvjSYVoUB(R z`?E<5ud3a=qapD6p=VxRQN~25fS#~^G&UvrV#S!Zlv-nu;;AX2+$zsD{!de(CbZ4u zaW6}l8`n0c;>PT@sVCo^F=e)$`E8cPpIjqdoThYYK)Dl8^( zs>s8Axp3%8m5dDZJ}CU!>aVOUDq=u2pz4xKusykwVJs=Z(=L{#b^nBe^)Ru^ek8e*E5*1`t&1LuYPT8z(q4+-fED` z^>Ai}J0O)EkrC0l8bnfgM=)`Lg2f+-K-OMnZGD44tyMD>?OTI}^;2c;5dND5MH?QG zz@`7&;mxDY!^*?X@vR8#7a=WT;=B+y4jV^CM@?s>;xnf4anqRTCj9iuY(K4GI!Z&= zqM}cUW7>Omr4<3#^tnWFl-K5sg57w{-w6bLie@J}7Q5UC*3_K9@8ZrYbdTw|S9skk zc;JgXF+{zv`Prv(n&{V+|NKAC_}%+%e%Pa#XFuqVxjhy1a@81mDDS*_G`TUQWo_YC zZ|5f6ZIEFPO~2~CVn38_cyEP=)wzFv*Y%oV-7*{T$G5ClwgEN5;{k0>#VX)LW#pbP zBIr5@nVVs9Fd(K|fY}rWW-;6kICTNr)xZ1_SoRqHPMzv!HKCYPH;h3)G$aQbXH_X% zkLOO$D?L{7lXn%sO>H5mf$^NZJXsVFD*|x3B9?W|spv!>>^mit4t>AB2veZ(q0b*?Tx>u>b_GE=}LRs$(@rvE= zdnymV^>str_VrCfmn_$p`w+%9mRNl1AD1A$_iQ=u{lwHhqjv77hj0>>;r|{o-4TFS z95_SQKcu{!+OtUe5hMdAEE3O4`s2nxqx=Jt#28IL+8nnT@a zTI!vCF5X|5=k?v9Qzo|W?;sH`RuC*N?ea5mN@Z0b0@tfa_+^piZLWn1SPe%tl zUI~6lpGpEtfcjqLc>B6_0gMghl~yJN!>P)4sV~1(Fy$*udazr|2rCR3_b#3lDyR^M zwH^g(wVNp=9kf5AzpN9SOezi)o@579MuFb`l7L9R__fONL$cMT^@#Me381y=W}j(dgEeK3%drDg9p`}kwL{(gOC zG2g~Si^^Bg&dqC9Bgp?VakCU!8N0d&$8duG+G2K=x3tBw`I`6L%HlkvKIF7mh;JXF z`bf0w-_V>V{)sw&&M67xE1UE$j>SEnBzUbt&d0yMi{r>RBAWRBtVQ##q4-Xyd%o_I z7k3;AYd@Ek$aVV@-knYiR#DX+9x&5mhxR8$vkK9$Qf^{)KWj_NLwT z;YfX8;h~q4b)U71+HHGP`~*U5_Re(;$!BMFu39PSB8(;>wX`|_L%F)^c!R8(2Z2*ly{*%9YDrT3Z z%n?m}A1-Vyo73J58!J42Pj@v45}Ri)Eg3AD z)0%%aDBgG)>TKP~vpBH(!Qdn%$FWjlj)3fQW{v7QMb&O;Fi`&v;IC<~ajtDD?#L%f z5-2&Ct#{0>FmE-F1r-vfb<9um4e$9uP{=Fx2{4ow(tut#hBrDU&+mDAG9% zs@*0Wk3&o=WHLq|xr}omV#-Wi+Blk(mbmfVncF9TQ6W~Y%sJ8k?`Gwu2$-^24I2y_ z9lL)^+;ShRf?0f#K;DNTr8CUXrw9pb(xjRFTfW1v-mpgY3~Xlhkv!sEtvby!&8Q%2kSA{n)5Nc#hi3y2fZbl!)jDIn%L0oULa#?h?exHPRJ=aLmc zr>W=m%bB!D7*it?ArH8+ItV24+f2;gONzuSg(Pxc~H*1aywRJnMKG zhFH9jNkWDhI6BMgGz!@`P<0H8)@%%X1Pn$-j9W~b3HW$^U80RrH=edglB!U|yP1oW z54TlZn>5u6D*s6`?>=4MOpm9bg8k2=@VQ93-(keqcA)M&DYn_6UAoBVuC4(1g(adW zJB-qq4j)N9-Kh*fGI4n-%<+I9p%=9!t@_-a)K&LQ7h4$0ciB2j>@BdyzQkjmiQDAf zbNO%C+TJGq1W?pMv=j)H!_`x`Sm=k=v2sh;0S;_k(_fpb0I~*>uUwt1QnDN<+|FxD z1YC0x8+oTC?gX8YS#@@ESIIGTIe31O3BktVxa8>yIt(#Vj!rKNi8Iw$4~ZPSih%To z#E9?YMh?@)Wk1TD$LE!qx>RitM+xZbD=~TU@X~yEn*&BYfj&R&Z#J})^qZPtr0HLX zQBR%6?*ohnl1qik1k3ya=We2~8IML+m&puVR%Ab2KOWf%-3*-0 z3!Jw_XS{BTBgW!*b47%uPEJFBDH(W*^q$DREH-#a5tddQ7mwtM9E9k^HJI@E&myFw zsGu{c%2sX!JWnOuyT+fYx^ut`*8YJQ_A(ru1$cx3Cd7ejo|5P;H%a=p_gAPY&565@ zbsK)n>XWBxDLp!j$9GJIL zK`ID)gI&J`E|Q_g1vGX)aTR|(z0=BHjKu^J-Q{MeG zb-IYie+PZuBPk2#=CR-XFD)Xwuaz1`j2nZnK~Ap&XBvUBZ9<)4T{IL~B$=e`<~V;I z6Q*n40=u=vxzm^EHW`m-pu{p0Pg zQE`bN|8ujMBn0&gDnRpfBZK)Z-6fj4LR;+ffACN;b0g_%>c355ojtvk+WLgsN*YmE zLLdcSF_w!5%__%FJ`!Ls-z#;Ahu5G065!T%AjC--%_JjqZ!Jz9;&L)PUJJD?1BK0r zAY{)~4?VF$-w!G2llBETa?;p!_(FgW(gFmj&*({OF?8JS##eFmiTM$w8}HkTuE+I_ z)MHPp=YIfu*z8tk=;|JI6zNx6X#qGk8Y`|?KDa1VGNkWgQrzOF$IZVzfNN1O^9GwL#0SkLk?9=RpzZla% z;=vs~>+&XvZ?BOd;A{yF2S;2TFoMgsZIaAgApN;Ko4iC|XOF1xVxHR@jdN5SqTffq zT+@2&Yu{=eNU-EG0jgXM^1IYL?M@@5!ljpXWA~Y>xbz@ID5<05va8?Z^vVH)Xw7oD zIqENti+l1Hz{0V*Ot%TY71&a{1+Pc1Bzi3jo2mZQJxhyh88@YGFpphQlf=zUyr)pS zTO=_WVbPd3Ej~FRu=8-)d3f|5%UprDWJ+wK(_tmTk|q?9SHP;Alg1H&GGV3m4E$~1 zaBFtn{@h9T)=RovINk3wo`9+~HIQ7&(pjak6UfuXcX3erIdp1&Q$L+6P*SpJ^hqw` zKWE6v^31LRYu;{DCfpBZKgg`Qq_@Etj%?YL{Kc@S;+|G!V($bF$Mx__|73&xIBS%O z1StwQH-bxl;j5{^tjQaQIXTNO0Lnz|Y?oKqQ0kAE|$&c%UwU zSFV0r-EJHa>F9I`whRj@BtOiD2m4rSmxga!O8f~&p-ATvpfYqgrRPzGyV1V{~TQr zjgp@O+)UlE0qO}*@u6}C?^Tf>uNXuDpj{NRhq5uZ-z92+kQ0rW=os$?>y<^Td9gGfD<5yhA;`aw+>?r&jjG@GxZDC_@s-2b-O=hx&^Npq|fL1_gbAVVN&Aa$1~x!NjaieWMK{U&xnw)Z-xA9pg(&{E-~>xaF~T6x}~f&-0R&w~U(Kv{Z~X z1Ys7FeYx;fX=NtUDoEArP;P?L(_?&TS|TG8M!6g%zh=&}^CkqA-;6p`L&flcT5>6= zgc{)`UOhJU!~@9JZvg;Z$&C*Bz<2Hj4;*XXIrIMrd*+*@Ev1K7mW$ zzOB<)IOGI7LN0ro~l?#iZ?m zjr%Ko-Et-VO(SPfP_rq8m#5;A=Oz7OBehLj=7MN4fR-p?*)=ZO`k;+Q;pSiAD9MtH zamn-(7HLK(7sLo*6N{{9%k`p*rGw|P;)r0z*;_50AWCChGPUFR&n~+@TaxsvPs{Ru=ti9C=xPDpIG`89#8ZYOY~@ z^83YFBB;XDoI3m_uUY%N#dGgQRsZzGUz;z`iA|hz2g)`8z)De=iesurwJpUSnHT-F z;QpcAC!w+P6|$d2bBS(T`^3MxIynR5fFX0VgJ}WD5xnme_1HmE(nl7Nh8rtP-?&6+ z%L?(@5;Q|%;;HGQ|8Mv~2@(GbC;IheeH@EkOjNj&=B$2qV|ji}prO60efW3>bAvCB zv{h-!xq11|r24G-&zGv3HSMmLkywwzeHl$MA?pE;Q3jJCPhAq=KmctFT2QtnIA@M^M$wEx!wPaA}eKkaqv zP2;AU@?+4CCHxDNJ>%6CuL>GX*vtRwTysY#{(~XDe5;(wuqBl*Ypv+`V4cG7rIzZW zta8%m1lZVWmubzsA65Lv)B7qm+dPix*BUZDOwn9X=y3I7DJdrCFjEV`8JP|GcaUz& z?)bx-20Z{{j8C8beZ_mC!d^K=#TFiW_uAMsz1?D$TKAZ@LvTh$9LX$!*s0_!x=!vL zANmNF2n&D6w_g0Ua(=p;GZVqa(}6A1meluCFo~smZM!1q%n;)^Qfafn`K!Dt1<#~) zq&V@z3t|$)DT<0Fl)Zod!S~F0Jq6r%6dxI8t(mKJHo8u?EY-hh?-$8sK2MQ}4(Ow^ zQa3y0`i0fXZjvzXOu{6($i7i+brEs$&g_L;Y@P~x@*-Zl+$Yc^wox0W1QvhwbWN+(4P)qGadz`+}l(AiaYI_*}qMTcw19x}D0Va2VKxaUEgJ?BbR zrren>TAZo#yn%x_#lp~%(C)l;_(wzO<(xU$NvXZ0!VEA&dv|K=ye}O=?`V`^-;rTY zS<-FRy@jpdfuri0wTXaz#UfOw7tH-n{wa5v68bc@pYS*|27`wd+920ATj^pRg(xq=L>AQkENA3KgC@tNvH zEGnu05^`;J3N=SR#F1vz9lF%8ZmW)c?7AwoT76^r1j-)c49^n}ziNHc$P6Exj*!I} zygX@od1K6xn)T>aqdHA9zKeJZ&lReTF}|$i!3@jjxe+~%VBE7CCnS#2la5{{p`ej!ox^2JSCeoc4s&h8{ZqC7V?}2Pu)D^@Lrp+Y$&+v7+ z75AX3f+W+ZX)LKE-xfcnR(&kQ@UjIQ|K&R#n_;bf9gLez`9H@+fk&Xf`Hla54NVzee@AXUAcvPP&+Gal;mTf@J|JJiDAFeZ z3Ph24=9^KEGyL#d>P?<%1f-`^Ms8*XpypG}h5zZZcgqkv3z4vCq_@0LIIF$b{|xr! zqe`q|ZeM9~*s6S(*A(g2`T%nKtDJD}4_t#+&W=8128%M1((ao6nN*o)(Sm@lTvT>Fb9yQAA(Mp zZCD0ewHc14J2Y~Iv{PZUN~c(GA`jND{`WgL_i3==?Kd(Ke+`L0Dh)A(k}6&&cophb6_6>*2<$v#__QsJQ%|CmZM$YG$@z~946W&%=lNeC@=LkvzQiPNdnswNsem&cZD$#BZL+I4D{kR8ZU?T4_-%&2Y@gG ze?NhYo)cwfKmFcRi1GSJI@`hxD5Z<8YIz~70SbhL z%!mV#27yLhbtQ5#(j9SW-lX7L{978p%Rd;rcsK>)F?ctOcXiGx{Fgi7#Fj-UfJ$ga z5y}d85u_=a+anR6zr6Ao)U)h{w^4%jGp@eCKDPK86ohPdaSY4Tiy?UPD1uBtEJNi2 zXj9Ep(~#MiKwwmXctpm3}Jg`{!=Zjo6qzNh@*j@z$-jR#GvIcyuV@Djo{QyNN3@g8Y zL1#&j%^BNQkDORI8zxtnAOzTUZP`6OA6i(Byzu?w34LQ~RPMmhrYZZ9nk3SMVYlYN zX?k3(=m+}2%hImhRa4=8Ya%%ivak`K37^jz0Ck1(s$A;3!ks&DNI^*a8Z|N|NVF9*8!xvtBtmW&laSo{3W`aq52C{ zJ0UzCXN|$LqLHWIxyNw;Kz!1~FAfKelAxYkl#=$aa#qDzpVc6)(9{vC^gk}sL2LQo z2Ileu_al~Ws@!oLkO=4>NM4!z@J+0B&o^x`42NGa zNES+DOI`rrS0P1{%usyoriUcAQeqVOdLogyF+3badLFxS*?Km->E$syBn>k_lv zTRNgp!imG>dET6CMdnDxI+B;J5^E(_QlnBnloB0DT)Xye`+0K22dD$wJ7-$c415fMo*m34B;m48Rvbt3n9LTB)2R zmP^y+5G&GfXwa8u*R&P!gU(i#xRYrJfiZzXhuuCyNwDFL)lx=~my6(FU8P+d9PBAb z8565hK!eUU)dmYSFtUnV9Z9e>gM_)lKW?o1Sf4^p75OZ6-TKA}r7DYk#-@~bFs|B5 z(fL^_%VlE`bdjuS z3fB5knP7p_#P}+$aA}^^CL5%wA_Kur%FGZ!%jJlyM$BRfK$Ijw9U}x*V>m@%*#11D zkd6!BlEO%bq>@y161Xl0DcPlx9e|T81u3xr4k&3N5>V=no7J4T!u~R6G9`;hXoTKQ zS7U9+#k$W1O7pYq(q@sxxCPfNEXvqkN37B-hU$2NC#~3I5kQiNZw3xQFs%6z@y^h5 zWf+puQY%D&;)!0jMJYiLp$ulG$YEIl$t4801Gcwz)$(~>kz6ewm(L3p@dpcFo)7`{ zrV&gn3jz?eWslbRqrKcIFa9Is$k&{^uYEZaW3{fq(O##4AOeCR$W3vTS{iEY{}Hqp z&`NZ66My6CkgNf6mJIfIgG?U#tJ3*s;SGoK1b)RBmg2&P>oYS{^q$ z7n!fmvCw%T`pts`K!Za#Os|pR41%Dhx(J&Ynb}}GIXg$(!M9VLYMN95y%@y%vX>~# zmjIfJ{11kKJf8euroBrk#OUV1z)VNu$O=f)eUAg~z4yT`RwQ^&|F<-5o)^~=hHi*n;A4A$96(u& zz6T106j0hR3DPeTNbf1M#P-%Ug!q7F*$QAC*a{}`=vD}y|E*Bwpj%;lvCWS+ZY6Df zp#Q|mWcQ2wG`fIEz~R|2yIyCHq>JN9709?zrxh9nFf0eEDvGLz8A|2!(&v@c;kzcn zf4EaN&ZprZC$OM*A;Izny+@6(b_nHep5(q)OVVd`K?!y{?`q8aj-;f>QjS)i2dyFYrS!>kqBs}4GqHx?fK}?|FQH)>w~y5#C>4c) z(n^WMxURLFY4nL%>LqOI7zPpoce+JLmjkDL;Mgn9U?i&=Xx7mkO7Ux}anNNo1rf{i zuQGWS>*fYR9_nFbxInJ z#uoh|XEqfs9h?40SNOkmyE+ksM8qVdWaLN`8iU2*DJZF^X=v%_8JSsFC9z3nmm*b~ zbQv;b72AESi(9rFx$@*IP^d_;5~Vz{atew{$||aA>Kd9_+B&*=`UZwZ#wMm_<`$NS zz|c;cd~CM~TTR;U9VeVjp?6&m3NU~}ANbHm-t$QWfB-@u0%9NmQXm6zKmrOn<+Mkg z^@uas2$nAxaJ=~O!g$E5*Y6+D`MCLyLWh-i4-R(QPQ>evZ*Io=XD{oa1=%ve_1lg$szem2=a}pBF z({>1!YW6>)A>=45Iy@o?=U_`XF9_boBw^wWi5~%ZWLiFk5K!Q?g0XFX!t=lRfchkR z_c?-{3kuwtd~(P+Pka?%gva;py-f6~&*%sWg=MMdU_Lnd&V$AMVIMdYH~;_u7N@=P literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..eb24a7ba282b03d830fa6c63ee897d92a5188736 GIT binary patch literal 26272 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�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 bkH82T%!~T~dOL3iZSfI!+IWoKhyte*`46Vs literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..29657023adc09956249f6295746c8ce4469b50d3 GIT binary patch literal 16400 zcmV+rK<~eIPew8T0RR9106-7`4gdfE0D43K06(+<0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..215c143fd7805a5c2b222bd7892a1a2b09610020 GIT binary patch literal 16440 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 G>;nKVNojrn literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 b/app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..349c06dc609f896392fd5bc8b364d3bc3efc9330 GIT binary patch literal 12028 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 GG{*pT{>~Tx literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b3048fc115681ee6c1bc86b0aa158cfbbf59daa3 GIT binary patch literal 9644 zcmV;dB~#jWPew8T0RR91041yd4gdfE06`=G03}%f0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c5a8462fbfe2c39a7c1857b9e296e62500a8a8a5 GIT binary patch literal 5468 zcmV-i6{G5RPew8T0RR9102N#S4gdfE059|a02KrP0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..680c13085076a2f6c5a7e695935ec3f21cddb65f GIT binary patch literal 4928 zcmV-G6Tj?tPew8T0RR91024p}4gdfE04S^g021l|0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 b/app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..771f1af705f5cef5f578b3a1e7d8eff66f9b76b0 GIT binary patch literal 13568 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 G0ssIW + + + + + + + + + + +

+ diff --git a/app/src/main/assets/katex/katex.min.css b/app/src/main/assets/katex/katex.min.css new file mode 100644 index 0000000..71298b5 --- /dev/null +++ b/app/src/main/assets/katex/katex.min.css @@ -0,0 +1 @@ +@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.17.0"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .hbox{width:100%}.katex .hbox,.katex .thinbox{display:inline-flex;flex-direction:row}.katex .thinbox{max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{border:0 solid;display:inline-block;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo} diff --git a/app/src/main/assets/katex/katex.min.js b/app/src/main/assets/katex/katex.min.js new file mode 100644 index 0000000..91882b1 --- /dev/null +++ b/app/src/main/assets/katex/katex.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return mo}});class r extends Error{constructor(e,t){let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,l;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),l=o+15e.replace(o,"-$1").toLowerCase(),i={"&":"&",">":">","<":"<",'"':""","'":"'"},l=/[&><"']/g,a=e=>String(e).replace(l,e=>i[e]),c=e=>"ordgroup"===e.type||"color"===e.type?1===e.body.length?c(e.body[0]):e:"font"===e.type?c(e.body):e,h=new Set(["mathord","textord","atom"]),m=e=>h.has(c(e).type),u={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand ",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function p(e){if(void 0!==e.default)return e.default;return function(e){if("string"!=typeof e)return e.enum[0];switch(e){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{};default:throw new Error("Unexpected schema type; settings must declare an explicit default.")}}(Array.isArray(e.type)?e.type[0]:e.type)}function d(e,t,r,n){const o=r[t];e[t]=void 0!==o?n.processor?n.processor(o):o:p(n)}class g{constructor(e){void 0===e&&(e={}),this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(const t of Object.keys(u)){const r=u[t];r&&d(this,t,e,r)}}reportNonstrict(e,t,r){let o=this.strict;if("function"==typeof o&&(o=o(e,t,r)),o&&"ignore"!==o){if(!0===o||"error"===o)throw new n("LaTeX-incompatible input and strict mode is set to 'error': "+t+" ["+e+"]",r);"warn"===o?"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"):"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+o+"': "+t+" ["+e+"]")}}useStrictBehavior(e,t,r){let n=this.strict;if("function"==typeof n)try{n=n(e,t,r)}catch(e){n="error"}return!(!n||"ignore"===n)&&(!0===n||"error"===n||("warn"===n?("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"),!1):("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]"),!1)))}isTrusted(e){if("url"in e&&e.url&&!e.protocol){const t=(e=>{const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"})(e.url);if(null==t)return!1;e.protocol=t}const t="function"==typeof this.trust?this.trust(e):this.trust;return Boolean(t)}}class f{constructor(e,t,r){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=t,this.cramped=r}sup(){return b[y[this.id]]}sub(){return b[x[this.id]]}fracNum(){return b[w[this.id]]}fracDen(){return b[v[this.id]]}cramp(){return b[k[this.id]]}text(){return b[z[this.id]]}isTight(){return this.size>=2}}const b=[new f(0,0,!1),new f(1,0,!0),new f(2,1,!1),new f(3,1,!0),new f(4,2,!1),new f(5,2,!0),new f(6,3,!1),new f(7,3,!0)],y=[4,5,4,5,6,7,6,7],x=[5,5,5,5,7,7,7,7],w=[2,3,4,5,6,7,6,7],v=[3,3,5,5,7,7,7,7],k=[1,1,3,3,5,5,7,7],z=[0,1,2,3,2,3,2,3];var S={DISPLAY:b[0],TEXT:b[2],SCRIPT:b[4],SCRIPTSCRIPT:b[6]};const M=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];const A=[];function T(e){for(let t=0;t=A[t]&&e<=A[t+1])return!0;return!1}M.forEach(e=>e.blocks.forEach(e=>A.push(...e)));const C=e=>e+" "+e,B=80,q={doubleleftarrow:"M262 157\nl10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3\n 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28\n 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5\nc2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5\n 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87\n-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7\n-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z\nm8 0v40h399730v-40zm0 194v40h399730v-40z",doublerightarrow:"M399738 392l\n-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5\n 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88\n-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68\n-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18\n-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782\nc-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3\n-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z",leftarrow:"M400000 241H110l3-3c68.7-52.7 113.7-120\n 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8\n-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247\nc-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208\n 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3\n 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202\n l-3-3h399890zM100 241v40h399900v-40z",leftbrace:"M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117\n-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7\n 5-6 9-10 13-.7 1-7.3 1-20 1H6z",leftbraceunder:"M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13\n 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688\n 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7\n-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z",leftgroup:"M400000 80\nH435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0\n 435 0h399565z",leftgroupunder:"M400000 262\nH435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219\n 435 219h399565z",leftharpoon:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3\n-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5\n-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7\n-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z",leftharpoonplus:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5\n 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3\n-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7\n-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z\nm0 0v40h400000v-40z",leftharpoondown:"M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333\n 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5\n 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667\n-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z",leftharpoondownplus:"M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12\n 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7\n-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0\nv40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z",lefthook:"M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5\n-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3\n-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21\n 71.5 23h399859zM103 281v-40h399897v40z",leftlinesegment:C("M40 281 V428 H0 V94 H40 V241 H400000 v40z"),leftbracketunder:C("M0 0 h120 V290 H399995 v120 H0z"),leftbracketover:C("M0 440 h120 V150 H399995 v-120 H0z"),leftmapsto:C("M40 281 V448H0V74H40V241H400000v40z"),leftToFrom:"M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23\n-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8\nc28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3\n 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z",longequal:C("M0 50 h400000 v40H0z m0 194h40000v40H0z"),midbrace:"M200428 334\nc-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14\n-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7\n 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11\n 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z",midbraceunder:"M199572 214\nc100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14\n 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3\n 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0\n-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z",oiintSize1:"M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6\n-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z\nm368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8\n60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z",oiintSize2:"M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8\n-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z\nm502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2\nc0 110 84 276 504 276s502.4-166 502.4-276z",oiiintSize1:"M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6\n-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z\nm525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0\n85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z",oiiintSize2:"M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8\n-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z\nm770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1\nc0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z",rightarrow:"M0 241v40h399891c-47.3 35.3-84 78-110 128\n-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20\n 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7\n 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85\n-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n 151.7 139 205zm0 0v40h399900v-40z",rightbrace:"M400000 542l\n-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5\ns-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1\nc124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z",rightbraceunder:"M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3\n 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237\n-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z",rightgroup:"M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0\n 3-1 3-3v-38c-76-158-257-219-435-219H0z",rightgroupunder:"M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18\n 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z",rightharpoon:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3\n-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2\n-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58\n 69.2 92 94.5zm0 0v40h399900v-40z",rightharpoonplus:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11\n-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7\n 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z\nm0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z",rightharpoondown:"M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8\n 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5\n-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95\n-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z",rightharpoondownplus:"M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8\n 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3\n 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3\n-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z\nm0-194v40h400000v-40zm0 0v40h400000v-40z",righthook:"M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3\n 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0\n-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21\n 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z",rightlinesegment:C("M399960 241 V94 h40 V428 h-40 V281 H0 v-40z"),rightbracketunder:C("M399995 0 h-120 V290 H0 v120 H400000z"),rightbracketover:C("M399995 440 h-120 V150 H0 v-120 H399995z"),rightToFrom:"M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23\n 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32\n-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142\n-167z M100 147v40h399900v-40zM0 341v40h399900v-40z",twoheadleftarrow:"M0 167c68 40\n 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69\n-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3\n-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19\n-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101\n 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z",twoheadrightarrow:"M400000 167\nc-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3\n 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42\n 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333\n-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70\n 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z",tilde1:"M200 55.538c-77 0-168 73.953-177 73.953-3 0-7\n-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0\n 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0\n 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128\n-68.267.847-113-73.952-191-73.952z",tilde2:"M344 55.266c-142 0-300.638 81.316-311.5 86.418\n-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9\n 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114\nc1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751\n 181.476 676 181.476c-149 0-189-126.21-332-126.21z",tilde3:"M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457\n-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0\n 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697\n 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696\n -338 0-409-156.573-744-156.573z",tilde4:"M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345\n-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409\n 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9\n 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409\n -175.236-744-175.236z",vec:"M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5\n3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11\n10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63\n-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1\n-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59\nH213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359\nc-16-25.333-24-45-24-59z",widehat1:"M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22\nc-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z",widehat2:"M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat3:"M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat4:"M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widecheck1:"M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1,\n-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z",widecheck2:"M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck3:"M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck4:"M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",baraboveleftarrow:"M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202\nc4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5\nc-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130\ns-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47\n121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6\ns2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11\nc0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z\nM100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z",rightarrowabovebar:"M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32\n-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0\n13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39\n-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5\n-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z",baraboveshortleftharpoon:"M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17\nc2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21\nc-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40\nc-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z\nM0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z",rightharpoonaboveshortbar:"M0,241 l0,40c399126,0,399993,0,399993,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z",shortbaraboveleftharpoon:"M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9,\n1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7,\n-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z\nM93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z",shortrightharpoonabovebar:"M53,241l0,40c398570,0,399437,0,399437,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z"};class I{constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return this.classes.includes(e)}toNode(){const e=document.createDocumentFragment();for(let t=0;t{if("toText"in e)return e.toText();throw new Error("Expected MathDomNode with toText, got "+e.constructor.name)}).join("")}}const R={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:1.00375,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:1.00375},H={ex:!0,em:!0,mu:!0},E=function(e){return"string"!=typeof e&&(e=e.unit),e in R||e in H||"ex"===e},N=function(e,t){let r;if(e.unit in R)r=R[e.unit]/t.fontMetrics().ptPerEm/t.sizeMultiplier;else if("mu"===e.unit)r=t.fontMetrics().cssEmPerMu;else{let o;if(o=t.style.isTight()?t.havingStyle(t.style.text()):t,"ex"===e.unit)r=o.fontMetrics().xHeight;else{if("em"!==e.unit)throw new n("Invalid unit: '"+e.unit+"'");r=o.fontMetrics().quad}o!==t&&(r*=o.sizeMultiplier/t.sizeMultiplier)}return Math.min(e.number*r,t.maxSize)},O=function(e){return+e.toFixed(4)+"em"},D=function(e){return e.filter(e=>e).join(" ")},L=function(e){let t="";for(const r of Object.keys(e)){const n=e[r];void 0!==n&&(t+=s(r)+":"+n+";")}return t},P=function(e,t,r){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=r||{},t){t.style.isTight()&&this.classes.push("mtight");const e=t.getColor();e&&(this.style.color=e)}},F=function(e){const t=document.createElement(e);t.className=D(this.classes),Object.assign(t.style,this.style);for(const e of Object.keys(this.attributes))t.setAttribute(e,this.attributes[e]);for(let e=0;e/=\x00-\x1f]/,G=function(e){let t="<"+e;this.classes.length&&(t+=' class="'+a(D(this.classes))+'"');const r=L(this.style);r&&(t+=' style="'+a(r)+'"');for(const e of Object.keys(this.attributes)){if(V.test(e))throw new n("Invalid attribute name '"+e+"'");t+=" "+e+'="'+a(this.attributes[e])+'"'}t+=">";for(let e=0;e",t};class U{constructor(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,this.italic=void 0,P.call(this,e,r,n),this.children=t||[]}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return this.classes.includes(e)}toNode(){return F.call(this,"span")}toMarkup(){return G.call(this,"span")}}class X{constructor(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,P.call(this,t,n),this.children=r||[],this.setAttribute("href",e)}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return this.classes.includes(e)}toNode(){return F.call(this,"a")}toMarkup(){return G.call(this,"a")}}class Y{constructor(e,t,r){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=t,this.src=e,this.classes=["mord"],this.height=0,this.depth=0,this.maxFontSize=0,this.style=r}hasClass(e){return this.classes.includes(e)}toNode(){const e=document.createElement("img");return e.src=this.src,e.alt=this.alt,e.className="mord",Object.assign(e.style,this.style),e}toMarkup(){let e=''+a(this.alt)+'=n[0]&&e<=n[1])return r.name}}return null}(this.text.charCodeAt(0));a&&this.classes.push(a+"_fallback"),/[\xee\xef\xed\xec]/.test(this.text)&&(this.text=j[this.text])}hasClass(e){return this.classes.includes(e)}toNode(){const e=document.createTextNode(this.text);let t=null;return this.italic>0&&(t=document.createElement("span"),t.style.marginRight=O(this.italic)),this.classes.length>0&&(t=t||document.createElement("span"),t.className=D(this.classes)),Object.keys(this.style).length>0&&(t=t||document.createElement("span"),Object.assign(t.style,this.style)),t?(t.appendChild(e),t):e}toMarkup(){let e=!1,t="0&&(r+="margin-right:"+O(this.italic)+";"),r+=L(this.style),r&&(e=!0,t+=' style="'+a(r)+'"');const n=a(this.text);return e?(t+=">",t+=n,t+="",t):n}}class _{constructor(e,t){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=t||{}}toNode(){const e=document.createElementNS("http://www.w3.org/2000/svg","svg");for(const t of Object.keys(this.attributes))e.setAttribute(t,this.attributes[t]);for(let t=0;t':''}}class Z{constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){const e=document.createElementNS("http://www.w3.org/2000/svg","line");for(const t of Object.keys(this.attributes))e.setAttribute(t,this.attributes[t]);return e}toMarkup(){let e="","\\gt",!0),oe(se,le,be,"\u2208","\\in",!0),oe(se,le,be,"\ue020","\\@not"),oe(se,le,be,"\u2282","\\subset",!0),oe(se,le,be,"\u2283","\\supset",!0),oe(se,le,be,"\u2286","\\subseteq",!0),oe(se,le,be,"\u2287","\\supseteq",!0),oe(se,ae,be,"\u2288","\\nsubseteq",!0),oe(se,ae,be,"\u2289","\\nsupseteq",!0),oe(se,le,be,"\u22a8","\\models"),oe(se,le,be,"\u2190","\\leftarrow",!0),oe(se,le,be,"\u2264","\\le"),oe(se,le,be,"\u2264","\\leq",!0),oe(se,le,be,"<","\\lt",!0),oe(se,le,be,"\u2192","\\rightarrow",!0),oe(se,le,be,"\u2192","\\to"),oe(se,ae,be,"\u2271","\\ngeq",!0),oe(se,ae,be,"\u2270","\\nleq",!0),oe(se,le,ye,"\xa0","\\ "),oe(se,le,ye,"\xa0","\\space"),oe(se,le,ye,"\xa0","\\nobreakspace"),oe(ie,le,ye,"\xa0","\\ "),oe(ie,le,ye,"\xa0"," "),oe(ie,le,ye,"\xa0","\\space"),oe(ie,le,ye,"\xa0","\\nobreakspace"),oe(se,le,ye,"","\\nobreak"),oe(se,le,ye,"","\\allowbreak"),oe(se,le,fe,",",","),oe(se,le,fe,";",";"),oe(se,ae,he,"\u22bc","\\barwedge",!0),oe(se,ae,he,"\u22bb","\\veebar",!0),oe(se,le,he,"\u2299","\\odot",!0),oe(se,le,he,"\u2295","\\oplus",!0),oe(se,le,he,"\u2297","\\otimes",!0),oe(se,le,xe,"\u2202","\\partial",!0),oe(se,le,he,"\u2298","\\oslash",!0),oe(se,ae,he,"\u229a","\\circledcirc",!0),oe(se,ae,he,"\u22a1","\\boxdot",!0),oe(se,le,he,"\u25b3","\\bigtriangleup"),oe(se,le,he,"\u25bd","\\bigtriangledown"),oe(se,le,he,"\u2020","\\dagger"),oe(se,le,he,"\u22c4","\\diamond"),oe(se,le,he,"\u22c6","\\star"),oe(se,le,he,"\u25c3","\\triangleleft"),oe(se,le,he,"\u25b9","\\triangleright"),oe(se,le,ge,"{","\\{"),oe(ie,le,xe,"{","\\{"),oe(ie,le,xe,"{","\\textbraceleft"),oe(se,le,me,"}","\\}"),oe(ie,le,xe,"}","\\}"),oe(ie,le,xe,"}","\\textbraceright"),oe(se,le,ge,"{","\\lbrace"),oe(se,le,me,"}","\\rbrace"),oe(se,le,ge,"[","\\lbrack",!0),oe(ie,le,xe,"[","\\lbrack",!0),oe(se,le,me,"]","\\rbrack",!0),oe(ie,le,xe,"]","\\rbrack",!0),oe(se,le,ge,"(","\\lparen",!0),oe(se,le,me,")","\\rparen",!0),oe(ie,le,xe,"<","\\textless",!0),oe(ie,le,xe,">","\\textgreater",!0),oe(se,le,ge,"\u230a","\\lfloor",!0),oe(se,le,me,"\u230b","\\rfloor",!0),oe(se,le,ge,"\u2308","\\lceil",!0),oe(se,le,me,"\u2309","\\rceil",!0),oe(se,le,xe,"\\","\\backslash"),oe(se,le,xe,"\u2223","|"),oe(se,le,xe,"\u2223","\\vert"),oe(ie,le,xe,"|","\\textbar",!0),oe(se,le,xe,"\u2225","\\|"),oe(se,le,xe,"\u2225","\\Vert"),oe(ie,le,xe,"\u2225","\\textbardbl"),oe(ie,le,xe,"~","\\textasciitilde"),oe(ie,le,xe,"\\","\\textbackslash"),oe(ie,le,xe,"^","\\textasciicircum"),oe(se,le,be,"\u2191","\\uparrow",!0),oe(se,le,be,"\u21d1","\\Uparrow",!0),oe(se,le,be,"\u2193","\\downarrow",!0),oe(se,le,be,"\u21d3","\\Downarrow",!0),oe(se,le,be,"\u2195","\\updownarrow",!0),oe(se,le,be,"\u21d5","\\Updownarrow",!0),oe(se,le,de,"\u2210","\\coprod"),oe(se,le,de,"\u22c1","\\bigvee"),oe(se,le,de,"\u22c0","\\bigwedge"),oe(se,le,de,"\u2a04","\\biguplus"),oe(se,le,de,"\u22c2","\\bigcap"),oe(se,le,de,"\u22c3","\\bigcup"),oe(se,le,de,"\u222b","\\int"),oe(se,le,de,"\u222b","\\intop"),oe(se,le,de,"\u222c","\\iint"),oe(se,le,de,"\u222d","\\iiint"),oe(se,le,de,"\u220f","\\prod"),oe(se,le,de,"\u2211","\\sum"),oe(se,le,de,"\u2a02","\\bigotimes"),oe(se,le,de,"\u2a01","\\bigoplus"),oe(se,le,de,"\u2a00","\\bigodot"),oe(se,le,de,"\u222e","\\oint"),oe(se,le,de,"\u222f","\\oiint"),oe(se,le,de,"\u2230","\\oiiint"),oe(se,le,de,"\u2a06","\\bigsqcup"),oe(se,le,de,"\u222b","\\smallint"),oe(ie,le,ue,"\u2026","\\textellipsis"),oe(se,le,ue,"\u2026","\\mathellipsis"),oe(ie,le,ue,"\u2026","\\ldots",!0),oe(se,le,ue,"\u2026","\\ldots",!0),oe(se,le,ue,"\u22ef","\\@cdots",!0),oe(se,le,ue,"\u22f1","\\ddots",!0),oe(se,le,xe,"\u22ee","\\varvdots"),oe(ie,le,xe,"\u22ee","\\varvdots"),oe(se,le,ce,"\u02ca","\\acute"),oe(se,le,ce,"\u02cb","\\grave"),oe(se,le,ce,"\xa8","\\ddot"),oe(se,le,ce,"~","\\tilde"),oe(se,le,ce,"\u02c9","\\bar"),oe(se,le,ce,"\u02d8","\\breve"),oe(se,le,ce,"\u02c7","\\check"),oe(se,le,ce,"^","\\hat"),oe(se,le,ce,"\u20d7","\\vec"),oe(se,le,ce,"\u02d9","\\dot"),oe(se,le,ce,"\u02da","\\mathring"),oe(se,le,pe,"\ue131","\\@imath"),oe(se,le,pe,"\ue237","\\@jmath"),oe(se,le,xe,"\u0131","\u0131"),oe(se,le,xe,"\u0237","\u0237"),oe(ie,le,xe,"\u0131","\\i",!0),oe(ie,le,xe,"\u0237","\\j",!0),oe(ie,le,xe,"\xdf","\\ss",!0),oe(ie,le,xe,"\xe6","\\ae",!0),oe(ie,le,xe,"\u0153","\\oe",!0),oe(ie,le,xe,"\xf8","\\o",!0),oe(ie,le,xe,"\xc6","\\AE",!0),oe(ie,le,xe,"\u0152","\\OE",!0),oe(ie,le,xe,"\xd8","\\O",!0),oe(ie,le,ce,"\u02ca","\\'"),oe(ie,le,ce,"\u02cb","\\`"),oe(ie,le,ce,"\u02c6","\\^"),oe(ie,le,ce,"\u02dc","\\~"),oe(ie,le,ce,"\u02c9","\\="),oe(ie,le,ce,"\u02d8","\\u"),oe(ie,le,ce,"\u02d9","\\."),oe(ie,le,ce,"\xb8","\\c"),oe(ie,le,ce,"\u02da","\\r"),oe(ie,le,ce,"\u02c7","\\v"),oe(ie,le,ce,"\xa8",'\\"'),oe(ie,le,ce,"\u02dd","\\H"),oe(ie,le,ce,"\u25ef","\\textcircled");const we={"--":!0,"---":!0,"``":!0,"''":!0};oe(ie,le,xe,"\u2013","--",!0),oe(ie,le,xe,"\u2013","\\textendash"),oe(ie,le,xe,"\u2014","---",!0),oe(ie,le,xe,"\u2014","\\textemdash"),oe(ie,le,xe,"\u2018","`",!0),oe(ie,le,xe,"\u2018","\\textquoteleft"),oe(ie,le,xe,"\u2019","'",!0),oe(ie,le,xe,"\u2019","\\textquoteright"),oe(ie,le,xe,"\u201c","``",!0),oe(ie,le,xe,"\u201c","\\textquotedblleft"),oe(ie,le,xe,"\u201d","''",!0),oe(ie,le,xe,"\u201d","\\textquotedblright"),oe(se,le,xe,"\xb0","\\degree",!0),oe(ie,le,xe,"\xb0","\\degree"),oe(ie,le,xe,"\xb0","\\textdegree",!0),oe(se,le,xe,"\xa3","\\pounds"),oe(se,le,xe,"\xa3","\\mathsterling",!0),oe(ie,le,xe,"\xa3","\\pounds"),oe(ie,le,xe,"\xa3","\\textsterling",!0),oe(se,ae,xe,"\u2720","\\maltese"),oe(ie,ae,xe,"\u2720","\\maltese");const ve='0123456789/@."';for(let e=0;e<14;e++){const t=ve.charAt(e);oe(se,le,xe,t,t)}const ke='0123456789!@*()-=+";:?/.,';for(let e=0;e<25;e++){const t=ke.charAt(e);oe(ie,le,xe,t,t)}const ze="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";for(let e=0;e<52;e++){const t=ze.charAt(e);oe(se,le,pe,t,t),oe(ie,le,xe,t,t)}let Se;oe(se,ae,xe,"C","\u2102"),oe(ie,ae,xe,"C","\u2102"),oe(se,ae,xe,"H","\u210d"),oe(ie,ae,xe,"H","\u210d"),oe(se,ae,xe,"N","\u2115"),oe(ie,ae,xe,"N","\u2115"),oe(se,ae,xe,"P","\u2119"),oe(ie,ae,xe,"P","\u2119"),oe(se,ae,xe,"Q","\u211a"),oe(ie,ae,xe,"Q","\u211a"),oe(se,ae,xe,"R","\u211d"),oe(ie,ae,xe,"R","\u211d"),oe(se,ae,xe,"Z","\u2124"),oe(ie,ae,xe,"Z","\u2124"),oe(se,le,pe,"h","\u210e"),oe(ie,le,pe,"h","\u210e");for(let e=0;e<52;e++){const t=ze.charAt(e);Se=String.fromCharCode(55349,56320+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56372+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56424+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56580+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56684+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56736+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56788+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56840+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56944+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),e<26&&(Se=String.fromCharCode(55349,56632+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,56476+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se))}Se=String.fromCharCode(55349,56668),oe(se,le,pe,"k",Se),oe(ie,le,xe,"k",Se);for(let e=0;e<10;e++){const t=e.toString();Se=String.fromCharCode(55349,57294+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,57314+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,57324+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se),Se=String.fromCharCode(55349,57334+e),oe(se,le,pe,t,Se),oe(ie,le,xe,t,Se)}const Me="\xd0\xde\xfe";for(let e=0;e<3;e++){const t=Me.charAt(e);oe(se,le,pe,t,t),oe(ie,le,xe,t,t)}const Ae={mathClass:"mathbf",textClass:"textbf",font:"Main-Bold"},Te={mathClass:"mathnormal",textClass:"textit",font:"Math-Italic"},Ce={mathClass:"boldsymbol",textClass:"boldsymbol",font:"Main-BoldItalic"},Be={mathClass:"",textClass:"",font:""},qe={mathClass:"mathfrak",textClass:"textfrak",font:"Fraktur-Regular"},Ie={mathClass:"mathbb",textClass:"textbb",font:"AMS-Regular"},Re={mathClass:"mathboldfrak",textClass:"textboldfrak",font:"Fraktur-Regular"},He={mathClass:"mathsf",textClass:"textsf",font:"SansSerif-Regular"},Ee={mathClass:"mathboldsf",textClass:"textboldsf",font:"SansSerif-Bold"},Ne={mathClass:"mathitsf",textClass:"textitsf",font:"SansSerif-Italic"},Oe={mathClass:"mathtt",textClass:"texttt",font:"Typewriter-Regular"},De=[Ae,Ae,Te,Te,Ce,Ce,{mathClass:"mathscr",textClass:"textscr",font:"Script-Regular"},Be,Be,Be,qe,qe,Ie,Ie,Re,Re,He,He,Ee,Ee,Ne,Ne,Be,Be,Oe,Oe],Le=[Ae,Be,He,Ee,Oe],Pe=function(e,t,r){if(ne[r][e]){const t=ne[r][e].replace;t&&(e=t)}return{value:e,metrics:ee(e,t,r)}},Fe=function(e,t,r,n,o){const s=Pe(e,t,r),i=s.metrics;let l;if(e=s.value,i){let t=i.italic;("text"===r||n&&"mathit"===n.font)&&(t=0),l=new W(e,i.height,i.depth,t,i.skew,i.width,o)}else"undefined"!=typeof console&&console.warn("No character metrics for '"+e+"' in style '"+t+"' and mode '"+r+"'"),l=new W(e,0,0,0,0,0,o);if(n){l.maxFontSize=n.sizeMultiplier,n.style.isTight()&&l.classes.push("mtight");const e=n.getColor();e&&(l.style.color=e)}return l},Ve=function(e,t,r,n){return void 0===n&&(n=[]),"boldsymbol"===r.font&&Pe(e,"Main-Bold",t).metrics?Fe(e,"Main-Bold",t,r,n.concat(["mathbf"])):"\\"===e||"main"===ne[t][e].font?Fe(e,"Main-Regular",t,r,n):Fe(e,"AMS-Regular",t,r,n.concat(["amsrm"]))},Ge=function(e,t){const r="mathord"===e.type?"mathord":"textord",o=e.mode,s=e.text,i=["mord"],{font:l,fontFamily:a,fontWeight:c,fontShape:h}=t,m="math"===o||"text"===o&&!!l,u=m?l:a;let p="",d="";if(55349===s.charCodeAt(0)){const e=(e=>{const t=1024*(e.charCodeAt(0)-55296)+(e.charCodeAt(1)-56320)+65536;if(119808<=t&&t<120484){const e=Math.floor((t-119808)/26);return De[e]}if(120782<=t&&t<=120831){const e=Math.floor((t-120782)/10);return Le[e]}if(120485===t||120486===t)return De[0];if(120486{if(D(e.classes)!==D(t.classes)||e.skew!==t.skew||e.maxFontSize!==t.maxFontSize||0!==e.italic&&e.hasClass("mathnormal"))return!1;if(1===e.classes.length){const t=e.classes[0];if("mbin"===t||"mord"===t)return!1}for(const r of Object.keys(e.style))if(e.style[r]!==t.style[r])return!1;for(const r of Object.keys(t.style))if(e.style[r]!==t.style[r])return!1;return!0},Xe=e=>{for(let t=0;tt&&(t=s.height),s.depth>r&&(r=s.depth),s.maxFontSize>n&&(n=s.maxFontSize)}e.height=t,e.depth=r,e.maxFontSize=n},je=function(e,t,r,n){const o=new U(e,t,r,n);return Ye(o),o},We=(e,t,r,n)=>new U(e,t,r,n),_e=function(e,t,r){const n=je([e],[],t);return n.height=Math.max(r||t.fontMetrics().defaultRuleThickness,t.minRuleThickness),n.style.borderBottomWidth=O(n.height),n.maxFontSize=1,n},$e=function(e){const t=new I(e);return Ye(t),t},Ze=function(e,t){return e instanceof I?je([],[e],t):e},Ke=function(e,t){const{children:r,depth:n}=function(e){if("individualShift"===e.positionType){const t=e.children,r=[t[0]],n=-t[0].shift-t[0].elem.depth;let o=n;for(let e=1;e{const r=je(["mspace"],[],t),n=N(e,t);return r.style.marginRight=O(n),r},Qe=(e,t,r)=>{let n,o;switch(e){case"amsrm":n="AMS";break;case"textrm":n="Main";break;case"textsf":n="SansSerif";break;case"texttt":n="Typewriter";break;default:n=e}return o="textbf"===t&&"textit"===r?"BoldItalic":"textbf"===t?"Bold":"textit"===r?"Italic":"Regular",n+"-"+o},et={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathsfit:{variant:"sans-serif-italic",fontName:"SansSerif-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},tt={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},rt=function(e,t){const[r,n,o]=tt[e],s=new $(r),i=new _([s],{width:O(n),height:O(o),style:"width:"+O(n),viewBox:"0 0 "+1e3*n+" "+1e3*o,preserveAspectRatio:"xMinYMin"}),l=We(["overlay"],[i],t);return l.height=o,l.style.height=O(o),l.style.width=O(n),l},nt={number:3,unit:"mu"},ot={number:4,unit:"mu"},st={number:5,unit:"mu"},it={mord:{mop:nt,mbin:ot,mrel:st,minner:nt},mop:{mord:nt,mop:nt,mrel:st,minner:nt},mbin:{mord:ot,mop:ot,mopen:ot,minner:ot},mrel:{mord:st,mop:st,mopen:st,minner:st},mopen:{},mclose:{mop:nt,mbin:ot,mrel:st,minner:nt},mpunct:{mord:nt,mop:nt,mrel:st,mopen:nt,mclose:nt,mpunct:nt,minner:nt},minner:{mord:nt,mop:nt,mbin:ot,mrel:st,mopen:nt,mpunct:nt,minner:nt}},lt={mord:{mop:nt},mop:{mord:nt,mop:nt},mbin:{},mrel:{},mopen:{},mclose:{mop:nt},mpunct:{},minner:{mop:nt}},at={},ct={},ht={};function mt(e){const{type:t,names:r,htmlBuilder:n,mathmlBuilder:o}=e;for(let t=0;t{const r=t.classes[0],n=e.classes[0];"mbin"===r&&ft.has(n)?t.classes[0]="mord":"mbin"===n&>.has(r)&&(e.classes[0]="mord")},{node:i},l,a),wt(o,(e,t)=>{var r,n;const o=zt(t),i=zt(e),l=o&&i?e.hasClass("mtight")?null==(r=lt[o])?void 0:r[i]:null==(n=it[o])?void 0:n[i]:null;if(l)return Je(l,s)},{node:i},l,a),o},wt=function(e,t,r,n,o){n&&e.push(n);let s=0;for(;sr=>{e.splice(t+1,0,r),s++})(s)}n&&e.pop()},vt=function(e){return e instanceof I||e instanceof X||e instanceof U&&e.hasClass("enclosing")?e:null},kt=function(e,t){const r=vt(e);if(r){const e=r.children;if(e.length){if("right"===t)return kt(e[e.length-1],"right");if("left"===t)return kt(e[0],"left")}}return e},zt=function(e,t){if(!e)return null;t&&(e=kt(e,t));const r=e.classes[0];return yt[r]||null},St=function(e,t){const r=["nulldelimiter"].concat(e.baseSizingClasses());return je(t.concat(r))},Mt=function(e,t,r){if(!e)return je();if(ct[e.type]){let n=ct[e.type](e,t);if(r&&t.size!==r.size){n=je(t.sizingClasses(r),[n],t);const e=t.sizeMultiplier/r.sizeMultiplier;n.height*=e,n.depth*=e}return n}throw new n("Got group of unknown type: '"+e.type+"'")};function At(e,t){const r=je(["base"],e,t),n=je(["strut"]);return n.style.height=O(r.height+r.depth),r.depth&&(n.style.verticalAlign=O(-r.depth)),r.children.unshift(n),r}function Tt(e,t){let r=null;1===e.length&&"tag"===e[0].type&&(r=e[0].tag,e=e[0].body);const n=xt(e,t,"root");let o;2===n.length&&n[1].hasClass("tag")&&(o=n.pop());const s=[];let i,l=[];for(let e=0;e0&&(s.push(At(l,t)),l=[]),s.push(n[e]));l.length>0&&s.push(At(l,t)),r?(i=At(xt(r,t,!0),t),i.classes=["tag"],s.push(i)):o&&s.push(o);const a=je(["katex-html"],s);if(a.setAttribute("aria-hidden","true"),i){const e=i.children[0];e.style.height=O(a.height+a.depth),a.depth&&(e.style.verticalAlign=O(-a.depth))}return a}function Ct(e){return new I(e)}class Bt{constructor(e,t,r){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=t||[],this.classes=r||[]}setAttribute(e,t){this.attributes[e]=t}getAttribute(e){return this.attributes[e]}toNode(){const e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(const t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);this.classes.length>0&&(e.className=D(this.classes));for(let t=0;t0&&(e+=' class ="'+a(D(this.classes))+'"'),e+=">";for(let t=0;t",e}toText(){return this.children.map(e=>e.toText()).join("")}}class qt{constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return a(this.toText())}toText(){return this.text}}class It{constructor(e){this.width=void 0,this.character=void 0,this.width=e,this.character=e>=.05555&&e<=.05556?"\u200a":e>=.1666&&e<=.1667?"\u2009":e>=.2222&&e<=.2223?"\u2005":e>=.2777&&e<=.2778?"\u2005\u200a":e>=-.05556&&e<=-.05555?"\u200a\u2063":e>=-.1667&&e<=-.1666?"\u2009\u2063":e>=-.2223&&e<=-.2222?"\u205f\u2063":e>=-.2778&&e<=-.2777?"\u2005\u2063":null}toNode(){if(this.character)return document.createTextNode(this.character);{const e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",O(this.width)),e}}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}}const Rt=new Set(["\\imath","\\jmath"]),Ht=new Set(["mrow","mtable"]),Et=function(e,t,r){return!ne[t][e]||!ne[t][e].replace||55349===e.charCodeAt(0)||we.hasOwnProperty(e)&&r&&(r.fontFamily&&"tt"===r.fontFamily.slice(4,6)||r.font&&"tt"===r.font.slice(4,6))||(e=ne[t][e].replace),new qt(e)},Nt=function(e){return 1===e.length?e[0]:new Bt("mrow",e)},Ot={mathit:"italic",boldsymbol:e=>"textord"===e.type?"bold":"bold-italic",mathbf:"bold",mathbb:"double-struck",mathsfit:"sans-serif-italic",mathfrak:"fraktur",mathscr:"script",mathcal:"script",mathsf:"sans-serif",mathtt:"monospace"},Dt=(e,t)=>{if("text"===e.mode){if("texttt"===t.fontFamily)return"monospace";if("textsf"===t.fontFamily)return"textit"===t.fontShape&&"textbf"===t.fontWeight?"sans-serif-bold-italic":"textit"===t.fontShape?"sans-serif-italic":"textbf"===t.fontWeight?"bold-sans-serif":"sans-serif";if("textit"===t.fontShape&&"textbf"===t.fontWeight)return"bold-italic";if("textit"===t.fontShape)return"italic";if("textbf"===t.fontWeight)return"bold"}const r=t.font;if(!r||"mathnormal"===r)return null;const n=e.mode,o=Ot[r];if(o)return"function"==typeof o?o(e):o;let s=e.text;if(Rt.has(s))return null;if(ne[n][s]){const e=ne[n][s].replace;e&&(s=e)}return ee(s,et[r].fontName,n)?et[r].variant:null};function Lt(e){if(!e)return!1;if("mi"===e.type&&1===e.children.length){const t=e.children[0];return t instanceof qt&&"."===t.text}if("mo"===e.type&&1===e.children.length&&"true"===e.getAttribute("separator")&&"0em"===e.getAttribute("lspace")&&"0em"===e.getAttribute("rspace")){const t=e.children[0];return t instanceof qt&&","===t.text}return!1}const Pt=function(e,t,r){if(1===e.length){const n=Vt(e[0],t);return r&&n instanceof Bt&&"mo"===n.type&&(n.setAttribute("lspace","0em"),n.setAttribute("rspace","0em")),[n]}const n=[];let o;for(let r=0;r=1&&("mn"===o.type||Lt(o))){const e=s.children[0];e instanceof Bt&&"mn"===e.type&&(e.children=[...o.children,...e.children],n.pop())}else if("mi"===o.type&&1===o.children.length){const e=o.children[0];if(e instanceof qt&&"\u0338"===e.text&&("mo"===s.type||"mi"===s.type||"mn"===s.type)){const e=s.children[0];e instanceof qt&&e.text.length>0&&(e.text=e.text.slice(0,1)+"\u0338"+e.text.slice(1),n.pop())}}}n.push(s),o=s}return n},Ft=function(e,t,r){return Nt(Pt(e,t,r))},Vt=function(e,t){if(!e)return new Bt("mrow");if(ht[e.type])return ht[e.type](e,t);throw new n("Got group of unknown type: '"+e.type+"'")};function Gt(e,t,r,n,o){const s=Pt(e,r);let i;i=1===s.length&&s[0]instanceof Bt&&Ht.has(s[0].type)?s[0]:new Bt("mrow",s);const l=new Bt("annotation",[new qt(t)]);l.setAttribute("encoding","application/x-tex");const a=new Bt("semantics",[i,l]),c=new Bt("math",[a]);c.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&c.setAttribute("display","block");return je([o?"katex":"katex-mathml"],[c])}const Ut=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],Xt=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],Yt=function(e,t){return t.size<2?e:Ut[e-1][t.size-1]};class jt{constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||jt.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=Xt[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){const t={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};return Object.assign(t,e),new jt(t)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:Yt(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:Xt[e-1]})}havingBaseStyle(e){e=e||this.style.text();const t=Yt(jt.BASESIZE,e);return this.size===t&&this.textSize===jt.BASESIZE&&this.style===e?this:this.extend({style:e,size:t})}havingBaseSizing(){let e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==jt.BASESIZE?["sizing","reset-size"+this.size,"size"+jt.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=function(e){let t;if(t=e>=5?0:e>=3?1:2,!te[t]){const e=te[t]={cssEmPerMu:J.quad[t]/18};for(const r in J)J.hasOwnProperty(r)&&(e[r]=J[r][t])}return te[t]}(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}}jt.BASESIZE=6;var Wt=jt;const _t=function(e){return new Wt({style:e.displayMode?S.DISPLAY:S.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},$t=function(e,t){if(t.displayMode){const r=["katex-display"];t.leqno&&r.push("leqno"),t.fleqn&&r.push("fleqn"),e=je(r,[e])}return e},Zt=function(e,t,r){const n=_t(r);let o;if("mathml"===r.output)return Gt(e,t,n,r.displayMode,!0);if("html"===r.output){const t=Tt(e,n);o=je(["katex"],[t])}else{const s=Gt(e,t,n,r.displayMode,!1),i=Tt(e,n);o=je(["katex"],[s,i])}return $t(o,r)};const Kt={widehat:"^",widecheck:"\u02c7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23df",overbrace:"\u23de",underbracket:"\u23b5",overbracket:"\u23b4",overgroup:"\u23e0",undergroup:"\u23e1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21d2",xRightarrow:"\u21d2",overleftharpoon:"\u21bc",xleftharpoonup:"\u21bc",overrightharpoon:"\u21c0",xrightharpoonup:"\u21c0",xLeftarrow:"\u21d0",xLeftrightarrow:"\u21d4",xhookleftarrow:"\u21a9",xhookrightarrow:"\u21aa",xmapsto:"\u21a6",xrightharpoondown:"\u21c1",xleftharpoondown:"\u21bd",xrightleftharpoons:"\u21cc",xleftrightharpoons:"\u21cb",xtwoheadleftarrow:"\u219e",xtwoheadrightarrow:"\u21a0",xlongequal:"=",xtofrom:"\u21c4",xrightleftarrows:"\u21c4",xrightequilibrium:"\u21cc",xleftequilibrium:"\u21cb","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},Jt=function(e){const t=new Bt("mo",[new qt(Kt[e.replace(/^\\/,"")])]);return t.setAttribute("stretchy","true"),t},Qt={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overbracket:[["leftbracketover","rightbracketover"],1.6,440],underbracket:[["leftbracketunder","rightbracketunder"],1.6,410],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},er=new Set(["widehat","widecheck","widetilde","utilde"]),tr=function(e,t){const{span:r,minWidth:n,height:o}=function(){let r=4e5;const n=e.label.slice(1);if(er.has(n)&&"base"in e){const o="ordgroup"===e.base.type?e.base.body.length:1;let s,i,l;if(o>5)"widehat"===n||"widecheck"===n?(s=420,r=2364,l=.42,i=n+"4"):(s=312,r=2340,l=.34,i="tilde4");else{const e=[1,1,2,2,3,3][o];"widehat"===n||"widecheck"===n?(r=[0,1062,2364,2364,2364][e],s=[0,239,300,360,420][e],l=[0,.24,.3,.3,.36,.42][e],i=n+e):(r=[0,600,1033,2339,2340][e],s=[0,260,286,306,312][e],l=[0,.26,.286,.3,.306,.34][e],i="tilde"+e)}const a=new $(i),c=new _([a],{width:"100%",height:O(l),viewBox:"0 0 "+r+" "+s,preserveAspectRatio:"none"});return{span:We([],[c],t),minWidth:0,height:l}}{const e=[],o=Qt[n];if(!o)throw new Error('No SVG data for "'+n+'".');const[s,i,l]=o,a=l/1e3,c=s.length;let h,m;if(1===c){if(4!==o.length)throw new Error('Expected 4-tuple for single-path SVG data "'+n+'".');h=["hide-tail"],m=[o[3]]}else if(2===c)h=["halfarrow-left","halfarrow-right"],m=["xMinYMin","xMaxYMin"];else{if(3!==c)throw new Error("Correct katexImagesData or update code here to support\n "+c+" children.");h=["brace-left","brace-center","brace-right"],m=["xMinYMin","xMidYMin","xMaxYMin"]}for(let n=0;n0&&(r.style.minWidth=O(n)),r},rr={bin:1,close:1,inner:1,open:1,punct:1,rel:1},nr={"accent-token":1,mathord:1,"op-token":1,spacing:1,textord:1};function or(e,t){if(!e||e.type!==t)throw new Error("Expected node of type "+t+", but got "+(e?"node of type "+e.type:String(e)));return e}function sr(e){const t=ir(e);if(!t)throw new Error("Expected node of symbol group type, but got "+(e?"node of type "+e.type:String(e)));return t}function ir(e){return e&&("atom"===e.type||nr.hasOwnProperty(e.type))?e:null}const lr=e=>{return e instanceof W?e:((t=e)instanceof U||t instanceof X||t instanceof I)&&1===e.children.length?lr(e.children[0]):void 0;var t},ar=(e,t)=>{let r,n,o;e&&"supsub"===e.type?(n=or(e.base,"accent"),r=n.base,e.base=r,o=function(e){if(e instanceof U)return e;throw new Error("Expected span but got "+String(e)+".")}(Mt(e,t)),e.base=n):(n=or(e,"accent"),r=n.base);const s=Mt(r,t.havingCrampedStyle());let i=0;var l,a;n.isShifty&&m(r)&&(i=null!=(l=null==(a=lr(s))?void 0:a.skew)?l:0);const c="\\c"===n.label;let h,u=c?s.height+s.depth:Math.min(s.height,t.fontMetrics().xHeight);if(n.isStretchy)h=tr(n,t),h=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"elem",elem:h,wrapperClasses:["svg-align"],wrapperStyle:i>0?{width:"calc(100% - "+O(2*i)+")",marginLeft:O(2*i)}:void 0}]});else{let e,r;"\\vec"===n.label?(e=rt("vec",t),r=tt.vec[1]):(e=Ge({type:"textord",mode:n.mode,text:n.label},t),e=function(e){if(e instanceof W)return e;throw new Error("Expected symbolNode but got "+String(e)+".")}(e),e.italic=0,r=e.width,c&&(u+=e.depth)),h=je(["accent-body"],[e]);const o="\\textcircled"===n.label;o&&(h.classes.push("accent-full"),u=s.height);let l=i;o||(l-=r/2),h.style.left=O(l),"\\textcircled"===n.label&&(h.style.top=".2em"),h=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:-u},{type:"elem",elem:h}]})}const p=je(["mord","accent"],[h],t);return o?(o.children[0]=p,o.height=Math.max(p.height,o.height),o.classes[0]="mord",o):p},cr=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(e=>"\\"+e).join("|"));mt({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],numArgs:1,handler:(e,t)=>{const r=pt(t[0]),n=!cr.test(e.funcName),o=!n||"\\widehat"===e.funcName||"\\widetilde"===e.funcName||"\\widecheck"===e.funcName;return{type:"accent",mode:e.parser.mode,label:e.funcName,isStretchy:n,isShifty:o,base:r}},htmlBuilder:ar,mathmlBuilder:(e,t)=>{const r=e.isStretchy?Jt(e.label):new Bt("mo",[Et(e.label,e.mode)]),n=new Bt("mover",[Vt(e.base,t),r]);return n.setAttribute("accent","true"),n}}),mt({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"],handler:(e,t)=>{const r=t[0];let n=e.parser.mode;return"math"===n&&(e.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+e.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:e.funcName,isStretchy:!1,isShifty:!0,base:r}}}),mt({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],numArgs:1,handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0];return{type:"accentUnder",mode:r.mode,label:n,base:o}},htmlBuilder:(e,t)=>{const r=Mt(e.base,t),n=tr(e,t),o="\\utilde"===e.label?.12:0,s=Ke({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:o},{type:"elem",elem:r}]});return je(["mord","accentunder"],[s],t)},mathmlBuilder:(e,t)=>{const r=Jt(e.label),n=new Bt("munder",[Vt(e.base,t),r]);return n.setAttribute("accentunder","true"),n}});const hr=e=>{const t=new Bt("mpadded",e?[e]:[]);return t.setAttribute("width","+0.6em"),t.setAttribute("lspace","0.3em"),t};mt({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],numArgs:1,numOptionalArgs:1,handler(e,t,r){let{parser:n,funcName:o}=e;return{type:"xArrow",mode:n.mode,label:o,body:t[0],below:r[0]}},htmlBuilder(e,t){const r=t.style;let n=t.havingStyle(r.sup());const o=Ze(Mt(e.body,n,t),t),s="\\x"===e.label.slice(0,2)?"x":"cd";let i;o.classes.push(s+"-arrow-pad"),e.below&&(n=t.havingStyle(r.sub()),i=Ze(Mt(e.below,n,t),t),i.classes.push(s+"-arrow-pad"));const l=tr(e,t),a=-t.fontMetrics().axisHeight+.5*l.height;let c,h=-t.fontMetrics().axisHeight-.5*l.height-.111;if((o.depth>.25||"\\xleftequilibrium"===e.label)&&(h-=o.depth),i){const e=-t.fontMetrics().axisHeight+i.height+.5*l.height+.111;c=Ke({positionType:"individualShift",children:[{type:"elem",elem:o,shift:h},{type:"elem",elem:l,shift:a,wrapperClasses:["svg-align"]},{type:"elem",elem:i,shift:e}]})}else c=Ke({positionType:"individualShift",children:[{type:"elem",elem:o,shift:h},{type:"elem",elem:l,shift:a,wrapperClasses:["svg-align"]}]});return je(["mrel","x-arrow"],[c],t)},mathmlBuilder(e,t){const r=Jt(e.label);let n;if(r.setAttribute("minsize","x"===e.label.charAt(0)?"1.75em":"3.0em"),e.body){const o=hr(Vt(e.body,t));if(e.below){const s=hr(Vt(e.below,t));n=new Bt("munderover",[r,s,o])}else n=new Bt("mover",[r,o])}else if(e.below){const o=hr(Vt(e.below,t));n=new Bt("munder",[r,o])}else n=hr(),n=new Bt("mover",[r,n]);return n}}),mt({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],numArgs:1,primitive:!0,handler(e,t){let{parser:r,funcName:n}=e;const o=t[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:dt(o),isCharacterBox:m(o)}},htmlBuilder:function(e,t){const r=xt(e.body,t,!0);return je([e.mclass],r,t)},mathmlBuilder:function(e,t){let r;const n=Pt(e.body,t);return"minner"===e.mclass?r=new Bt("mpadded",n):"mord"===e.mclass?e.isCharacterBox?(r=n[0],r.type="mi"):r=new Bt("mi",n):(e.isCharacterBox?(r=n[0],r.type="mo"):r=new Bt("mo",n),"mbin"===e.mclass?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):"mpunct"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):"mopen"!==e.mclass&&"mclose"!==e.mclass||(r.attributes.lspace="0em",r.attributes.rspace="0em")),r}});const mr=e=>{const t="ordgroup"===e.type&&e.body.length?e.body[0]:e;return"atom"!==t.type||"bin"!==t.family&&"rel"!==t.family?"mord":"m"+t.family};mt({type:"mclass",names:["\\@binrel"],numArgs:2,handler(e,t){let{parser:r}=e;return{type:"mclass",mode:r.mode,mclass:mr(t[0]),body:dt(t[1]),isCharacterBox:m(t[1])}}}),mt({type:"mclass",names:["\\stackrel","\\overset","\\underset"],numArgs:2,handler(e,t){let{parser:r,funcName:n}=e;const o=t[1],s=t[0];let i;i="\\stackrel"!==n?mr(o):"mrel";const l={type:"op",mode:o.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:"\\stackrel"!==n,body:dt(o)},a="\\underset"===n?{type:"supsub",mode:s.mode,base:l,sub:s}:{type:"supsub",mode:s.mode,base:l,sup:s};return{type:"mclass",mode:r.mode,mclass:i,body:[a],isCharacterBox:m(a)}}}),mt({type:"pmb",names:["\\pmb"],numArgs:1,allowedInText:!0,handler(e,t){let{parser:r}=e;return{type:"pmb",mode:r.mode,mclass:mr(t[0]),body:dt(t[0])}},htmlBuilder(e,t){const r=xt(e.body,t,!0),n=je([e.mclass],r,t);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(e,t){const r=Pt(e.body,t),n=new Bt("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});const ur={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},pr=()=>({type:"styling",body:[],mode:"math",style:"display",resetFont:!0}),dr=e=>"textord"===e.type&&"@"===e.text,gr=(e,t)=>("mathord"===e.type||"atom"===e.type)&&e.text===t;function fr(e,t,r){const n=ur[e];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[t[0]],[t[1]]);case"\\uparrow":case"\\downarrow":{const e={type:"atom",text:n,mode:"math",family:"rel"},o={type:"ordgroup",mode:"math",body:[r.callFunction("\\\\cdleft",[t[0]],[]),r.callFunction("\\Big",[e],[]),r.callFunction("\\\\cdright",[t[1]],[])]};return r.callFunction("\\\\cdparent",[o],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{const e={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[e],[])}default:return{type:"textord",text:" ",mode:"math"}}}mt({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],numArgs:1,handler(e,t){let{parser:r,funcName:n}=e;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:t[0]}},htmlBuilder(e,t){const r=t.havingStyle(t.style.sup()),n=Ze(Mt(e.label,r,t),t);return n.classes.push("cd-label-"+e.side),n.style.bottom=O(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(e,t){let r=new Bt("mrow",[Vt(e.label,t)]);return r=new Bt("mpadded",[r]),r.setAttribute("width","0"),"left"===e.side&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new Bt("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}}),mt({type:"cdlabelparent",names:["\\\\cdparent"],numArgs:1,handler(e,t){let{parser:r}=e;return{type:"cdlabelparent",mode:r.mode,fragment:t[0]}},htmlBuilder(e,t){const r=Ze(Mt(e.fragment,t),t);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(e,t){return new Bt("mrow",[Vt(e.fragment,t)])}}),mt({type:"textord",names:["\\@char"],numArgs:1,allowedInText:!0,handler(e,t){let{parser:r}=e;const o=or(t[0],"ordgroup").body;let s="";for(let e=0;e=1114111)throw new n("\\@char with invalid code point "+s);return l<=65535?i=String.fromCharCode(l):(l-=65536,i=String.fromCharCode(55296+(l>>10),56320+(1023&l))),{type:"textord",mode:r.mode,text:i}}});mt({type:"color",names:["\\textcolor"],numArgs:2,allowedInText:!0,argTypes:["color","original"],handler(e,t){let{parser:r}=e;const n=or(t[0],"color-token").color,o=t[1];return{type:"color",mode:r.mode,color:n,body:dt(o)}},htmlBuilder:(e,t)=>{const r=xt(e.body,t.withColor(e.color),!1);return $e(r)},mathmlBuilder:(e,t)=>{const r=Pt(e.body,t.withColor(e.color)),n=new Bt("mstyle",r);return n.setAttribute("mathcolor",e.color),n}}),mt({type:"color",names:["\\color"],numArgs:1,allowedInText:!0,argTypes:["color"],handler(e,t){let{parser:r,breakOnTokenText:n}=e;const o=or(t[0],"color-token").color;r.gullet.macros.set("\\current@color",o);const s=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:o,body:s}}}),mt({type:"cr",names:["\\\\"],numArgs:0,numOptionalArgs:0,allowedInText:!0,handler(e,t,r){let{parser:n}=e;const o="["===n.gullet.future().text?n.parseSizeGroup(!0):null,s=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:s,size:o&&or(o,"size").value}},htmlBuilder(e,t){const r=je(["mspace"],[],t);return e.newLine&&(r.classes.push("newline"),e.size&&(r.style.marginTop=O(N(e.size,t)))),r},mathmlBuilder(e,t){const r=new Bt("mspace");return e.newLine&&(r.setAttribute("linebreak","newline"),e.size&&r.setAttribute("height",O(N(e.size,t)))),r}});const br={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},yr=e=>{const t=e.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(t))throw new n("Expected a control sequence",e);return t},xr=(e,t,r,n)=>{let o=e.gullet.macros.get(r.text);null==o&&(r.noexpand=!0,o={tokens:[r],numArgs:0,unexpandable:!e.gullet.isExpandable(r.text)}),e.gullet.macros.set(t,o,n)};mt({type:"internal",names:["\\global","\\long","\\\\globallong"],numArgs:0,allowedInText:!0,handler(e){let{parser:t,funcName:r}=e;t.consumeSpaces();const o=t.fetch();if(br[o.text])return"\\global"!==r&&"\\\\globallong"!==r||(o.text=br[o.text]),or(t.parseFunction(),"internal");throw new n("Invalid token after macro prefix",o)}}),mt({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],numArgs:0,allowedInText:!0,primitive:!0,handler(e){let{parser:t,funcName:r}=e,o=t.gullet.popToken();const s=o.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(s))throw new n("Expected a control sequence",o);let i,l=0;const a=[[]];for(;"{"!==t.gullet.future().text;)if(o=t.gullet.popToken(),"#"===o.text){if("{"===t.gullet.future().text){i=t.gullet.future(),a[l].push("{");break}if(o=t.gullet.popToken(),!/^[1-9]$/.test(o.text))throw new n('Invalid argument number "'+o.text+'"');if(parseInt(o.text)!==l+1)throw new n('Argument number "'+o.text+'" out of order');l++,a.push([])}else{if("EOF"===o.text)throw new n("Expected a macro definition");a[l].push(o.text)}let{tokens:c}=t.gullet.consumeArg();return i&&c.unshift(i),"\\edef"!==r&&"\\xdef"!==r||(c=t.gullet.expandTokens(c),c.reverse()),t.gullet.macros.set(s,{tokens:c,numArgs:l,delimiters:a},r===br[r]),{type:"internal",mode:t.mode}}}),mt({type:"internal",names:["\\let","\\\\globallet"],numArgs:0,allowedInText:!0,primitive:!0,handler(e){let{parser:t,funcName:r}=e;const n=yr(t.gullet.popToken());t.gullet.consumeSpaces();const o=(e=>{let t=e.gullet.popToken();return"="===t.text&&(t=e.gullet.popToken()," "===t.text&&(t=e.gullet.popToken())),t})(t);return xr(t,n,o,"\\\\globallet"===r),{type:"internal",mode:t.mode}}}),mt({type:"internal",names:["\\futurelet","\\\\globalfuture"],numArgs:0,allowedInText:!0,primitive:!0,handler(e){let{parser:t,funcName:r}=e;const n=yr(t.gullet.popToken()),o=t.gullet.popToken(),s=t.gullet.popToken();return xr(t,n,s,"\\\\globalfuture"===r),t.gullet.pushToken(s),t.gullet.pushToken(o),{type:"internal",mode:t.mode}}});const wr=function(e,t,r){const n=ee(ne.math[e]&&ne.math[e].replace||e,t,r);if(!n)throw new Error("Unsupported symbol "+e+" and font size "+t+".");return n},vr=function(e,t,r,n){const o=r.havingBaseStyle(t),s=je(n.concat(o.sizingClasses(r)),[e],r),i=o.sizeMultiplier/r.sizeMultiplier;return s.height*=i,s.depth*=i,s.maxFontSize=o.sizeMultiplier,s},kr=function(e,t,r){const n=t.havingBaseStyle(r),o=(1-t.sizeMultiplier/n.sizeMultiplier)*t.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=O(o),e.height-=o,e.depth+=o},zr=function(e,t,r,n,o,s){const i=function(e,t,r,n){return Fe(e,"Size"+t+"-Regular",r,n)}(e,t,o,n),l=vr(je(["delimsizing","size"+t],[i],n),S.TEXT,n,s);return r&&kr(l,n,S.TEXT),l},Sr=function(e,t,r){let n;n="Size1-Regular"===t?"delim-size1":"delim-size4";return{type:"elem",elem:je(["delimsizinginner",n],[je([],[Fe(e,t,r)])])}},Mr=function(e,t,r){const n=K["Size4-Regular"][e.charCodeAt(0)]?K["Size4-Regular"][e.charCodeAt(0)][4]:K["Size1-Regular"][e.charCodeAt(0)][4],o=new $("inner",function(e,t){switch(e){case"\u239c":return C("M291 0 H417 V"+t+" H291z");case"\u2223":return C("M145 0 H188 V"+t+" H145z");case"\u2225":return C("M145 0 H188 V"+t+" H145z")+C("M367 0 H410 V"+t+" H367z");case"\u239f":return C("M457 0 H583 V"+t+" H457z");case"\u23a2":return C("M319 0 H403 V"+t+" H319z");case"\u23a5":return C("M263 0 H347 V"+t+" H263z");case"\u23aa":return C("M384 0 H504 V"+t+" H384z");case"\u23d0":return C("M312 0 H355 V"+t+" H312z");case"\u2016":return C("M257 0 H300 V"+t+" H257z")+C("M478 0 H521 V"+t+" H478z");default:return""}}(e,Math.round(1e3*t))),s=new _([o],{width:O(n),height:O(t),style:"width:"+O(n),viewBox:"0 0 "+1e3*n+" "+Math.round(1e3*t),preserveAspectRatio:"xMinYMin"}),i=We([],[s],r);return i.height=t,i.style.height=O(t),i.style.width=O(n),{type:"elem",elem:i}},Ar={type:"kern",size:-.008},Tr=new Set(["|","\\lvert","\\rvert","\\vert"]),Cr=new Set(["\\|","\\lVert","\\rVert","\\Vert"]),Br=function(e,t,r,n,o,s){let i,l,a,c,h="",m=0;i=a=c=e,l=null;let u="Size1-Regular";"\\uparrow"===e?a=c="\u23d0":"\\Uparrow"===e?a=c="\u2016":"\\downarrow"===e?i=a="\u23d0":"\\Downarrow"===e?i=a="\u2016":"\\updownarrow"===e?(i="\\uparrow",a="\u23d0",c="\\downarrow"):"\\Updownarrow"===e?(i="\\Uparrow",a="\u2016",c="\\Downarrow"):Tr.has(e)?(a="\u2223",h="vert",m=333):Cr.has(e)?(a="\u2225",h="doublevert",m=556):"["===e||"\\lbrack"===e?(i="\u23a1",a="\u23a2",c="\u23a3",u="Size4-Regular",h="lbrack",m=667):"]"===e||"\\rbrack"===e?(i="\u23a4",a="\u23a5",c="\u23a6",u="Size4-Regular",h="rbrack",m=667):"\\lfloor"===e||"\u230a"===e?(a=i="\u23a2",c="\u23a3",u="Size4-Regular",h="lfloor",m=667):"\\lceil"===e||"\u2308"===e?(i="\u23a1",a=c="\u23a2",u="Size4-Regular",h="lceil",m=667):"\\rfloor"===e||"\u230b"===e?(a=i="\u23a5",c="\u23a6",u="Size4-Regular",h="rfloor",m=667):"\\rceil"===e||"\u2309"===e?(i="\u23a4",a=c="\u23a5",u="Size4-Regular",h="rceil",m=667):"("===e||"\\lparen"===e?(i="\u239b",a="\u239c",c="\u239d",u="Size4-Regular",h="lparen",m=875):")"===e||"\\rparen"===e?(i="\u239e",a="\u239f",c="\u23a0",u="Size4-Regular",h="rparen",m=875):"\\{"===e||"\\lbrace"===e?(i="\u23a7",l="\u23a8",c="\u23a9",a="\u23aa",u="Size4-Regular"):"\\}"===e||"\\rbrace"===e?(i="\u23ab",l="\u23ac",c="\u23ad",a="\u23aa",u="Size4-Regular"):"\\lgroup"===e||"\u27ee"===e?(i="\u23a7",c="\u23a9",a="\u23aa",u="Size4-Regular"):"\\rgroup"===e||"\u27ef"===e?(i="\u23ab",c="\u23ad",a="\u23aa",u="Size4-Regular"):"\\lmoustache"===e||"\u23b0"===e?(i="\u23a7",c="\u23ad",a="\u23aa",u="Size4-Regular"):"\\rmoustache"!==e&&"\u23b1"!==e||(i="\u23ab",c="\u23a9",a="\u23aa",u="Size4-Regular");const p=wr(i,u,o),d=p.height+p.depth,g=wr(a,u,o),f=g.height+g.depth,b=wr(c,u,o),y=b.height+b.depth;let x=0,w=1;if(null!==l){const e=wr(l,u,o);x=e.height+e.depth,w=2}const v=d+y+x,k=v+Math.max(0,Math.ceil((t-v)/(w*f)))*w*f;let z=n.fontMetrics().axisHeight;r&&(z*=n.sizeMultiplier);const M=k/2-z,A=[];if(h.length>0){const e=k-d-y,t=Math.round(1e3*k),r=function(e,t){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v1759 v84 h347 v-84\nH403z M403 1759 V0 H319 V1759 v"+t+" v1759 v84 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v1759 H0 v84 H347z\nM347 1759 V0 H263 V1759 v"+t+" v1759 h84z";case"vert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z";case"doublevert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z\nM367 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M410 15 H367 v585 v"+t+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+t+" v1715 h263 v84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+t+" v1799 H0 v-84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v602 h84z\nM403 1759 V0 H319 V1759 v"+t+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v602 h84z\nM347 1759 V0 h-84 V1759 v"+t+" v602 h84z";case"lparen":return"M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1\nc-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349,\n-36,557 l0,"+(t+84)+"c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210,\n949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9\nc0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5,\n-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189\nl0,-"+(t+92)+"c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3,\n-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z";case"rparen":return"M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3,\n63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5\nc11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,"+(t+9)+"\nc-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664\nc-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11\nc0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17\nc242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558\nl0,-"+(t+144)+"c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,\n-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z";default:throw new Error("Unknown stretchy delimiter.")}}(h,Math.round(1e3*e)),o=new $(h,r),s=O(m/1e3),i=O(t/1e3),l=new _([o],{width:s,height:i,viewBox:"0 0 "+m+" "+t}),a=We([],[l],n);a.height=t/1e3,a.style.width=s,a.style.height=i,A.push({type:"elem",elem:a})}else{if(A.push(Sr(c,u,o)),A.push(Ar),null===l){const e=k-d-y+.016;A.push(Mr(a,e,n))}else{const e=(k-d-y-x)/2+.016;A.push(Mr(a,e,n)),A.push(Ar),A.push(Sr(l,u,o)),A.push(Ar),A.push(Mr(a,e,n))}A.push(Ar),A.push(Sr(i,u,o))}const T=n.havingBaseStyle(S.TEXT),C=Ke({positionType:"bottom",positionData:M,children:A});return vr(je(["delimsizing","mult"],[C],T),S.TEXT,n,s)},qr=.08,Ir=function(e,t,r,n,o){const s=function(e,t,r){t*=1e3;let n="";switch(e){case"sqrtMain":n=function(e,t){return"M95,"+(622+e+t)+"\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl"+e/2.075+" -"+e+"\nc5.3,-9.3,12,-14,20,-14\nH400000v"+(40+e)+"H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM"+(834+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize1":n=function(e,t){return"M263,"+(601+e+t)+"c0.7,0,18,39.7,52,119\nc34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120\nc340,-704.7,510.7,-1060.3,512,-1067\nl"+e/2.084+" -"+e+"\nc4.7,-7.3,11,-11,19,-11\nH40000v"+(40+e)+"H1012.3\ns-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232\nc-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1\ns-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26\nc-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z\nM"+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize2":n=function(e,t){return"M983 "+(10+e+t)+"\nl"+e/3.13+" -"+e+"\nc4,-6.7,10,-10,18,-10 H400000v"+(40+e)+"\nH1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7\ns-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744\nc-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30\nc26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722\nc56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5\nc53.7,-170.3,84.5,-266.8,92.5,-289.5z\nM"+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize3":n=function(e,t){return"M424,"+(2398+e+t)+"\nc-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514\nc0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20\ns-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121\ns209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081\nl"+e/4.223+" -"+e+"c4,-6.7,10,-10,18,-10 H400000\nv"+(40+e)+"H1014.6\ns-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185\nc-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2z M"+(1001+e)+" "+t+"\nh400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize4":n=function(e,t){return"M473,"+(2713+e+t)+"\nc339.3,-1799.3,509.3,-2700,510,-2702 l"+e/5.298+" -"+e+"\nc3.3,-7.3,9.3,-11,18,-11 H400000v"+(40+e)+"H1017.7\ns-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200\nc0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26\ns76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104,\n606zM"+(1001+e)+" "+t+"h400000v"+(40+e)+"H1017.7z"}(t,B);break;case"sqrtTall":n=function(e,t,r){return"M702 "+(e+t)+"H400000"+(40+e)+"\nH742v"+(r-54-t-e)+"l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1\nh-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170\nc-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667\n219 661 l218 661zM702 "+t+"H400000v"+(40+e)+"H742z"}(t,B,r)}return n}(e,n,r),i=new $(e,s),l=new _([i],{width:"400em",height:O(t),viewBox:"0 0 400000 "+r,preserveAspectRatio:"xMinYMin slice"});return We(["hide-tail"],[l],o)},Rr=new Set(["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","\\surd"]),Hr=new Set(["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1"]),Er=new Set(["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"]),Nr=[0,1.2,1.8,2.4,3],Or=function(e,t,r,o,s){if("<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),Rr.has(e)||Er.has(e))return zr(e,t,!1,r,o,s);if(Hr.has(e))return Br(e,Nr[t],!1,r,o,s);throw new n("Illegal delimiter: '"+e+"'")},Dr=[{type:"small",style:S.SCRIPTSCRIPT},{type:"small",style:S.SCRIPT},{type:"small",style:S.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],Lr=[{type:"small",style:S.SCRIPTSCRIPT},{type:"small",style:S.SCRIPT},{type:"small",style:S.TEXT},{type:"stack"}],Pr=[{type:"small",style:S.SCRIPTSCRIPT},{type:"small",style:S.SCRIPT},{type:"small",style:S.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],Fr=function(e){if("small"===e.type)return"Main-Regular";if("large"===e.type)return"Size"+e.size+"-Regular";if("stack"===e.type)return"Size4-Regular";{const t=e.type;throw new Error("Add support for delim type '"+t+"' here.")}},Vr=function(e,t,r,n){for(let o=Math.min(2,3-n.style.size);ot)return s}return r[r.length-1]},Gr=function(e,t,r,n,o,s){let i;"<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),i=Er.has(e)?Dr:Rr.has(e)?Pr:Lr;const l=Vr(e,t,i,n);return"small"===l.type?function(e,t,r,n,o,s){const i=Fe(e,"Main-Regular",o,n),l=vr(i,t,n,s);return r&&kr(l,n,t),l}(e,l.style,r,n,o,s):"large"===l.type?zr(e,l.size,r,n,o,s):Br(e,t,r,n,o,s)},Ur=function(e,t,r,n,o,s){const i=n.fontMetrics().axisHeight*n.sizeMultiplier,l=5/n.fontMetrics().ptPerEm,a=Math.max(t-i,r+i),c=Math.max(a/500*901,2*a-l);return Gr(e,c,!0,n,o,s)},Xr={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},Yr=new Set(["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27e8","\\rangle","\u27e9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."]);function jr(e){return"isMiddle"in e}function Wr(e,t){const r=ir(e);if(r&&Yr.has(r.text))return r;throw new n(r?"Invalid delimiter '"+r.text+"' after '"+t.funcName+"'":"Invalid delimiter type '"+e.type+"'",e)}function _r(e){if(!e.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}mt({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],numArgs:1,argTypes:["primitive"],handler:(e,t)=>{const r=Wr(t[0],e);return{type:"delimsizing",mode:e.parser.mode,size:Xr[e.funcName].size,mclass:Xr[e.funcName].mclass,delim:r.text}},htmlBuilder:(e,t)=>"."===e.delim?je([e.mclass]):Or(e.delim,e.size,t,e.mode,[e.mclass]),mathmlBuilder:e=>{const t=[];"."!==e.delim&&t.push(Et(e.delim,e.mode));const r=new Bt("mo",t);"mopen"===e.mclass||"mclose"===e.mclass?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");const n=O(Nr[e.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r}}),mt({type:"leftright-right",names:["\\right"],numArgs:1,primitive:!0,handler:(e,t)=>{const r=e.parser.gullet.macros.get("\\current@color");if(r&&"string"!=typeof r)throw new n("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:e.parser.mode,delim:Wr(t[0],e).text,color:r}}}),mt({type:"leftright",names:["\\left"],numArgs:1,primitive:!0,handler:(e,t)=>{const r=Wr(t[0],e),n=e.parser;++n.leftrightDepth;const o=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);const s=or(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:o,left:r.text,right:s.delim,rightColor:s.color}},htmlBuilder:(e,t)=>{_r(e);const r=xt(e.body,t,!0,["mopen","mclose"]);let n,o,s=0,i=0,l=!1;for(let e=0;e{_r(e);const r=Pt(e.body,t);if("."!==e.left){const t=new Bt("mo",[Et(e.left,e.mode)]);t.setAttribute("fence","true"),r.unshift(t)}if("."!==e.right){const t=new Bt("mo",[Et(e.right,e.mode)]);t.setAttribute("fence","true"),e.rightColor&&t.setAttribute("mathcolor",e.rightColor),r.push(t)}return Nt(r)}}),mt({type:"middle",names:["\\middle"],numArgs:1,primitive:!0,handler:(e,t)=>{const r=Wr(t[0],e);if(!e.parser.leftrightDepth)throw new n("\\middle without preceding \\left",r);return{type:"middle",mode:e.parser.mode,delim:r.text}},htmlBuilder:(e,t)=>{let r;return"."===e.delim?r=St(t,[]):(r=Or(e.delim,1,t,e.mode,[]),r.isMiddle={delim:e.delim,options:t}),r},mathmlBuilder:(e,t)=>{const r="\\vert"===e.delim||"|"===e.delim?Et("|","text"):Et(e.delim,e.mode),n=new Bt("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n}});mt({type:"enclose",names:["\\colorbox"],numArgs:2,allowedInText:!0,argTypes:["color","hbox"],handler(e,t,r){let{parser:n,funcName:o}=e;const s=or(t[0],"color-token").color,i=t[1];return{type:"enclose",mode:n.mode,label:o,backgroundColor:s,body:i}},htmlBuilder:(e,t)=>{const r=Ze(Mt(e.body,t),t),n=e.label.slice(1);let o,s,i=t.sizeMultiplier;const l=m(e.body);if("sout"===n)o=je(["stretchy","sout"]),o.height=t.fontMetrics().defaultRuleThickness/i,s=-.5*t.fontMetrics().xHeight;else if("phase"===n){const e=N({number:.6,unit:"pt"},t),n=N({number:.35,unit:"ex"},t);i/=t.havingBaseSizing().sizeMultiplier;const l=r.height+r.depth+e+n;r.style.paddingLeft=O(l/2+e);const c=Math.floor(1e3*l*i),h="M400000 "+(a=c)+" H0 L"+a/2+" 0 l65 45 L145 "+(a-80)+" H400000z",m=new _([new $("phase",h)],{width:"400em",height:O(c/1e3),viewBox:"0 0 400000 "+c,preserveAspectRatio:"xMinYMin slice"});o=We(["hide-tail"],[m],t),o.style.height=O(l),s=r.depth+e+n}else{let i,a;/cancel/.test(n)?l||r.classes.push("cancel-pad"):"angl"===n?r.classes.push("anglpad"):r.classes.push("boxpad");let c=0;/box/.test(n)?(c=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness),i=t.fontMetrics().fboxsep+("colorbox"===n?0:c),a=i):"angl"===n?(c=Math.max(t.fontMetrics().defaultRuleThickness,t.minRuleThickness),i=4*c,a=Math.max(0,.25-r.depth)):(i=l?.2:0,a=i),o=function(e,t,r,n,o){let s;const i=e.height+e.depth+r+n;if(/fbox|color|angl/.test(t)){if(s=je(["stretchy",t],[],o),"fbox"===t){const e=o.color&&o.getColor();e&&(s.style.borderColor=e)}}else{const e=[];/^[bx]cancel$/.test(t)&&e.push(new Z({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(t)&&e.push(new Z({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));const r=new _(e,{width:"100%",height:O(i)});s=We([],[r],o)}return s.height=i,s.style.height=O(i),s}(r,n,i,a,t),/fbox|boxed|fcolorbox/.test(n)?(o.style.borderStyle="solid",o.style.borderWidth=O(c)):"angl"===n&&.049!==c&&(o.style.borderTopWidth=O(c),o.style.borderRightWidth=O(c)),s=r.depth+a,e.backgroundColor&&(o.style.backgroundColor=e.backgroundColor,e.borderColor&&(o.style.borderColor=e.borderColor))}var a;let c;if(e.backgroundColor)c=Ke({positionType:"individualShift",children:[{type:"elem",elem:o,shift:s},{type:"elem",elem:r,shift:0}]});else{const e=/cancel|phase/.test(n)?["svg-align"]:[];c=Ke({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:o,shift:s,wrapperClasses:e}]})}return/cancel/.test(n)&&(c.height=r.height,c.depth=r.depth),/cancel/.test(n)&&!l?je(["mord","cancel-lap"],[c],t):je(["mord"],[c],t)},mathmlBuilder:(e,t)=>{let r;const n=new Bt(e.label.includes("colorbox")?"mpadded":"menclose",[Vt(e.body,t)]);switch(e.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=t.fontMetrics().fboxsep*t.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),"\\fcolorbox"===e.label){const r=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness);n.setAttribute("style","border: "+O(r)+" solid "+e.borderColor)}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike")}return e.backgroundColor&&n.setAttribute("mathbackground",e.backgroundColor),n}}),mt({type:"enclose",names:["\\fcolorbox"],numArgs:3,allowedInText:!0,argTypes:["color","color","hbox"],handler(e,t,r){let{parser:n,funcName:o}=e;const s=or(t[0],"color-token").color,i=or(t[1],"color-token").color,l=t[2];return{type:"enclose",mode:n.mode,label:o,backgroundColor:i,borderColor:s,body:l}}}),mt({type:"enclose",names:["\\fbox"],numArgs:1,argTypes:["hbox"],allowedInText:!0,handler(e,t){let{parser:r}=e;return{type:"enclose",mode:r.mode,label:"\\fbox",body:t[0]}}}),mt({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\phase"],numArgs:1,handler(e,t){let{parser:r,funcName:n}=e;const o=t[0];return{type:"enclose",mode:r.mode,label:n,body:o}}}),mt({type:"enclose",names:["\\sout"],numArgs:1,allowedInText:!0,handler(e,t){let{parser:r,funcName:n}=e;"math"===r.mode&&r.settings.reportNonstrict("mathVsSout","LaTeX's \\sout works only in text mode");const o=t[0];return{type:"enclose",mode:r.mode,label:n,body:o}}}),mt({type:"enclose",names:["\\angl"],numArgs:1,argTypes:["hbox"],allowedInText:!1,handler(e,t){let{parser:r}=e;return{type:"enclose",mode:r.mode,label:"\\angl",body:t[0]}}});const $r={};function Zr(e){let{type:t,names:r,props:n,handler:o,htmlBuilder:s,mathmlBuilder:i}=e;const l={type:t,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:o};for(let e=0;e{if(!e.parser.settings.displayMode)throw new n("{"+e.envName+"} can be used only in display mode.")},nn=new Set(["gather","gather*"]);function on(e){if(!e.includes("ed"))return!e.includes("*")}function sn(e,t,r){let{hskipBeforeAndAfter:o,addJot:s,cols:i,arraystretch:l,colSeparationType:a,autoTag:c,singleRow:h,emptySingleRow:m,maxNumCols:u,leqno:p}=t;if(e.gullet.beginGroup(),h||e.gullet.macros.set("\\cr","\\\\\\relax"),!l){const t=e.gullet.expandMacroAsText("\\arraystretch");if(null==t)l=1;else if(l=parseFloat(t),!l||l<0)throw new n("Invalid \\arraystretch: "+t)}e.gullet.beginGroup();let d=[];const g=[d],f=[],b=[],y=null!=c?[]:void 0;function x(){c&&e.gullet.macros.set("\\@eqnsw","1",!0)}function w(){y&&(e.gullet.macros.get("\\df@tag")?(y.push(e.subparse([new en("\\df@tag")])),e.gullet.macros.set("\\df@tag",void 0,!0)):y.push(Boolean(c)&&"1"===e.gullet.macros.get("\\@eqnsw")))}for(x(),b.push(tn(e));;){const t=e.parseExpression(!1,h?"\\end":"\\\\");e.gullet.endGroup(),e.gullet.beginGroup();let o={type:"ordgroup",mode:e.mode,body:t};r&&(o={type:"styling",mode:e.mode,style:r,resetFont:!0,body:[o]}),d.push(o);const s=e.fetch().text;if("&"===s){if(u&&d.length===u){if(h||a)throw new n("Too many tab characters: &",e.nextToken);e.settings.reportNonstrict("textEnv","Too few columns specified in the {array} column argument.")}e.consume()}else{if("\\end"===s){w(),1===d.length&&"styling"===o.type&&1===o.body.length&&"ordgroup"===o.body[0].type&&0===o.body[0].body.length&&(g.length>1||!m)&&g.pop(),b.length0&&(y+=.25),c.push({pos:y,isDashed:e[t]})}for(x(i[0]),r=0;r0&&(u+=b,ce))for(r=0;r=l)continue;var B,q;if(o>0||e.hskipBeforeAndAfter)i=null!=(B=null==(q=c)?void 0:q.pregap)?B:u,0!==i&&(z=je(["arraycolsep"],[]),z.style.width=O(i),k.push(z));const p=[];for(r=0;r0){const e=_e("hline",t,h),r=_e("hdashline",t,h),n=[{type:"elem",elem:H,shift:0}];for(;c.length>0;){const t=c.pop(),o=t.pos-w;t.isDashed?n.push({type:"elem",elem:r,shift:o}):n.push({type:"elem",elem:e,shift:o})}H=Ke({positionType:"individualShift",children:n})}if(0===A.length)return je(["mord"],[H],t);{const e=Ke({positionType:"individualShift",children:A}),r=je(["tag"],[e],t);return $e([H,r])}},cn={c:"center ",l:"left ",r:"right "},hn=function(e,t){const r=[],n=new Bt("mtd",[],["mtr-glue"]),o=new Bt("mtd",[],["mml-eqn-num"]);for(let s=0;s0){const t=e.cols;let r="",n=!1,o=0,i=t.length;"separator"===t[0].type&&(l+="top ",o=1),"separator"===t[t.length-1].type&&(l+="bottom ",i-=1);for(let e=o;e0?"left ":"",l+=h[h.length-1].length>0?"right ":"";for(let e=1;e0&&c&&(n=1),r[e]={type:"align",align:t,pregap:n,postgap:0}}return s.colSeparationType=c?"align":"alignat",s};Zr({type:"array",names:["array","darray"],props:{numArgs:1},handler(e,t){const r=(ir(t[0])?[t[0]]:or(t[0],"ordgroup").body).map(function(e){const t=sr(e).text;if("lcr".includes(t))return{type:"align",align:t};if("|"===t)return{type:"separator",separator:"|"};if(":"===t)return{type:"separator",separator:":"};throw new n("Unknown column alignment: "+t,e)}),o={cols:r,hskipBeforeAndAfter:!0,maxNumCols:r.length};return sn(e.parser,o,ln(e.envName))},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(e){const t={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[e.envName.replace("*","")];let r="c";const o={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if("*"===e.envName.charAt(e.envName.length-1)){const t=e.parser;if(t.consumeSpaces(),"["===t.fetch().text){if(t.consume(),t.consumeSpaces(),r=t.fetch().text,!"lcr".includes(r))throw new n("Expected l or c or r",t.nextToken);t.consume(),t.consumeSpaces(),t.expect("]"),t.consume(),o.cols=[{type:"align",align:r}]}}const s=sn(e.parser,o,ln(e.envName)),i=Math.max(0,...s.body.map(e=>e.length));return s.cols=new Array(i).fill({type:"align",align:r}),t?{type:"leftright",mode:e.mode,body:[s],left:t[0],right:t[1],rightColor:void 0}:s},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(e){const t=sn(e.parser,{arraystretch:.5},"script");return t.colSeparationType="small",t},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["subarray"],props:{numArgs:1},handler(e,t){const r=(ir(t[0])?[t[0]]:or(t[0],"ordgroup").body).map(function(e){const t=sr(e).text;if("lc".includes(t))return{type:"align",align:t};throw new n("Unknown column alignment: "+t,e)});if(r.length>1)throw new n("{subarray} can contain only one column");const o={cols:r,hskipBeforeAndAfter:!1,arraystretch:.5},s=sn(e.parser,o,"script");if(s.body.length>0&&s.body[0].length>1)throw new n("{subarray} can contain only one column");return s},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(e){const t=sn(e.parser,{arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},ln(e.envName));return{type:"leftright",mode:e.mode,body:[t],left:e.envName.includes("r")?".":"\\{",right:e.envName.includes("r")?"\\}":".",rightColor:void 0}},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:mn,htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(e){nn.has(e.envName)&&rn(e);const t={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:on(e.envName),emptySingleRow:!0,leqno:e.parser.settings.leqno};return sn(e.parser,t,"display")},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:mn,htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(e){rn(e);const t={autoTag:on(e.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:e.parser.settings.leqno};return sn(e.parser,t,"display")},htmlBuilder:an,mathmlBuilder:hn}),Zr({type:"array",names:["CD"],props:{numArgs:0},handler(e){return rn(e),function(e){const t=[];for(e.gullet.beginGroup(),e.gullet.macros.set("\\cr","\\\\\\relax"),e.gullet.beginGroup();;){t.push(e.parseExpression(!1,"\\\\")),e.gullet.endGroup(),e.gullet.beginGroup();const r=e.fetch().text;if("&"!==r&&"\\\\"!==r){if("\\end"===r){0===t[t.length-1].length&&t.pop();break}throw new n("Expected \\\\ or \\cr or \\end",e.nextToken)}e.consume()}let r=[];const o=[r];for(let s=0;sAV".includes(o))throw new n('Expected one of "<>AV=|." after @',i[t]);for(let e=0;e<2;e++){let r=!0;for(let l=t+1;l{let{parser:r,funcName:n}=e;const o=pt(t[0]),s=n in pn?pn[n]:n;return{type:"font",mode:r.mode,font:s.slice(1),body:o}},htmlBuilder:(e,t)=>{const r=e.font,n=t.withFont(r);return Mt(e.body,n)},mathmlBuilder:(e,t)=>{const r=e.font,n=t.withFont(r);return Vt(e.body,n)}}),mt({type:"mclass",names:["\\boldsymbol","\\bm"],numArgs:1,handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"mclass",mode:r.mode,mclass:mr(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:m(n)}}}),mt({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],numArgs:0,allowedInText:!0,handler:(e,t)=>{let{parser:r,funcName:n,breakOnTokenText:o}=e;const{mode:s}=r,i=r.parseExpression(!0,o);return{type:"font",mode:s,font:"math"+n.slice(1),body:{type:"ordgroup",mode:r.mode,body:i}}}});const dn=(e,t)=>{if(!t)return e;return{type:"styling",mode:e.mode,style:t,body:[e]}};mt({type:"genfrac",names:["\\cfrac","\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],numArgs:2,allowedInArgument:!0,handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0],s=t[1];let i,l=null,a=null;switch(n){case"\\cfrac":case"\\dfrac":case"\\frac":case"\\tfrac":i=!0;break;case"\\\\atopfrac":i=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":i=!1,l="(",a=")";break;case"\\\\bracefrac":i=!1,l="\\{",a="\\}";break;case"\\\\brackfrac":i=!1,l="[",a="]";break;default:throw new Error("Unrecognized genfrac command")}const c="\\cfrac"===n;let h=null;return c||n.startsWith("\\d")?h="display":n.startsWith("\\t")&&(h="text"),dn({type:"genfrac",mode:r.mode,numer:o,denom:s,continued:c,hasBarLine:i,leftDelim:l,rightDelim:a,barSize:null},h)},htmlBuilder:(e,t)=>{const r=t.style,n=r.fracNum(),o=r.fracDen();let s;s=t.havingStyle(n);const i=Mt(e.numer,s,t);if(e.continued){const e=8.5/t.fontMetrics().ptPerEm,r=3.5/t.fontMetrics().ptPerEm;i.height=i.height0?3*h:7*h,p=t.fontMetrics().denom1):(c>0?(m=t.fontMetrics().num2,u=h):(m=t.fontMetrics().num3,u=3*h),p=t.fontMetrics().denom2),a){const e=t.fontMetrics().axisHeight;m-i.depth-(e+.5*c){const r=new Bt("mfrac",[Vt(e.numer,t),Vt(e.denom,t)]);if(e.hasBarLine){if(e.barSize){const n=N(e.barSize,t);r.setAttribute("linethickness",O(n))}}else r.setAttribute("linethickness","0px");if(null!=e.leftDelim||null!=e.rightDelim){const t=[];if(null!=e.leftDelim){const r=new Bt("mo",[new qt(e.leftDelim.replace("\\",""))]);r.setAttribute("fence","true"),t.push(r)}if(t.push(r),null!=e.rightDelim){const r=new Bt("mo",[new qt(e.rightDelim.replace("\\",""))]);r.setAttribute("fence","true"),t.push(r)}return Nt(t)}return r}}),mt({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],numArgs:0,infix:!0,handler(e){let t,{parser:r,funcName:n,token:o}=e;switch(n){case"\\over":t="\\frac";break;case"\\choose":t="\\binom";break;case"\\atop":t="\\\\atopfrac";break;case"\\brace":t="\\\\bracefrac";break;case"\\brack":t="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:r.mode,replaceWith:t,token:o}}});const gn=["display","text","script","scriptscript"],fn=function(e){let t=null;return e.length>0&&(t=e,t="."===t?null:t),t};mt({type:"genfrac",names:["\\genfrac"],numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"],handler(e,t){let{parser:r}=e;const n=t[4],o=t[5],s=pt(t[0]),i="atom"===s.type&&"open"===s.family?fn(s.text):null,l=pt(t[1]),a="atom"===l.type&&"close"===l.family?fn(l.text):null,c=or(t[2],"size");let h,m=null;c.isBlank?h=!0:(m=c.value,h=m.number>0);let u=null,p=t[3];if("ordgroup"===p.type){if(p.body.length>0){const e=or(p.body[0],"textord");u=gn[Number(e.text)]}}else p=or(p,"textord"),u=gn[Number(p.text)];return dn({type:"genfrac",mode:r.mode,numer:n,denom:o,continued:!1,hasBarLine:h,barSize:m,leftDelim:i,rightDelim:a},u)}}),mt({type:"infix",names:["\\above"],numArgs:1,argTypes:["size"],infix:!0,handler(e,t){let{parser:r,funcName:n,token:o}=e;return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:or(t[0],"size").value,token:o}}}),mt({type:"genfrac",names:["\\\\abovefrac"],numArgs:3,argTypes:["math","size","math"],handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0],s=or(t[1],"infix").size;if(!s)throw new Error("\\\\abovefrac expected size, but got "+String(s));const i=t[2],l=s.number>0;return{type:"genfrac",mode:r.mode,numer:o,denom:i,continued:!1,hasBarLine:l,barSize:s,leftDelim:null,rightDelim:null}}});const bn=(e,t)=>{const r=t.style;let n,o;"supsub"===e.type?(n=e.sup?Mt(e.sup,t.havingStyle(r.sup()),t):Mt(e.sub,t.havingStyle(r.sub()),t),o=or(e.base,"horizBrace")):o=or(e,"horizBrace");const s=Mt(o.base,t.havingBaseStyle(S.DISPLAY)),i=tr(o,t);let l;if(l=o.isOver?Ke({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:i,wrapperClasses:["svg-align"]}]}):Ke({positionType:"bottom",positionData:s.depth+.1+i.height,children:[{type:"elem",elem:i,wrapperClasses:["svg-align"]},{type:"kern",size:.1},{type:"elem",elem:s}]}),n){const e=je(["minner",o.isOver?"mover":"munder"],[l],t);l=o.isOver?Ke({positionType:"firstBaseline",children:[{type:"elem",elem:e},{type:"kern",size:.2},{type:"elem",elem:n}]}):Ke({positionType:"bottom",positionData:e.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:e}]})}return je(["minner",o.isOver?"mover":"munder"],[l],t)};mt({type:"horizBrace",names:["\\overbrace","\\underbrace","\\overbracket","\\underbracket"],numArgs:1,handler(e,t){let{parser:r,funcName:n}=e;return{type:"horizBrace",mode:r.mode,label:n,isOver:n.includes("\\over"),base:t[0]}},htmlBuilder:bn,mathmlBuilder:(e,t)=>{const r=Jt(e.label);return new Bt(e.isOver?"mover":"munder",[Vt(e.base,t),r])}}),mt({type:"href",names:["\\href"],numArgs:2,argTypes:["url","original"],allowedInText:!0,handler:(e,t)=>{let{parser:r}=e;const n=t[1],o=or(t[0],"url").url;return r.settings.isTrusted({command:"\\href",url:o})?{type:"href",mode:r.mode,href:o,body:dt(n)}:r.formatUnsupportedCmd("\\href")},htmlBuilder:(e,t)=>{const r=xt(e.body,t,!1);return function(e,t,r,n){const o=new X(e,t,r,n);return Ye(o),o}(e.href,[],r,t)},mathmlBuilder:(e,t)=>{let r=Ft(e.body,t);return r instanceof Bt||(r=new Bt("mrow",[r])),r.setAttribute("href",e.href),r}}),mt({type:"href",names:["\\url"],numArgs:1,argTypes:["url"],allowedInText:!0,handler:(e,t)=>{let{parser:r}=e;const n=or(t[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");const o=[];for(let e=0;e{let{parser:r,funcName:o,token:s}=e;const i=or(t[0],"raw").string,l=t[1];let a;r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");const c={};switch(o){case"\\htmlClass":c.class=i,a={command:"\\htmlClass",class:i};break;case"\\htmlId":c.id=i,a={command:"\\htmlId",id:i};break;case"\\htmlStyle":c.style=i,a={command:"\\htmlStyle",style:i};break;case"\\htmlData":{const e=i.split(",");for(let t=0;t{const r=xt(e.body,t,!1),n=["enclosing"];e.attributes.class&&n.push(...e.attributes.class.trim().split(/\s+/));const o=je(n,r,t);for(const t in e.attributes)"class"!==t&&e.attributes.hasOwnProperty(t)&&o.setAttribute(t,e.attributes[t]);return o},mathmlBuilder:(e,t)=>Ft(e.body,t)}),mt({type:"htmlmathml",names:["\\html@mathml"],numArgs:2,allowedInArgument:!0,allowedInText:!0,handler:(e,t)=>{let{parser:r}=e;return{type:"htmlmathml",mode:r.mode,html:dt(t[0]),mathml:dt(t[1])}},htmlBuilder:(e,t)=>{const r=xt(e.html,t,!1);return $e(r)},mathmlBuilder:(e,t)=>Ft(e.mathml,t)});const yn=function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};{const t=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!t)throw new n("Invalid size: '"+e+"' in \\includegraphics");const r={number:+(t[1]+t[2]),unit:t[3]};if(!E(r))throw new n("Invalid unit: '"+r.unit+"' in \\includegraphics.");return r}};mt({type:"includegraphics",names:["\\includegraphics"],numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1,handler:(e,t,r)=>{let{parser:o}=e,s={number:0,unit:"em"},i={number:.9,unit:"em"},l={number:0,unit:"em"},a="";if(r[0]){const e=or(r[0],"raw").string.split(",");for(let t=0;t{const r=N(e.height,t);let n=0;e.totalheight.number>0&&(n=N(e.totalheight,t)-r);let o=0;e.width.number>0&&(o=N(e.width,t));const s={height:O(r+n)};o>0&&(s.width=O(o)),n>0&&(s.verticalAlign=O(-n));const i=new Y(e.src,e.alt,s);return i.height=r,i.depth=n,i},mathmlBuilder:(e,t)=>{const r=new Bt("mglyph",[]);r.setAttribute("alt",e.alt);const n=N(e.height,t);let o=0;if(e.totalheight.number>0&&(o=N(e.totalheight,t)-n,r.setAttribute("valign",O(-o))),r.setAttribute("height",O(n+o)),e.width.number>0){const n=N(e.width,t);r.setAttribute("width",O(n))}return r.setAttribute("src",e.src),r}}),mt({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0,handler(e,t){let{parser:r,funcName:n}=e;const o=or(t[0],"size");if(r.settings.strict){const e="m"===n[1],t="mu"===o.value.unit;e?(t||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, not "+o.value.unit+" units"),"math"!==r.mode&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):t&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:o.value}},htmlBuilder(e,t){return Je(e.dimension,t)},mathmlBuilder(e,t){const r=N(e.dimension,t);return new It(r)}}),mt({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],numArgs:1,allowedInText:!0,handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:o}},htmlBuilder:(e,t)=>{let r;"clap"===e.alignment?(r=je([],[Mt(e.body,t)]),r=je(["inner"],[r],t)):r=je(["inner"],[Mt(e.body,t)]);const n=je(["fix"],[]);let o=je([e.alignment],[r,n],t);const s=je(["strut"]);return s.style.height=O(o.height+o.depth),o.depth&&(s.style.verticalAlign=O(-o.depth)),o.children.unshift(s),o=je(["thinbox"],[o],t),je(["mord","vbox"],[o],t)},mathmlBuilder:(e,t)=>{const r=new Bt("mpadded",[Vt(e.body,t)]);if("rlap"!==e.alignment){const t="llap"===e.alignment?"-1":"-0.5";r.setAttribute("lspace",t+"width")}return r.setAttribute("width","0px"),r}}),mt({type:"styling",names:["\\(","$"],numArgs:0,allowedInText:!0,allowedInMath:!1,handler(e,t){let{funcName:r,parser:n}=e;const o=n.mode;n.switchMode("math");const s="\\("===r?"\\)":"$",i=n.parseExpression(!1,s);return n.expect(s),n.switchMode(o),{type:"styling",mode:n.mode,style:"text",resetFont:!0,body:i}}}),mt({type:"text",names:["\\)","\\]"],numArgs:0,allowedInText:!0,allowedInMath:!1,handler(e,t){throw new n("Mismatched "+e.funcName)}});const xn=(e,t)=>{switch(t.style.size){case S.DISPLAY.size:return e.display;case S.TEXT.size:return e.text;case S.SCRIPT.size:return e.script;case S.SCRIPTSCRIPT.size:return e.scriptscript;default:return e.text}};mt({type:"mathchoice",names:["\\mathchoice"],numArgs:4,primitive:!0,handler:(e,t)=>{let{parser:r}=e;return{type:"mathchoice",mode:r.mode,display:dt(t[0]),text:dt(t[1]),script:dt(t[2]),scriptscript:dt(t[3])}},htmlBuilder:(e,t)=>{const r=xn(e,t),n=xt(r,t,!1);return $e(n)},mathmlBuilder:(e,t)=>{const r=xn(e,t);return Ft(r,t)}});const wn=(e,t,r,n,o,s,i)=>{e=je([],[e]);const l=r&&m(r);let a,c,h;if(t){const e=Mt(t,n.havingStyle(o.sup()),n);c={elem:e,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-e.depth)}}if(r){const e=Mt(r,n.havingStyle(o.sub()),n);a={elem:e,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-e.height)}}if(c&&a){const t=n.fontMetrics().bigOpSpacing5+a.elem.height+a.elem.depth+a.kern+e.depth+i;h=Ke({positionType:"bottom",positionData:t,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:a.elem,marginLeft:O(-s)},{type:"kern",size:a.kern},{type:"elem",elem:e},{type:"kern",size:c.kern},{type:"elem",elem:c.elem,marginLeft:O(s)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]})}else if(a){const t=e.height-i;h=Ke({positionType:"top",positionData:t,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:a.elem,marginLeft:O(-s)},{type:"kern",size:a.kern},{type:"elem",elem:e}]})}else{if(!c)return e;{const t=e.depth+i;h=Ke({positionType:"bottom",positionData:t,children:[{type:"elem",elem:e},{type:"kern",size:c.kern},{type:"elem",elem:c.elem,marginLeft:O(s)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]})}}const u=[h];if(a&&0!==s&&!l){const e=je(["mspace"],[],n);e.style.marginRight=O(s),u.unshift(e)}return je(["mop","op-limits"],u,n)},vn=new Set(["\\smallint"]),kn=(e,t)=>{let r,n,o,s=!1;"supsub"===e.type?(r=e.sup,n=e.sub,o=or(e.base,"op"),s=!0):o=or(e,"op");const i=t.style;let l,a,c=!1;if(i.size===S.DISPLAY.size&&o.symbol&&!vn.has(o.name)&&(c=!0),o.symbol){const e=c?"Size2-Regular":"Size1-Regular";let r="";if("\\oiint"!==o.name&&"\\oiiint"!==o.name||(r=o.name.slice(1),o.name="oiint"===r?"\\iint":"\\iiint"),l=Fe(o.name,e,"math",t,["mop","op-symbol",c?"large-op":"small-op"]),a=l.italic,r.length>0){const e=rt(r+"Size"+(c?"2":"1"),t);l=Ke({positionType:"individualShift",children:[{type:"elem",elem:l,shift:0},{type:"elem",elem:e,shift:c?.08:0}]}),o.name="\\"+r,l.classes.unshift("mop"),l.italic=a}}else if(o.body){const e=xt(o.body,t,!0);1===e.length&&e[0]instanceof W?(l=e[0],l.classes[0]="mop"):l=je(["mop"],e,t)}else{const e=[];for(let r=1;r{let{parser:r,funcName:n}=e,o=n;return 1===o.length&&(o=zn[o]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:o}},htmlBuilder:kn,mathmlBuilder:(e,t)=>{let r;if(e.symbol)r=new Bt("mo",[Et(e.name,e.mode)]),vn.has(e.name)&&r.setAttribute("largeop","false");else if(e.body)r=new Bt("mo",Pt(e.body,t));else{r=new Bt("mi",[new qt(e.name.slice(1))]);const t=new Bt("mo",[Et("\u2061","text")]);r=e.parentIsSupSub?new Bt("mrow",[r,t]):Ct([r,t])}return r}}),mt({type:"op",names:["\\mathop"],numArgs:1,primitive:!0,handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:dt(n)}}});const Sn={"\u222b":"\\int","\u222c":"\\iint","\u222d":"\\iiint","\u222e":"\\oint","\u222f":"\\oiint","\u2230":"\\oiiint"};mt({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],numArgs:0,handler(e){let{parser:t,funcName:r}=e;return{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}}}),mt({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],numArgs:0,handler(e){let{parser:t,funcName:r}=e;return{type:"op",mode:t.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}}}),mt({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222b","\u222c","\u222d","\u222e","\u222f","\u2230"],numArgs:0,allowedInArgument:!0,handler(e){let{parser:t,funcName:r}=e,n=r;return 1===n.length&&(n=Sn[n]),{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:n}}});const Mn=(e,t)=>{let r,n,o,s,i=!1;if("supsub"===e.type?(r=e.sup,n=e.sub,o=or(e.base,"operatorname"),i=!0):o=or(e,"operatorname"),o.body.length>0){const e=o.body.map(e=>{const t="text"in e?e.text:void 0;return"string"==typeof t?{type:"textord",mode:e.mode,text:t}:e}),r=xt(e,t.withFont("mathrm"),!0);for(let e=0;e{let{parser:r,funcName:n}=e;const o=t[0];return{type:"operatorname",mode:r.mode,body:dt(o),alwaysHandleSupSub:"\\operatornamewithlimits"===n,limits:!1,parentIsSupSub:!1}},htmlBuilder:Mn,mathmlBuilder:(e,t)=>{let r=Pt(e.body,t.withFont("mathrm")),n=!0;for(let e=0;ee.toText()).join("");r=[new qt(e)]}const o=new Bt("mi",r);o.setAttribute("mathvariant","normal");const s=new Bt("mo",[Et("\u2061","text")]);return e.parentIsSupSub?new Bt("mrow",[o,s]):Ct([o,s])}}),Jr("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@"),ut({type:"ordgroup",htmlBuilder(e,t){return e.semisimple?$e(xt(e.body,t,!1)):je(["mord"],xt(e.body,t,!0),t)},mathmlBuilder(e,t){return Ft(e.body,t,!0)}}),mt({type:"overline",names:["\\overline"],numArgs:1,handler(e,t){let{parser:r}=e;const n=t[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(e,t){const r=Mt(e.body,t.havingCrampedStyle()),n=_e("overline-line",t),o=t.fontMetrics().defaultRuleThickness,s=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*o},{type:"elem",elem:n},{type:"kern",size:o}]});return je(["mord","overline"],[s],t)},mathmlBuilder(e,t){const r=new Bt("mo",[new qt("\u203e")]);r.setAttribute("stretchy","true");const n=new Bt("mover",[Vt(e.body,t),r]);return n.setAttribute("accent","true"),n}}),mt({type:"phantom",names:["\\phantom"],numArgs:1,allowedInText:!0,handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"phantom",mode:r.mode,body:dt(n)}},htmlBuilder:(e,t)=>{const r=xt(e.body,t.withPhantom(),!1);return $e(r)},mathmlBuilder:(e,t)=>{const r=Pt(e.body,t);return new Bt("mphantom",r)}}),Jr("\\hphantom","\\smash{\\phantom{#1}}"),mt({type:"vphantom",names:["\\vphantom"],numArgs:1,allowedInText:!0,handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"vphantom",mode:r.mode,body:n}},htmlBuilder:(e,t)=>{const r=je(["inner"],[Mt(e.body,t.withPhantom())]),n=je(["fix"],[]);return je(["mord","rlap"],[r,n],t)},mathmlBuilder:(e,t)=>{const r=Pt(dt(e.body),t),n=new Bt("mphantom",r),o=new Bt("mpadded",[n]);return o.setAttribute("width","0px"),o}}),mt({type:"raisebox",names:["\\raisebox"],numArgs:2,argTypes:["size","hbox"],allowedInText:!0,handler(e,t){let{parser:r}=e;const n=or(t[0],"size").value,o=t[1];return{type:"raisebox",mode:r.mode,dy:n,body:o}},htmlBuilder(e,t){const r=Mt(e.body,t),n=N(e.dy,t);return Ke({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]})},mathmlBuilder(e,t){const r=new Bt("mpadded",[Vt(e.body,t)]),n=e.dy.number+e.dy.unit;return r.setAttribute("voffset",n),r}}),mt({type:"internal",names:["\\relax"],numArgs:0,allowedInText:!0,allowedInArgument:!0,handler(e){let{parser:t}=e;return{type:"internal",mode:t.mode}}}),mt({type:"rule",names:["\\rule"],numArgs:2,numOptionalArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["size","size","size"],handler(e,t,r){let{parser:n}=e;const o=r[0],s=or(t[0],"size"),i=or(t[1],"size");return{type:"rule",mode:n.mode,shift:o&&or(o,"size").value,width:s.value,height:i.value}},htmlBuilder(e,t){const r=je(["mord","rule"],[],t),n=N(e.width,t),o=N(e.height,t),s=e.shift?N(e.shift,t):0;return r.style.borderRightWidth=O(n),r.style.borderTopWidth=O(o),r.style.bottom=O(s),r.width=n,r.height=o+s,r.depth=-s,r.maxFontSize=1.125*o*t.sizeMultiplier,r},mathmlBuilder(e,t){const r=N(e.width,t),n=N(e.height,t),o=e.shift?N(e.shift,t):0,s=t.color&&t.getColor()||"black",i=new Bt("mspace");i.setAttribute("mathbackground",s),i.setAttribute("width",O(r)),i.setAttribute("height",O(n));const l=new Bt("mpadded",[i]);return o>=0?l.setAttribute("height",O(o)):(l.setAttribute("height",O(o)),l.setAttribute("depth",O(-o))),l.setAttribute("voffset",O(o)),l}});const Tn=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"];mt({type:"sizing",names:Tn,numArgs:0,allowedInText:!0,handler:(e,t)=>{let{breakOnTokenText:r,funcName:n,parser:o}=e;const s=o.parseExpression(!1,r);return{type:"sizing",mode:o.mode,size:Tn.indexOf(n)+1,body:s}},htmlBuilder:(e,t)=>{const r=t.havingSize(e.size);return An(e.body,r,t)},mathmlBuilder:(e,t)=>{const r=t.havingSize(e.size),n=Pt(e.body,r),o=new Bt("mstyle",n);return o.setAttribute("mathsize",O(r.sizeMultiplier)),o}}),mt({type:"smash",names:["\\smash"],numArgs:1,numOptionalArgs:1,allowedInText:!0,handler:(e,t,r)=>{let{parser:n}=e,o=!1,s=!1;const i=r[0]&&or(r[0],"ordgroup");if(i){let e;for(let t=0;t{const r=je([],[Mt(e.body,t)]);if(!e.smashHeight&&!e.smashDepth)return r;if(e.smashHeight&&(r.height=0),e.smashDepth&&(r.depth=0),e.smashHeight&&e.smashDepth)return je(["mord","smash"],[r],t);if(r.children)for(let t=0;t{const r=new Bt("mpadded",[Vt(e.body,t)]);return e.smashHeight&&r.setAttribute("height","0px"),e.smashDepth&&r.setAttribute("depth","0px"),r}}),mt({type:"sqrt",names:["\\sqrt"],numArgs:1,numOptionalArgs:1,handler(e,t,r){let{parser:n}=e;const o=r[0],s=t[0];return{type:"sqrt",mode:n.mode,body:s,index:o}},htmlBuilder(e,t){let r=Mt(e.body,t.havingCrampedStyle());0===r.height&&(r.height=t.fontMetrics().xHeight),r=Ze(r,t);const n=t.fontMetrics().defaultRuleThickness;let o=n;t.style.idr.height+r.depth+s&&(s=(s+h-r.height-r.depth)/2);const m=l.height-r.height-s-a;r.style.paddingLeft=O(c);const u=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+m)},{type:"elem",elem:l},{type:"kern",size:a}]});if(e.index){const r=t.havingStyle(S.SCRIPTSCRIPT),n=Mt(e.index,r,t),o=.6*(u.height-u.depth),s=Ke({positionType:"shift",positionData:-o,children:[{type:"elem",elem:n}]}),i=je(["root"],[s]);return je(["mord","sqrt"],[i,u],t)}return je(["mord","sqrt"],[u],t)},mathmlBuilder(e,t){const{body:r,index:n}=e;return n?new Bt("mroot",[Vt(r,t),Vt(n,t)]):new Bt("msqrt",[Vt(r,t)])}});const Cn={display:S.DISPLAY,text:S.TEXT,script:S.SCRIPT,scriptscript:S.SCRIPTSCRIPT};mt({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],numArgs:0,allowedInText:!0,primitive:!0,handler(e,t){let{breakOnTokenText:r,funcName:n,parser:o}=e;const s=o.parseExpression(!0,r),i=n.slice(1,n.length-5);if(!(i in Cn))throw new Error("Unknown style: "+i);return{type:"styling",mode:o.mode,style:i,body:s}},htmlBuilder(e,t){const r=Cn[e.style];let n=t.havingStyle(r);return e.resetFont&&(n=n.withFont("")),An(e.body,n,t)},mathmlBuilder(e,t){const r=Cn[e.style];let n=t.havingStyle(r);e.resetFont&&(n=n.withFont(""));const o=Pt(e.body,n),s=new Bt("mstyle",o),i={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]}[e.style];return s.setAttribute("scriptlevel",i[0]),s.setAttribute("displaystyle",i[1]),s}});ut({type:"supsub",htmlBuilder(e,t){const r=function(e,t){const r=e.base;if(r)return"op"===r.type?r.limits&&(t.style.size===S.DISPLAY.size||r.alwaysHandleSupSub)?kn:null:"operatorname"===r.type?r.alwaysHandleSupSub&&(t.style.size===S.DISPLAY.size||r.limits)?Mn:null:"accent"===r.type?m(r.base)?ar:null:"horizBrace"===r.type&&!e.sub===r.isOver?bn:null;return null}(e,t);if(r)return r(e,t);const{base:n,sup:o,sub:s}=e,i=Mt(n,t);let l,a;const c=t.fontMetrics();let h=0,u=0;const p=n&&m(n);if(o){const e=t.havingStyle(t.style.sup());l=Mt(o,e,t),p||(h=i.height-e.fontMetrics().supDrop*e.sizeMultiplier/t.sizeMultiplier)}if(s){const e=t.havingStyle(t.style.sub());a=Mt(s,e,t),p||(u=i.depth+e.fontMetrics().subDrop*e.sizeMultiplier/t.sizeMultiplier)}let d;d=t.style===S.DISPLAY?c.sup1:t.style.cramped?c.sup3:c.sup2;const g=t.sizeMultiplier,f=O(.5/c.ptPerEm/g);let b,y=null;if(a){const t=e.base&&"op"===e.base.type&&e.base.name&&("\\oiint"===e.base.name||"\\oiiint"===e.base.name);var x;if(i instanceof W||t)y=O(-(null!=(x=i.italic)?x:0))}if(l&&a){h=Math.max(h,d,l.depth+.25*c.xHeight),u=Math.max(u,c.sub2);const e=4*c.defaultRuleThickness;if(h-l.depth-(a.height-u)0&&(h+=t,u-=t)}b=Ke({positionType:"individualShift",children:[{type:"elem",elem:a,shift:u,marginRight:f,marginLeft:y},{type:"elem",elem:l,shift:-h,marginRight:f}]})}else if(a){u=Math.max(u,c.sub1,a.height-.8*c.xHeight);b=Ke({positionType:"shift",positionData:u,children:[{type:"elem",elem:a,marginLeft:y,marginRight:f}]})}else{if(!l)throw new Error("supsub must have either sup or sub.");h=Math.max(h,d,l.depth+.25*c.xHeight),b=Ke({positionType:"shift",positionData:-h,children:[{type:"elem",elem:l,marginRight:f}]})}const w=zt(i,"right")||"mord";return je([w],[i,je(["msupsub"],[b])],t)},mathmlBuilder(e,t){let r,n,o=!1;e.base&&"horizBrace"===e.base.type&&(n=!!e.sup,n===e.base.isOver&&(o=!0,r=e.base.isOver)),!e.base||"op"!==e.base.type&&"operatorname"!==e.base.type||(e.base.parentIsSupSub=!0);const s=[Vt(e.base,t)];let i;if(e.sub&&s.push(Vt(e.sub,t)),e.sup&&s.push(Vt(e.sup,t)),o)i=r?"mover":"munder";else if(e.sub)if(e.sup){const r=e.base;i=r&&"op"===r.type&&r.limits&&t.style===S.DISPLAY||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(t.style===S.DISPLAY||r.limits)?"munderover":"msubsup"}else{const r=e.base;i=r&&"op"===r.type&&r.limits&&(t.style===S.DISPLAY||r.alwaysHandleSupSub)||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(r.limits||t.style===S.DISPLAY)?"munder":"msub"}else{const r=e.base;i=r&&"op"===r.type&&r.limits&&(t.style===S.DISPLAY||r.alwaysHandleSupSub)||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(r.limits||t.style===S.DISPLAY)?"mover":"msup"}return new Bt(i,s)}}),ut({type:"atom",htmlBuilder(e,t){return Ve(e.text,e.mode,t,["m"+e.family])},mathmlBuilder(e,t){const r=new Bt("mo",[Et(e.text,e.mode)]);if("bin"===e.family){const n=Dt(e,t);"bold-italic"===n&&r.setAttribute("mathvariant",n)}else"punct"===e.family?r.setAttribute("separator","true"):"open"!==e.family&&"close"!==e.family||r.setAttribute("stretchy","false");return r}});const Bn={mi:"italic",mn:"normal",mtext:"normal"};ut({type:"mathord",htmlBuilder(e,t){return Ge(e,t)},mathmlBuilder(e,t){const r=new Bt("mi",[Et(e.text,e.mode,t)]),n=Dt(e,t)||"italic";return n!==Bn[r.type]&&r.setAttribute("mathvariant",n),r}}),ut({type:"textord",htmlBuilder(e,t){return Ge(e,t)},mathmlBuilder(e,t){const r=Et(e.text,e.mode,t),n=Dt(e,t)||"normal";let o;return o="text"===e.mode?new Bt("mtext",[r]):/[0-9]/.test(e.text)?new Bt("mn",[r]):"\\prime"===e.text?new Bt("mo",[r]):new Bt("mi",[r]),n!==Bn[o.type]&&o.setAttribute("mathvariant",n),o}});const qn={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},In={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};ut({type:"spacing",htmlBuilder(e,t){if(In.hasOwnProperty(e.text)){const r=In[e.text].className||"";if("text"===e.mode){const n=Ge(e,t);return n.classes.push(r),n}return je(["mspace",r],[Ve(e.text,e.mode,t)],t)}if(qn.hasOwnProperty(e.text))return je(["mspace",qn[e.text]],[],t);throw new n('Unknown type of space "'+e.text+'"')},mathmlBuilder(e,t){let r;if(!In.hasOwnProperty(e.text)){if(qn.hasOwnProperty(e.text))return new Bt("mspace");throw new n('Unknown type of space "'+e.text+'"')}return r=new Bt("mtext",[new qt("\xa0")]),r}});const Rn=()=>{const e=new Bt("mtd",[]);return e.setAttribute("width","50%"),e};ut({type:"tag",mathmlBuilder(e,t){const r=new Bt("mtable",[new Bt("mtr",[Rn(),new Bt("mtd",[Ft(e.body,t)]),Rn(),new Bt("mtd",[Ft(e.tag,t)])])]);return r.setAttribute("width","100%"),r}});const Hn={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},En={"\\textbf":"textbf","\\textmd":"textmd"},Nn={"\\textit":"textit","\\textup":"textup"},On=(e,t)=>{const r=e.font;return r?Hn[r]?t.withTextFontFamily(Hn[r]):En[r]?t.withTextFontWeight(En[r]):"\\emph"===r?"textit"===t.fontShape?t.withTextFontShape("textup"):t.withTextFontShape("textit"):t.withTextFontShape(Nn[r]):t};mt({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0,handler(e,t){let{parser:r,funcName:n}=e;const o=t[0];return{type:"text",mode:r.mode,body:dt(o),font:n}},htmlBuilder(e,t){const r=On(e,t),n=xt(e.body,r,!0);return je(["mord","text"],n,r)},mathmlBuilder(e,t){const r=On(e,t);return Ft(e.body,r)}}),mt({type:"underline",names:["\\underline"],numArgs:1,allowedInText:!0,handler(e,t){let{parser:r}=e;return{type:"underline",mode:r.mode,body:t[0]}},htmlBuilder(e,t){const r=Mt(e.body,t),n=_e("underline-line",t),o=t.fontMetrics().defaultRuleThickness,s=Ke({positionType:"top",positionData:r.height,children:[{type:"kern",size:o},{type:"elem",elem:n},{type:"kern",size:3*o},{type:"elem",elem:r}]});return je(["mord","underline"],[s],t)},mathmlBuilder(e,t){const r=new Bt("mo",[new qt("\u203e")]);r.setAttribute("stretchy","true");const n=new Bt("munder",[Vt(e.body,t),r]);return n.setAttribute("accentunder","true"),n}}),mt({type:"vcenter",names:["\\vcenter"],numArgs:1,argTypes:["original"],allowedInText:!1,handler(e,t){let{parser:r}=e;return{type:"vcenter",mode:r.mode,body:t[0]}},htmlBuilder(e,t){const r=Mt(e.body,t),n=t.fontMetrics().axisHeight,o=.5*(r.height-n-(r.depth+n));return Ke({positionType:"shift",positionData:o,children:[{type:"elem",elem:r}]})},mathmlBuilder(e,t){const r=new Bt("mpadded",[Vt(e.body,t)],["vcenter"]);return new Bt("mrow",[r])}}),mt({type:"verb",names:["\\verb"],numArgs:0,allowedInText:!0,handler(e,t,r){throw new n("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(e,t){const r=Dn(e),n=[],o=t.havingStyle(t.style.text());for(let t=0;te.body.replace(/ /g,e.star?"\u2423":"\xa0");var Ln=at;const Pn="[ \r\n\t]",Fn="(\\\\[a-zA-Z@]+)"+Pn+"*",Vn="[\u0300-\u036f]",Gn=new RegExp(Vn+"+$"),Un="("+Pn+"+)|\\\\(\n|[ \r\t]+\n?)[ \r\t]*|([!-\\[\\]-\u2027\u202a-\ud7ff\uf900-\uffff]"+Vn+"*|[\ud800-\udbff][\udc00-\udfff]"+Vn+"*|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5|"+Fn+"|\\\\[^\ud800-\udfff])";class Xn{constructor(e,t){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=t,this.tokenRegex=new RegExp(Un,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,t){this.catcodes[e]=t}lex(){const e=this.input,t=this.tokenRegex.lastIndex;if(t===e.length)return new en("EOF",new Qr(this,t,t));const r=this.tokenRegex.exec(e);if(null===r||r.index!==t)throw new n("Unexpected character: '"+e[t]+"'",new en(e[t],new Qr(this,t,t+1)));const o=r[6]||r[3]||(r[2]?"\\ ":" ");if(14===this.catcodes[o]){const t=e.indexOf("\n",this.tokenRegex.lastIndex);return-1===t?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=t+1,this.lex()}return new en(o,new Qr(this,t,this.tokenRegex.lastIndex))}}class Yn{constructor(e,t){void 0===e&&(e={}),void 0===t&&(t={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=t,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(0===this.undefStack.length)throw new n("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");const e=this.undefStack.pop();for(const t in e)e.hasOwnProperty(t)&&(null==e[t]?delete this.current[t]:this.current[t]=e[t])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,t,r){if(void 0===r&&(r=!1),r){for(let t=0;t0&&(this.undefStack[this.undefStack.length-1][e]=t)}else{const t=this.undefStack[this.undefStack.length-1];t&&!t.hasOwnProperty(e)&&(t[e]=this.current[e])}null==t?delete this.current[e]:this.current[e]=t}}var jn=Kr;Jr("\\noexpand",function(e){const t=e.popToken();return e.isExpandable(t.text)&&(t.noexpand=!0,t.treatAsRelax=!0),{tokens:[t],numArgs:0}}),Jr("\\expandafter",function(e){const t=e.popToken();return e.expandOnce(!0),{tokens:[t],numArgs:0}}),Jr("\\@firstoftwo",function(e){return{tokens:e.consumeArgs(2)[0],numArgs:0}}),Jr("\\@secondoftwo",function(e){return{tokens:e.consumeArgs(2)[1],numArgs:0}}),Jr("\\@ifnextchar",function(e){const t=e.consumeArgs(3);e.consumeSpaces();const r=e.future();return 1===t[0].length&&t[0][0].text===r.text?{tokens:t[1],numArgs:0}:{tokens:t[2],numArgs:0}}),Jr("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}"),Jr("\\TextOrMath",function(e){const t=e.consumeArgs(2);return"text"===e.mode?{tokens:t[0],numArgs:0}:{tokens:t[1],numArgs:0}});const Wn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};Jr("\\char",function(e){let t,r=e.popToken(),o=0;if("'"===r.text)t=8,r=e.popToken();else if('"'===r.text)t=16,r=e.popToken();else if("`"===r.text)if(r=e.popToken(),"\\"===r.text[0])o=r.text.charCodeAt(1);else{if("EOF"===r.text)throw new n("\\char` missing argument");o=r.text.charCodeAt(0)}else t=10;if(t){if(o=Wn[r.text],null==o||o>=t)throw new n("Invalid base-"+t+" digit "+r.text);let s;for(;null!=(s=Wn[e.future().text])&&s{let s=e.consumeArg().tokens;if(1!==s.length)throw new n("\\newcommand's first argument must be a macro name");const i=s[0].text,l=e.isDefined(i);if(l&&!t)throw new n("\\newcommand{"+i+"} attempting to redefine "+i+"; use \\renewcommand");if(!l&&!r)throw new n("\\renewcommand{"+i+"} when command "+i+" does not yet exist; use \\newcommand");let a=0;if(s=e.consumeArg().tokens,1===s.length&&"["===s[0].text){let t="",r=e.expandNextToken();for(;"]"!==r.text&&"EOF"!==r.text;)t+=r.text,r=e.expandNextToken();if(!t.match(/^\s*[0-9]+\s*$/))throw new n("Invalid number of arguments: "+t);a=parseInt(t),s=e.consumeArg().tokens}return l&&o||e.macros.set(i,{tokens:s,numArgs:a}),""};Jr("\\newcommand",e=>_n(e,!1,!0,!1)),Jr("\\renewcommand",e=>_n(e,!0,!1,!1)),Jr("\\providecommand",e=>_n(e,!0,!0,!0)),Jr("\\message",e=>{const t=e.consumeArgs(1)[0];return console.log(t.reverse().map(e=>e.text).join("")),""}),Jr("\\errmessage",e=>{const t=e.consumeArgs(1)[0];return console.error(t.reverse().map(e=>e.text).join("")),""}),Jr("\\show",e=>{const t=e.popToken(),r=t.text;return console.log(t,e.macros.get(r),Ln[r],ne.math[r],ne.text[r]),""}),Jr("\\bgroup","{"),Jr("\\egroup","}"),Jr("~","\\nobreakspace"),Jr("\\lq","`"),Jr("\\rq","'"),Jr("\\aa","\\r a"),Jr("\\AA","\\r A"),Jr("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xa9}"),Jr("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}"),Jr("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xae}"),Jr("\u212c","\\mathscr{B}"),Jr("\u2130","\\mathscr{E}"),Jr("\u2131","\\mathscr{F}"),Jr("\u210b","\\mathscr{H}"),Jr("\u2110","\\mathscr{I}"),Jr("\u2112","\\mathscr{L}"),Jr("\u2133","\\mathscr{M}"),Jr("\u211b","\\mathscr{R}"),Jr("\u212d","\\mathfrak{C}"),Jr("\u210c","\\mathfrak{H}"),Jr("\u2128","\\mathfrak{Z}"),Jr("\\Bbbk","\\Bbb{k}"),Jr("\\llap","\\mathllap{\\textrm{#1}}"),Jr("\\rlap","\\mathrlap{\\textrm{#1}}"),Jr("\\clap","\\mathclap{\\textrm{#1}}"),Jr("\\mathstrut","\\vphantom{(}"),Jr("\\underbar","\\underline{\\text{#1}}"),Jr("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}\\nobreak}{\\char"338}'),Jr("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}"),Jr("\\ne","\\neq"),Jr("\u2260","\\neq"),Jr("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}"),Jr("\u2209","\\notin"),Jr("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}"),Jr("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}"),Jr("\u225a","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225a}}"),Jr("\u225b","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225b}}"),Jr("\u225d","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225d}}"),Jr("\u225e","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225e}}"),Jr("\u225f","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225f}}"),Jr("\u27c2","\\perp"),Jr("\u203c","\\mathclose{!\\mkern-0.8mu!}"),Jr("\u220c","\\notni"),Jr("\u231c","\\ulcorner"),Jr("\u231d","\\urcorner"),Jr("\u231e","\\llcorner"),Jr("\u231f","\\lrcorner"),Jr("\xa9","\\copyright"),Jr("\xae","\\textregistered"),Jr("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}'),Jr("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}'),Jr("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}'),Jr("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}'),Jr("\\vdots","{\\varvdots\\rule{0pt}{15pt}}"),Jr("\u22ee","\\vdots"),Jr("\\varGamma","\\mathit{\\Gamma}"),Jr("\\varDelta","\\mathit{\\Delta}"),Jr("\\varTheta","\\mathit{\\Theta}"),Jr("\\varLambda","\\mathit{\\Lambda}"),Jr("\\varXi","\\mathit{\\Xi}"),Jr("\\varPi","\\mathit{\\Pi}"),Jr("\\varSigma","\\mathit{\\Sigma}"),Jr("\\varUpsilon","\\mathit{\\Upsilon}"),Jr("\\varPhi","\\mathit{\\Phi}"),Jr("\\varPsi","\\mathit{\\Psi}"),Jr("\\varOmega","\\mathit{\\Omega}"),Jr("\\substack","\\begin{subarray}{c}#1\\end{subarray}"),Jr("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax"),Jr("\\boxed","\\fbox{$\\displaystyle{#1}$}"),Jr("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;"),Jr("\\implies","\\DOTSB\\;\\Longrightarrow\\;"),Jr("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;"),Jr("\\dddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ...}}{#1}}"),Jr("\\ddddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ....}}{#1}}");const $n={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"},Zn=new Set(["bin","rel"]);Jr("\\dots",function(e){let t="\\dotso";const r=e.expandAfterFuture().text;return r in $n?t=$n[r]:("\\not"===r.slice(0,4)||r in ne.math&&Zn.has(ne.math[r].group))&&(t="\\dotsb"),t});const Kn={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};Jr("\\dotso",function(e){return e.future().text in Kn?"\\ldots\\,":"\\ldots"}),Jr("\\dotsc",function(e){const t=e.future().text;return t in Kn&&","!==t?"\\ldots\\,":"\\ldots"}),Jr("\\cdots",function(e){return e.future().text in Kn?"\\@cdots\\,":"\\@cdots"}),Jr("\\dotsb","\\cdots"),Jr("\\dotsm","\\cdots"),Jr("\\dotsi","\\!\\cdots"),Jr("\\dotsx","\\ldots\\,"),Jr("\\DOTSI","\\relax"),Jr("\\DOTSB","\\relax"),Jr("\\DOTSX","\\relax"),Jr("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax"),Jr("\\,","\\tmspace+{3mu}{.1667em}"),Jr("\\thinspace","\\,"),Jr("\\>","\\mskip{4mu}"),Jr("\\:","\\tmspace+{4mu}{.2222em}"),Jr("\\medspace","\\:"),Jr("\\;","\\tmspace+{5mu}{.2777em}"),Jr("\\thickspace","\\;"),Jr("\\!","\\tmspace-{3mu}{.1667em}"),Jr("\\negthinspace","\\!"),Jr("\\negmedspace","\\tmspace-{4mu}{.2222em}"),Jr("\\negthickspace","\\tmspace-{5mu}{.277em}"),Jr("\\enspace","\\kern.5em "),Jr("\\enskip","\\hskip.5em\\relax"),Jr("\\quad","\\hskip1em\\relax"),Jr("\\qquad","\\hskip2em\\relax"),Jr("\\tag","\\@ifstar\\tag@literal\\tag@paren"),Jr("\\tag@paren","\\tag@literal{({#1})}"),Jr("\\tag@literal",e=>{if(e.macros.get("\\df@tag"))throw new n("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"}),Jr("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}"),Jr("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)"),Jr("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}"),Jr("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1"),Jr("\\newline","\\\\\\relax"),Jr("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");const Jn=O(K["Main-Regular"]["T".charCodeAt(0)][1]-.7*K["Main-Regular"]["A".charCodeAt(0)][1]);Jr("\\LaTeX","\\textrm{\\html@mathml{L\\kern-.36em\\raisebox{"+Jn+"}{\\scriptstyle A}\\kern-.15em\\TeX}{LaTeX}}"),Jr("\\KaTeX","\\textrm{\\html@mathml{K\\kern-.17em\\raisebox{"+Jn+"}{\\scriptstyle A}\\kern-.15em\\TeX}{KaTeX}}"),Jr("\\hspace","\\@ifstar\\@hspacer\\@hspace"),Jr("\\@hspace","\\hskip #1\\relax"),Jr("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax"),Jr("\\ordinarycolon",":"),Jr("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}"),Jr("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}'),Jr("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}'),Jr("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}'),Jr("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}'),Jr("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}'),Jr("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}'),Jr("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}'),Jr("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}'),Jr("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}'),Jr("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}'),Jr("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}'),Jr("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}'),Jr("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}'),Jr("\u2237","\\dblcolon"),Jr("\u2239","\\eqcolon"),Jr("\u2254","\\coloneqq"),Jr("\u2255","\\eqqcolon"),Jr("\u2a74","\\Coloneqq"),Jr("\\ratio","\\vcentcolon"),Jr("\\coloncolon","\\dblcolon"),Jr("\\colonequals","\\coloneqq"),Jr("\\coloncolonequals","\\Coloneqq"),Jr("\\equalscolon","\\eqqcolon"),Jr("\\equalscoloncolon","\\Eqqcolon"),Jr("\\colonminus","\\coloneq"),Jr("\\coloncolonminus","\\Coloneq"),Jr("\\minuscolon","\\eqcolon"),Jr("\\minuscoloncolon","\\Eqcolon"),Jr("\\coloncolonapprox","\\Colonapprox"),Jr("\\coloncolonsim","\\Colonsim"),Jr("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),Jr("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}"),Jr("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),Jr("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}"),Jr("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220c}}"),Jr("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}"),Jr("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}"),Jr("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}"),Jr("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}"),Jr("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}"),Jr("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}"),Jr("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}"),Jr("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}"),Jr("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}"),Jr("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}"),Jr("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}"),Jr("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}"),Jr("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}"),Jr("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}"),Jr("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}"),Jr("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}"),Jr("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}"),Jr("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}"),Jr("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228a}"),Jr("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2acb}"),Jr("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228b}"),Jr("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2acc}"),Jr("\\imath","\\html@mathml{\\@imath}{\u0131}"),Jr("\\jmath","\\html@mathml{\\@jmath}{\u0237}"),Jr("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27e6}}"),Jr("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27e7}}"),Jr("\u27e6","\\llbracket"),Jr("\u27e7","\\rrbracket"),Jr("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}"),Jr("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}"),Jr("\u2983","\\lBrace"),Jr("\u2984","\\rBrace"),Jr("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29b5}}"),Jr("\u29b5","\\minuso"),Jr("\\darr","\\downarrow"),Jr("\\dArr","\\Downarrow"),Jr("\\Darr","\\Downarrow"),Jr("\\lang","\\langle"),Jr("\\rang","\\rangle"),Jr("\\uarr","\\uparrow"),Jr("\\uArr","\\Uparrow"),Jr("\\Uarr","\\Uparrow"),Jr("\\N","\\mathbb{N}"),Jr("\\R","\\mathbb{R}"),Jr("\\Z","\\mathbb{Z}"),Jr("\\alef","\\aleph"),Jr("\\alefsym","\\aleph"),Jr("\\Alpha","\\mathrm{A}"),Jr("\\Beta","\\mathrm{B}"),Jr("\\bull","\\bullet"),Jr("\\Chi","\\mathrm{X}"),Jr("\\clubs","\\clubsuit"),Jr("\\cnums","\\mathbb{C}"),Jr("\\Complex","\\mathbb{C}"),Jr("\\Dagger","\\ddagger"),Jr("\\diamonds","\\diamondsuit"),Jr("\\empty","\\emptyset"),Jr("\\Epsilon","\\mathrm{E}"),Jr("\\Eta","\\mathrm{H}"),Jr("\\exist","\\exists"),Jr("\\harr","\\leftrightarrow"),Jr("\\hArr","\\Leftrightarrow"),Jr("\\Harr","\\Leftrightarrow"),Jr("\\hearts","\\heartsuit"),Jr("\\image","\\Im"),Jr("\\infin","\\infty"),Jr("\\Iota","\\mathrm{I}"),Jr("\\isin","\\in"),Jr("\\Kappa","\\mathrm{K}"),Jr("\\larr","\\leftarrow"),Jr("\\lArr","\\Leftarrow"),Jr("\\Larr","\\Leftarrow"),Jr("\\lrarr","\\leftrightarrow"),Jr("\\lrArr","\\Leftrightarrow"),Jr("\\Lrarr","\\Leftrightarrow"),Jr("\\Mu","\\mathrm{M}"),Jr("\\natnums","\\mathbb{N}"),Jr("\\Nu","\\mathrm{N}"),Jr("\\Omicron","\\mathrm{O}"),Jr("\\plusmn","\\pm"),Jr("\\rarr","\\rightarrow"),Jr("\\rArr","\\Rightarrow"),Jr("\\Rarr","\\Rightarrow"),Jr("\\real","\\Re"),Jr("\\reals","\\mathbb{R}"),Jr("\\Reals","\\mathbb{R}"),Jr("\\Rho","\\mathrm{P}"),Jr("\\sdot","\\cdot"),Jr("\\sect","\\S"),Jr("\\spades","\\spadesuit"),Jr("\\sub","\\subset"),Jr("\\sube","\\subseteq"),Jr("\\supe","\\supseteq"),Jr("\\Tau","\\mathrm{T}"),Jr("\\thetasym","\\vartheta"),Jr("\\weierp","\\wp"),Jr("\\Zeta","\\mathrm{Z}"),Jr("\\argmin","\\DOTSB\\operatorname*{arg\\,min}"),Jr("\\argmax","\\DOTSB\\operatorname*{arg\\,max}"),Jr("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits"),Jr("\\bra","\\mathinner{\\langle{#1}|}"),Jr("\\ket","\\mathinner{|{#1}\\rangle}"),Jr("\\braket","\\mathinner{\\langle{#1}\\rangle}"),Jr("\\Bra","\\left\\langle#1\\right|"),Jr("\\Ket","\\left|#1\\right\\rangle");const Qn=e=>t=>{const r=t.consumeArg().tokens,n=t.consumeArg().tokens,o=t.consumeArg().tokens,s=t.consumeArg().tokens,i=t.macros.get("|"),l=t.macros.get("\\|");t.macros.beginGroup();const a=t=>r=>{e&&(r.macros.set("|",i),o.length&&r.macros.set("\\|",l));let s=t;if(!t&&o.length){"|"===r.future().text&&(r.popToken(),s=!0)}return{tokens:s?o:n,numArgs:0}};t.macros.set("|",a(!1)),o.length&&t.macros.set("\\|",a(!0));const c=t.consumeArg().tokens,h=t.expandTokens([...s,...c,...r]);return t.macros.endGroup(),{tokens:h.reverse(),numArgs:0}};Jr("\\bra@ket",Qn(!1)),Jr("\\bra@set",Qn(!0)),Jr("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}"),Jr("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}"),Jr("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}"),Jr("\\angln","{\\angl n}"),Jr("\\blue","\\textcolor{##6495ed}{#1}"),Jr("\\orange","\\textcolor{##ffa500}{#1}"),Jr("\\pink","\\textcolor{##ff00af}{#1}"),Jr("\\red","\\textcolor{##df0030}{#1}"),Jr("\\green","\\textcolor{##28ae7b}{#1}"),Jr("\\gray","\\textcolor{gray}{#1}"),Jr("\\purple","\\textcolor{##9d38bd}{#1}"),Jr("\\blueA","\\textcolor{##ccfaff}{#1}"),Jr("\\blueB","\\textcolor{##80f6ff}{#1}"),Jr("\\blueC","\\textcolor{##63d9ea}{#1}"),Jr("\\blueD","\\textcolor{##11accd}{#1}"),Jr("\\blueE","\\textcolor{##0c7f99}{#1}"),Jr("\\tealA","\\textcolor{##94fff5}{#1}"),Jr("\\tealB","\\textcolor{##26edd5}{#1}"),Jr("\\tealC","\\textcolor{##01d1c1}{#1}"),Jr("\\tealD","\\textcolor{##01a995}{#1}"),Jr("\\tealE","\\textcolor{##208170}{#1}"),Jr("\\greenA","\\textcolor{##b6ffb0}{#1}"),Jr("\\greenB","\\textcolor{##8af281}{#1}"),Jr("\\greenC","\\textcolor{##74cf70}{#1}"),Jr("\\greenD","\\textcolor{##1fab54}{#1}"),Jr("\\greenE","\\textcolor{##0d923f}{#1}"),Jr("\\goldA","\\textcolor{##ffd0a9}{#1}"),Jr("\\goldB","\\textcolor{##ffbb71}{#1}"),Jr("\\goldC","\\textcolor{##ff9c39}{#1}"),Jr("\\goldD","\\textcolor{##e07d10}{#1}"),Jr("\\goldE","\\textcolor{##a75a05}{#1}"),Jr("\\redA","\\textcolor{##fca9a9}{#1}"),Jr("\\redB","\\textcolor{##ff8482}{#1}"),Jr("\\redC","\\textcolor{##f9685d}{#1}"),Jr("\\redD","\\textcolor{##e84d39}{#1}"),Jr("\\redE","\\textcolor{##bc2612}{#1}"),Jr("\\maroonA","\\textcolor{##ffbde0}{#1}"),Jr("\\maroonB","\\textcolor{##ff92c6}{#1}"),Jr("\\maroonC","\\textcolor{##ed5fa6}{#1}"),Jr("\\maroonD","\\textcolor{##ca337c}{#1}"),Jr("\\maroonE","\\textcolor{##9e034e}{#1}"),Jr("\\purpleA","\\textcolor{##ddd7ff}{#1}"),Jr("\\purpleB","\\textcolor{##c6b9fc}{#1}"),Jr("\\purpleC","\\textcolor{##aa87ff}{#1}"),Jr("\\purpleD","\\textcolor{##7854ab}{#1}"),Jr("\\purpleE","\\textcolor{##543b78}{#1}"),Jr("\\mintA","\\textcolor{##f5f9e8}{#1}"),Jr("\\mintB","\\textcolor{##edf2df}{#1}"),Jr("\\mintC","\\textcolor{##e0e5cc}{#1}"),Jr("\\grayA","\\textcolor{##f6f7f7}{#1}"),Jr("\\grayB","\\textcolor{##f0f1f2}{#1}"),Jr("\\grayC","\\textcolor{##e3e5e6}{#1}"),Jr("\\grayD","\\textcolor{##d6d8da}{#1}"),Jr("\\grayE","\\textcolor{##babec2}{#1}"),Jr("\\grayF","\\textcolor{##888d93}{#1}"),Jr("\\grayG","\\textcolor{##626569}{#1}"),Jr("\\grayH","\\textcolor{##3b3e40}{#1}"),Jr("\\grayI","\\textcolor{##21242c}{#1}"),Jr("\\kaBlue","\\textcolor{##314453}{#1}"),Jr("\\kaGreen","\\textcolor{##71B307}{#1}");const eo={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0};class to{constructor(e,t,r){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=t,this.expansionCount=0,this.feed(e),this.macros=new Yn(jn,t.macros),this.mode=r,this.stack=[]}feed(e){this.lexer=new Xn(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return 0===this.stack.length&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){let t,r,n;if(e){if(this.consumeSpaces(),"["!==this.future().text)return null;t=this.popToken(),({tokens:n,end:r}=this.consumeArg(["]"]))}else({tokens:n,start:t,end:r}=this.consumeArg());return this.pushToken(new en("EOF",r.loc)),this.pushTokens(n),new en("",Qr.range(t,r))}consumeSpaces(){for(;;){if(" "!==this.future().text)break;this.stack.pop()}}consumeArg(e){const t=[],r=e&&e.length>0;r||this.consumeSpaces();const o=this.future();let s,i=0,l=0;do{if(s=this.popToken(),t.push(s),"{"===s.text)++i;else if("}"===s.text){if(--i,-1===i)throw new n("Extra }",s)}else if("EOF"===s.text)throw new n("Unexpected end of input in a macro argument, expected '"+(e&&r?e[l]:"}")+"'",s);if(e&&r)if((0===i||1===i&&"{"===e[l])&&s.text===e[l]){if(++l,l===e.length){t.splice(-l,l);break}}else l=0}while(0!==i||r);return"{"===o.text&&"}"===t[t.length-1].text&&(t.pop(),t.shift()),t.reverse(),{tokens:t,start:o,end:s}}consumeArgs(e,t){if(t){if(t.length!==e+1)throw new n("The length of delimiters doesn't match the number of args!");const r=t[0];for(let e=0;ethis.settings.maxExpand)throw new n("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){const t=this.popToken(),r=t.text,o=t.noexpand?null:this._getExpansion(r);if(null==o||e&&o.unexpandable){if(e&&null==o&&"\\"===r[0]&&!this.isDefined(r))throw new n("Undefined control sequence: "+r);return this.pushToken(t),!1}this.countExpansion(1);let s=o.tokens;const i=this.consumeArgs(o.numArgs,o.delimiters);if(o.numArgs){s=s.slice();for(let e=s.length-1;e>=0;--e){let t=s[e];if("#"===t.text){if(0===e)throw new n("Incomplete placeholder at end of macro body",t);if(t=s[--e],"#"===t.text)s.splice(e+1,1);else{if(!/^[1-9]$/.test(t.text))throw new n("Not a valid argument number",t);s.splice(e,2,...i[+t.text-1])}}}}return this.pushTokens(s),s.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(!1===this.expandOnce()){const e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}}expandMacro(e){return this.macros.has(e)?this.expandTokens([new en(e)]):void 0}expandTokens(e){const t=[],r=this.stack.length;for(this.pushTokens(e);this.stack.length>r;)if(!1===this.expandOnce(!0)){const e=this.stack.pop();e.treatAsRelax&&(e.noexpand=!1,e.treatAsRelax=!1),t.push(e)}return this.countExpansion(t.length),t}expandMacroAsText(e){const t=this.expandMacro(e);return t?t.map(e=>e.text).join(""):t}_getExpansion(e){const t=this.macros.get(e);if(null==t)return t;if(1===e.length){const t=this.lexer.catcodes[e];if(null!=t&&13!==t)return}const r="function"==typeof t?t(this):t;if("string"==typeof r){let e=0;if(r.includes("#")){const t=r.replace(/##/g,"");for(;t.includes("#"+(e+1));)++e}const t=new Xn(r,this.settings),n=[];let o=t.lex();for(;"EOF"!==o.text;)n.push(o),o=t.lex();n.reverse();return{tokens:n,numArgs:e}}return r}isDefined(e){return this.macros.has(e)||Ln.hasOwnProperty(e)||ne.math.hasOwnProperty(e)||ne.text.hasOwnProperty(e)||eo.hasOwnProperty(e)}isExpandable(e){const t=this.macros.get(e);return null!=t?"string"==typeof t||"function"==typeof t||!t.unexpandable:Ln.hasOwnProperty(e)&&!Ln[e].primitive}}const ro=/^[\u208a\u208b\u208c\u208d\u208e\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089\u2090\u2091\u2095\u1d62\u2c7c\u2096\u2097\u2098\u2099\u2092\u209a\u1d63\u209b\u209c\u1d64\u1d65\u2093\u1d66\u1d67\u1d68\u1d69\u1d6a]/,no=Object.freeze({"\u208a":"+","\u208b":"-","\u208c":"=","\u208d":"(","\u208e":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1d62":"i","\u2c7c":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209a":"p","\u1d63":"r","\u209b":"s","\u209c":"t","\u1d64":"u","\u1d65":"v","\u2093":"x","\u1d66":"\u03b2","\u1d67":"\u03b3","\u1d68":"\u03c1","\u1d69":"\u03d5","\u1d6a":"\u03c7","\u207a":"+","\u207b":"-","\u207c":"=","\u207d":"(","\u207e":")","\u2070":"0","\xb9":"1","\xb2":"2","\xb3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1d2c":"A","\u1d2e":"B","\u1d30":"D","\u1d31":"E","\u1d33":"G","\u1d34":"H","\u1d35":"I","\u1d36":"J","\u1d37":"K","\u1d38":"L","\u1d39":"M","\u1d3a":"N","\u1d3c":"O","\u1d3e":"P","\u1d3f":"R","\u1d40":"T","\u1d41":"U","\u2c7d":"V","\u1d42":"W","\u1d43":"a","\u1d47":"b","\u1d9c":"c","\u1d48":"d","\u1d49":"e","\u1da0":"f","\u1d4d":"g","\u02b0":"h","\u2071":"i","\u02b2":"j","\u1d4f":"k","\u02e1":"l","\u1d50":"m","\u207f":"n","\u1d52":"o","\u1d56":"p","\u02b3":"r","\u02e2":"s","\u1d57":"t","\u1d58":"u","\u1d5b":"v","\u02b7":"w","\u02e3":"x","\u02b8":"y","\u1dbb":"z","\u1d5d":"\u03b2","\u1d5e":"\u03b3","\u1d5f":"\u03b4","\u1d60":"\u03d5","\u1d61":"\u03c7","\u1dbf":"\u03b8"}),oo={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030c":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030a":{text:"\\r",math:"\\mathring"},"\u030b":{text:"\\H"},"\u0327":{text:"\\c"}},so={"\xe1":"a\u0301","\xe0":"a\u0300","\xe4":"a\u0308","\u01df":"a\u0308\u0304","\xe3":"a\u0303","\u0101":"a\u0304","\u0103":"a\u0306","\u1eaf":"a\u0306\u0301","\u1eb1":"a\u0306\u0300","\u1eb5":"a\u0306\u0303","\u01ce":"a\u030c","\xe2":"a\u0302","\u1ea5":"a\u0302\u0301","\u1ea7":"a\u0302\u0300","\u1eab":"a\u0302\u0303","\u0227":"a\u0307","\u01e1":"a\u0307\u0304","\xe5":"a\u030a","\u01fb":"a\u030a\u0301","\u1e03":"b\u0307","\u0107":"c\u0301","\u1e09":"c\u0327\u0301","\u010d":"c\u030c","\u0109":"c\u0302","\u010b":"c\u0307","\xe7":"c\u0327","\u010f":"d\u030c","\u1e0b":"d\u0307","\u1e11":"d\u0327","\xe9":"e\u0301","\xe8":"e\u0300","\xeb":"e\u0308","\u1ebd":"e\u0303","\u0113":"e\u0304","\u1e17":"e\u0304\u0301","\u1e15":"e\u0304\u0300","\u0115":"e\u0306","\u1e1d":"e\u0327\u0306","\u011b":"e\u030c","\xea":"e\u0302","\u1ebf":"e\u0302\u0301","\u1ec1":"e\u0302\u0300","\u1ec5":"e\u0302\u0303","\u0117":"e\u0307","\u0229":"e\u0327","\u1e1f":"f\u0307","\u01f5":"g\u0301","\u1e21":"g\u0304","\u011f":"g\u0306","\u01e7":"g\u030c","\u011d":"g\u0302","\u0121":"g\u0307","\u0123":"g\u0327","\u1e27":"h\u0308","\u021f":"h\u030c","\u0125":"h\u0302","\u1e23":"h\u0307","\u1e29":"h\u0327","\xed":"i\u0301","\xec":"i\u0300","\xef":"i\u0308","\u1e2f":"i\u0308\u0301","\u0129":"i\u0303","\u012b":"i\u0304","\u012d":"i\u0306","\u01d0":"i\u030c","\xee":"i\u0302","\u01f0":"j\u030c","\u0135":"j\u0302","\u1e31":"k\u0301","\u01e9":"k\u030c","\u0137":"k\u0327","\u013a":"l\u0301","\u013e":"l\u030c","\u013c":"l\u0327","\u1e3f":"m\u0301","\u1e41":"m\u0307","\u0144":"n\u0301","\u01f9":"n\u0300","\xf1":"n\u0303","\u0148":"n\u030c","\u1e45":"n\u0307","\u0146":"n\u0327","\xf3":"o\u0301","\xf2":"o\u0300","\xf6":"o\u0308","\u022b":"o\u0308\u0304","\xf5":"o\u0303","\u1e4d":"o\u0303\u0301","\u1e4f":"o\u0303\u0308","\u022d":"o\u0303\u0304","\u014d":"o\u0304","\u1e53":"o\u0304\u0301","\u1e51":"o\u0304\u0300","\u014f":"o\u0306","\u01d2":"o\u030c","\xf4":"o\u0302","\u1ed1":"o\u0302\u0301","\u1ed3":"o\u0302\u0300","\u1ed7":"o\u0302\u0303","\u022f":"o\u0307","\u0231":"o\u0307\u0304","\u0151":"o\u030b","\u1e55":"p\u0301","\u1e57":"p\u0307","\u0155":"r\u0301","\u0159":"r\u030c","\u1e59":"r\u0307","\u0157":"r\u0327","\u015b":"s\u0301","\u1e65":"s\u0301\u0307","\u0161":"s\u030c","\u1e67":"s\u030c\u0307","\u015d":"s\u0302","\u1e61":"s\u0307","\u015f":"s\u0327","\u1e97":"t\u0308","\u0165":"t\u030c","\u1e6b":"t\u0307","\u0163":"t\u0327","\xfa":"u\u0301","\xf9":"u\u0300","\xfc":"u\u0308","\u01d8":"u\u0308\u0301","\u01dc":"u\u0308\u0300","\u01d6":"u\u0308\u0304","\u01da":"u\u0308\u030c","\u0169":"u\u0303","\u1e79":"u\u0303\u0301","\u016b":"u\u0304","\u1e7b":"u\u0304\u0308","\u016d":"u\u0306","\u01d4":"u\u030c","\xfb":"u\u0302","\u016f":"u\u030a","\u0171":"u\u030b","\u1e7d":"v\u0303","\u1e83":"w\u0301","\u1e81":"w\u0300","\u1e85":"w\u0308","\u0175":"w\u0302","\u1e87":"w\u0307","\u1e98":"w\u030a","\u1e8d":"x\u0308","\u1e8b":"x\u0307","\xfd":"y\u0301","\u1ef3":"y\u0300","\xff":"y\u0308","\u1ef9":"y\u0303","\u0233":"y\u0304","\u0177":"y\u0302","\u1e8f":"y\u0307","\u1e99":"y\u030a","\u017a":"z\u0301","\u017e":"z\u030c","\u1e91":"z\u0302","\u017c":"z\u0307","\xc1":"A\u0301","\xc0":"A\u0300","\xc4":"A\u0308","\u01de":"A\u0308\u0304","\xc3":"A\u0303","\u0100":"A\u0304","\u0102":"A\u0306","\u1eae":"A\u0306\u0301","\u1eb0":"A\u0306\u0300","\u1eb4":"A\u0306\u0303","\u01cd":"A\u030c","\xc2":"A\u0302","\u1ea4":"A\u0302\u0301","\u1ea6":"A\u0302\u0300","\u1eaa":"A\u0302\u0303","\u0226":"A\u0307","\u01e0":"A\u0307\u0304","\xc5":"A\u030a","\u01fa":"A\u030a\u0301","\u1e02":"B\u0307","\u0106":"C\u0301","\u1e08":"C\u0327\u0301","\u010c":"C\u030c","\u0108":"C\u0302","\u010a":"C\u0307","\xc7":"C\u0327","\u010e":"D\u030c","\u1e0a":"D\u0307","\u1e10":"D\u0327","\xc9":"E\u0301","\xc8":"E\u0300","\xcb":"E\u0308","\u1ebc":"E\u0303","\u0112":"E\u0304","\u1e16":"E\u0304\u0301","\u1e14":"E\u0304\u0300","\u0114":"E\u0306","\u1e1c":"E\u0327\u0306","\u011a":"E\u030c","\xca":"E\u0302","\u1ebe":"E\u0302\u0301","\u1ec0":"E\u0302\u0300","\u1ec4":"E\u0302\u0303","\u0116":"E\u0307","\u0228":"E\u0327","\u1e1e":"F\u0307","\u01f4":"G\u0301","\u1e20":"G\u0304","\u011e":"G\u0306","\u01e6":"G\u030c","\u011c":"G\u0302","\u0120":"G\u0307","\u0122":"G\u0327","\u1e26":"H\u0308","\u021e":"H\u030c","\u0124":"H\u0302","\u1e22":"H\u0307","\u1e28":"H\u0327","\xcd":"I\u0301","\xcc":"I\u0300","\xcf":"I\u0308","\u1e2e":"I\u0308\u0301","\u0128":"I\u0303","\u012a":"I\u0304","\u012c":"I\u0306","\u01cf":"I\u030c","\xce":"I\u0302","\u0130":"I\u0307","\u0134":"J\u0302","\u1e30":"K\u0301","\u01e8":"K\u030c","\u0136":"K\u0327","\u0139":"L\u0301","\u013d":"L\u030c","\u013b":"L\u0327","\u1e3e":"M\u0301","\u1e40":"M\u0307","\u0143":"N\u0301","\u01f8":"N\u0300","\xd1":"N\u0303","\u0147":"N\u030c","\u1e44":"N\u0307","\u0145":"N\u0327","\xd3":"O\u0301","\xd2":"O\u0300","\xd6":"O\u0308","\u022a":"O\u0308\u0304","\xd5":"O\u0303","\u1e4c":"O\u0303\u0301","\u1e4e":"O\u0303\u0308","\u022c":"O\u0303\u0304","\u014c":"O\u0304","\u1e52":"O\u0304\u0301","\u1e50":"O\u0304\u0300","\u014e":"O\u0306","\u01d1":"O\u030c","\xd4":"O\u0302","\u1ed0":"O\u0302\u0301","\u1ed2":"O\u0302\u0300","\u1ed6":"O\u0302\u0303","\u022e":"O\u0307","\u0230":"O\u0307\u0304","\u0150":"O\u030b","\u1e54":"P\u0301","\u1e56":"P\u0307","\u0154":"R\u0301","\u0158":"R\u030c","\u1e58":"R\u0307","\u0156":"R\u0327","\u015a":"S\u0301","\u1e64":"S\u0301\u0307","\u0160":"S\u030c","\u1e66":"S\u030c\u0307","\u015c":"S\u0302","\u1e60":"S\u0307","\u015e":"S\u0327","\u0164":"T\u030c","\u1e6a":"T\u0307","\u0162":"T\u0327","\xda":"U\u0301","\xd9":"U\u0300","\xdc":"U\u0308","\u01d7":"U\u0308\u0301","\u01db":"U\u0308\u0300","\u01d5":"U\u0308\u0304","\u01d9":"U\u0308\u030c","\u0168":"U\u0303","\u1e78":"U\u0303\u0301","\u016a":"U\u0304","\u1e7a":"U\u0304\u0308","\u016c":"U\u0306","\u01d3":"U\u030c","\xdb":"U\u0302","\u016e":"U\u030a","\u0170":"U\u030b","\u1e7c":"V\u0303","\u1e82":"W\u0301","\u1e80":"W\u0300","\u1e84":"W\u0308","\u0174":"W\u0302","\u1e86":"W\u0307","\u1e8c":"X\u0308","\u1e8a":"X\u0307","\xdd":"Y\u0301","\u1ef2":"Y\u0300","\u0178":"Y\u0308","\u1ef8":"Y\u0303","\u0232":"Y\u0304","\u0176":"Y\u0302","\u1e8e":"Y\u0307","\u0179":"Z\u0301","\u017d":"Z\u030c","\u1e90":"Z\u0302","\u017b":"Z\u0307","\u03ac":"\u03b1\u0301","\u1f70":"\u03b1\u0300","\u1fb1":"\u03b1\u0304","\u1fb0":"\u03b1\u0306","\u03ad":"\u03b5\u0301","\u1f72":"\u03b5\u0300","\u03ae":"\u03b7\u0301","\u1f74":"\u03b7\u0300","\u03af":"\u03b9\u0301","\u1f76":"\u03b9\u0300","\u03ca":"\u03b9\u0308","\u0390":"\u03b9\u0308\u0301","\u1fd2":"\u03b9\u0308\u0300","\u1fd1":"\u03b9\u0304","\u1fd0":"\u03b9\u0306","\u03cc":"\u03bf\u0301","\u1f78":"\u03bf\u0300","\u03cd":"\u03c5\u0301","\u1f7a":"\u03c5\u0300","\u03cb":"\u03c5\u0308","\u03b0":"\u03c5\u0308\u0301","\u1fe2":"\u03c5\u0308\u0300","\u1fe1":"\u03c5\u0304","\u1fe0":"\u03c5\u0306","\u03ce":"\u03c9\u0301","\u1f7c":"\u03c9\u0300","\u038e":"\u03a5\u0301","\u1fea":"\u03a5\u0300","\u03ab":"\u03a5\u0308","\u1fe9":"\u03a5\u0304","\u1fe8":"\u03a5\u0306","\u038f":"\u03a9\u0301","\u1ffa":"\u03a9\u0300"};class io{constructor(e,t){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new to(e,t,this.mode),this.settings=t,this.leftrightDepth=0,this.nextToken=null}expect(e,t){if(void 0===t&&(t=!0),this.fetch().text!==e)throw new n("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());t&&this.consume()}consume(){this.nextToken=null}fetch(){return null==this.nextToken&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{const e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){const t=this.nextToken;this.consume(),this.gullet.pushToken(new en("}")),this.gullet.pushTokens(e);const r=this.parseExpression(!1);return this.expect("}"),this.nextToken=t,r}parseExpression(e,t){const r=[];for(;;){"math"===this.mode&&this.consumeSpaces();const n=this.fetch();if(io.endOfExpression.has(n.text))break;if(t&&n.text===t)break;if(e&&Ln[n.text]&&Ln[n.text].infix)break;const o=this.parseAtom(t);if(!o)break;"internal"!==o.type&&r.push(o)}return"text"===this.mode&&this.formLigatures(r),this.handleInfixNodes(r)}handleInfixNodes(e){let t,r=-1;for(let o=0;o=128))return null;this.settings.strict&&(T(t.charCodeAt(0))?"math"===this.mode&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+t[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+t[0]+'" ('+t.charCodeAt(0)+")",e)),o={type:"textord",mode:"text",loc:Qr.range(e),text:t}}if(this.consume(),r)for(let t=0;t { + const container = document.getElementById("math"); + try { + document.body.style.width = `${job.widthCssPx}px`; + container.style.color = job.color; + container.style.fontSize = `${job.fontSizeCssPx}px`; + container.replaceChildren(); + katex.render(job.latex, container, { + displayMode: true, + maxExpand: 1000, + maxSize: 10, + strict: "ignore", + throwOnError: true, + trust: false, + }); + await document.fonts.ready; + await new Promise((resolve) => setTimeout(resolve, 0)); + const initialBounds = container.getBoundingClientRect(); + const width = Math.ceil(Math.max(initialBounds.width, container.scrollWidth)); + document.body.style.width = `${width}px`; + await new Promise((resolve) => setTimeout(resolve, 0)); + const finalBounds = container.getBoundingClientRect(); + const height = Math.ceil(Math.max(finalBounds.height, container.scrollHeight)); + window.ChatMathBridge.postMessage( + JSON.stringify({ id: job.id, widthCssPx: width, heightCssPx: height, success: true }), + ); + } catch (_) { + window.ChatMathBridge.postMessage( + JSON.stringify({ id: job.id, widthCssPx: 0, heightCssPx: 0, success: false }), + ); + } +}; diff --git a/app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt b/app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt new file mode 100644 index 0000000..7c02abe --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt @@ -0,0 +1,40 @@ +package ai.openclaw.app + +import android.content.res.AssetManager + +internal const val ANDROID_LICENSE_ASSET_DIRECTORY = "openclaw/licenses" + +internal data class AndroidLicenseNotice( + val title: String, + val fileName: String, + val text: String, +) + +internal fun loadAndroidLicenseNotices(assetManager: AssetManager): List { + val files = + assetManager + .list(ANDROID_LICENSE_ASSET_DIRECTORY) + .orEmpty() + .filter(::isAndroidLicenseFileName) + + return files + .map { fileName -> + val rawText = + assetManager + .open("$ANDROID_LICENSE_ASSET_DIRECTORY/$fileName") + .bufferedReader(Charsets.UTF_8) + .use { reader -> reader.readText() } + AndroidLicenseNotice(title = androidLicenseTitleFromFileName(fileName), fileName = fileName, text = rawText) + }.sortedWith( + compareBy(String.CASE_INSENSITIVE_ORDER) { notice -> notice.title } + .thenBy(String.CASE_INSENSITIVE_ORDER) { notice -> notice.fileName }, + ) +} + +internal fun isAndroidLicenseFileName(fileName: String): Boolean = fileName.endsWith(".txt", ignoreCase = true) + +internal fun androidLicenseTitleFromFileName(fileName: String): String = + fileName + .substringBeforeLast('.') + .trim() + .ifBlank { "License" } diff --git a/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt b/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt new file mode 100644 index 0000000..f9db0ab --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt @@ -0,0 +1,436 @@ +package ai.openclaw.app + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal object AndroidScreenshotFixture { + @Volatile private var scene: AndroidScreenshotScene = AndroidScreenshotScene.Home + + fun configure(scene: AndroidScreenshotScene) { + this.scene = scene + } + + const val gatewayId = "android-screenshot-gateway" + const val mainSessionKey = "agent:main:node-screenshot" + const val primarySessionTitle = "Android release planning" + const val cronJobId = "android-release-digest" + const val cronJobName = "Android release digest" + + val agents = + listOf( + GatewayAgentSummary( + id = "main", + name = "Molty", + emoji = "M", + ), + ) + + val models = + listOf( + GatewayModelSummary( + id = "gpt-5.2", + name = "GPT-5.2", + provider = "openai", + available = true, + supportsVision = true, + supportsAudio = true, + supportsVideo = true, + supportsDocuments = true, + supportsReasoning = true, + contextTokens = 200_000, + ), + ) + + val providers = + listOf( + GatewayModelProviderSummary( + id = "openai", + displayName = "OpenAI", + status = "ready", + profileCount = 1, + ), + ) + + val nodes = + GatewayNodesDevicesSummary( + nodes = + listOf( + GatewayNodeSummary( + id = "android-screenshot", + displayName = "Pixel", + remoteIp = "100.64.0.24", + version = BuildConfig.VERSION_NAME, + deviceFamily = "Android", + paired = true, + connected = true, + approvalState = GatewayNodeApprovalState.Approved, + pendingRequestId = null, + capabilities = listOf("camera", "location", "notifications"), + commands = emptyList(), + ), + ), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ) + + val channels = + GatewayChannelsSummary( + updatedAtMs = 1_783_555_200_000, + channels = + listOf( + GatewayChannelSummary( + id = "discord", + label = "Discord", + accountCount = 1, + enabled = true, + configured = true, + linked = true, + running = true, + connected = true, + error = null, + ), + ), + ) + + fun request( + method: String, + paramsJson: String?, + ): String = + when (method) { + "health" -> buildJsonObject { put("ok", JsonPrimitive(true)) }.toString() + "chat.history" -> chatHistory() + "sessions.list" -> sessionList(paramsJson) + "chat.metadata" -> chatMetadata() + "cron.list" -> cronList() + "cron.get" -> cronJob().toString() + "cron.runs" -> cronRuns() + "openclaw.chat" -> systemAgentChat(paramsJson) + else -> error("Screenshot fixture does not implement gateway method $method with params $paramsJson") + } + + private fun systemAgentChat(paramsJson: String?): String { + val message = + paramsJson + ?.let { Json.parseToJsonElement(it).jsonObject["message"] } + ?.jsonPrimitive + ?.contentOrNull + return buildJsonObject { + put("sessionId", JsonPrimitive("android-screenshot-openclaw")) + put( + "reply", + JsonPrimitive( + if (message == null) { + "I can check Gateway status, repair configuration, change models, or connect channels." + } else { + "I’ll keep this conversation separate from ordinary agent chat." + }, + ), + ) + put("action", JsonPrimitive("none")) + if (message == null) { + put( + "question", + buildJsonObject { + put("id", JsonPrimitive("help")) + put("header", JsonPrimitive("OpenClaw")) + put("question", JsonPrimitive("What should we look at first?")) + put( + "options", + buildJsonArray { + add( + buildJsonObject { + put("label", JsonPrimitive("Check status")) + put("description", JsonPrimitive("Review the Gateway and active services.")) + put("recommended", JsonPrimitive(true)) + put("reply", JsonPrimitive("Check Gateway status")) + }, + ) + add( + buildJsonObject { + put("label", JsonPrimitive("Review setup")) + put("description", JsonPrimitive("Inspect models, channels, and configuration.")) + put("reply", JsonPrimitive("Review setup")) + }, + ) + }, + ) + }, + ) + } + }.toString() + } + + private fun cronList(): String = + buildJsonObject { + put( + "jobs", + buildJsonArray { + add(cronJob()) + }, + ) + }.toString() + + private fun cronJob() = + buildJsonObject { + put("id", JsonPrimitive(cronJobId)) + put("name", JsonPrimitive(cronJobName)) + put("enabled", JsonPrimitive(true)) + put("createdAtMs", JsonPrimitive(1_783_468_800_000)) + put("updatedAtMs", JsonPrimitive(1_783_555_200_000)) + put("configRevision", JsonPrimitive("sha256:screenshot-fixture")) + put( + "schedule", + buildJsonObject { + put("kind", JsonPrimitive("every")) + put("everyMs", JsonPrimitive(86_400_000)) + put("anchorMs", JsonPrimitive(1_783_468_800_000)) + }, + ) + put("sessionTarget", JsonPrimitive("isolated")) + put("wakeMode", JsonPrimitive("now")) + put( + "payload", + buildJsonObject { + put("kind", JsonPrimitive("agentTurn")) + put("message", JsonPrimitive("Summarize Android release readiness.")) + put("model", JsonPrimitive("openai/gpt-5.2")) + }, + ) + put( + "state", + buildJsonObject { + put("nextRunAtMs", JsonPrimitive(1_783_641_600_000)) + put("lastRunAtMs", JsonPrimitive(1_783_555_200_000)) + put("lastStatus", JsonPrimitive("ok")) + put("lastDurationMs", JsonPrimitive(1_842)) + put("consecutiveErrors", JsonPrimitive(0)) + put("consecutiveSkipped", JsonPrimitive(0)) + put("lastDeliveryStatus", JsonPrimitive("delivered")) + }, + ) + } + + private fun cronRuns(): String = + buildJsonObject { + put( + "entries", + buildJsonArray { + add( + buildJsonObject { + put("ts", JsonPrimitive(1_783_555_200_000)) + put("jobId", JsonPrimitive(cronJobId)) + put("runId", JsonPrimitive("android-release-digest-run-2")) + put("action", JsonPrimitive("finished")) + put("status", JsonPrimitive("ok")) + put("summary", JsonPrimitive("Release checklist ready")) + put("durationMs", JsonPrimitive(1_842)) + put("deliveryStatus", JsonPrimitive("delivered")) + put("model", JsonPrimitive("openai/gpt-5.2")) + }, + ) + add( + buildJsonObject { + put("ts", JsonPrimitive(1_783_468_800_000)) + put("jobId", JsonPrimitive(cronJobId)) + put("runId", JsonPrimitive("android-release-digest-run-1")) + put("action", JsonPrimitive("finished")) + put("status", JsonPrimitive("error")) + put("error", JsonPrimitive("Play publish blocked")) + put("durationMs", JsonPrimitive(927)) + put("deliveryStatus", JsonPrimitive("not-requested")) + put("model", JsonPrimitive("openai/gpt-5.2")) + }, + ) + }, + ) + }.toString() + + private fun chatHistory(): String = + buildJsonObject { + put("sessionId", JsonPrimitive("screenshot-session")) + put("thinkingLevel", JsonPrimitive("low")) + put( + "messages", + buildJsonArray { + add(chatMessage("user", "What is blocking the Android release?", 1_783_555_020_000)) + add( + chatMessage( + "assistant", + "Two review threads are still open on the release branch, and the localization sync needs one more pass. " + + "Once those land, the changelog draft is ready for review and the tag can go out.", + 1_783_555_080_000, + ), + ) + add(chatMessage("user", "Summarize the open review feedback for me.", 1_783_555_140_000)) + add( + chatMessage( + "assistant", + "The main thread asks for a regression test around session restore, and the second one wants the new " + + "config key documented before merge. Both are small; I can draft patches for each if you want.", + 1_783_555_200_000, + ), + ) + add(chatMessage("user", "Draft a short status update for the team.", 1_783_555_260_000)) + add( + chatMessage( + "assistant", + "The Android release is close. Two review follow-ups and one localization pass remain; once those land, " + + "the changelog can be reviewed and the tag can go out.", + 1_783_555_320_000, + ), + ) + }, + ) + put( + "sessionInfo", + buildJsonObject { + put("key", JsonPrimitive(mainSessionKey)) + put("displayName", JsonPrimitive("New chat")) + put("updatedAt", JsonPrimitive(1_783_555_320_000)) + put("unread", JsonPrimitive(false)) + put("modelProvider", JsonPrimitive("openai")) + put("model", JsonPrimitive("gpt-5.2")) + put("contextTokens", JsonPrimitive(200_000)) + }, + ) + }.toString() + + private fun chatMessage( + role: String, + content: String, + timestamp: Long, + ) = buildJsonObject { + put("role", JsonPrimitive(role)) + put("content", JsonPrimitive(content)) + put("timestamp", JsonPrimitive(timestamp)) + } + + private fun sessionList(paramsJson: String?): String { + val spawnedBy = + paramsJson + ?.let { + runCatching { + Json + .parseToJsonElement(it) + .jsonObject["spawnedBy"] + ?.jsonPrimitive + ?.contentOrNull + }.getOrNull() + } + if (scene == AndroidScreenshotScene.Swarm && spawnedBy != null) { + val children = swarmChildren(spawnedBy) + return buildJsonObject { + put("sessions", buildJsonArray { children.forEach(::add) }) + put("count", JsonPrimitive(children.size)) + put("totalCount", JsonPrimitive(children.size)) + put("hasMore", JsonPrimitive(false)) + }.toString() + } + return buildJsonObject { + put( + "sessions", + buildJsonArray { + add(session("discord:release-planning", primarySessionTitle, 1_783_555_200_000)) + add(session("main", "Product notes", 1_783_468_800_000)) + add(session("discord:android", "Android QA", 1_783_382_400_000)) + }, + ) + put("totalCount", JsonPrimitive(3)) + }.toString() + } + + private fun swarmChildren(parentKey: String) = + listOf( + swarmChild("research-polling", "National polling", "done", parentKey), + swarmChild("research-work", "Work and labor", "running", parentKey), + swarmChild("research-health", "Health", "running", parentKey), + swarmChild("research-trust", "Governance and trust", null, parentKey, queued = true), + swarmChild("research-media", "Media signals", "failed", parentKey), + ) + + private fun swarmChild( + key: String, + label: String, + status: String?, + parentKey: String, + queued: Boolean = false, + ) = session("agent:main:subagent:$key", label, 1_783_555_320_000).toMutableMap().let { values -> + buildJsonObject { + values.forEach { (field, value) -> put(field, value) } + put("parentSessionKey", JsonPrimitive(parentKey)) + put("spawnedBy", JsonPrimitive(parentKey)) + put("swarmGroupId", JsonPrimitive("swarm:$parentKey:research")) + put("swarmPhase", JsonPrimitive("Research")) + put("swarmPhaseRank", JsonPrimitive(0)) + put("swarmLog", JsonPrimitive("Comparing labor, education, health, trust, and media signals.")) + status?.let { put("status", JsonPrimitive(it)) } + if (queued) put("subagentRunState", JsonPrimitive("active")) + if (status == "running") put("hasActiveRun", JsonPrimitive(true)) + } + } + + private fun session( + key: String, + displayName: String, + updatedAt: Long, + ) = buildJsonObject { + put("key", JsonPrimitive(key)) + put("displayName", JsonPrimitive(displayName)) + put("updatedAt", JsonPrimitive(updatedAt)) + put("lastActivityAt", JsonPrimitive(updatedAt)) + put("unread", JsonPrimitive(false)) + put("archived", JsonPrimitive(false)) + put("category", JsonNull) + put("modelProvider", JsonPrimitive("openai")) + put("model", JsonPrimitive("gpt-5.2")) + put("totalTokens", JsonPrimitive(18_420)) + put("contextTokens", JsonPrimitive(200_000)) + } + + private fun chatMetadata(): String = + buildJsonObject { + put("swarmEnabled", JsonPrimitive(scene == AndroidScreenshotScene.Swarm)) + put( + "commands", + buildJsonArray { + add( + buildJsonObject { + put("name", JsonPrimitive("status")) + put("description", JsonPrimitive("Show current OpenClaw status")) + put("acceptsArgs", JsonPrimitive(false)) + }, + ) + }, + ) + put( + "models", + buildJsonArray { + add( + buildJsonObject { + put("id", JsonPrimitive("gpt-5.2")) + put("name", JsonPrimitive("GPT-5.2")) + put("provider", JsonPrimitive("openai")) + put("available", JsonPrimitive(true)) + put("reasoning", JsonPrimitive(true)) + put("contextWindow", JsonPrimitive(200_000)) + put( + "input", + buildJsonArray { + add(JsonPrimitive("text")) + add(JsonPrimitive("image")) + add(JsonPrimitive("audio")) + add(JsonPrimitive("document")) + }, + ) + }, + ) + }, + ) + }.toString() +} diff --git a/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt b/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt new file mode 100644 index 0000000..181f44e --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt @@ -0,0 +1,33 @@ +package ai.openclaw.app + +import ai.openclaw.app.ui.SettingsRoute +import android.content.Intent + +const val extraAndroidScreenshotMode = "openclaw.screenshotMode" +const val extraAndroidScreenshotScene = "openclaw.screenshotScene" + +enum class AndroidScreenshotScene( + val rawValue: String, + val homeDestination: HomeDestination, + internal val settingsRoute: SettingsRoute? = null, +) { + Home("home", HomeDestination.Connect), + Chat("chat", HomeDestination.Chat), + Swarm("swarm", HomeDestination.Chat), + Settings("settings", HomeDestination.Settings), + Gateway("gateway", HomeDestination.Settings, SettingsRoute.Gateway), + OpenClaw("openclaw", HomeDestination.Settings, SettingsRoute.SystemAgent), + VoiceWake("voice-wake", HomeDestination.Settings, SettingsRoute.Voice), + ; + + companion object { + fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Home + } +} + +fun parseAndroidScreenshotModeIntent(intent: Intent?): AndroidScreenshotScene? { + if (intent?.getBooleanExtra(extraAndroidScreenshotMode, false) != true) { + return null + } + return AndroidScreenshotScene.fromRawValue(intent.getStringExtra(extraAndroidScreenshotScene)) +} diff --git a/app/src/main/java/ai/openclaw/app/AppLanguage.kt b/app/src/main/java/ai/openclaw/app/AppLanguage.kt new file mode 100644 index 0000000..d536934 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/AppLanguage.kt @@ -0,0 +1,98 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeStringResources +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.i18n.notifyNativeLocaleChanged +import android.content.Context +import android.content.res.Resources +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.LocaleManagerCompat +import androidx.core.os.LocaleListCompat +import java.util.Locale + +/** Keep these tags aligned with androidResources.localeFilters so Android never offers an unsupported locale. */ +internal enum class AppLanguage( + val languageTag: String?, + val displayName: String, +) { + System(languageTag = null, displayName = "System"), + English(languageTag = "en", displayName = "English"), + Arabic(languageTag = "ar", displayName = "العربية"), + German(languageTag = "de", displayName = "Deutsch"), + Spanish(languageTag = "es", displayName = "Español"), + Persian(languageTag = "fa", displayName = "فارسی"), + French(languageTag = "fr", displayName = "Français"), + Hindi(languageTag = "hi", displayName = "हिन्दी"), + Indonesian(languageTag = "id", displayName = "Bahasa Indonesia"), + Italian(languageTag = "it", displayName = "Italiano"), + Japanese(languageTag = "ja", displayName = "日本語"), + Korean(languageTag = "ko", displayName = "한국어"), + Dutch(languageTag = "nl", displayName = "Nederlands"), + Polish(languageTag = "pl", displayName = "Polski"), + PortugueseBrazil(languageTag = "pt-BR", displayName = "Português (Brasil)"), + Russian(languageTag = "ru", displayName = "Русский"), + Swedish(languageTag = "sv", displayName = "Svenska"), + Thai(languageTag = "th", displayName = "ไทย"), + Turkish(languageTag = "tr", displayName = "Türkçe"), + Ukrainian(languageTag = "uk", displayName = "Українська"), + Vietnamese(languageTag = "vi", displayName = "Tiếng Việt"), + ChineseSimplified(languageTag = "zh-CN", displayName = "简体中文"), + ChineseTraditional(languageTag = "zh-TW", displayName = "繁體中文"), + ; + + companion object { + fun fromLanguageTag(languageTag: String?): AppLanguage { + val locale = languageTag?.trim()?.takeIf(String::isNotEmpty)?.let(Locale::forLanguageTag) + return locale?.let(::fromLocale) ?: System + } + + internal fun fromLocale(locale: Locale): AppLanguage? { + val exactTag = locale.toLanguageTag() + val exactMatch = entries.firstOrNull { language -> language.languageTag?.equals(exactTag, ignoreCase = true) == true } + if (exactMatch != null) return exactMatch + + return entries.firstOrNull { language -> + val supportedLocale = language.languageTag?.let(Locale::forLanguageTag) ?: return@firstOrNull false + LocaleListCompat.matchesLanguageAndScript(locale, supportedLocale) + } + } + } +} + +internal fun appLanguageFromLocales(locales: LocaleListCompat): AppLanguage = + if (locales.isEmpty) { + AppLanguage.System + } else { + (0 until locales.size()).firstNotNullOfOrNull { index -> locales[index]?.let(AppLanguage::fromLocale) } + ?: AppLanguage.System + } + +internal fun currentAppLanguage(): AppLanguage = appLanguageFromLocales(AppCompatDelegate.getApplicationLocales()) + +internal fun localesForAppLanguage(language: AppLanguage): LocaleListCompat = language.languageTag?.let(LocaleListCompat::forLanguageTags) ?: LocaleListCompat.getEmptyLocaleList() + +internal fun setAppLanguage(language: AppLanguage) { + val locales = localesForAppLanguage(language) + NativeStringResources.setApplicationLocales(locales) + if (locales != AppCompatDelegate.getApplicationLocales()) { + AppCompatDelegate.setApplicationLocales(locales) + notifyNativeLocaleChanged() + } +} + +internal fun currentSystemLanguageTag(context: Context): String { + val systemLocales = LocaleManagerCompat.getSystemLocales(context) + val locale = systemLocales[0] ?: Resources.getSystem().configuration.locales[0] + return locale.toLanguageTag() +} + +internal fun appLanguageRowSubtitle( + language: AppLanguage, + systemLanguageTag: String, +): String { + val languageTag = language.languageTag + if (languageTag != null) { + return nativeString("OpenClaw translations · \$languageTag", languageTag) + } + return nativeString("Follow Android · \$systemLanguageTag", systemLanguageTag) +} diff --git a/app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt b/app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt new file mode 100644 index 0000000..f4f1cc4 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt @@ -0,0 +1,25 @@ +package ai.openclaw.app + +/** User-selectable app theme mode for Android appearance settings. */ +enum class AppearanceThemeMode( + val rawValue: String, + val displayLabel: String, +) { + System(rawValue = "system", displayLabel = "System"), + Dark(rawValue = "dark", displayLabel = "Dark"), + Light(rawValue = "light", displayLabel = "Light"), + ; + + fun isDark(systemDark: Boolean): Boolean = + when (this) { + System -> systemDark + Dark -> true + Light -> false + } + + companion object { + fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark + + fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark + } +} diff --git a/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt b/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt new file mode 100644 index 0000000..b0c4823 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt @@ -0,0 +1,223 @@ +package ai.openclaw.app + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import androidx.core.content.IntentCompat +import java.util.Locale + +/** Android Assistant entry point used by manifest-declared app actions. */ +const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW" + +/** Debug action that opens the Voice tab directly for Android E2E automation. */ +const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E" + +/** Intent extra that carries an optional assistant prompt for app actions. */ +const val extraAssistantPrompt = "prompt" + +/** + * Top-level home destinations that external actions may request. + */ +enum class HomeDestination { + Connect, + Chat, + Voice, + Screen, + Settings, +} + +/** + * Normalized launch request from Android Assistant or explicit app actions. + */ +data class AssistantLaunchRequest( + val source: String, + val prompt: String?, + val autoSend: Boolean, +) + +/** Shared content staged in chat for user review before sending. */ +data class ShareLaunchRequest( + val text: String?, + val attachments: List, + val droppedAttachmentCount: Int, +) + +enum class SharedAttachmentKind { + Image, + Audio, + Video, + Document, +} + +data class SharedAttachment( + val uri: Uri, + val kind: SharedAttachmentKind, + val mimeType: String, +) + +private data class SharedAttachmentSelection( + val attachments: List, + val droppedCount: Int, +) + +internal val SHARED_ATTACHMENT_MIME_ALLOWLIST = + setOf( + "image/*", + "audio/*", + "video/*", + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/csv", + "text/markdown", + ) + +internal val SHARED_AUDIO_DOCUMENT_MIME_TYPES = + SHARED_ATTACHMENT_MIME_ALLOWLIST.filterNot { it == "image/*" || it == "video/*" }.toTypedArray() +internal val SHARED_VIDEO_MIME_TYPES = arrayOf("video/*") + +/** + * Parses app-owned navigation actions that should open a specific home tab. + */ +fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? { + val action = intent?.action ?: return null + return when { + // Debug-only shortcut keeps E2E navigation out of release builds. + BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice + else -> null + } +} + +/** + * Parse external assistant entry points without starting any UI side effects. + */ +fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? { + val action = intent?.action ?: return null + return when (action) { + Intent.ACTION_ASSIST -> + AssistantLaunchRequest( + source = "assist", + prompt = null, + autoSend = false, + ) + + actionAskOpenClaw -> { + val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null } + AssistantLaunchRequest( + source = "app_action", + prompt = prompt, + autoSend = false, + ) + } + + else -> null + } +} + +/** Parses Android Sharesheet metadata without opening or reading shared payload bytes. */ +fun parseShareLaunchIntent( + intent: Intent?, + resolveMimeType: (Uri) -> String?, +): ShareLaunchRequest? { + val action = intent?.action ?: return null + if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null + + val text = + listOf(intent.getStringExtra(Intent.EXTRA_SUBJECT), intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()) + .mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } } + .distinct() + .joinToString(separator = "\n\n") + .ifEmpty { null } + val attachmentSelection = sharedAttachments(intent, action, resolveMimeType) + + if (text == null && attachmentSelection.attachments.isEmpty() && attachmentSelection.droppedCount == 0) return null + return ShareLaunchRequest( + text = text, + attachments = attachmentSelection.attachments, + droppedAttachmentCount = attachmentSelection.droppedCount, + ) +} + +private fun sharedAttachments( + intent: Intent, + action: String, + resolveMimeType: (Uri) -> String?, +): SharedAttachmentSelection { + val streamUris = + when (action) { + Intent.ACTION_SEND -> + listOfNotNull(IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)) + + Intent.ACTION_SEND_MULTIPLE -> + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java).orEmpty() + + else -> emptyList() + } + val clipUris = + intent.clipData + ?.let { clip -> + (0 until clip.itemCount).mapNotNull { index -> clip.getItemAt(index).uri } + }.orEmpty() + + // Only provider-backed content URIs use the sender's temporary read grant. Rejecting file:// + // prevents an external intent from turning OpenClaw into a reader for its own private files. + val validUris = + (streamUris + clipUris) + .filter { uri -> uri.scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) } + .distinct() + val fallbackMimeType = + normalizeSharedAttachmentMimeType(intent.type) + ?.takeIf(::isStageableSharedAttachmentMimeType) + val resolved = mutableListOf() + var droppedCount = 0 + for ((index, uri) in validUris.withIndex()) { + if (resolved.size >= MAX_SHARED_ATTACHMENT_COUNT) { + droppedCount += validUris.size - index + break + } + val providerMimeType = + try { + normalizeSharedAttachmentMimeType(resolveMimeType(uri)) + } catch (_: Exception) { + null + } + val mimeType = providerMimeType ?: fallbackMimeType + val kind = sharedAttachmentKindForMimeType(mimeType) + if (!isStageableSharedAttachmentMimeType(mimeType) || kind == null) { + droppedCount += 1 + continue + } + resolved += SharedAttachment(uri = uri, kind = kind, mimeType = requireNotNull(mimeType)) + } + return SharedAttachmentSelection( + attachments = resolved, + droppedCount = droppedCount, + ) +} + +internal fun sharedAttachmentKindForMimeType(mimeType: String?): SharedAttachmentKind? { + val normalized = normalizeSharedAttachmentMimeType(mimeType) ?: return null + return when { + normalized.startsWith("image/") -> SharedAttachmentKind.Image + normalized.startsWith("audio/") -> SharedAttachmentKind.Audio + normalized.startsWith("video/") -> SharedAttachmentKind.Video + normalized in SHARED_ATTACHMENT_MIME_ALLOWLIST -> SharedAttachmentKind.Document + else -> null + } +} + +internal fun isStageableSharedAttachmentMimeType(mimeType: String?): Boolean { + val normalized = normalizeSharedAttachmentMimeType(mimeType) ?: return false + val kind = sharedAttachmentKindForMimeType(normalized) ?: return false + return kind == SharedAttachmentKind.Image || !normalized.endsWith("/*") +} + +internal fun normalizeSharedAttachmentMimeType(mimeType: String?): String? = + mimeType + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.US) + ?.takeIf { it.isNotEmpty() } + +private const val MAX_SHARED_ATTACHMENT_COUNT = 8 diff --git a/app/src/main/java/ai/openclaw/app/CameraHudState.kt b/app/src/main/java/ai/openclaw/app/CameraHudState.kt new file mode 100644 index 0000000..fb83dc8 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/CameraHudState.kt @@ -0,0 +1,16 @@ +package ai.openclaw.app + +/** Camera HUD state categories shown over the Android UI during capture. */ +enum class CameraHudKind { + Photo, + Recording, + Success, + Error, +} + +/** One-shot camera HUD message keyed by token so repeated text still replays. */ +data class CameraHudState( + val token: Long, + val kind: CameraHudKind, + val message: String, +) diff --git a/app/src/main/java/ai/openclaw/app/CronJobDetail.kt b/app/src/main/java/ai/openclaw/app/CronJobDetail.kt new file mode 100644 index 0000000..216f3cc --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/CronJobDetail.kt @@ -0,0 +1,316 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.joinedNativeText +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.verbatimText +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject + +data class GatewayCronJobDetail( + val id: String, + val name: String, + val description: String, + val enabled: Boolean, + val deleteAfterRun: Boolean, + val scheduleKind: String, + val scheduleLabel: NativeText, + val scheduleDetail: NativeText, + val scheduleAt: String?, + val scheduleEveryMs: Long?, + val scheduleAnchorMs: Long?, + val scheduleCronExpr: String?, + val scheduleTimezone: String?, + val scheduleStaggerMs: Long?, + val scheduleCommand: String?, + val scheduleCwd: String?, + val sessionTarget: String, + val wakeMode: String, + val payloadKind: String, + val payloadText: String?, + val payloadLabel: NativeText, + val payloadModel: String?, + val payloadThinking: String?, + val payloadCommandArgv: List?, + val payloadCommandCwd: String?, + val deliveryLabel: NativeText, + val failureAlertLabel: NativeText, + val createdAtMs: Long, + val updatedAtMs: Long, + val configRevision: String?, + val nextRunAtMs: Long?, + val runningAtMs: Long?, + val lastRunAtMs: Long?, + val lastRunStatus: String?, + val lastError: String?, + val lastDiagnosticSummary: String?, + val lastDurationMs: Long?, + val consecutiveErrors: Long?, + val consecutiveSkipped: Long?, + val lastDeliveryStatus: String?, + val lastDeliveryError: String?, +) + +sealed interface GatewayCronJobDetailState { + data object Idle : GatewayCronJobDetailState + + data class Loading( + val id: String, + ) : GatewayCronJobDetailState + + data class Loaded( + val job: GatewayCronJobDetail, + ) : GatewayCronJobDetailState + + data class Error( + val id: String, + val message: NativeText, + ) : GatewayCronJobDetailState +} + +internal data class CronJobDetailRequest( + val id: String, + val generation: Long, +) + +/** Couples the selected job id to its generation so older RPCs cannot publish into a new screen. */ +internal class CronJobDetailRequestGuard { + private val lock = Any() + private var generation = 0L + private var selectedId: String? = null + + fun begin(rawId: String): CronJobDetailRequest? { + val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null + return synchronized(lock) { + generation += 1 + selectedId = id + CronJobDetailRequest(id = id, generation = generation) + } + } + + fun beginIfCurrent( + rawId: String, + onBegin: (CronJobDetailRequest) -> Unit, + ): CronJobDetailRequest? { + val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null + return synchronized(lock) { + if (selectedId != id) return@synchronized null + generation += 1 + CronJobDetailRequest(id = id, generation = generation).also(onBegin) + } + } + + fun cancel(onCancel: () -> Unit = {}) { + synchronized(lock) { + generation += 1 + selectedId = null + onCancel() + } + } + + fun cancelIfCurrent( + rawId: String, + onCancel: () -> Unit, + ): Boolean { + val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { + if (selectedId != id) return@synchronized false + generation += 1 + selectedId = null + onCancel() + true + } + } + + fun publishIfCurrent( + request: CronJobDetailRequest, + publish: () -> Unit, + ): Boolean = + synchronized(lock) { + if (request.generation != generation || request.id != selectedId) return@synchronized false + publish() + true + } +} + +internal fun cronJobGetParams(id: String): String = + buildJsonObject { + put("id", JsonPrimitive(id)) + }.toString() + +internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail? { + val value = job ?: return null + val id = value.string("id") ?: return null + val name = value.string("name") ?: return null + val createdAtMs = value.long("createdAtMs") ?: return null + val updatedAtMs = value.long("updatedAtMs") ?: return null + val schedule = value["schedule"].asObjectOrNull() ?: return null + val payload = value["payload"].asObjectOrNull() ?: return null + val sessionTarget = value.string("sessionTarget") ?: return null + val wakeMode = value.string("wakeMode") ?: return null + val payloadKind = payload.string("kind") ?: return null + val scheduleKind = schedule.string("kind") ?: return null + if (scheduleKind !in setOf("at", "every", "cron", "on-exit")) return null + if (payloadKind !in setOf("systemEvent", "agentTurn", "command", "script")) return null + val state = value["state"].asObjectOrNull() ?: return null + + return GatewayCronJobDetail( + id = id, + name = name, + description = value.string("description").orEmpty(), + enabled = value.boolean("enabled"), + deleteAfterRun = value.boolean("deleteAfterRun"), + scheduleKind = scheduleKind, + scheduleLabel = cronScheduleLabel(schedule), + scheduleDetail = cronScheduleDetail(schedule), + scheduleAt = schedule.string("at"), + scheduleEveryMs = schedule.long("everyMs"), + scheduleAnchorMs = schedule.long("anchorMs"), + scheduleCronExpr = schedule.string("expr"), + scheduleTimezone = schedule.string("tz"), + scheduleStaggerMs = schedule.long("staggerMs"), + scheduleCommand = schedule.string("command"), + scheduleCwd = schedule.string("cwd"), + sessionTarget = sessionTarget, + wakeMode = wakeMode, + payloadKind = payloadKind, + payloadText = cronPayloadText(payload), + payloadLabel = cronPayloadLabel(payload), + payloadModel = payload.string("model"), + payloadThinking = payload.string("thinking"), + payloadCommandArgv = + (payload["argv"] as? JsonArray) + ?.mapNotNull { it.asStringOrNull() }, + payloadCommandCwd = payload.string("cwd"), + deliveryLabel = cronDeliveryLabel(value["delivery"].asObjectOrNull()), + failureAlertLabel = cronFailureAlertLabel(value["failureAlert"]), + createdAtMs = createdAtMs, + updatedAtMs = updatedAtMs, + configRevision = value.string("configRevision"), + nextRunAtMs = state.long("nextRunAtMs"), + runningAtMs = state.long("runningAtMs"), + lastRunAtMs = state.long("lastRunAtMs"), + lastRunStatus = cronJobLastRunStatus(state), + lastError = state.string("lastError"), + lastDiagnosticSummary = state.string("lastDiagnosticSummary"), + lastDurationMs = state.long("lastDurationMs"), + consecutiveErrors = state.long("consecutiveErrors"), + consecutiveSkipped = state.long("consecutiveSkipped"), + lastDeliveryStatus = state.string("lastDeliveryStatus"), + lastDeliveryError = state.string("lastDeliveryError"), + ) +} + +internal fun formatCronInterval(everyMs: Long): NativeText { + val minutes = everyMs / 60_000L + val hours = minutes / 60L + val days = hours / 24L + return when { + days >= 1 && hours % 24L == 0L -> nativeText("Every \${days}d", days) + hours >= 1 && minutes % 60L == 0L -> nativeText("Every \${hours}h", hours) + minutes >= 1 -> nativeText("Every \${minutes}m", minutes) + else -> nativeText("Repeating") + } +} + +private fun cronScheduleLabel(schedule: JsonObject): NativeText = + when (schedule.string("kind")) { + "at" -> nativeText("One time") + "every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating") + "cron" -> schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron") + else -> nativeText("Scheduled") + } + +private fun cronScheduleDetail(schedule: JsonObject): NativeText = + when (schedule.string("kind")) { + "at" -> schedule.string("at")?.let(::verbatimText) ?: nativeText("One time") + "every" -> { + val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating") + val anchor = schedule.long("anchorMs")?.let { nativeText("Anchor \$it", it) } + joinedNativeText(" · ", listOfNotNull(every, anchor)) + } + "cron" -> { + val expression = schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron") + val timezone = schedule.string("tz")?.let(::verbatimText) + val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { nativeText("Stagger \${formatCronInterval(it)}", formatCronInterval(it)) } + joinedNativeText(" · ", listOfNotNull(expression, timezone, stagger)) + } + else -> nativeText("Scheduled") + } + +private fun cronPayloadText(payload: JsonObject): String? = + when (payload.string("kind")) { + "systemEvent" -> payload.string("text") + "agentTurn" -> payload.string("message") + "command" -> + (payload["argv"] as? JsonArray) + ?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } } + ?.joinToString(" ") + "script" -> payload["script"].asStringOrNull() + else -> null + } + +private fun cronPayloadLabel(payload: JsonObject): NativeText = + when (payload.string("kind")) { + "systemEvent" -> nativeText("System event") + "agentTurn" -> { + val model = payload.string("model")?.let(::verbatimText) + val thinking = payload.string("thinking")?.let { nativeText("Thinking \$it", it) } + joinedNativeText(" · ", listOfNotNull(nativeText("Agent turn"), model, thinking)) + } + "command" -> nativeText("Command") + "script" -> { + val timeout = payload.long("timeoutSeconds")?.let { nativeText("Timeout \${it}s", it) } + val budget = payload.long("toolBudget")?.let { nativeText("\$it tools", it) } + joinedNativeText(" · ", listOfNotNull(nativeText("Script"), timeout, budget)) + } + else -> nativeText("Payload") + } + +private fun cronDeliveryLabel(delivery: JsonObject?): NativeText { + val value = delivery ?: return nativeText("Default") + val mode = value.string("mode") ?: return nativeText("Default") + return joinedNativeText( + " · ", + listOfNotNull( + verbatimText(mode.replaceFirstChar { it.uppercaseChar() }), + value.string("channel")?.let(::verbatimText), + value.string("to")?.let(::verbatimText), + value.string("accountId")?.let { nativeText("Account \$it", it) }, + ), + ) +} + +private fun cronFailureAlertLabel(failureAlert: JsonElement?): NativeText { + if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return nativeText("Off") + val alert = failureAlert.asObjectOrNull() ?: return nativeText("Default") + val parts = + listOfNotNull( + alert.long("after")?.let { nativeText("After \$it", it) }, + alert.string("mode")?.replaceFirstChar { it.uppercaseChar() }?.let(::verbatimText), + alert.string("channel")?.let(::verbatimText), + alert.string("to")?.let(::verbatimText), + alert.long("cooldownMs")?.takeIf { it > 0L }?.let { nativeText("Cooldown \${formatCronInterval(it)}", formatCronInterval(it)) }, + ) + return if (parts.isEmpty()) nativeText("On") else joinedNativeText(" · ", parts) +} + +private fun JsonObject.string(key: String): String? = + this[key] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + +private fun JsonObject.long(key: String): Long? = + (this[key] as? JsonPrimitive) + ?.content + ?.trim() + ?.toLongOrNull() + +private fun JsonObject.boolean(key: String): Boolean = (this[key] as? JsonPrimitive)?.booleanOrNull == true diff --git a/app/src/main/java/ai/openclaw/app/CronJobManagement.kt b/app/src/main/java/ai/openclaw/app/CronJobManagement.kt new file mode 100644 index 0000000..0afaf7f --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/CronJobManagement.kt @@ -0,0 +1,671 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject + +data class GatewayCronRunSummary( + val ts: Long, + val runId: String?, + val status: String?, + val summary: String?, + val error: String?, + val durationMs: Long?, + val deliveryStatus: String?, + val sessionKey: String?, + val model: String?, +) + +sealed interface GatewayCronRunHistoryState { + data object Idle : GatewayCronRunHistoryState + + data class Loading( + val id: String, + ) : GatewayCronRunHistoryState + + data class Loaded( + val id: String, + val runs: List, + ) : GatewayCronRunHistoryState + + data class Error( + val id: String, + val message: String, + ) : GatewayCronRunHistoryState +} + +enum class GatewayCronAction { + Run, + Enable, + Disable, + Save, + Delete, +} + +enum class GatewayCronNoticeKind { + Success, + Warning, + Error, +} + +sealed interface GatewayCronActionState { + data object Idle : GatewayCronActionState + + data class Running( + val id: String, + val action: GatewayCronAction, + ) : GatewayCronActionState + + data class Notice( + val id: String, + val message: NativeText, + val kind: GatewayCronNoticeKind, + val deleted: Boolean = false, + ) : GatewayCronActionState { + constructor( + id: String, + message: String, + kind: GatewayCronNoticeKind, + deleted: Boolean = false, + ) : this(id = id, message = nativeText(message), kind = kind, deleted = deleted) + } +} + +/** Owns one queued manual run id per job so a stale tracker cannot clear a newer run. */ +internal class PendingCronRunRegistry { + private val lock = Any() + private val runIdsByJob = linkedMapOf() + + fun contains(rawJobId: String): Boolean { + val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { runIdsByJob.containsKey(jobId) } + } + + fun begin( + rawJobId: String, + rawRunId: String, + publish: (Set) -> Unit, + ): Boolean { + val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false + val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { + if (runIdsByJob.containsKey(jobId)) return@synchronized false + runIdsByJob[jobId] = runId + publish(runIdsByJob.keys.toSet()) + true + } + } + + fun finish( + rawJobId: String, + rawRunId: String, + publish: (Set) -> Unit, + ): Boolean { + val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false + val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { + if (runIdsByJob[jobId] != runId) return@synchronized false + runIdsByJob.remove(jobId) + publish(runIdsByJob.keys.toSet()) + true + } + } + + fun clear(publish: (Set) -> Unit) { + synchronized(lock) { + runIdsByJob.clear() + publish(emptySet()) + } + } +} + +internal fun nextCronJobsPageOffset( + page: JsonObject?, + requestedOffset: Int, + pageCount: Int, +): Int? { + val responseOffset = + page + ?.long("offset") + ?.takeIf { it in 0L..Int.MAX_VALUE.toLong() } + ?.toInt() + ?: requestedOffset + val total = page?.long("total")?.takeIf { it >= 0 } + val hasMore = + (page?.get("hasMore") as? JsonPrimitive)?.booleanOrNull + ?: (total != null && responseOffset.toLong() + pageCount < total) + if (!hasMore) return null + + val nextOffset = + page + ?.long("nextOffset") + ?.takeIf { it in 0L..Int.MAX_VALUE.toLong() } + ?.toInt() + ?: Math.addExact(responseOffset, pageCount) + require(nextOffset > requestedOffset) { "Gateway returned a non-advancing cron jobs page." } + return nextOffset +} + +sealed interface GatewayCronScheduleEdit { + data class At( + val at: String, + ) : GatewayCronScheduleEdit + + data class Every( + val everyMs: String, + val anchorMs: String, + ) : GatewayCronScheduleEdit + + data class Cron( + val expression: String, + val timezone: String, + val staggerMs: String, + ) : GatewayCronScheduleEdit + + data class OnExit( + val command: String, + val cwd: String, + ) : GatewayCronScheduleEdit +} + +sealed interface GatewayCronPayloadEdit { + data class SystemEvent( + val text: String, + ) : GatewayCronPayloadEdit + + data class AgentTurn( + val message: String, + val model: String, + val thinking: String, + ) : GatewayCronPayloadEdit + + data class Command( + val argvJson: String, + val cwd: String, + ) : GatewayCronPayloadEdit + + data class ReadOnlyScript( + val script: String, + ) : GatewayCronPayloadEdit +} + +data class GatewayCronJobEdit( + val name: String, + val description: String, + val enabled: Boolean, + val deleteAfterRun: Boolean, + val schedule: GatewayCronScheduleEdit, + val sessionTarget: String, + val wakeMode: String, + val payload: GatewayCronPayloadEdit, +) { + fun withSchedule(value: GatewayCronScheduleEdit): GatewayCronJobEdit = + copy( + schedule = value, + deleteAfterRun = deleteAfterRun && value is GatewayCronScheduleEdit.At, + ) +} + +internal data class CronEditorDraftState( + val baseline: GatewayCronJobEdit, + val edit: GatewayCronJobEdit, + val savePending: Boolean = false, + val saveSucceeded: Boolean = false, + val hasIncomingConflict: Boolean = false, +) { + val isDirty: Boolean + get() = edit != baseline + + val requiresResolution: Boolean + get() = isDirty || hasIncomingConflict + + fun withEdit(value: GatewayCronJobEdit): CronEditorDraftState = copy(edit = value) + + fun saveStarted(): CronEditorDraftState = copy(savePending = true, saveSucceeded = false) + + fun saveAborted(): CronEditorDraftState = copy(savePending = false, saveSucceeded = false) + + fun observeSaveNotice(kind: GatewayCronNoticeKind): CronEditorDraftState { + if (!savePending) return this + return if (kind == GatewayCronNoticeKind.Success) { + copy(saveSucceeded = true) + } else { + copy(savePending = false, saveSucceeded = false) + } + } + + fun observeJob(job: GatewayCronJobDetail): CronEditorDraftState { + val incoming = job.toCronJobEdit() + if (incoming == edit) { + return CronEditorDraftState( + baseline = incoming, + edit = incoming, + ) + } + if (incoming == baseline) { + return copy(hasIncomingConflict = false) + } + val canAdopt = !isDirty || saveSucceeded + if (!canAdopt) { + return copy(hasIncomingConflict = true) + } + return CronEditorDraftState( + baseline = incoming, + edit = incoming, + ) + } + + companion object { + fun from(job: GatewayCronJobDetail): CronEditorDraftState { + val edit = job.toCronJobEdit() + return CronEditorDraftState( + baseline = edit, + edit = edit, + ) + } + } +} + +internal fun CronEditorDraftState.reconcileRestoredAction( + isConnected: Boolean, + jobId: String, + actionState: GatewayCronActionState, +): CronEditorDraftState { + if (!savePending) return this + // Activity recreation retains the runtime action; process death does not. + // Preserve pending only when the restored runtime still owns this Save. + val retainedSaveState = + when (actionState) { + is GatewayCronActionState.Running -> + actionState.id == jobId && actionState.action == GatewayCronAction.Save + is GatewayCronActionState.Notice -> actionState.id == jobId + GatewayCronActionState.Idle -> false + } + return if (isConnected && retainedSaveState) this else saveAborted() +} + +internal enum class GatewayCronRunSkipReason { + NotDue, + AlreadyRunning, + RestartRecoveryPending, + InvalidSpec, + Stopped, + ; + + val message: String + get() = messageText.resolveNativeText() + + val messageText: NativeText + get() = + when (this) { + NotDue -> nativeText("Automation is not due yet.") + AlreadyRunning -> nativeText("Automation is already running.") + RestartRecoveryPending -> nativeText("Gateway restart recovery is still in progress.") + InvalidSpec -> nativeText("Automation has an invalid configuration.") + Stopped -> nativeText("Cron scheduler is stopped.") + } +} + +internal sealed interface GatewayCronRunOutcome { + data class Started( + val runId: String?, + ) : GatewayCronRunOutcome + + data class Skipped( + val reason: GatewayCronRunSkipReason, + ) : GatewayCronRunOutcome + + data object Rejected : GatewayCronRunOutcome +} + +internal fun cronRunShouldRefresh(outcome: GatewayCronRunOutcome): Boolean = + when (outcome) { + is GatewayCronRunOutcome.Started -> true + is GatewayCronRunOutcome.Skipped -> outcome.reason == GatewayCronRunSkipReason.InvalidSpec + GatewayCronRunOutcome.Rejected -> false + } + +internal fun cronRunCompletionNotice( + jobId: String, + status: String?, +): GatewayCronActionState.Notice { + val (message, kind) = + when (status) { + "ok" -> nativeText("Automation run finished.") to GatewayCronNoticeKind.Success + "skipped" -> nativeText("Automation run skipped.") to GatewayCronNoticeKind.Warning + "error" -> nativeText("Automation run failed.") to GatewayCronNoticeKind.Error + else -> nativeText("Automation run finished with an unknown status.") to GatewayCronNoticeKind.Warning + } + return GatewayCronActionState.Notice(id = jobId, message = message, kind = kind) +} + +internal fun isCronJobRevisionConflict(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "CRON_JOB_CHANGED" + +internal fun GatewayCronJobDetail.toCronJobEdit(): GatewayCronJobEdit = + GatewayCronJobEdit( + name = name, + description = description, + enabled = enabled, + // Gateway deletion only runs after a successful one-shot schedule. + deleteAfterRun = deleteAfterRun && scheduleKind == "at", + schedule = + when (scheduleKind) { + "at" -> GatewayCronScheduleEdit.At(at = scheduleAt.orEmpty()) + "every" -> + GatewayCronScheduleEdit.Every( + everyMs = scheduleEveryMs?.toString().orEmpty(), + anchorMs = scheduleAnchorMs?.toString().orEmpty(), + ) + "cron" -> + GatewayCronScheduleEdit.Cron( + expression = scheduleCronExpr.orEmpty(), + timezone = scheduleTimezone.orEmpty(), + staggerMs = scheduleStaggerMs?.toString().orEmpty(), + ) + "on-exit" -> + GatewayCronScheduleEdit.OnExit( + command = scheduleCommand.orEmpty(), + cwd = scheduleCwd.orEmpty(), + ) + else -> error("Unsupported cron schedule kind: $scheduleKind") + }, + sessionTarget = sessionTarget, + wakeMode = wakeMode, + payload = + when (payloadKind) { + "systemEvent" -> GatewayCronPayloadEdit.SystemEvent(text = payloadText.orEmpty()) + "agentTurn" -> + GatewayCronPayloadEdit.AgentTurn( + message = payloadText.orEmpty(), + model = payloadModel.orEmpty(), + thinking = payloadThinking.orEmpty(), + ) + "command" -> + GatewayCronPayloadEdit.Command( + argvJson = JsonArray(payloadCommandArgv.orEmpty().map(::JsonPrimitive)).toString(), + cwd = payloadCommandCwd.orEmpty(), + ) + "script" -> GatewayCronPayloadEdit.ReadOnlyScript(script = payloadText.orEmpty()) + else -> error("Unsupported cron payload kind: $payloadKind") + }, + ) + +internal fun buildCronUpdateParams( + original: GatewayCronJobDetail, + edit: GatewayCronJobEdit, +): String { + val name = edit.name.trim() + require(name.isNotEmpty()) { "Automation name is required." } + val description = edit.description.trim() + val sessionTarget = edit.sessionTarget.trim() + require( + sessionTarget == "main" || + sessionTarget == "isolated" || + sessionTarget == "current" || + (sessionTarget.startsWith("session:") && sessionTarget.removePrefix("session:").isNotBlank()), + ) { "Session target must be main, isolated, current, or session:." } + val wakeMode = edit.wakeMode.trim() + require(wakeMode == "now" || wakeMode == "next-heartbeat") { + "Wake mode must be now or next-heartbeat." + } + + val schedulePatch = buildCronSchedulePatch(original = original, edit = edit.schedule) + val payloadPatch = buildCronPayloadPatch(original = original, edit = edit.payload) + val patch = + buildJsonObject { + if (name != original.name) put("name", JsonPrimitive(name)) + if (description != original.description) put("description", JsonPrimitive(description)) + if (edit.enabled != original.enabled) put("enabled", JsonPrimitive(edit.enabled)) + if (edit.deleteAfterRun != original.deleteAfterRun) { + put("deleteAfterRun", JsonPrimitive(edit.deleteAfterRun)) + } + schedulePatch?.let { put("schedule", it) } + if (sessionTarget != original.sessionTarget) { + put("sessionTarget", JsonPrimitive(sessionTarget)) + } + if (wakeMode != original.wakeMode) put("wakeMode", JsonPrimitive(wakeMode)) + payloadPatch?.let { put("payload", it) } + } + require(patch.isNotEmpty()) { "No cron changes to save." } + val configRevision = + requireNotNull(original.configRevision) { + "Update the gateway before saving cron changes from Android." + } + return buildJsonObject { + put("id", JsonPrimitive(original.id)) + put("expectedConfigRevision", JsonPrimitive(configRevision)) + put("patch", patch) + }.toString() +} + +internal fun parseGatewayCronRunOutcome(root: JsonObject?): GatewayCronRunOutcome? { + val value = root ?: return null + val ok = value.optionalBoolean("ok") ?: return null + if (!ok) return GatewayCronRunOutcome.Rejected + if (value.optionalBoolean("ran") == true) { + return GatewayCronRunOutcome.Started(runId = value.string("runId")) + } + if (value.optionalBoolean("enqueued") == true) { + val runId = value.string("runId") ?: return null + return GatewayCronRunOutcome.Started(runId = runId) + } + if (value.optionalBoolean("ran") != false) return null + val reason = + when (value.string("reason")) { + "not-due" -> GatewayCronRunSkipReason.NotDue + "already-running" -> GatewayCronRunSkipReason.AlreadyRunning + "restart-recovery-pending" -> GatewayCronRunSkipReason.RestartRecoveryPending + "invalid-spec" -> GatewayCronRunSkipReason.InvalidSpec + "stopped" -> GatewayCronRunSkipReason.Stopped + else -> return null + } + return GatewayCronRunOutcome.Skipped(reason) +} + +internal fun parseGatewayCronRunHistory(entries: JsonArray?): List = + entries + ?.mapNotNull { item -> + val value = item.asObjectOrNull() ?: return@mapNotNull null + val ts = value.long("ts") ?: return@mapNotNull null + GatewayCronRunSummary( + ts = ts, + runId = value.string("runId"), + status = value.string("status"), + summary = value.string("summary"), + error = value.string("error"), + durationMs = value.long("durationMs"), + deliveryStatus = value.string("deliveryStatus"), + sessionKey = value.string("sessionKey"), + model = value.string("model"), + ) + }.orEmpty() + +private fun buildCronSchedulePatch( + original: GatewayCronJobDetail, + edit: GatewayCronScheduleEdit, +): JsonObject? = + when (edit) { + is GatewayCronScheduleEdit.At -> { + require(original.scheduleKind == "at") { "Changing schedule type is not supported here." } + val at = edit.at.trim() + require(at.isNotEmpty()) { "One-time automations need an ISO time." } + if (at == original.scheduleAt) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("at")) + put("at", JsonPrimitive(at)) + } + } + } + is GatewayCronScheduleEdit.Every -> { + require(original.scheduleKind == "every") { "Changing schedule type is not supported here." } + val everyMs = edit.everyMs.trim().toLongOrNull() + require(everyMs != null && everyMs > 0L) { "Interval must be a positive number of milliseconds." } + val anchorMs = parseOptionalNonNegativeLong(edit.anchorMs, "Anchor") + if (everyMs == original.scheduleEveryMs && anchorMs == original.scheduleAnchorMs) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("every")) + put("everyMs", JsonPrimitive(everyMs)) + anchorMs?.let { put("anchorMs", JsonPrimitive(it)) } + } + } + } + is GatewayCronScheduleEdit.Cron -> { + require(original.scheduleKind == "cron") { "Changing schedule type is not supported here." } + val expression = edit.expression.trim() + require(expression.isNotEmpty()) { "Cron expression is required." } + val timezone = edit.timezone.trim().ifEmpty { null } + val requestedStaggerMs = parseOptionalNonNegativeLong(edit.staggerMs, "Stagger") + val staggerMs = + requestedStaggerMs ?: if (original.scheduleStaggerMs != null) 0L else null + if ( + expression == original.scheduleCronExpr && + timezone == original.scheduleTimezone && + staggerMs == original.scheduleStaggerMs + ) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("cron")) + put("expr", JsonPrimitive(expression)) + timezone?.let { put("tz", JsonPrimitive(it)) } + staggerMs?.let { put("staggerMs", JsonPrimitive(it)) } + } + } + } + is GatewayCronScheduleEdit.OnExit -> { + require(original.scheduleKind == "on-exit") { "Changing schedule type is not supported here." } + val command = edit.command.trim() + require(command.isNotEmpty()) { "On-exit automations need a command." } + val cwd = edit.cwd.trim().ifEmpty { null } + if (command == original.scheduleCommand && cwd == original.scheduleCwd) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("on-exit")) + put("command", JsonPrimitive(command)) + cwd?.let { put("cwd", JsonPrimitive(it)) } + } + } + } + } + +private fun buildCronPayloadPatch( + original: GatewayCronJobDetail, + edit: GatewayCronPayloadEdit, +): JsonObject? = + when (edit) { + is GatewayCronPayloadEdit.SystemEvent -> { + require(original.payloadKind == "systemEvent") { "Changing payload type is not supported here." } + val text = edit.text.trim() + require(text.isNotEmpty()) { "System event text is required." } + if (text == original.payloadText) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("systemEvent")) + put("text", JsonPrimitive(text)) + } + } + } + is GatewayCronPayloadEdit.AgentTurn -> { + require(original.payloadKind == "agentTurn") { "Changing payload type is not supported here." } + val message = edit.message.trim() + require(message.isNotEmpty()) { "Agent message is required." } + val model = edit.model.trim().ifEmpty { null } + val thinking = edit.thinking.trim().ifEmpty { null } + if ( + message == original.payloadText && + model == original.payloadModel && + thinking == original.payloadThinking + ) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("agentTurn")) + if (message != original.payloadText) put("message", JsonPrimitive(message)) + if (model != original.payloadModel) put("model", model?.let(::JsonPrimitive) ?: JsonNull) + if (thinking != original.payloadThinking) { + put("thinking", thinking?.let(::JsonPrimitive) ?: JsonNull) + } + } + } + } + is GatewayCronPayloadEdit.Command -> { + require(original.payloadKind == "command") { "Changing payload type is not supported here." } + val argv = parseCommandArgv(edit.argvJson) + val cwd = edit.cwd.trim().ifEmpty { null } + if (cwd == null && original.payloadCommandCwd != null) { + error("The gateway does not support clearing a command working directory.") + } + if (argv == original.payloadCommandArgv && cwd == original.payloadCommandCwd) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("command")) + if (argv != original.payloadCommandArgv) { + put("argv", JsonArray(argv.map(::JsonPrimitive))) + } + if (cwd != original.payloadCommandCwd) put("cwd", JsonPrimitive(requireNotNull(cwd))) + } + } + } + is GatewayCronPayloadEdit.ReadOnlyScript -> { + require(original.payloadKind == "script" && edit.script == original.payloadText) { + "Script payloads are read-only on Android." + } + null + } + } + +private fun parseCommandArgv(raw: String): List { + val value = + runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonArray + ?: error("Command argv must be a JSON array.") + val argv = + value.map { item -> + val primitive = item as? JsonPrimitive + primitive?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() } + ?: error("Command argv entries must be non-empty strings.") + } + require(argv.isNotEmpty()) { "Command argv must contain at least one entry." } + return argv +} + +private fun parseOptionalNonNegativeLong( + raw: String, + label: String, +): Long? { + val value = raw.trim() + if (value.isEmpty()) return null + val parsed = value.toLongOrNull() + require(parsed != null && parsed >= 0L) { "$label must be a non-negative number of milliseconds." } + return parsed +} + +private fun JsonObject.string(key: String): String? = + this[key] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + +private fun JsonObject.long(key: String): Long? = + (this[key] as? JsonPrimitive) + ?.content + ?.trim() + ?.toLongOrNull() + +private fun JsonObject.optionalBoolean(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull diff --git a/app/src/main/java/ai/openclaw/app/DeviceNames.kt b/app/src/main/java/ai/openclaw/app/DeviceNames.kt new file mode 100644 index 0000000..a170036 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/DeviceNames.kt @@ -0,0 +1,28 @@ +package ai.openclaw.app + +import android.content.Context +import android.os.Build +import android.provider.Settings + +object DeviceNames { + /** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */ + fun bestDefaultNodeName(context: Context): String { + val deviceName = + runCatching { + Settings.Global.getString(context.contentResolver, "device_name") + }.getOrNull() + ?.trim() + .orEmpty() + + if (deviceName.isNotEmpty()) return deviceName + + // Manufacturer/model are best-effort platform fields; keep the final + // fallback stable so stored default names do not become blank. + val model = + listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() }) + .joinToString(" ") + .trim() + + return model.ifEmpty { "Android Node" } + } +} diff --git a/app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt b/app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt new file mode 100644 index 0000000..f9d8152 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt @@ -0,0 +1,41 @@ +package ai.openclaw.app + +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +data class GatewayAgentSummary( + val id: String, + val name: String?, + val emoji: String?, + val avatar: String? = null, + val avatarUrl: String? = null, + val workspaceGit: Boolean = false, + val kind: String? = null, +) + +/** Parses validated agents.list rows into the smaller Android display model. */ +internal fun parseGatewayAgentSummaries(root: JsonObject): List = (root["agents"] as? JsonArray)?.mapNotNull(::parseGatewayAgentSummary) ?: emptyList() + +private fun parseGatewayAgentSummary(item: JsonElement): GatewayAgentSummary? { + val agent = item.asObjectOrNull() ?: return null + val id = agent["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return null + val identity = agent["identity"].asObjectOrNull() + return GatewayAgentSummary( + id = id, + kind = agent["kind"].asStringOrNull().normalizedAgentValue(), + name = agent["name"].asStringOrNull().normalizedAgentValue(), + emoji = identity?.get("emoji").asStringOrNull().normalizedAgentValue(), + avatar = identity?.get("avatar").asStringOrNull().normalizedAgentValue(), + avatarUrl = identity?.get("avatarUrl").asStringOrNull().normalizedAgentValue(), + workspaceGit = (agent["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true, + ) +} + +internal fun List.selectableAgents(): List = filter { it.kind != "system" } + +private fun String?.normalizedAgentValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt b/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt new file mode 100644 index 0000000..67bdfa3 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt @@ -0,0 +1,587 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.verbatimText +import ai.openclaw.app.node.asObjectOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import java.util.concurrent.atomic.AtomicLong + +data class GatewayExecApprovalSummary( + val id: String, + val commandText: NativeText, + val commandPreview: String?, + val warningText: String?, + val allowedDecisions: List, + val host: String?, + val nodeId: String?, + val agentId: String?, + val createdAtMs: Long?, + val expiresAtMs: Long?, + val resolvingDecision: String? = null, + val errorText: String? = null, +) + +internal enum class GatewayApprovalTerminalStatus { + Allowed, + Denied, + Expired, + Cancelled, +} + +internal sealed interface GatewayExecApprovalSnapshot { + val id: String + + data class Pending( + val summary: GatewayExecApprovalSummary, + ) : GatewayExecApprovalSnapshot { + override val id: String = summary.id + } + + data class Terminal( + override val id: String, + val status: GatewayApprovalTerminalStatus, + val decision: String?, + ) : GatewayExecApprovalSnapshot +} + +internal data class GatewayExecApprovalResolution( + val applied: Boolean, + val approval: GatewayExecApprovalSnapshot.Terminal, + val attribution: GatewayExecApprovalResolutionAttribution = + if (applied) GatewayExecApprovalResolutionAttribution.AppliedHere else GatewayExecApprovalResolutionAttribution.PriorResponse, +) + +internal enum class GatewayExecApprovalResolutionAttribution { + AppliedHere, + PriorResponse, + Unknown, +} + +private val execApprovalNoticePublications = AtomicLong() + +data class GatewayExecApprovalNotice( + val approvalId: String, + val message: String, + val warning: Boolean, + // Distinct per constructed notice: a re-requested approval can lose again with an + // identical id/message, and the dismiss compareAndSet must not treat the stale + // banner as equal to its replacement. + val publication: Long = execApprovalNoticePublications.incrementAndGet(), +) + +internal fun gatewayExecApprovalResolutionNotice( + resolution: GatewayExecApprovalResolution, +): GatewayExecApprovalNotice = + when (resolution.approval.status) { + GatewayApprovalTerminalStatus.Allowed -> { + val saved = resolution.approval.decision == "allow-always" + GatewayExecApprovalNotice( + approvalId = resolution.approval.id, + message = gatewayExecApprovalAllowedMessage(attribution = resolution.attribution, saved = saved), + warning = false, + ) + } + GatewayApprovalTerminalStatus.Denied -> + GatewayExecApprovalNotice( + approvalId = resolution.approval.id, + message = gatewayExecApprovalDeniedMessage(resolution.attribution), + warning = true, + ) + GatewayApprovalTerminalStatus.Expired -> + GatewayExecApprovalNotice( + approvalId = resolution.approval.id, + message = gatewayExecApprovalTerminalMessage(resolution.approval.status), + warning = true, + ) + GatewayApprovalTerminalStatus.Cancelled -> + GatewayExecApprovalNotice( + approvalId = resolution.approval.id, + message = gatewayExecApprovalTerminalMessage(resolution.approval.status), + warning = true, + ) + } + +private fun gatewayExecApprovalAllowedMessage( + attribution: GatewayExecApprovalResolutionAttribution, + saved: Boolean, +): String { + if (attribution == GatewayExecApprovalResolutionAttribution.AppliedHere) { + if (saved) return "Approval allowed and saved." + return "Approval allowed once." + } + if (attribution == GatewayExecApprovalResolutionAttribution.PriorResponse) { + if (saved) return "A prior response already allowed this command and saved the choice." + return "A prior response already allowed this command once." + } + if (saved) return "Gateway recorded approval and saved the choice." + return "Gateway recorded approval once." +} + +private fun gatewayExecApprovalDeniedMessage(attribution: GatewayExecApprovalResolutionAttribution): String = + when (attribution) { + GatewayExecApprovalResolutionAttribution.AppliedHere -> "Approval denied." + GatewayExecApprovalResolutionAttribution.PriorResponse -> "A prior response already denied this approval." + GatewayExecApprovalResolutionAttribution.Unknown -> "Gateway recorded a denial." + } + +private fun gatewayExecApprovalTerminalMessage(status: GatewayApprovalTerminalStatus): String = + when (status) { + GatewayApprovalTerminalStatus.Expired -> "This approval expired before it could be resolved." + GatewayApprovalTerminalStatus.Cancelled -> "This approval was cancelled before it could be resolved." + else -> error("approval is not expired or cancelled") + } + +internal fun gatewayExecApprovalRemoteTerminalNotice( + approval: GatewayExecApprovalSnapshot.Terminal, +): GatewayExecApprovalNotice = + gatewayExecApprovalResolutionNotice( + GatewayExecApprovalResolution(applied = false, approval = approval), + ) + +internal fun gatewayExecApprovalPriorResolutionNotice(id: String): GatewayExecApprovalNotice = + GatewayExecApprovalNotice( + approvalId = id, + message = gatewayExecApprovalPriorResolutionMessage(), + warning = true, + ) + +private fun gatewayExecApprovalPriorResolutionMessage(): String = "A prior response already resolved this approval." + +internal fun normalizeGatewayExecApprovalDecision(value: String): String? = + when (value) { + "allow-once" -> "allow-once" + "allow-always" -> "allow-always" + "deny" -> "deny" + else -> null + } + +/** Parses the terminal winner from an authenticated Gateway resolution event. */ +internal fun parseGatewayExecApprovalResolvedEventTerminal( + payloadJson: String, + json: Json, +): GatewayExecApprovalSnapshot.Terminal? = + try { + val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null + val id = root.strictApprovalId("id") ?: return null + val decision = root.strictString("decision")?.let(::normalizeGatewayExecApprovalDecision) ?: return null + legacyGatewayExecApprovalTerminal(id, decision) + } catch (_: Throwable) { + null + } + +internal enum class GatewayApprovalRpcFamily { + Canonical, + Legacy, + Unavailable, +} + +/** + * Selects one read/write family for the lifetime of a Gateway hello catalog. + * Legacy exec.approval.* serves shipped Gateway v4 peers until the minimum supported + * Gateway advertises approval.get/approval.resolve. + */ +internal fun selectGatewayApprovalRpcFamily(methods: Set): GatewayApprovalRpcFamily { + val hasCanonicalGet = "approval.get" in methods + val hasCanonicalResolve = "approval.resolve" in methods + if (hasCanonicalGet && hasCanonicalResolve) return GatewayApprovalRpcFamily.Canonical + if ( + !hasCanonicalGet && + !hasCanonicalResolve && + "exec.approval.get" in methods && + "exec.approval.resolve" in methods + ) { + return GatewayApprovalRpcFamily.Legacy + } + return GatewayApprovalRpcFamily.Unavailable +} + +internal fun buildGatewayExecApprovalGetParams(id: String): JsonObject = buildJsonObject { put("id", id) } + +internal fun buildGatewayExecApprovalResolveParams( + id: String, + decision: String, +): JsonObject = + buildJsonObject { + put("id", id) + put("kind", "exec") + put("decision", decision) + } + +internal fun parseGatewayExecApprovalListPayload( + payloadJson: String, + json: Json, +): List = + try { + (json.parseToJsonElement(payloadJson) as? JsonArray) + ?.mapNotNull(::parseGatewayExecApprovalListEntry) + ?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE } + .orEmpty() + } catch (_: Throwable) { + emptyList() + } + +internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? { + val obj = item.asObjectOrNull() ?: return null + val id = obj.strictApprovalId("id") ?: return null + val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null + val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null + // The legacy list is discovery-only. Its embedded request can contain runtime-only + // details, so rendering waits for the reviewer-safe unified approval projection. + return GatewayExecApprovalSummary( + id = id, + commandText = nativeText("Command request"), + commandPreview = null, + warningText = null, + allowedDecisions = emptyList(), + host = null, + nodeId = null, + agentId = null, + createdAtMs = createdAtMs, + expiresAtMs = expiresAtMs, + ) +} + +internal fun gatewayExecApprovalTextForDisplay(text: String): String = + when (text) { + "Approval allowed and saved." -> nativeString("Approval allowed and saved.") + "Approval allowed once." -> nativeString("Approval allowed once.") + "A prior response already allowed this command and saved the choice." -> + nativeString("A prior response already allowed this command and saved the choice.") + "A prior response already allowed this command once." -> + nativeString("A prior response already allowed this command once.") + "Gateway recorded approval and saved the choice." -> + nativeString("Gateway recorded approval and saved the choice.") + "Gateway recorded approval once." -> nativeString("Gateway recorded approval once.") + "Approval denied." -> nativeString("Approval denied.") + "A prior response already denied this approval." -> + nativeString("A prior response already denied this approval.") + "Gateway recorded a denial." -> nativeString("Gateway recorded a denial.") + "This approval expired before it could be resolved." -> + nativeString("This approval expired before it could be resolved.") + "This approval was cancelled before it could be resolved." -> + nativeString("This approval was cancelled before it could be resolved.") + "A prior response already resolved this approval." -> + nativeString("A prior response already resolved this approval.") + "Command request" -> nativeString("Command request") + "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified." -> + nativeString("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.") + "The Gateway still shows this approval as pending. Review it before trying again." -> + nativeString("The Gateway still shows this approval as pending. Review it before trying again.") + "Could not load approval details. Refresh and try again." -> + nativeString("Could not load approval details. Refresh and try again.") + "Could not load approvals." -> nativeString("Could not load approvals.") + "Could not resolve approval. Refresh and try again." -> + nativeString("Could not resolve approval. Refresh and try again.") + else -> text + } + +internal fun parseGatewayExecApprovalGetPayload( + payloadJson: String, + json: Json, + expectedId: String, +): GatewayExecApprovalSnapshot? = + try { + val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null + if (!root.hasExactKeys(APPROVAL_GET_RESULT_KEYS)) return null + parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null) + ?.takeIf { it.id == expectedId } + } catch (_: Throwable) { + null + } + +internal fun parseGatewayExecApprovalResolvePayload( + payloadJson: String, + json: Json, + expectedId: String, + expectedDecision: String, +): GatewayExecApprovalResolution? = + try { + val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null + if (!root.hasExactKeys(APPROVAL_RESOLVE_RESULT_KEYS)) return null + val applied = root.strictBoolean("applied") ?: return null + val approval = + parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null) + as? GatewayExecApprovalSnapshot.Terminal + ?: return null + if (approval.id != expectedId) return null + // `applied=true` claims this write won. A different returned decision is an + // ambiguous write outcome, never evidence that the attempted approval applied. + if (applied && approval.decision != expectedDecision) return null + GatewayExecApprovalResolution(applied = applied, approval = approval) + } catch (_: Throwable) { + null + } + +/** Parses the shipped pre-unified exec reviewer projection for old Gateway v4 peers. */ +internal fun parseLegacyGatewayExecApprovalGetPayload( + payloadJson: String, + json: Json, + expectedId: String, + createdAtMs: Long?, +): GatewayExecApprovalSnapshot.Pending? = + try { + val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null + val id = obj.strictApprovalId("id") ?: return null + if (id != expectedId) return null + val normalizedCreatedAtMs = createdAtMs?.takeIf { it >= 0 } ?: return null + val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null + val commandText = obj.strictNonEmptyString("commandText") ?: return null + val commandPreview = obj.optionalString("commandPreview") ?: return null + val host = obj.optionalString("host") ?: return null + val nodeId = obj.optionalString("nodeId", requireNonEmpty = true) ?: return null + val agentId = obj.optionalString("agentId", requireNonEmpty = true) ?: return null + val allowedDecisions = parseAllowedDecisions(obj["allowedDecisions"] as? JsonArray) ?: return null + GatewayExecApprovalSnapshot.Pending( + GatewayExecApprovalSummary( + id = id, + commandText = verbatimText(commandText), + commandPreview = commandPreview.value?.takeIf { it != commandText }, + warningText = null, + allowedDecisions = allowedDecisions, + host = host.value, + nodeId = nodeId.value, + agentId = agentId.value, + createdAtMs = normalizedCreatedAtMs, + expiresAtMs = expiresAtMs, + ), + ) + } catch (_: Throwable) { + null + } + +internal fun parseLegacyGatewayExecApprovalResolvePayload( + payloadJson: String, + json: Json, +): Boolean = + try { + val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return false + root.strictBoolean("ok") == true + } catch (_: Throwable) { + false + } + +internal fun legacyGatewayExecApprovalTerminal( + id: String, + decision: String, +): GatewayExecApprovalSnapshot.Terminal? { + val status = + when (decision) { + "allow-once", "allow-always" -> GatewayApprovalTerminalStatus.Allowed + "deny" -> GatewayApprovalTerminalStatus.Denied + else -> return null + } + return GatewayExecApprovalSnapshot.Terminal(id, status, decision) +} + +private fun parseGatewayExecApprovalSnapshot(obj: JsonObject): GatewayExecApprovalSnapshot? { + val status = obj.strictString("status") ?: return null + val expectedKeys = APPROVAL_SNAPSHOT_KEYS_BY_STATUS[status] ?: return null + if (!obj.hasExactKeys(expectedKeys)) return null + val id = obj.strictApprovalId("id") ?: return null + obj.strictNonEmptyString("urlPath") ?: return null + val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null + val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null + val presentation = obj["presentation"].asObjectOrNull() ?: return null + val summary = parseGatewayExecApprovalPresentation(id, createdAtMs, expiresAtMs, presentation) ?: return null + return when (status) { + "pending" -> GatewayExecApprovalSnapshot.Pending(summary) + "allowed" -> + parseTerminalApproval( + obj = obj, + id = id, + status = GatewayApprovalTerminalStatus.Allowed, + expectedDecision = setOf("allow-once", "allow-always"), + )?.takeIf { terminal -> + terminal.decision?.let(summary.allowedDecisions::contains) == true + } + "denied" -> + parseTerminalApproval( + obj = obj, + id = id, + status = GatewayApprovalTerminalStatus.Denied, + expectedDecision = setOf("deny"), + ) + "expired" -> + parseTerminalApproval( + obj = obj, + id = id, + status = GatewayApprovalTerminalStatus.Expired, + expectedDecision = null, + ) + "cancelled" -> + parseTerminalApproval( + obj = obj, + id = id, + status = GatewayApprovalTerminalStatus.Cancelled, + expectedDecision = null, + ) + else -> null + } +} + +private fun parseGatewayExecApprovalPresentation( + id: String, + createdAtMs: Long, + expiresAtMs: Long, + presentation: JsonObject, +): GatewayExecApprovalSummary? { + if (!presentation.hasOnlyKeys(EXEC_APPROVAL_PRESENTATION_KEYS)) return null + if (!presentation.keys.containsAll(EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS)) return null + // A unified lookup can return other approval owners. Android's exec inbox must + // never reinterpret plugin copy or metadata as an executable command request. + if (presentation.strictString("kind") != "exec") return null + val commandText = presentation.strictNonEmptyString("commandText") ?: return null + val allowedDecisions = parseAllowedDecisions(presentation["allowedDecisions"] as? JsonArray) ?: return null + val commandPreview = presentation.optionalString("commandPreview") ?: return null + val warningText = presentation.optionalString("warningText") ?: return null + val host = presentation.optionalString("host") ?: return null + val nodeId = presentation.optionalString("nodeId", requireNonEmpty = true) ?: return null + val agentId = presentation.optionalString("agentId", requireNonEmpty = true) ?: return null + return GatewayExecApprovalSummary( + id = id, + commandText = verbatimText(commandText), + commandPreview = commandPreview.value?.takeIf { it != commandText }, + warningText = warningText.value, + allowedDecisions = allowedDecisions, + host = host.value, + nodeId = nodeId.value, + agentId = agentId.value, + createdAtMs = createdAtMs, + expiresAtMs = expiresAtMs, + ) +} + +private fun parseTerminalApproval( + obj: JsonObject, + id: String, + status: GatewayApprovalTerminalStatus, + expectedDecision: Set?, +): GatewayExecApprovalSnapshot.Terminal? { + obj.strictNonNegativeLong("resolvedAtMs") ?: return null + val reason = obj.strictString("reason") ?: return null + if (reason !in APPROVAL_TERMINAL_REASONS) return null + val decision = obj.strictString("decision") + if (expectedDecision == null) { + if (obj.containsKey("decision")) return null + } else if (decision !in expectedDecision) { + return null + } + return GatewayExecApprovalSnapshot.Terminal(id = id, status = status, decision = decision) +} + +private fun parseAllowedDecisions(items: JsonArray?): List? { + if (items == null || items.size !in 1..3) return null + val decisions = items.map { item -> item.strictString() ?: return null } + if (decisions.distinct().size != decisions.size || "deny" !in decisions) return null + return decisions.takeIf { values -> values.all { it in APPROVAL_DECISIONS } } +} + +private data class OptionalString( + val value: String?, +) + +private fun JsonObject.optionalString( + key: String, + requireNonEmpty: Boolean = false, +): OptionalString? { + val value = this[key] + if (value == null || value is JsonNull) return OptionalString(null) + val string = value.strictString() ?: return null + if (requireNonEmpty && string.isEmpty()) return null + return OptionalString(string) +} + +private fun JsonObject.strictString(key: String): String? = this[key].strictString() + +private fun JsonElement?.strictString(): String? = + (this as? JsonPrimitive) + ?.takeIf { it.isString } + ?.content + +private fun JsonObject.strictNonEmptyString(key: String): String? = + strictString(key) + ?.takeIf { it.isNotEmpty() } + +private fun JsonObject.strictApprovalId(key: String): String? = + strictString(key) + ?.takeIf(::isWellFormedGatewayApprovalId) + +private fun JsonObject.strictBoolean(key: String): Boolean? = + (this[key] as? JsonPrimitive) + ?.takeUnless { it.isString } + ?.booleanOrNull + +private fun JsonObject.strictNonNegativeLong(key: String): Long? = + (this[key] as? JsonPrimitive) + ?.takeUnless { it.isString } + ?.longOrNull + ?.takeIf { it >= 0 } + +// Closed-schema contract: the gateway protocol declares approval results with +// additionalProperties:false, so additive protocol changes hard-fail old clients by design. +private fun JsonObject.hasExactKeys(expected: Set): Boolean = keys == expected + +private fun JsonObject.hasOnlyKeys(allowed: Set): Boolean = keys.all(allowed::contains) + +internal fun isWellFormedGatewayApprovalId(value: String): Boolean { + if (value.isEmpty() || value == "." || value == "..") return false + var index = 0 + while (index < value.length) { + val current = value[index] + when { + Character.isHighSurrogate(current) -> { + if (index + 1 >= value.length || !Character.isLowSurrogate(value[index + 1])) return false + index += 2 + } + Character.isLowSurrogate(current) -> return false + else -> index += 1 + } + } + return true +} + +private val APPROVAL_GET_RESULT_KEYS = setOf("approval") + +private val APPROVAL_RESOLVE_RESULT_KEYS = setOf("applied", "approval") + +private val APPROVAL_SNAPSHOT_COMMON_KEYS = + setOf("id", "urlPath", "status", "createdAtMs", "expiresAtMs", "presentation") + +private val APPROVAL_SNAPSHOT_KEYS_BY_STATUS = + mapOf( + "pending" to APPROVAL_SNAPSHOT_COMMON_KEYS, + "allowed" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"), + "denied" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"), + "expired" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"), + "cancelled" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"), + ) + +private val EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS = setOf("kind", "commandText", "allowedDecisions") + +private val EXEC_APPROVAL_PRESENTATION_KEYS = + EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS + + setOf("commandPreview", "warningText", "host", "nodeId", "agentId") + +private val APPROVAL_DECISIONS = setOf("allow-once", "allow-always", "deny") + +private val APPROVAL_TERMINAL_REASONS = + setOf( + "user", + "timeout", + "malformed-verdict", + "no-route", + "run-aborted", + "gateway-restart", + "storage-corrupt", + ) diff --git a/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt b/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt new file mode 100644 index 0000000..9c06155 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt @@ -0,0 +1,237 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.i18n.verbatimText +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +data class GatewayTalkSetupReadiness( + val realtimeTalk: GatewayTalkSetupState, + val dictation: GatewayTalkSetupState, +) { + companion object { + fun unverified( + issue: GatewayTalkSetupIssue = GatewayTalkSetupIssue.CatalogNotLoaded, + ): GatewayTalkSetupReadiness = + GatewayTalkSetupReadiness( + realtimeTalk = GatewayTalkSetupState.Unverified(issue), + dictation = GatewayTalkSetupState.Unverified(issue), + ) + } +} + +sealed interface GatewayTalkSetupState { + data class Ready( + val provider: GatewayTalkProvider, + ) : GatewayTalkSetupState + + data class NeedsSetup( + val issue: GatewayTalkSetupIssue, + val provider: GatewayTalkProvider? = null, + ) : GatewayTalkSetupState + + /** Catalog failures must not disable a startup path that the Gateway still validates. */ + data class Unverified( + val issue: GatewayTalkSetupIssue, + ) : GatewayTalkSetupState +} + +enum class GatewayTalkSetupTarget( + val title: NativeText, +) { + REALTIME_TALK(nativeText("Realtime Talk")), + DICTATION(nativeText("Dictation")), +} + +sealed interface GatewayTalkSetupIssue { + data object CatalogNotLoaded : GatewayTalkSetupIssue + + data object CatalogLoadFailed : GatewayTalkSetupIssue + + data class GroupMissing( + val target: GatewayTalkSetupTarget, + ) : GatewayTalkSetupIssue + + data class NoProvider( + val target: GatewayTalkSetupTarget, + ) : GatewayTalkSetupIssue + + data class UnknownProvider( + val target: GatewayTalkSetupTarget, + val providerId: String, + ) : GatewayTalkSetupIssue + + data class MissingReadiness( + val target: GatewayTalkSetupTarget, + ) : GatewayTalkSetupIssue + + data class ConfigureProvider( + val target: GatewayTalkSetupTarget, + ) : GatewayTalkSetupIssue + + data class MissingActiveProvider( + val target: GatewayTalkSetupTarget, + ) : GatewayTalkSetupIssue + + data class UnsupportedProvider( + val target: GatewayTalkSetupTarget, + ) : GatewayTalkSetupIssue + + data class ConfigureSelectedProvider( + val providerLabel: String, + ) : GatewayTalkSetupIssue +} + +data class GatewayTalkProvider( + val id: String, + val label: String, +) + +val GatewayTalkSetupState.isReady: Boolean + get() = this is GatewayTalkSetupState.Ready + +val GatewayTalkSetupState.requiresSetup: Boolean + get() = this is GatewayTalkSetupState.NeedsSetup + +internal fun isAndroidRealtimeRelayModelSupported(model: String?): Boolean { + val normalized = model?.trim()?.lowercase() ?: return true + // extensions/openai/realtime-quicksilver.ts makes gpt-live WebRTC-only; + // the Gateway relay rejects it instead of providing a usable Android session. + return normalized != "gpt-live" && !normalized.startsWith("gpt-live-") +} + +fun gatewayTalkSetupStatusText(state: GatewayTalkSetupState): String = + when (state) { + is GatewayTalkSetupState.Ready -> nativeString("Ready") + is GatewayTalkSetupState.NeedsSetup -> nativeString("Needs setup") + is GatewayTalkSetupState.Unverified -> nativeString("Unverified") + } + +fun gatewayTalkSetupDescription(state: GatewayTalkSetupState): String = gatewayTalkSetupDescriptionText(state).resolveNativeText() + +internal fun gatewayTalkSetupDescriptionText(state: GatewayTalkSetupState): NativeText = + when (state) { + is GatewayTalkSetupState.Ready -> + nativeText("\${state.provider.label} via Gateway relay", verbatimText(state.provider.label)) + is GatewayTalkSetupState.NeedsSetup -> gatewayTalkSetupIssueDescriptionText(state.issue) + is GatewayTalkSetupState.Unverified -> gatewayTalkSetupIssueDescriptionText(state.issue) + } + +internal fun gatewayTalkSetupIssueDescriptionText(issue: GatewayTalkSetupIssue): NativeText = + when (issue) { + GatewayTalkSetupIssue.CatalogNotLoaded -> nativeText("Gateway talk catalog not loaded") + GatewayTalkSetupIssue.CatalogLoadFailed -> nativeText("Could not load Gateway talk catalog") + is GatewayTalkSetupIssue.GroupMissing -> + nativeText("Gateway did not return \${issue.target.title} setup", issue.target.title) + is GatewayTalkSetupIssue.NoProvider -> + nativeText("No \${issue.target.title} provider is configured on the Gateway", issue.target.title) + is GatewayTalkSetupIssue.UnknownProvider -> + nativeText("Gateway selected unknown provider \${issue.providerId}", verbatimText(issue.providerId)) + is GatewayTalkSetupIssue.MissingReadiness -> + nativeText("Gateway did not return \${issue.target.title} readiness", issue.target.title) + is GatewayTalkSetupIssue.ConfigureProvider -> + nativeText("Configure a \${issue.target.title} provider on the Gateway", issue.target.title) + is GatewayTalkSetupIssue.MissingActiveProvider -> + nativeText("Gateway did not identify the active \${issue.target.title} provider", issue.target.title) + is GatewayTalkSetupIssue.UnsupportedProvider -> + nativeText("Choose a supported \${issue.target.title} provider on the Gateway", issue.target.title) + is GatewayTalkSetupIssue.ConfigureSelectedProvider -> + nativeText("Configure \${issue.providerLabel} on the Gateway", verbatimText(issue.providerLabel)) + } + +internal fun parseGatewayTalkSetupReadiness(catalog: JsonObject?): GatewayTalkSetupReadiness { + if (catalog == null) return GatewayTalkSetupReadiness.unverified() + return GatewayTalkSetupReadiness( + realtimeTalk = + parseTalkCatalogGroup(catalog = catalog, key = "realtime", target = GatewayTalkSetupTarget.REALTIME_TALK), + dictation = + parseTalkCatalogGroup(catalog = catalog, key = "transcription", target = GatewayTalkSetupTarget.DICTATION), + ) +} + +private fun parseTalkCatalogGroup( + catalog: JsonObject, + key: String, + target: GatewayTalkSetupTarget, +): GatewayTalkSetupState { + val group = + catalog[key].asObjectOrNull() + ?: return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.GroupMissing(target)) + val providers = + (group["providers"] as? JsonArray) + ?.mapNotNull(::parseTalkCatalogProvider) + .orEmpty() + val ready = (group["ready"] as? JsonPrimitive)?.booleanOrNull + val activeProviderId = group["activeProvider"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) + if (providers.isEmpty()) { + return when { + ready == false -> GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.NoProvider(target)) + activeProviderId != null -> + GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId)) + else -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target)) + } + } + + if (activeProviderId == null) { + if (ready == false) { + return GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.ConfigureProvider(target)) + } + // Older Gateways can omit the selected provider and report alias-backed rows as unconfigured + // even though session startup resolves them. Only an explicit readiness result is authoritative. + return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingActiveProvider(target)) + } + val selected = + // Match Gateway registry precedence: canonical ids win before alias fallback. + providers.firstOrNull { it.matchesId(activeProviderId) } + ?: providers.firstOrNull { it.matchesAlias(activeProviderId) } + ?: return if (ready == false) { + GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.UnsupportedProvider(target)) + } else { + GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId)) + } + val provider = GatewayTalkProvider(id = selected.id, label = selected.label) + return when (ready) { + true -> GatewayTalkSetupState.Ready(provider) + false -> + GatewayTalkSetupState.NeedsSetup( + issue = GatewayTalkSetupIssue.ConfigureSelectedProvider(selected.label), + provider = provider, + ) + null -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target)) + } +} + +private data class TalkCatalogProvider( + val id: String, + val label: String, + val configured: Boolean, + val aliases: List, +) { + fun matchesId(candidate: String): Boolean = id.equals(candidate, ignoreCase = true) + + fun matchesAlias(candidate: String): Boolean = aliases.any { it.equals(candidate, ignoreCase = true) } +} + +private fun parseTalkCatalogProvider(item: JsonElement): TalkCatalogProvider? { + val value = item.asObjectOrNull() ?: return null + val id = value["id"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return null + val label = value["label"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: id + val aliases = + (value["aliases"] as? JsonArray) + ?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) } + .orEmpty() + return TalkCatalogProvider( + id = id, + label = label, + configured = (value["configured"] as? JsonPrimitive)?.booleanOrNull == true, + aliases = aliases, + ) +} diff --git a/app/src/main/java/ai/openclaw/app/LocationMode.kt b/app/src/main/java/ai/openclaw/app/LocationMode.kt new file mode 100644 index 0000000..4a45ad0 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/LocationMode.kt @@ -0,0 +1,34 @@ +package ai.openclaw.app + +/** + * Persisted location capture mode advertised to the gateway. + */ +enum class LocationMode( + val rawValue: String, +) { + Off("off"), + WhileUsing("whileUsing"), + Always("always"), + ; + + companion object { + /** Parses persisted location mode text. */ + fun fromRawValue(raw: String?): LocationMode { + val normalized = raw?.trim()?.lowercase() + return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off + } + } +} + +/** Resolves the in-app mode after Android's external background-location settings return. */ +internal fun locationModeAfterBackgroundSettings( + previousMode: LocationMode, + foregroundGranted: Boolean, + backgroundGranted: Boolean, +): LocationMode = + when { + foregroundGranted && backgroundGranted -> LocationMode.Always + !foregroundGranted -> LocationMode.Off + previousMode == LocationMode.Always -> LocationMode.WhileUsing + else -> previousMode + } diff --git a/app/src/main/java/ai/openclaw/app/MainActivity.kt b/app/src/main/java/ai/openclaw/app/MainActivity.kt new file mode 100644 index 0000000..9f98138 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -0,0 +1,382 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.ui.OpenClawTheme +import ai.openclaw.app.ui.RootScreen +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Main Android activity that owns Compose UI attachment and runtime UI wiring. + */ +class MainActivity : AppCompatActivity() { + private val viewModel: MainViewModel by viewModels() + private val permissionRequester: PermissionRequester + get() = (application as NodeApp).permissionRequester + private var initializedViewModel: MainViewModel? = null + private var didStartViewModelCollectors = false + private var foreground = false + private val pendingIntentRouter = MainActivityPendingIntentRouter() + private val runtimeUiStarter = MainActivityRuntimeUiStarter() + private var screenshotScene: AndroidScreenshotScene? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pendingIntentRouter.setInitialIntent(intent) + WindowCompat.setDecorFitsSystemWindows(window, false) + permissionRequester.attach(this) + if (BuildConfig.DEBUG) { + screenshotScene = parseAndroidScreenshotModeIntent(intent) + if (screenshotScene != null) hideScreenshotModeStatusBar() + } + + setContent { + var activeViewModel by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withFrameNanos { } + withContext(Dispatchers.Default) { + (application as NodeApp).prefs + } + val readyViewModel = viewModel + screenshotScene?.let(readyViewModel::enterScreenshotFixtureMode) + activateViewModel(readyViewModel) + activeViewModel = readyViewModel + } + + val currentViewModel = activeViewModel + if (currentViewModel == null) { + OpenClawTheme { + StartupSurface() + } + } else { + val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState() + OpenClawTheme(themeMode = appearanceThemeMode) { + RootScreen(viewModel = currentViewModel) + } + } + } + } + + private fun hideScreenshotModeStatusBar() { + WindowCompat + .getInsetsController(window, window.decorView) + .apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.statusBars()) + } + } + + override fun onStart() { + super.onStart() + foreground = true + initializedViewModel?.setForeground(true) + } + + override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) { + super.onTopResumedActivityChanged(isTopResumedActivity) + // minSdk 31 guarantees this callback and lets multi-resume select the actually interactive task. + updateTopResumedPermissionHost( + isTopResumedActivity = isTopResumedActivity, + activate = { permissionRequester.activate(this) }, + deactivate = { permissionRequester.deactivate(this) }, + refreshPermissionSurface = { initializedViewModel?.refreshNodePermissionSurface() }, + ) + } + + override fun onStop() { + // Top-resumed ownership normally clears first; this also covers abnormal lifecycle ordering. + permissionRequester.deactivate(this) + foreground = false + if (shouldNotifyRuntimeBackgrounded(isChangingConfigurations)) { + initializedViewModel?.setForeground(false) + } + super.onStop() + } + + override fun onDestroy() { + permissionRequester.detach(this) + super.onDestroy() + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + val accepted = + pendingIntentRouter.onNewIntent(intent) { routedIntent -> + initializedViewModel?.let { handleLaunchIntent(viewModel = it, intent = routedIntent) } + } + if (!accepted) return + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + // AppCompatActivity marks this callback @CallSuper; it preserves Fragment and ActivityResult dispatch. + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + permissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults) + initializedViewModel?.refreshNodePermissionSurface() + } + + /** + * Wires MainViewModel only after Activity first draw and background prefs warm-up. + */ + private fun activateViewModel(readyViewModel: MainViewModel) { + if (initializedViewModel != null) return + initializedViewModel = readyViewModel + readyViewModel.setForeground(foreground) + startViewModelCollectors(readyViewModel) + if (!readyViewModel.claimInitialIntentRouting()) { + pendingIntentRouter.discardInitialIntent() + } + pendingIntentRouter.activate { initialIntent -> + handleLaunchIntent(viewModel = readyViewModel, intent = initialIntent) + } + readyViewModel.reportShareLaunchOverflow(pendingIntentRouter.takeShareOverflowCount()) + } + + /** + * Starts lifecycle collectors after ViewModel construction so they cannot force early startup. + */ + private fun startViewModelCollectors(readyViewModel: MainViewModel) { + if (didStartViewModelCollectors) return + didStartViewModelCollectors = true + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + readyViewModel.preventSleep.collect { enabled -> + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + readyViewModel.runtimeInitialized.collect { ready -> + runtimeUiStarter.onRuntimeInitialized( + ready = ready, + startRuntimeUi = screenshotScene == null, + attachRuntimeUi = { + // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. + readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + }, + startNodeService = { + NodeForegroundService.start(this@MainActivity) + }, + ) + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + readyViewModel.shareLaunchOverflowRevision.collect { revision -> + if (revision == 0L) return@collect + repeat(readyViewModel.takeShareLaunchOverflowCount()) { + Toast + .makeText( + this@MainActivity, + nativeString("Too many shares are waiting to be added."), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + /** + * Routes assistant/app-action intents into ViewModel state without recreating the activity. + */ + private fun handleLaunchIntent( + viewModel: MainViewModel, + intent: Intent?, + ) { + if (intent?.isShareLaunchIntent() == true) { + viewModel.handleShareLaunchIntent(intent) + return + } + parseHomeDestinationIntent(intent)?.let { destination -> + viewModel.requestHomeDestination(destination) + return + } + val request = parseAssistantLaunchIntent(intent) ?: return + viewModel.handleAssistantLaunch(request) + } +} + +/** Queues shares until ViewModel activation while retaining only the latest ordinary launch intent. */ +internal class MainActivityPendingIntentRouter { + private data class PendingLaunchIntent( + val sequence: Long, + val intent: Intent, + val initial: Boolean, + ) + + private var activated = false + private var sequence = 0L + private val pendingShareIntents = ArrayDeque() + private var pendingNonShareIntent: PendingLaunchIntent? = null + private var shareOverflowCount = 0 + + fun setInitialIntent(intent: Intent?) { + if (!activated && intent != null) store(intent = intent, initial = true) + } + + fun onNewIntent( + intent: Intent, + routeIntent: (Intent) -> Unit, + ): Boolean { + if (activated) { + routeIntent(intent) + return true + } + return store(intent = intent, initial = false) + } + + fun discardInitialIntent() { + if (activated) return + pendingShareIntents.removeAll { it.initial } + if (pendingNonShareIntent?.initial == true) pendingNonShareIntent = null + } + + fun activate(routeIntent: (Intent) -> Unit): Boolean { + if (activated) return false + activated = true + (pendingShareIntents + listOfNotNull(pendingNonShareIntent)) + .sortedBy(PendingLaunchIntent::sequence) + .forEach { pending -> routeIntent(pending.intent) } + pendingShareIntents.clear() + pendingNonShareIntent = null + return true + } + + fun takeShareOverflowCount(): Int = + shareOverflowCount.also { + shareOverflowCount = 0 + } + + private fun store( + intent: Intent, + initial: Boolean, + ): Boolean { + val pending = PendingLaunchIntent(sequence = sequence++, intent = intent, initial = initial) + if (!intent.isShareLaunchIntent()) { + pendingNonShareIntent = pending + return true + } + if (pendingShareIntents.size >= MAX_PENDING_CHAT_SHARES) { + shareOverflowCount += 1 + return false + } + pendingShareIntents.addLast(pending) + return true + } +} + +private fun Intent.isShareLaunchIntent(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE + +/** Keeps launch intents one-shot across same-process Activity recreation, but not process death. */ +internal class MainActivityInitialIntentGate { + private var claimed = false + + fun claim(): Boolean { + if (claimed) return false + claimed = true + return true + } +} + +internal fun shouldNotifyRuntimeBackgrounded(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations + +internal fun updateTopResumedPermissionHost( + isTopResumedActivity: Boolean, + activate: () -> Unit, + deactivate: () -> Unit, + refreshPermissionSurface: () -> Unit, +) { + if (isTopResumedActivity) { + activate() + refreshPermissionSurface() + } else { + deactivate() + } +} + +/** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */ +internal class MainActivityRuntimeUiStarter { + private var completed = false + + fun onRuntimeInitialized( + ready: Boolean, + startRuntimeUi: Boolean, + attachRuntimeUi: () -> Unit, + startNodeService: () -> Unit, + ) { + if (!ready || completed) return + if (!startRuntimeUi) { + completed = true + return + } + attachRuntimeUi() + completed = true + startNodeService() + } +} + +@Composable +private fun StartupSurface() { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Black, + contentColor = Color.White, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "OPENCLAW", + fontSize = 22.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} diff --git a/app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt b/app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt new file mode 100644 index 0000000..48f0100 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt @@ -0,0 +1,22 @@ +package ai.openclaw.app + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent + +/** Reuses the existing app task when a system surface brings OpenClaw forward. */ +internal fun mainActivityPendingIntent( + context: Context, + requestCode: Int, +): PendingIntent { + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + return PendingIntent.getActivity( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} diff --git a/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/app/src/main/java/ai/openclaw/app/MainViewModel.kt new file mode 100644 index 0000000..e88dd45 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -0,0 +1,1967 @@ +package ai.openclaw.app + +import ai.openclaw.app.chat.BackgroundTask +import ai.openclaw.app.chat.ChatActiveRunPresentation +import ai.openclaw.app.chat.ChatCommandEntry +import ai.openclaw.app.chat.ChatComposerOwner +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatOutboxItem +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatQuestionPrompt +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatSwarmGroup +import ai.openclaw.app.chat.ChatThinkingLevelSelection +import ai.openclaw.app.chat.ChatTranscriptAnchorState +import ai.openclaw.app.chat.ChatWidgetResource +import ai.openclaw.app.chat.GatewayDefaultAgentOwner +import ai.openclaw.app.chat.MessageSpeechState +import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.chat.SessionBranch +import ai.openclaw.app.chat.SessionForkResult +import ai.openclaw.app.chat.SessionRewindResult +import ai.openclaw.app.chat.defaultChatThinkingLevelSelection +import ai.openclaw.app.chat.resolveChatComposerOwner +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayMediaKind +import ai.openclaw.app.gateway.GatewayRegistryEntry +import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary +import ai.openclaw.app.node.CameraCaptureManager +import ai.openclaw.app.node.CanvasController +import ai.openclaw.app.node.SmsManager +import ai.openclaw.app.systemagent.SystemAgentChatState +import ai.openclaw.app.ui.GatewayConnectPlan +import ai.openclaw.app.ui.GatewaySavedAuthAction +import ai.openclaw.app.ui.SettingsRoute +import ai.openclaw.app.ui.chat.ChatComposerSendStartResult +import ai.openclaw.app.ui.chat.ChatComposerStateStore +import ai.openclaw.app.ui.chat.PendingAttachment +import ai.openclaw.app.ui.chat.chatComposerTextDraftsFromSnapshot +import ai.openclaw.app.ui.chat.matchesSession +import ai.openclaw.app.ui.chat.shouldMigrateComposerDraft +import ai.openclaw.app.ui.chat.toOutgoingAttachment +import ai.openclaw.app.voice.AndroidAudioInputSession +import ai.openclaw.app.voice.AudioInputDeviceOption +import ai.openclaw.app.voice.VoiceConversationEntry +import ai.openclaw.app.voice.VoiceWakePreferences +import android.Manifest +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong + +enum class ChatDraftPlacement { + Replace, + BeforeExisting, +} + +internal data class ChatDraft( + val text: String, + val placement: ChatDraftPlacement, + val owner: ChatComposerOwner? = null, + val expectedExistingText: String? = null, + val acceptsEmptyText: Boolean = false, + // Attachment payloads stay in ViewModel heap state; saved drafts persist text only. + val attachments: List? = null, +) + +internal fun claimChatDraftForOwner( + draft: ChatDraft, + owner: ChatComposerOwner, + mainSessionKey: String, +): ChatDraft? { + val capturedOwner = draft.owner ?: return draft + if (capturedOwner == owner) return draft + if (!shouldMigrateComposerDraft(capturedOwner, owner, mainSessionKey)) return null + return draft.copy(owner = owner) +} + +internal data class PendingAssistantAutoSend( + val prompt: String, + val owner: ChatComposerOwner, + val id: String = UUID.randomUUID().toString(), +) + +private data class AssistantAutoSendOperation( + var owner: ChatComposerOwner, + val pendingId: String, + val composerSendId: String, +) + +internal fun clearCompletedAssistantAutoSend( + current: PendingAssistantAutoSend?, + completedId: String, +): PendingAssistantAutoSend? = current?.takeUnless { it.id == completedId } + +internal fun retainRefusedAssistantPrompt( + prompt: String, + existing: String, +): String = + when { + existing.isBlank() -> prompt + prompt.isBlank() || existing == prompt -> existing + else -> "$prompt\n\n$existing" + } + +data class ChatShareDraft( + val id: Long, + val text: String?, + val attachments: List, + val droppedAttachmentCount: Int, +) + +internal const val MAX_PENDING_CHAT_SHARES = 16 +private const val CHAT_COMPOSER_DRAFTS_STATE_KEY = "chat-composer-text-drafts" + +/** Bounded process-local queue whose stable head survives Activity recreation with the ViewModel. */ +internal class ChatShareDraftQueue( + private val capacity: Int = MAX_PENDING_CHAT_SHARES, +) { + private val lock = Any() + private val drafts = ArrayDeque() + private val ownersById = mutableMapOf() + private val headLease = Mutex() + private val _head = MutableStateFlow(null) + val head: StateFlow = _head.asStateFlow() + private val _queued = MutableStateFlow>(emptyList()) + val queued: StateFlow> = _queued.asStateFlow() + private val _ownerRevision = MutableStateFlow(0L) + val ownerRevision: StateFlow = _ownerRevision.asStateFlow() + + init { + require(capacity > 0) + } + + fun enqueue( + draft: ChatShareDraft, + owner: ChatComposerOwner, + ): Boolean = + synchronized(lock) { + if (drafts.size >= capacity) return@synchronized false + drafts.addLast(draft) + ownersById[draft.id] = owner + publishQueueLocked() + true + } + + /** Only the active loader may advance the queue; stale effects cannot acknowledge a newer head. */ + fun acknowledgeHead( + id: Long, + owner: ChatComposerOwner, + ): Boolean = + synchronized(lock) { + val ownedHead = firstForOwnerLocked(owner) + if (ownedHead?.id != id) return@synchronized false + drafts.remove(ownedHead) + ownersById.remove(id) + publishQueueLocked() + true + } + + /** Serializes loaders across overlapping Activity instances while rechecking the stable head. */ + suspend fun withHeadLease( + id: Long, + owner: ChatComposerOwner, + block: suspend () -> Unit, + ): Boolean = + headLease.withLock { + val claimed = + synchronized(lock) { + firstForOwnerLocked(owner)?.id == id + } + if (!claimed) return@withLock false + block() + true + } + + fun migrateOwner( + from: ChatComposerOwner, + to: ChatComposerOwner, + ) { + if (from == to) return + synchronized(lock) { + var changed = false + for ((id, owner) in ownersById.toMap()) { + if (owner == from) { + ownersById[id] = to + changed = true + } + } + if (changed) _ownerRevision.value += 1 + } + } + + fun clear() { + synchronized(lock) { + drafts.clear() + ownersById.clear() + publishQueueLocked() + } + } + + suspend fun removeOwners(matches: (ChatComposerOwner) -> Boolean) { + headLease.withLock { + synchronized(lock) { + val removedIds = ownersById.filterValues(matches).keys + if (removedIds.isEmpty()) return@synchronized + drafts.removeAll { it.id in removedIds } + removedIds.forEach(ownersById::remove) + publishQueueLocked() + } + } + } + + fun ownerOf(id: Long): ChatComposerOwner? = synchronized(lock) { ownersById[id] } + + internal fun size(): Int = synchronized(lock) { drafts.size } + + private fun firstForOwnerLocked(owner: ChatComposerOwner): ChatShareDraft? = drafts.firstOrNull { draft -> ownersById[draft.id] == owner } + + private fun publishQueueLocked() { + _head.value = drafts.firstOrNull() + _queued.value = drafts.toList() + } +} + +internal fun shouldStartRuntimeOnForeground( + foreground: Boolean, + onboardingCompleted: Boolean, +): Boolean = foreground && onboardingCompleted + +internal class CronEditorDraftMemory { + private var retained: Pair? = null + + fun get(jobId: String): CronEditorDraftState? = retained?.takeIf { it.first == jobId }?.second + + fun set( + jobId: String, + state: CronEditorDraftState?, + ) { + if (state == null) { + clear(jobId) + } else { + retained = jobId to state + } + } + + fun clear(jobId: String) { + if (retained?.first == jobId) retained = null + } +} + +/** + * UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModel private constructor( + app: Application, + private val prefs: SecurePrefs, + savedStateHandle: SavedStateHandle, + private val resolveShareMimeType: (Uri) -> String?, + shareLaunchCapacity: Int, +) : AndroidViewModel(app) { + constructor( + app: Application, + savedStateHandle: SavedStateHandle, + ) : this( + app = app, + prefs = (app as NodeApp).prefs, + savedStateHandle = savedStateHandle, + resolveShareMimeType = app.contentResolver::getType, + shareLaunchCapacity = MAX_PENDING_CHAT_SHARES, + ) + + internal constructor( + app: NodeApp, + prefs: SecurePrefs, + savedStateHandle: SavedStateHandle, + resolveShareMimeType: (Uri) -> String? = app.contentResolver::getType, + shareLaunchCapacity: Int = MAX_PENDING_CHAT_SHARES, + ) : this( + app = app as Application, + prefs = prefs, + savedStateHandle = savedStateHandle, + resolveShareMimeType = resolveShareMimeType, + shareLaunchCapacity = shareLaunchCapacity, + ) + + private val nodeApp = app as NodeApp + private val runtimeRef = MutableStateFlow(null) + private val gatewayConfigOperationSeq = AtomicLong() + private val gatewayConfigOperationMutex = Mutex() + + // Multiple MainActivity instances can overlap across sender tasks; the process owns one queue. + private val chatShareDraftSeq = nodeApp.chatShareDraftSeq + private val chatShareDraftQueue = nodeApp.chatShareDraftQueue + private val shareLaunchMutex = Mutex() + private val shareLaunchSlots = Semaphore(shareLaunchCapacity) + private val shareLaunchOverflowLock = Any() + private var pendingShareLaunchOverflowCount = 0 + + // One bounded heap-only slot follows the ViewModel across Activity recreation. + // Detail disposal clears it; process death drops it with the ViewModel. + internal val cronEditorDraftMemory = CronEditorDraftMemory() + + @Volatile private var permissionRequester: PermissionRequester? = null + + @Volatile private var foreground = false + + @Volatile private var runtimeStartupQueued = false + private val initialIntentGate = MainActivityInitialIntentGate() + + private val _requestedHomeDestination = MutableStateFlow(null) + val requestedHomeDestination: StateFlow = _requestedHomeDestination + private val requestedSettingsRouteState = MutableStateFlow(null) + internal val requestedSettingsRoute: StateFlow get() = requestedSettingsRouteState + private val _startOnboardingAtGatewaySetup = MutableStateFlow(false) + val startOnboardingAtGatewaySetup: StateFlow = _startOnboardingAtGatewaySetup + private val chatDraftState = MutableStateFlow(null) + internal val chatDraft: StateFlow = chatDraftState + private val chatDraftLock = Any() + private var attachedComposerRuntime: NodeRuntime? = null + private var removeChatSessionDeletionListener: (() -> Unit)? = null + + // SavedStateHandle preserves a bounded set of complete owner-scoped drafts through process + // recreation. Durable admission clears only the accepted snapshot, so later edits survive. + internal val chatComposerState = + ChatComposerStateStore( + initialDrafts = chatComposerTextDraftsFromSnapshot(savedStateHandle[CHAT_COMPOSER_DRAFTS_STATE_KEY]), + onDraftSnapshotChanged = { snapshot -> savedStateHandle[CHAT_COMPOSER_DRAFTS_STATE_KEY] = snapshot }, + ) + private val assistantAutoSendLock = Any() + + init { + val recoveredChatComposerSends = chatComposerState.recoveredSends() + if (recoveredChatComposerSends.isNotEmpty()) { + // A pending checkpoint is hidden until the durable outbox gives a definitive answer. + // Database errors leave it parked instead of exposing text that may already be sending. + viewModelScope.launch { + val runtime = runCatching { ensureRuntime() }.getOrNull() ?: return@launch + recoveredChatComposerSends.forEach { pending -> + val admitted = + runCatching { runtime.wasChatOutboxCommandAdmitted(pending.commandId) }.getOrNull() ?: return@forEach + chatComposerState.resolveRecoveredSend( + commandId = pending.commandId, + fallbackOwner = pending.owner, + admitted = admitted, + ) + } + } + } + } + + val chatShareDraft: StateFlow = chatShareDraftQueue.head + internal val chatShareDrafts: StateFlow> = chatShareDraftQueue.queued + internal val chatShareDraftOwnerRevision: StateFlow = chatShareDraftQueue.ownerRevision + private val shareLaunchOverflowRevisionMutable = MutableStateFlow(0L) + internal val shareLaunchOverflowRevision: StateFlow = shareLaunchOverflowRevisionMutable.asStateFlow() + private val pendingAssistantAutoSendMutable = MutableStateFlow(null) + internal val pendingAssistantAutoSend: StateFlow = pendingAssistantAutoSendMutable + private val _assistantAutoSendInFlight = MutableStateFlow(false) + val assistantAutoSendInFlight: StateFlow = _assistantAutoSendInFlight + private var assistantAutoSendOperation: AssistantAutoSendOperation? = null + + /** + * Lazily starts NodeRuntime and preserves the current foreground bit across startup. + */ + private fun ensureRuntime(): NodeRuntime { + runtimeRef.value?.let { return it } + val runtime = nodeApp.ensureRuntime() + runtime.setForeground(foreground) + attachComposerRuntime(runtime) + return runtime + } + + private fun attachComposerRuntime(runtime: NodeRuntime) { + if (attachedComposerRuntime === runtime) { + runtimeRef.value = runtime + return + } + removeChatSessionDeletionListener?.invoke() + attachedComposerRuntime = runtime + removeChatSessionDeletionListener = + runtime.addChatSessionDeletionListener { deletion -> + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + deletion.gatewayId?.let { gatewayId -> + clearChatComposerSession( + gatewayStableId = gatewayId, + agentId = deletion.agentId, + sessionKey = deletion.sessionKey, + mainSessionKey = deletion.mainSessionKey, + ) + } + } + } + runtimeRef.value = runtime + } + + override fun onCleared() { + removeChatSessionDeletionListener?.invoke() + removeChatSessionDeletionListener = null + attachedComposerRuntime = null + } + + internal fun claimInitialIntentRouting(): Boolean = initialIntentGate.claim() + + internal fun enterScreenshotFixtureMode(scene: AndroidScreenshotScene) { + check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" } + AndroidScreenshotFixture.configure(scene) + runtimeRef.value?.let { runtime -> + // The ViewModel survives locale recreation; keep the fixture runtime instead of + // treating the restored Activity as a second fixture startup. + check(runtime.mode == NodeRuntimeMode.ScreenshotFixture) { + "Screenshot fixture mode must be selected before live runtime startup" + } + runtime.setForeground(foreground) + runtime.setVoiceWakeEnabled(scene == AndroidScreenshotScene.VoiceWake) + _requestedHomeDestination.value = scene.homeDestination + requestedSettingsRouteState.value = scene.settingsRoute + return + } + prefs.setOnboardingCompleted(true) + prefs.setAppearanceThemeMode(AppearanceThemeMode.Dark) + prefs.setDisplayName("Pixel") + prefs.setSpeakerEnabled(true) + prefs.setVoiceWakeEnabled(scene == AndroidScreenshotScene.VoiceWake) + prefs.setVoiceWakeWords(VoiceWakePreferences.defaultTriggerWords) + val runtime = nodeApp.ensureScreenshotFixtureRuntime() + runtime.setForeground(foreground) + attachComposerRuntime(runtime) + _requestedHomeDestination.value = scene.homeDestination + requestedSettingsRouteState.value = scene.settingsRoute + } + + /** Acknowledges the one-shot settings-route request that accompanies a home destination. */ + fun clearRequestedSettingsRoute() { + requestedSettingsRouteState.value = null + } + + /** + * Starts the node runtime off the main thread so fresh installs can render + * the shell before encrypted prefs, device identity, and gateway setup warm up. + */ + private fun queueRuntimeStartup() { + if (runtimeRef.value != null || runtimeStartupQueued) return + runtimeStartupQueued = true + viewModelScope.launch(Dispatchers.Default) { + runCatching { ensureRuntime() } + runtimeStartupQueued = false + } + } + + internal fun resumeNodeServiceForConnection() { + if (!prefs.onboardingCompleted.value) return + NodeForegroundService.resume(context = nodeApp, startNow = true) + } + + /** + * Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup. + */ + private fun runtimeState( + initial: T, + selector: (NodeRuntime) -> StateFlow, + ): StateFlow = + runtimeRef + .flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) } + .stateIn(viewModelScope, SharingStarted.Eagerly, initial) + + val runtimeInitialized: StateFlow = + runtimeRef + .flatMapLatest { runtime -> flowOf(runtime != null) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val canvasCurrentUrl: StateFlow = runtimeState(initial = null) { it.canvas.currentUrl } + val canvasPresentationState: StateFlow = + runtimeState(initial = CanvasController.PresentationState.Unmounted) { it.canvas.presentationState } + val canvasA2uiHydrated: StateFlow = runtimeState(initial = false) { it.canvasA2uiHydrated } + val canvasRehydratePending: StateFlow = runtimeState(initial = false) { it.canvasRehydratePending } + val canvasRehydrateErrorText: StateFlow = runtimeState(initial = null) { it.canvasRehydrateErrorText } + + val gateways: StateFlow> = runtimeState(initial = emptyList()) { it.gateways } + val discoveryStatusText: StateFlow = runtimeState(initial = "Searching…") { it.discoveryStatusText } + val notificationForwardingEnabled: StateFlow = prefs.notificationForwardingEnabled + val notificationForwardingMode: StateFlow = + prefs.notificationForwardingMode + val notificationForwardingPackages: StateFlow> = prefs.notificationForwardingPackages + val notificationForwardingQuietHoursEnabled: StateFlow = + prefs.notificationForwardingQuietHoursEnabled + val notificationForwardingQuietStart: StateFlow = prefs.notificationForwardingQuietStart + val notificationForwardingQuietEnd: StateFlow = prefs.notificationForwardingQuietEnd + val notificationForwardingMaxEventsPerMinute: StateFlow = + prefs.notificationForwardingMaxEventsPerMinute + val notificationForwardingSessionKey: StateFlow = prefs.notificationForwardingSessionKey + + val isConnected: StateFlow = runtimeState(initial = false) { it.isConnected } + val gatewayControlPage: StateFlow = + runtimeState(initial = null) { it.gatewayControlPage } + val isNodeConnected: StateFlow = runtimeState(initial = false) { it.nodeConnected } + val nodeCapabilityApproval: StateFlow = + runtimeState(initial = GatewayNodeCapabilityApproval.Loading) { it.nodeCapabilityApproval } + val statusText: StateFlow = runtimeState(initial = "Offline") { it.statusText } + val gatewayConnectionProblem: StateFlow = runtimeState(initial = null) { it.gatewayConnectionProblem } + val gatewayConnectionDisplay: StateFlow = + runtimeState(initial = GatewayConnectionDisplay(false, "Offline", null)) { it.gatewayConnectionDisplay } + val operatorAdminScopeAvailable: StateFlow = runtimeState(initial = false) { it.operatorAdminScopeAvailable } + internal val systemAgentChatState: StateFlow = + runtimeState(initial = SystemAgentChatState()) { it.systemAgentChatState } + val serverName: StateFlow = runtimeState(initial = null) { it.serverName } + val remoteAddress: StateFlow = runtimeState(initial = null) { it.remoteAddress } + val gatewayVersion: StateFlow = runtimeState(initial = null) { it.gatewayVersion } + val gatewayUpdateAvailable: StateFlow = runtimeState(initial = null) { it.gatewayUpdateAvailable } + val modelCatalog: StateFlow> = runtimeState(initial = emptyList()) { it.modelCatalog } + val providerModelCatalog: StateFlow> = runtimeState(initial = emptyList()) { it.providerModelCatalog } + val providerModelCatalogRefreshing: StateFlow = runtimeState(initial = false) { it.providerModelCatalogRefreshing } + val providerModelCatalogErrorText: StateFlow = runtimeState(initial = null) { it.providerModelCatalogErrorText } + val modelAuthProviders: StateFlow> = runtimeState(initial = emptyList()) { it.modelAuthProviders } + val modelCatalogRefreshing: StateFlow = runtimeState(initial = false) { it.modelCatalogRefreshing } + val modelCatalogErrorText: StateFlow = runtimeState(initial = null) { it.modelCatalogErrorText } + val modelFavorites: StateFlow> = prefs.modelFavorites + val modelRecents: StateFlow> = prefs.modelRecents + val sessionCustomGroups: StateFlow> = prefs.sessionCustomGroups + val talkSetupReadiness: StateFlow = + runtimeState(initial = GatewayTalkSetupReadiness.unverified()) { it.talkSetupReadiness } + val gatewayDefaultAgentId: StateFlow = runtimeState(initial = null) { it.gatewayDefaultAgentId } + internal val gatewayComposerDefaultAgentOwner: StateFlow = + runtimeState(initial = null) { it.gatewayComposerDefaultAgentOwner } + val gatewayAgents: StateFlow> = runtimeState(initial = emptyList()) { it.gatewayAgents } + val cronStatus: StateFlow = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus } + val cronJobs: StateFlow> = runtimeState(initial = emptyList()) { it.cronJobs } + val cronRefreshing: StateFlow = runtimeState(initial = false) { it.cronRefreshing } + val cronErrorText: StateFlow = runtimeState(initial = null) { it.cronErrorText } + val cronJobDetailState: StateFlow = runtimeState(initial = GatewayCronJobDetailState.Idle) { it.cronJobDetailState } + val cronRunHistoryState: StateFlow = runtimeState(initial = GatewayCronRunHistoryState.Idle) { it.cronRunHistoryState } + val cronActionState: StateFlow = runtimeState(initial = GatewayCronActionState.Idle) { it.cronActionState } + val pendingCronRunJobIds: StateFlow> = runtimeState(initial = emptySet()) { it.pendingCronRunJobIds } + val usageSummary: StateFlow = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary } + val usageRefreshing: StateFlow = runtimeState(initial = false) { it.usageRefreshing } + val usageErrorText: StateFlow = runtimeState(initial = null) { it.usageErrorText } + val skillsSummary: StateFlow = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary } + val skillsRefreshing: StateFlow = runtimeState(initial = false) { it.skillsRefreshing } + val skillsErrorText: StateFlow = runtimeState(initial = null) { it.skillsErrorText } + val clawHubSkillMethodsAvailable: StateFlow = + runtimeState(initial = false) { it.clawHubSkillMethodsAvailable } + val skillMutationKeys: StateFlow> = runtimeState(initial = emptySet()) { it.skillMutationKeys } + val clawHubSkillSearchState: StateFlow = + runtimeState(initial = GatewayClawHubSkillSearchState()) { it.clawHubSkillSearchState } + val skillWorkshopSummary: StateFlow = + runtimeState(initial = GatewaySkillWorkshopSummary(proposals = emptyList())) { it.skillWorkshopSummary } + val skillWorkshopRefreshing: StateFlow = runtimeState(initial = false) { it.skillWorkshopRefreshing } + val skillWorkshopErrorText: StateFlow = runtimeState(initial = null) { it.skillWorkshopErrorText } + val skillWorkshopNoticeText: StateFlow = runtimeState(initial = null) { it.skillWorkshopNoticeText } + val skillWorkshopInspectingProposalId: StateFlow = runtimeState(initial = null) { it.skillWorkshopInspectingProposalId } + val skillWorkshopMutatingProposalId: StateFlow = runtimeState(initial = null) { it.skillWorkshopMutatingProposalId } + val nodesDevicesSummary: StateFlow = + runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary } + val nodesDevicesRefreshing: StateFlow = runtimeState(initial = false) { it.nodesDevicesRefreshing } + val nodesDevicesErrorText: StateFlow = runtimeState(initial = null) { it.nodesDevicesErrorText } + val nodesDevicesNoticeText: StateFlow = runtimeState(initial = null) { it.nodesDevicesNoticeText } + val devicePairingCapabilities: StateFlow = + runtimeState(initial = GatewayDevicePairingCapabilities()) { it.devicePairingCapabilities } + val operatorScopes: StateFlow> = runtimeState(initial = emptyList()) { it.operatorScopes } + val devicePairingMutation: StateFlow = + runtimeState(initial = null) { it.devicePairingMutation } + val channelsSummary: StateFlow = + runtimeState(initial = GatewayChannelsSummary(channels = emptyList())) { it.channelsSummary } + val channelsRefreshing: StateFlow = runtimeState(initial = false) { it.channelsRefreshing } + val channelsErrorText: StateFlow = runtimeState(initial = null) { it.channelsErrorText } + val dreamingSummary: StateFlow = + runtimeState(initial = GatewayDreamingSummary()) { it.dreamingSummary } + val dreamingRefreshing: StateFlow = runtimeState(initial = false) { it.dreamingRefreshing } + val dreamingErrorText: StateFlow = runtimeState(initial = null) { it.dreamingErrorText } + val healthLogsSummary: StateFlow = + runtimeState(initial = GatewayHealthLogsSummary()) { it.healthLogsSummary } + val healthLogsRefreshing: StateFlow = runtimeState(initial = false) { it.healthLogsRefreshing } + val healthLogsErrorText: StateFlow = runtimeState(initial = null) { it.healthLogsErrorText } + val pendingGatewayTrust: StateFlow = runtimeState(initial = null) { it.pendingGatewayTrust } + val seamColorArgb: StateFlow = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb } + val mainSessionKey: StateFlow = runtimeState(initial = "main") { it.mainSessionKey } + + val cameraHud: StateFlow = runtimeState(initial = null) { it.cameraHud } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val pairedGateways: StateFlow> = prefs.gatewayRegistry.entries + val activeGatewayStableId: StateFlow = prefs.gatewayRegistry.activeStableId + val connectedGatewayStableIds: StateFlow> = prefs.gatewayRegistry.connectedStableIds + val onboardingCompleted: StateFlow = prefs.onboardingCompleted + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val installedAppsSharingEnabled: StateFlow = prefs.installedAppsSharingEnabled + val accessibilityControlEnabled: StateFlow = prefs.accessibilityControlEnabled + val speakerEnabled: StateFlow = prefs.speakerEnabled + val preferredCameraFacing: StateFlow = prefs.preferredCameraFacing + val preferredAudioInputDevice: StateFlow = prefs.preferredAudioInputDevice + val voiceWakeEnabled: StateFlow = prefs.voiceWakeEnabled + val voiceWakeWords: StateFlow> = prefs.voiceWakeWords + val voiceWakeAvailable: StateFlow = runtimeState(initial = false) { it.voiceWakeAvailable } + val voiceWakeIsListening: StateFlow = runtimeState(initial = false) { it.voiceWakeIsListening } + val voiceWakeStatusText: StateFlow = runtimeState(initial = "Off") { it.voiceWakeStatusText } + val voiceWakeLastTriggeredCommand: StateFlow = + runtimeState(initial = null) { it.voiceWakeLastTriggeredCommand } + val voiceWakeWordsSaving: StateFlow = runtimeState(initial = false) { it.voiceWakeWordsSaving } + val voiceWakeWordsNoticeText: StateFlow = runtimeState(initial = null) { it.voiceWakeWordsNoticeText } + val appearanceThemeMode: StateFlow = prefs.appearanceThemeMode + val voiceCaptureMode: StateFlow = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode } + val activeAudioInputDevicePreference: StateFlow = + runtimeState(initial = null) { it.activeAudioInputDevicePreference } + val micEnabled: StateFlow = runtimeState(initial = false) { it.micEnabled } + + val micCooldown: StateFlow = runtimeState(initial = false) { it.micCooldown } + val micStatusText: StateFlow = runtimeState(initial = "Mic off") { it.micStatusText } + val micLiveTranscript: StateFlow = runtimeState(initial = null) { it.micLiveTranscript } + val micIsListening: StateFlow = runtimeState(initial = false) { it.micIsListening } + val micQueuedMessages: StateFlow> = runtimeState(initial = emptyList()) { it.micQueuedMessages } + val micConversation: StateFlow> = runtimeState(initial = emptyList()) { it.micConversation } + val micInputLevel: StateFlow = runtimeState(initial = 0f) { it.micInputLevel } + val micIsSending: StateFlow = runtimeState(initial = false) { it.micIsSending } + val talkModeEnabled: StateFlow = runtimeState(initial = false) { it.talkModeEnabled } + val talkModeListening: StateFlow = runtimeState(initial = false) { it.talkModeListening } + val talkModeSpeaking: StateFlow = runtimeState(initial = false) { it.talkModeSpeaking } + val talkInputLevel: StateFlow = runtimeState(initial = 0f) { it.talkInputLevel } + val talkOutputLevel: StateFlow = runtimeState(initial = null) { it.talkOutputLevel } + val talkSpeechActive: StateFlow = runtimeState(initial = false) { it.talkSpeechActive } + val talkAwaitingAgent: StateFlow = runtimeState(initial = false) { it.talkAwaitingAgent } + val talkModeStatusText: StateFlow = runtimeState(initial = "Off") { it.talkModeStatusText } + val talkModeConversation: StateFlow> = + runtimeState(initial = emptyList()) { it.talkModeConversation } + + val chatSessionKey: StateFlow = runtimeState(initial = "main") { it.chatSessionKey } + val chatSessionOwnerAgentId: StateFlow = runtimeState(initial = null) { it.chatSessionOwnerAgentId } + val chatSessionId: StateFlow = runtimeState(initial = null) { it.chatSessionId } + val chatMessages: StateFlow> = runtimeState(initial = emptyList()) { it.chatMessages } + val chatTranscriptAnchor: StateFlow = + runtimeState(initial = null) { it.chatTranscriptAnchor } + val chatHistoryLoading: StateFlow = runtimeState(initial = false) { it.chatHistoryLoading } + val chatError: StateFlow = runtimeState(initial = null) { it.chatError } + val chatHealthOk: StateFlow = runtimeState(initial = false) { it.chatHealthOk } + val chatThinkingLevel: StateFlow = runtimeState(initial = "off") { it.chatThinkingLevel } + val chatThinkingLevelSelection: StateFlow = + runtimeState(initial = defaultChatThinkingLevelSelection) { it.chatThinkingLevelSelection } + val chatSelectedModelRef: StateFlow = runtimeState(initial = null) { it.chatSelectedModelRef } + val chatModelCatalog: StateFlow> = runtimeState(initial = emptyList()) { it.chatModelCatalog } + val chatStreamingAssistantText: StateFlow = runtimeState(initial = null) { it.chatStreamingAssistantText } + val chatPendingToolCalls: StateFlow> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls } + val chatQuestions: StateFlow> = runtimeState(initial = emptyList()) { it.chatQuestions } + val chatPlanSteps: StateFlow> = runtimeState(initial = emptyList()) { it.chatPlanSteps } + val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } + val chatSwarmGroups: StateFlow> = runtimeState(initial = emptyList()) { it.chatSwarmGroups } + val chatSessionBranches: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessionBranches } + val chatSessionBranchesLoading: StateFlow = runtimeState(initial = false) { it.chatSessionBranchesLoading } + val chatSessionBranchSwitching: StateFlow = runtimeState(initial = false) { it.chatSessionBranchSwitching } + val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } + internal val chatSelectedActiveRunPresentation: StateFlow = + runtimeState(initial = ChatActiveRunPresentation()) { it.chatSelectedActiveRunPresentation } + val chatCommands: StateFlow> = runtimeState(initial = emptyList()) { it.chatCommands } + val chatOutboxItems: StateFlow> = runtimeState(initial = emptyList()) { it.chatOutboxItems } + val chatOutboxPresentationRestored: StateFlow = runtimeState(initial = false) { it.chatOutboxPresentationRestored } + internal val chatMessageSpeech: StateFlow = + runtimeState(initial = null) { it.messageSpeechState } + val execApprovals: StateFlow> = runtimeState(initial = emptyList()) { it.execApprovals } + val execApprovalsRefreshing: StateFlow = runtimeState(initial = false) { it.execApprovalsRefreshing } + val execApprovalsErrorText: StateFlow = runtimeState(initial = null) { it.execApprovalsErrorText } + val execApprovalsNotice: StateFlow = runtimeState(initial = null) { it.execApprovalsNotice } + + val canvas: CanvasController + get() = ensureRuntime().canvas + + val camera: CameraCaptureManager + get() = ensureRuntime().camera + + val sms: SmsManager + get() = ensureRuntime().sms + + /** + * Attaches Activity-owned permission and lifecycle seams after runtime initialization. + */ + fun attachRuntimeUi( + owner: LifecycleOwner, + permissionRequester: PermissionRequester, + ) { + val runtime = runtimeRef.value ?: return + runtime.camera.attachLifecycleOwner(owner) + runtime.sms.attachPermissionRequester(permissionRequester) + this.permissionRequester = permissionRequester + } + + /** + * Starts runtime on foreground entry only after onboarding has completed. + */ + fun setForeground(value: Boolean) { + // The ViewModel survives configuration recreation. Ignore the replacement + // Activity's duplicate true edge so it cannot restart gateway work. + if (foreground == value) return + foreground = value + if ( + shouldStartRuntimeOnForeground( + foreground = value, + onboardingCompleted = prefs.onboardingCompleted.value, + ) + ) { + queueRuntimeStartup() + } + runtimeRef.value?.setForeground(value) + } + + fun refreshNodePermissionSurface() { + runtimeRef.value?.refreshNodePermissionSurface() + } + + fun setDisplayName(value: String) { + prefs.setDisplayName(value) + } + + fun setCameraEnabled(value: Boolean) { + runtimeRef.value?.setCameraEnabled(value) ?: prefs.setCameraEnabled(value) + } + + fun setLocationMode(mode: LocationMode) { + runtimeRef.value?.setLocationMode(mode) ?: prefs.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.setLocationPreciseEnabled(value) + } + + fun setPreventSleep(value: Boolean) { + prefs.setPreventSleep(value) + } + + fun setManualEnabled(value: Boolean) { + prefs.setManualEnabled(value) + } + + fun setManualHost(value: String) { + prefs.setManualHost(value) + } + + fun setManualPort(value: Int) { + prefs.setManualPort(value) + } + + fun setManualTls(value: Boolean) { + prefs.setManualTls(value) + } + + /** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */ + private suspend fun resetGatewaySetupAuth(stableId: String): Boolean { + val reset = nodeApp.resetGatewaySetupAuth(stableId) + nodeApp.peekRuntime()?.let(::attachComposerRuntime) + if (reset) clearChatComposerGateway(stableId) + return reset + } + + /** Auth replacement retires the old gateway identity, including every retained composer owner. */ + internal suspend fun clearChatComposerGateway(stableId: String) { + val gateway = stableId.trim() + if (gateway.isEmpty()) return + clearChatComposerOwners { it.gatewayStableId == gateway } + } + + internal suspend fun clearChatComposerSession( + gatewayStableId: String, + agentId: String, + sessionKey: String, + mainSessionKey: String, + ) { + val gateway = gatewayStableId.trim() + val agent = agentId.trim() + val key = sessionKey.trim() + if (gateway.isEmpty() || agent.isEmpty() || key.isEmpty()) return + clearChatComposerOwners { owner -> + owner.matchesSession( + gatewayStableId = gateway, + agentId = agent, + sessionKey = key, + mainSessionKey = mainSessionKey, + ) + } + } + + private suspend fun clearChatComposerOwners(matches: (ChatComposerOwner) -> Boolean) { + chatComposerState.removeMediaOwners(matches) + chatShareDraftQueue.removeOwners(matches) + synchronized(assistantAutoSendLock) { + // Read the live operation id while its start/finally paths are excluded so cleanup retains + // exactly that gate after removing other state owned by the retired identity. + chatComposerState.removeOwners(matches, assistantAutoSendOperation?.composerSendId) + } + pendingAssistantAutoSendMutable.update { pending -> + pending?.takeIf { !matches(it.owner) } + } + synchronized(chatDraftLock) { + chatDraftState.value = chatDraftState.value?.takeIf { draft -> draft.owner?.let(matches) != true } + } + // Repeat after suspending share cleanup. Any callback that raced the first tombstone is + // serialized with this final token-and-attachment purge before cleanup returns. + chatComposerState.removeMediaOwners(matches) + } + + internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) { + resumeNodeServiceForConnection() + val operation = gatewayConfigOperationSeq.incrementAndGet() + // Gateway pairing touches encrypted prefs, identity files, and sockets; keep + // the whole sequence off the Compose thread so retries cannot trigger ANRs. + viewModelScope.launch(Dispatchers.Default) { + gatewayConfigOperationMutex.withLock { + if (operation != gatewayConfigOperationSeq.get()) return@withLock + val config = plan.config + val endpoint = + GatewayEndpoint.manual( + host = config.host, + port = config.port, + tlsEnabled = config.tls, + ) + val targetAlreadyPaired = + prefs.gatewayRegistry.entries.value + .any { it.stableId == endpoint.stableId } + val blankCredentials = config.token.isEmpty() && config.bootstrapToken.isEmpty() && config.password.isEmpty() + val preservesPairedTarget = + targetAlreadyPaired && blankCredentials && plan.savedAuthAction == GatewaySavedAuthAction.REPLACE_ENDPOINT + val replacesSavedAuth = plan.savedAuthAction != GatewaySavedAuthAction.PRESERVE && !preservesPairedTarget + if (replacesSavedAuth && !resetGatewaySetupAuth(endpoint.stableId)) return@launch + if (operation != gatewayConfigOperationSeq.get()) return@launch + prefs.setManualEnabled(true) + prefs.setManualHost(config.host) + prefs.setManualPort(config.port) + prefs.setManualTls(config.tls) + + // A blank same-endpoint save means "keep access". Secrets remain runtime-owned, + // including password-only setups that Compose deliberately cannot read back. + if (replacesSavedAuth) { + prefs.saveGatewayCredentials( + stableId = endpoint.stableId, + token = config.token, + bootstrapToken = config.bootstrapToken, + password = config.password, + ) + } + + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = endpoint.name, + host = config.host, + port = config.port, + tls = config.tls, + ), + ) + + val runtime = ensureRuntime() + if (replacesSavedAuth) { + runtime.connectSwitchingGateway( + endpoint, + NodeRuntime.GatewayConnectAuth( + token = config.token.ifEmpty { null }, + bootstrapToken = config.bootstrapToken.ifEmpty { null }, + password = config.password.ifEmpty { null }, + ), + ) + } else { + runtime.connectSwitchingGateway(endpoint) + } + } + } + } + + /** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */ + fun setOnboardingCompleted(value: Boolean) { + if (value) { + ensureRuntime() + } + prefs.setOnboardingCompleted(value) + if (value) { + NodeForegroundService.resume(nodeApp, startNow = true) + } + } + + /** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */ + fun pairNewGateway() { + NodeForegroundService.stop(nodeApp) + val operation = gatewayConfigOperationSeq.incrementAndGet() + viewModelScope.launch(Dispatchers.Default) { + gatewayConfigOperationMutex.withLock { + if (operation != gatewayConfigOperationSeq.get()) return@withLock + nodeApp.peekRuntime()?.also { runtime -> + attachComposerRuntime(runtime) + runtime.prepareForGatewaySetup() + } + // Pairing another gateway no longer forgets existing gateways; per-gateway + // credentials and proxy headers are removed only by forgetGateway. + prefs.setOnboardingCompleted(false) + _startOnboardingAtGatewaySetup.value = true + } + } + } + + /** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */ + fun clearGatewaySetupStartRequest() { + _startOnboardingAtGatewaySetup.value = false + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + prefs.setCanvasDebugStatusEnabled(value) + } + + fun grantInstalledAppsDisclosureConsent() { + ensureRuntime().grantInstalledAppsDisclosureConsent() + } + + fun revokeInstalledAppsDisclosureConsent() { + ensureRuntime().revokeInstalledAppsDisclosureConsent() + } + + fun setAccessibilityControlEnabled(value: Boolean) { + prefs.setAccessibilityControlEnabled(value) + } + + fun setNotificationForwardingEnabled(value: Boolean) { + ensureRuntime().setNotificationForwardingEnabled(value) + } + + fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) { + ensureRuntime().setNotificationForwardingMode(mode) + } + + fun setNotificationForwardingPackagesCsv(csv: String) { + val packages = + csv + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + ensureRuntime().setNotificationForwardingPackages(packages) + } + + fun setNotificationForwardingQuietHours( + enabled: Boolean, + start: String, + end: String, + ): Boolean = ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end) + + fun setNotificationForwardingMaxEventsPerMinute(value: Int) { + ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value) + } + + fun setNotificationForwardingSessionKey(value: String?) { + ensureRuntime().setNotificationForwardingSessionKey(value) + } + + fun setVoiceScreenActive(active: Boolean) { + ensureRuntime().setVoiceScreenActive(active) + } + + /** Routes assistant intents into chat, either as a draft or queued auto-send prompt. */ + fun handleAssistantLaunch(request: AssistantLaunchRequest) { + _requestedHomeDestination.value = HomeDestination.Chat + chatShareDraftQueue.clear() + val owner = currentOrProvisionalChatComposerOwner() + if (request.autoSend) { + pendingAssistantAutoSendMutable.value = request.prompt?.let { PendingAssistantAutoSend(prompt = it, owner = owner) } + setChatDraft(null) + return + } + pendingAssistantAutoSendMutable.value = null + setChatDraft(request.prompt?.let { ChatDraft(text = it, placement = ChatDraftPlacement.Replace, owner = owner) }) + } + + /** + * Owns share admission through queue insertion so Activity recreation cannot cancel accepted work. + */ + internal fun handleShareLaunchIntent(intent: Intent): Boolean { + if (!shareLaunchSlots.tryAcquire()) { + reportShareLaunchOverflow() + return false + } + val retainedIntent = Intent(intent) + val owner = captureChatShareOwner() + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + shareLaunchMutex.withLock { + val request = + withContext(Dispatchers.IO) { + parseShareLaunchIntent(retainedIntent, resolveShareMimeType) + } ?: return@withLock + if (!enqueueShareLaunch(request, owner)) reportShareLaunchOverflow() + } + } finally { + shareLaunchSlots.release() + } + } + return true + } + + internal fun reportShareLaunchOverflow(count: Int = 1) { + if (count <= 0) return + synchronized(shareLaunchOverflowLock) { + pendingShareLaunchOverflowCount += count + shareLaunchOverflowRevisionMutable.value += 1 + } + } + + internal fun takeShareLaunchOverflowCount(): Int = + synchronized(shareLaunchOverflowLock) { + pendingShareLaunchOverflowCount.also { + pendingShareLaunchOverflowCount = 0 + } + } + + /** Opens shared content as a fresh composer draft; sending still requires an explicit tap. */ + private fun enqueueShareLaunch( + request: ShareLaunchRequest, + owner: ChatComposerOwner, + ): Boolean { + val accepted = + chatShareDraftQueue.enqueue( + ChatShareDraft( + id = chatShareDraftSeq.incrementAndGet(), + text = request.text, + attachments = request.attachments, + droppedAttachmentCount = request.droppedAttachmentCount, + ), + owner, + ) + if (!accepted) return false + _requestedHomeDestination.value = HomeDestination.Chat + pendingAssistantAutoSendMutable.value = null + setChatDraft(null) + return true + } + + fun clearRequestedHomeDestination() { + _requestedHomeDestination.value = null + } + + fun requestHomeDestination(destination: HomeDestination) { + _requestedHomeDestination.value = destination + } + + internal fun consumeChatDraft( + expected: ChatDraft, + owner: ChatComposerOwner, + mainSessionKey: String, + ): ChatDraft? = + synchronized(chatDraftLock) { + val current = chatDraftState.value + if (current !== expected) return@synchronized null + val claimed = claimChatDraftForOwner(current, owner, mainSessionKey) ?: return@synchronized null + chatDraftState.value = null + claimed + } + + internal fun setChatDraft(value: ChatDraft?) { + synchronized(chatDraftLock) { + chatDraftState.value = value + } + } + + internal fun acknowledgeChatShareDraft( + id: Long, + owner: ChatComposerOwner, + ): Boolean = chatShareDraftQueue.acknowledgeHead(id, owner) + + internal suspend fun withChatShareDraftLease( + id: Long, + owner: ChatComposerOwner, + block: suspend () -> Unit, + ): Boolean = chatShareDraftQueue.withHeadLease(id, owner, block) + + internal fun chatShareDraftTargetsOwner( + id: Long, + owner: ChatComposerOwner, + mainSessionKey: String, + ): Boolean { + val captured = chatShareDraftQueue.ownerOf(id) ?: return false + return captured == owner || shouldMigrateComposerDraft(captured, owner, mainSessionKey) + } + + internal fun chatShareDraftForOwner( + owner: ChatComposerOwner, + mainSessionKey: String, + ): ChatShareDraft? = + chatShareDraftQueue.queued.value.firstOrNull { draft -> + chatShareDraftTargetsOwner(draft.id, owner, mainSessionKey) + } + + internal fun resolveChatShareDraftOwner( + id: Long?, + owner: ChatComposerOwner, + mainSessionKey: String, + ) { + if (id == null) return + val captured = chatShareDraftQueue.ownerOf(id) ?: return + if (shouldMigrateComposerDraft(captured, owner, mainSessionKey)) { + chatShareDraftQueue.migrateOwner(captured, owner) + } + } + + internal fun setChatReplyDraft( + value: String, + owner: ChatComposerOwner, + ) { + if (!isCurrentChatComposerOwner(owner)) return + pendingAssistantAutoSendMutable.value = null + setChatDraft(ChatDraft(text = value, placement = ChatDraftPlacement.BeforeExisting, owner = owner)) + } + + /** Claims an assistant prompt before sending so Compose effect restarts cannot dispatch it twice. */ + internal fun dispatchPendingAssistantAutoSend( + pending: PendingAssistantAutoSend, + thinking: String, + ) { + val prompt = pending.prompt.trim().ifEmpty { return } + if (!chatHealthOk.value || pendingRunCount.value > 0) return + if (!isCurrentChatComposerOwner(pending.owner)) return + if (runtimeRef.value?.canSendForOwner(pending.owner) != true) return + val operation = + synchronized(assistantAutoSendLock) { + if (!_assistantAutoSendInFlight.compareAndSet(false, true)) return + if (pendingAssistantAutoSendMutable.value != pending) { + _assistantAutoSendInFlight.value = false + return + } + val composerSendId = chatComposerState.tryBeginTrackedSend(pending.owner) + if (composerSendId == null) { + _assistantAutoSendInFlight.value = false + return + } + val started = + AssistantAutoSendOperation( + owner = pending.owner, + pendingId = pending.id, + composerSendId = composerSendId, + ) + assistantAutoSendOperation = started + started + } + viewModelScope.launch { + try { + val accepted = + sendChatForOwnerAwaitAcceptance( + owner = pending.owner, + message = prompt, + thinking = thinking, + attachments = emptyList(), + idempotencyKey = UUID.randomUUID().toString(), + ) + if (accepted) { + pendingAssistantAutoSendMutable.update { current -> + clearCompletedAssistantAutoSend(current, operation.pendingId) + } + } else { + val current = pendingAssistantAutoSendMutable.value + if (current?.id == operation.pendingId && pendingAssistantAutoSendMutable.compareAndSet(current, null)) { + // Refusal can mean owner validation changed before admission. Preserve the one-shot + // prompt as editable text, using the operation owner that alias migration updates. + val currentDraft = chatComposerState.textDrafts[operation.owner] + chatComposerState.textDrafts[operation.owner] = retainRefusedAssistantPrompt(current.prompt, currentDraft) + } + } + } finally { + synchronized(assistantAutoSendLock) { + if (assistantAutoSendOperation === operation) { + chatComposerState.finishTrackedSend(operation.composerSendId) + assistantAutoSendOperation = null + // Observable releases wake a prompt blocked by this or a manual send admission. + _assistantAutoSendInFlight.value = false + } + } + } + } + } + + fun setMicEnabled(enabled: Boolean) { + ensureRuntime().setMicEnabled(enabled) + } + + fun cancelMicCapture() { + ensureRuntime().cancelMicCapture() + } + + fun setTalkModeEnabled(enabled: Boolean) { + ensureRuntime().setTalkModeEnabled(enabled) + } + + suspend fun requestVoiceNotePermission(): Boolean = requestRecordAudioPermission() + + suspend fun requestDictationPermission(): Boolean = requestRecordAudioPermission() + + private suspend fun requestRecordAudioPermission(): Boolean { + val requester = permissionRequester ?: return false + return try { + requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))[Manifest.permission.RECORD_AUDIO] == true + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + false + } + } + + internal fun tryAcquireVoiceNoteMic(): Boolean = runtimeRef.value?.tryAcquireVoiceNoteMic() == true + + internal fun releaseVoiceNoteMic() { + runtimeRef.value?.releaseVoiceNoteMic() + } + + internal fun tryAcquireDictationMic(): Boolean = runtimeRef.value?.tryAcquireDictationMic() == true + + internal fun releaseDictationMic() { + runtimeRef.value?.releaseDictationMic() + } + + fun setSpeakerEnabled(enabled: Boolean) { + ensureRuntime().setSpeakerEnabled(enabled) + } + + fun setPreferredCameraFacing(facing: String) { + ensureRuntime().setPreferredCameraFacing(facing) + } + + fun setPreferredAudioInputDevice(key: String?) { + ensureRuntime().setPreferredAudioInputDevice(key) + } + + suspend fun hasFrontAndBackCameras(): Boolean { + val facings = ensureRuntime().camera.listDevices().mapTo(mutableSetOf()) { it.position } + return "front" in facings && "back" in facings + } + + internal fun observeAudioInputDevices(onChanged: (List) -> Unit): AutoCloseable = AndroidAudioInputSession.observeAvailableDevices(getApplication(), onChanged) + + fun setVoiceWakeEnabled(enabled: Boolean) { + ensureRuntime().setVoiceWakeEnabled(enabled) + } + + fun setVoiceWakeWords(values: List) { + ensureRuntime().setVoiceWakeWords(values) + } + + fun refreshVoiceWakePermission() { + ensureRuntime().refreshVoiceWakePermission() + } + + fun setAppearanceThemeMode(mode: AppearanceThemeMode) { + prefs.setAppearanceThemeMode(mode) + } + + fun refreshGatewayConnection() { + resumeNodeServiceForConnection() + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().refreshGatewayConnection() + } + } + + fun startGatewayDiscovery() { + queueRuntimeStartup() + } + + fun connect(endpoint: GatewayEndpoint) { + resumeNodeServiceForConnection() + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().connectSwitchingGateway(endpoint) + } + } + + fun connect( + endpoint: GatewayEndpoint, + token: String?, + bootstrapToken: String?, + password: String?, + ) { + resumeNodeServiceForConnection() + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().connectSwitchingGateway( + endpoint, + NodeRuntime.GatewayConnectAuth( + token = token, + bootstrapToken = bootstrapToken, + password = password, + ), + ) + } + } + + fun connectManual() { + resumeNodeServiceForConnection() + ensureRuntime().connectManual() + } + + fun switchToGateway(stableId: String) { + resumeNodeServiceForConnection() + val operation = gatewayConfigOperationSeq.incrementAndGet() + viewModelScope.launch(Dispatchers.Default) { + gatewayConfigOperationMutex.withLock { + if (operation == gatewayConfigOperationSeq.get()) { + ensureRuntime().switchToGateway(stableId) + } + } + } + } + + fun setGatewayConnectionEnabled( + stableId: String, + enabled: Boolean, + ) { + ensureRuntime().setGatewayConnectionEnabled(stableId, enabled) + } + + fun forgetGateway(stableId: String) { + val operation = gatewayConfigOperationSeq.incrementAndGet() + viewModelScope.launch(Dispatchers.Default) { + gatewayConfigOperationMutex.withLock { + if (operation == gatewayConfigOperationSeq.get()) { + ensureRuntime().forgetGateway(stableId) + } + } + } + } + + fun disconnect() { + NodeForegroundService.stop(nodeApp) + val operation = gatewayConfigOperationSeq.incrementAndGet() + viewModelScope.launch(Dispatchers.Default) { + gatewayConfigOperationMutex.withLock { + if (operation == gatewayConfigOperationSeq.get()) { + runtimeRef.value?.disconnect() + } + } + } + } + + fun acceptGatewayTrustPrompt(manualFingerprint: String? = null) { + runtimeRef.value?.acceptGatewayTrustPrompt(manualFingerprint) + } + + fun useSystemGatewayTrustPrompt() { + runtimeRef.value?.useSystemGatewayTrustPrompt() + } + + fun declineGatewayTrustPrompt() { + runtimeRef.value?.declineGatewayTrustPrompt() + } + + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { + ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson) + } + + fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = ensureRuntime().isTrustedCanvasActionUrl(rawUrl) + + internal suspend fun resolveInlineWidgetResource( + path: String, + failedResource: ChatWidgetResource?, + ) = ensureRuntime().resolveInlineWidgetResource(path, failedResource) + + internal suspend fun loadChatImageArtifact(artifactId: String) = ensureRuntime().loadChatImageArtifact(artifactId) + + internal suspend fun loadChatMediaArtifact( + artifactId: String, + kind: GatewayMediaKind, + playbackRendition: Boolean, + ) = ensureRuntime().loadChatMediaArtifact(artifactId, kind, playbackRendition) + + fun requestCanvasRehydrate(source: String = "screen_tab") { + ensureRuntime().requestCanvasRehydrate(source = source, force = true) + } + + fun showCanvas() { + ensureRuntime().canvas.show() + } + + fun hideCanvas() { + runtimeRef.value?.canvas?.hide() + } + + fun refreshHomeCanvasOverviewIfConnected() { + ensureRuntime().refreshHomeCanvasOverviewIfConnected() + } + + fun refreshModelCatalog() { + ensureRuntime().refreshModelCatalog() + } + + fun refreshProviderModels() { + ensureRuntime().refreshProviderModels() + } + + fun refreshTalkSetupReadiness() { + ensureRuntime().refreshTalkSetupReadiness() + } + + fun refreshAgents() { + ensureRuntime().refreshAgents() + } + + fun refreshCronJobs() { + ensureRuntime().refreshCronJobs() + } + + fun loadCronJobDetail(id: String) { + ensureRuntime().loadCronJobDetail(id) + } + + fun refreshCronRunHistory(id: String) { + ensureRuntime().refreshCronRunHistory(id) + } + + fun clearCronJobDetail() { + ensureRuntime().clearCronJobDetail() + } + + fun dismissCronActionNotice(id: String) { + ensureRuntime().dismissCronActionNotice(id) + } + + fun runCronJob(id: String) { + ensureRuntime().runCronJob(id) + } + + fun setCronJobEnabled( + id: String, + enabled: Boolean, + ) { + ensureRuntime().setCronJobEnabled(id = id, enabled = enabled) + } + + fun updateCronJob( + original: GatewayCronJobDetail, + edit: GatewayCronJobEdit, + ) { + ensureRuntime().updateCronJob(original = original, edit = edit) + } + + fun deleteCronJob(id: String) { + ensureRuntime().deleteCronJob(id) + } + + fun refreshUsage() { + ensureRuntime().refreshUsage() + } + + fun refreshSkills() { + ensureRuntime().refreshSkills() + } + + fun refreshSkillWorkshopProposals(agentId: String? = null) { + ensureRuntime().refreshSkillWorkshopProposals(agentId = agentId) + } + + fun resetSkillWorkshopAgentScope(agentId: String? = null) { + ensureRuntime().resetSkillWorkshopAgentScope(agentId = agentId) + } + + fun inspectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().inspectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun applySkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().applySkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun rejectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().rejectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun quarantineSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + ensureRuntime().quarantineSkillWorkshopProposal(proposalId = proposalId, agentId = agentId) + } + + fun clearSkillWorkshopMessage() { + ensureRuntime().clearSkillWorkshopMessage() + } + + fun setSkillEnabled( + skillKey: String, + enabled: Boolean, + ) { + ensureRuntime().setSkillEnabled(skillKey, enabled) + } + + fun searchClawHubSkills(query: String) { + ensureRuntime().searchClawHubSkills(query) + } + + fun reviewClawHubSkillInstall(skill: GatewayClawHubSkillSummary) { + ensureRuntime().reviewClawHubSkillInstall(skill) + } + + fun dismissClawHubSkillInstallReview() { + ensureRuntime().dismissClawHubSkillInstallReview() + } + + fun installClawHubSkill( + slug: String, + acknowledgeClawHubRisk: Boolean = false, + version: String? = null, + ) { + ensureRuntime().installClawHubSkill(slug, acknowledgeClawHubRisk, version) + } + + fun clearClawHubSkillMessage() { + ensureRuntime().clearClawHubSkillMessage() + } + + fun refreshNodesDevices() { + ensureRuntime().refreshNodesDevices() + } + + fun approveDevicePairing( + requestId: String, + deviceId: String, + ) { + ensureRuntime().approveDevicePairing(requestId, deviceId) + } + + fun rejectDevicePairing(requestId: String) { + ensureRuntime().rejectDevicePairing(requestId) + } + + fun removePairedDevice(deviceId: String) { + ensureRuntime().removePairedDevice(deviceId) + } + + fun refreshExecApprovals() { + ensureRuntime().refreshExecApprovals() + } + + fun resolveExecApproval( + id: String, + decision: String, + ) { + ensureRuntime().resolveExecApproval(id = id, decision = decision) + } + + fun dismissExecApprovalsNotice(expected: GatewayExecApprovalNotice) { + ensureRuntime().dismissExecApprovalsNotice(expected) + } + + fun refreshChannels() { + ensureRuntime().refreshChannels() + } + + fun refreshDreaming() { + ensureRuntime().refreshDreaming() + } + + fun refreshHealthLogs() { + ensureRuntime().refreshHealthLogs() + } + + fun loadChat( + sessionKey: String, + ownerAgentId: String? = null, + ) { + ensureRuntime().loadChat(sessionKey, ownerAgentId) + } + + fun refreshChat() { + ensureRuntime().refreshChat() + } + + fun refreshChatSessions( + limit: Int? = null, + archived: Boolean = false, + ) { + ensureRuntime().refreshChatSessions(limit = limit, archived = archived) + } + + suspend fun patchChatSession( + key: String, + ownerAgentId: String? = null, + label: String? = null, + clearLabel: Boolean = false, + category: String? = null, + clearCategory: Boolean = false, + pinned: Boolean? = null, + archived: Boolean? = null, + unread: Boolean? = null, + ) { + ensureRuntime().patchChatSession( + key = key, + ownerAgentId = ownerAgentId, + label = label, + clearLabel = clearLabel, + category = category, + clearCategory = clearCategory, + pinned = pinned, + archived = archived, + unread = unread, + ) + } + + suspend fun deleteChatSession( + key: String, + ownerAgentId: String?, + ) { + val deleted = ensureRuntime().deleteChatSession(key, ownerAgentId) ?: return + deleted.gatewayId?.let { gatewayId -> + clearChatComposerSession( + gatewayStableId = gatewayId, + agentId = deleted.agentId, + sessionKey = deleted.sessionKey, + mainSessionKey = deleted.mainSessionKey, + ) + } + } + + /** Remembers a custom session group locally so it renders as an empty section. */ + fun addChatSessionGroup(name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + prefs.setSessionCustomGroups(prefs.sessionCustomGroups.value + trimmed) + } + + suspend fun renameChatSessionGroup( + from: String, + to: String, + ) { + val stored = prefs.sessionCustomGroups.value + // Web semantics: replace a stored name in place, otherwise remember the new name. + prefs.setSessionCustomGroups(if (from in stored) stored.map { if (it == from) to else it } else stored + to) + ensureRuntime().renameChatSessionGroup(from = from, to = to) + } + + suspend fun deleteChatSessionGroup(group: String) { + prefs.setSessionCustomGroups(prefs.sessionCustomGroups.value.filterNot { it == group }) + ensureRuntime().dissolveChatSessionGroup(group) + } + + suspend fun forkChatSession( + parentKey: String, + ownerAgentId: String? = null, + ): String? = ensureRuntime().forkChatSession(parentKey, ownerAgentId) + + suspend fun rewindChatAtEntry(entryId: String): SessionRewindResult? = ensureRuntime().rewindChatAtEntry(entryId) + + suspend fun forkChatAtEntry(entryId: String): SessionForkResult? = ensureRuntime().forkChatAtEntry(entryId) + + suspend fun refreshChatSessionBranches(): Boolean = ensureRuntime().refreshChatSessionBranches() + + suspend fun switchChatSessionBranch(leafEntryId: String): Boolean = ensureRuntime().switchChatSessionBranch(leafEntryId) + + suspend fun listWorkspaceFiles( + path: String?, + offset: Int? = null, + ): GatewayWorkspaceListing = ensureRuntime().listWorkspaceFiles(path = path, offset = offset) + + suspend fun fetchWorkspaceFile(path: String): GatewayWorkspaceFile = ensureRuntime().fetchWorkspaceFile(path) + + fun setChatThinkingLevel(level: String) { + ensureRuntime().setChatThinkingLevel(level) + } + + fun setChatSessionModel( + sessionKey: String, + modelRef: String?, + ) { + ensureRuntime().setChatSessionModel(sessionKey = sessionKey, modelRef = modelRef) + } + + fun toggleModelFavorite(ref: String) { + prefs.toggleModelFavorite(ref) + } + + fun toggleChatMessageSpeech( + messageId: String, + text: String, + ) { + ensureRuntime().toggleMessageSpeech(messageId = messageId, text = text) + } + + fun stopChatMessageSpeech() { + runtimeRef.value?.stopMessageSpeech() + } + + fun switchChatSession( + sessionKey: String, + ownerAgentId: String? = null, + ) { + ensureRuntime().switchChatSession(sessionKey, ownerAgentId) + } + + /** Reads the authoritative flows at commit time so stale Compose callbacks cannot cross chats. */ + private fun currentChatComposerOwner(): ChatComposerOwner? { + val runtime = runtimeRef.value ?: return null + return resolveChatComposerOwner( + gatewayStableId = activeGatewayStableId.value, + gatewayDefaultAgentId = runtime.chatSessionOwnerAgentId.value ?: runtime.gatewayDefaultAgentId.value, + lastVerifiedOwner = runtime.gatewayComposerDefaultAgentOwner.value, + sessionKey = runtime.chatSessionKey.value, + mainSessionKey = runtime.mainSessionKey.value, + ) + } + + /** Captures a share before async runtime startup; later hello/alias resolution may migrate it. */ + private fun currentOrProvisionalChatComposerOwner(): ChatComposerOwner = + currentChatComposerOwner() + ?: resolveChatComposerOwner( + gatewayStableId = activeGatewayStableId.value, + gatewayDefaultAgentId = chatSessionOwnerAgentId.value ?: gatewayDefaultAgentId.value, + lastVerifiedOwner = gatewayComposerDefaultAgentOwner.value, + sessionKey = chatSessionKey.value, + mainSessionKey = mainSessionKey.value, + ) + + internal fun captureChatShareOwner(): ChatComposerOwner = currentOrProvisionalChatComposerOwner() + + internal fun isCurrentChatComposerOwner(expected: ChatComposerOwner): Boolean = + ( + currentChatComposerOwner() ?: currentOrProvisionalChatComposerOwner() + ) == expected + + internal fun resolveChatComposerOwnerAliases( + to: ChatComposerOwner, + mainSessionKey: String, + ) { + val (composerSources, operationSource) = + synchronized(assistantAutoSendLock) { + // The gate and operation owner must move together so finally releases the migrated key. + val sources = chatComposerState.resolveAliases(to = to, mainSessionKey = mainSessionKey) + val operationSource = + assistantAutoSendOperation?.let { operation -> + operation.owner.takeIf { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) }?.also { + operation.owner = to + } + } + sources to operationSource + } + val pendingAutoSend = pendingAssistantAutoSendMutable.value + val pendingAutoSendSource = + pendingAutoSend + ?.owner + ?.takeIf { source -> shouldMigrateComposerDraft(source, to, mainSessionKey) } + if (pendingAutoSendSource != null) { + pendingAssistantAutoSendMutable.compareAndSet(pendingAutoSend, pendingAutoSend.copy(owner = to)) + } + val sources = composerSources + listOfNotNull(pendingAutoSendSource, operationSource) + sources.forEach { source -> chatShareDraftQueue.migrateOwner(from = source, to = to) } + } + + /** The ViewModel owns image decoding so Activity recreation cannot cancel an accepted picker result. */ + internal fun importChatComposerAttachments( + owner: ChatComposerOwner, + mediaAuthorizationId: String, + mainSessionKey: String, + expectedCount: Int, + load: suspend () -> List, + ) { + val importId = + chatComposerState.beginMediaImport(owner, mediaAuthorizationId, mainSessionKey) ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + val loaded = + try { + load() + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + emptyList() + } + chatComposerState.completeMediaImport( + importId = importId, + candidates = loaded, + failedCount = expectedCount - loaded.size, + ) + } catch (err: CancellationException) { + chatComposerState.cancelMediaImport(importId) + throw err + } + } + } + + internal fun refreshSystemAgentChat() { + ensureRuntime().refreshSystemAgentChat() + } + + internal fun clearSystemAgentChatInput() { + ensureRuntime().clearSystemAgentChatInput() + } + + internal fun setSystemAgentChatInput(value: String) { + ensureRuntime().setSystemAgentChatInput(value) + } + + internal fun sendSystemAgentChatInput() { + ensureRuntime().sendSystemAgentChatInput() + } + + internal fun answerSystemAgentQuestion( + messageId: String, + optionLabel: String, + ) { + ensureRuntime().answerSystemAgentQuestion(messageId, optionLabel) + } + + internal fun skipSystemAgentQuestion(messageId: String) { + ensureRuntime().skipSystemAgentQuestion(messageId) + } + + internal fun restartSystemAgentChat() { + ensureRuntime().restartSystemAgentChat() + } + + internal fun openSystemAgentChatHandoff() { + val handoff = ensureRuntime().consumeSystemAgentChatHandoff() ?: return + handoff.agentId + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::selectChatAgent) + handleAssistantLaunch(AssistantLaunchRequest(source = "system-agent", prompt = null, autoSend = false)) + } + + fun selectChatAgent(agentId: String) { + ensureRuntime().selectChatAgent(agentId) + } + + suspend fun fetchChatSessionList( + search: String?, + archived: Boolean, + ): List = ensureRuntime().fetchChatSessionList(search = search, archived = archived) + + fun abortChat() { + ensureRuntime().abortChat() + } + + fun startNewChat(worktree: Boolean = false) { + ensureRuntime().startNewChat(worktree = worktree) + } + + fun refreshChatCommands() { + ensureRuntime().refreshChatCommands() + } + + fun retryChatOutboxCommand(id: String) { + ensureRuntime().retryChatOutboxCommand(id) + } + + fun deleteChatOutboxCommand(id: String) { + ensureRuntime().deleteChatOutboxCommand(id) + } + + fun resolveChatQuestion( + id: String, + answers: Map>, + ) { + ensureRuntime().resolveChatQuestion(id, answers) + } + + fun skipChatQuestion(id: String) { + ensureRuntime().skipChatQuestion(id) + } + + suspend fun listBackgroundTasks(agentId: String): List = ensureRuntime().listBackgroundTasks(agentId) + + suspend fun getBackgroundTask(taskId: String): BackgroundTask = ensureRuntime().getBackgroundTask(taskId) + + fun sendChat( + message: String, + thinking: String, + attachments: List, + ) { + ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments) + } + + internal suspend fun sendChatForOwnerAwaitAcceptance( + owner: ChatComposerOwner, + message: String, + thinking: String, + attachments: List, + idempotencyKey: String, + ): Boolean = + ensureRuntime().sendChatForOwnerAwaitAcceptance( + owner = owner, + message = message, + thinking = thinking, + attachments = attachments, + idempotencyKey = idempotencyKey, + ) + + /** Admission outlives the composing Activity; accepted payloads clear by owner and snapshot. */ + internal fun beginChatComposerSend( + owner: ChatComposerOwner, + thinking: String, + ): ChatComposerSendStartResult { + if (!isCurrentChatComposerOwner(owner)) return ChatComposerSendStartResult.Unavailable + val start = chatComposerState.beginSend(owner) + val request = start.request ?: return start.result + val outgoing = request.attachments.map(PendingAttachment::toOutgoingAttachment) + viewModelScope.launch { + var accepted: Boolean? = null + try { + accepted = + sendChatForOwnerAwaitAcceptance( + owner = request.owner, + message = request.message, + thinking = thinking, + attachments = outgoing, + idempotencyKey = request.commandId, + ) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + accepted = false + } finally { + chatComposerState.completeSend(request, accepted) + } + } + return ChatComposerSendStartResult.Started + } + + internal fun acknowledgeChatComposerSendAdmission( + owner: ChatComposerOwner, + id: String, + ) { + chatComposerState.acknowledgeSendAdmission(owner, id) + } + + suspend fun sendChatAwaitAcceptance( + message: String, + thinking: String, + attachments: List, + ): Boolean = + ensureRuntime().sendChatAwaitAcceptance( + message = message, + thinking = thinking, + attachments = attachments, + ) +} diff --git a/app/src/main/java/ai/openclaw/app/NodeApp.kt b/app/src/main/java/ai/openclaw/app/NodeApp.kt new file mode 100644 index 0000000..c24f295 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -0,0 +1,123 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeStringResources +import ai.openclaw.app.i18n.notifyNativeLocaleChanged +import ai.openclaw.app.wear.GoogleWearMessageSender +import ai.openclaw.app.wear.GoogleWearPeerResolver +import ai.openclaw.app.wear.WearProxyBridge +import ai.openclaw.app.wear.WearRealtimeChannelRegistry +import android.app.Application +import android.content.res.Configuration +import android.os.StrictMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicLong + +/** + * Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup. + */ +class NodeApp : Application() { + val prefs: SecurePrefs by lazy { SecurePrefs(this) } + + // System share senders can create overlapping Activity tasks; keep one bounded process queue. + internal val chatShareDraftSeq = AtomicLong() + internal val chatShareDraftQueue = ChatShareDraftQueue() + internal val permissionRequester by lazy { PermissionRequester(this) } + + private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val runtimeLock = Any() + private var runtimeInstance: NodeRuntime? = null + + internal val wearProxyBridge: WearProxyBridge by lazy { + WearProxyBridge( + scope = runtimeScope, + sender = GoogleWearMessageSender(this), + peerResolver = GoogleWearPeerResolver(this), + handleRequest = { sourceNodeId, request -> + ensureBackgroundRuntime().handleWearProxyRequest(sourceNodeId, request) + }, + ) + } + + internal val wearRealtimeChannels: WearRealtimeChannelRegistry by lazy { + WearRealtimeChannelRegistry(this, runtimeScope) + } + + /** + * Returns the single NodeRuntime for this process, creating it on first use. + */ + fun ensureRuntime(): NodeRuntime = + synchronized(runtimeLock) { + runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it } + } + + /** Creates a cold-process runtime with foreground-only capabilities disabled before publication. */ + internal fun ensureBackgroundRuntime(): NodeRuntime = + synchronized(runtimeLock) { + runtimeInstance + ?: NodeRuntime(this, prefs, initialForeground = false).also { runtimeInstance = it } + } + + internal fun ensureScreenshotFixtureRuntime(): NodeRuntime = + synchronized(runtimeLock) { + check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" } + runtimeInstance?.also { runtime -> + check(runtime.mode == NodeRuntimeMode.ScreenshotFixture) { + "NodeRuntime already started in live mode" + } + } ?: NodeRuntime(this, prefs, NodeRuntimeMode.ScreenshotFixture).also { runtimeInstance = it } + } + + /** + * Reads the runtime without forcing startup, used by lifecycle probes and services. + */ + fun peekRuntime(): NodeRuntime? = synchronized(runtimeLock) { runtimeInstance } + + /** Disconnects the current or concurrently constructing runtime without blocking the caller. */ + internal fun disconnectRuntimeAsync() { + // The process-owned scope outlives a stopping service, so cancellation cannot + // strand an Activity-created runtime that the service has not observed yet. + runtimeScope.launch { peekRuntime()?.disconnect() } + } + + /** Clears pairing auth without racing lazy process-runtime construction. */ + suspend fun resetGatewaySetupAuth(stableId: String): Boolean { + val runtime = + synchronized(runtimeLock) { + runtimeInstance + ?: NodeRuntime.forGatewayAuthReset(this, prefs).also { runtimeInstance = it } + } + return runtime.resetGatewaySetupAuth(stableId) + } + + override fun onCreate() { + super.onCreate() + NativeStringResources.install(this) + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy + .Builder() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy + .Builder() + .detectAll() + .penaltyLog() + .build(), + ) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // The process runtime survives Activity recreation, so retained text and + // serialized Home Canvas state need an explicit locale refresh signal. + NativeStringResources.setConfigurationLocales(newConfig) + notifyNativeLocaleChanged() + } +} diff --git a/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt new file mode 100644 index 0000000..b4689a4 --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -0,0 +1,469 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.nativeLocaleChanges +import ai.openclaw.app.i18n.nativeString +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean + +/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */ +class NodeForegroundService : Service() { + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var notificationJob: Job? = null + private var runtimeRestoreJob: Job? = null + private var activeRuntime: NodeRuntime? = null + private var latestStartId = 0 + private var voiceCaptureMode = VoiceCaptureMode.Off + + @Volatile private var disconnectRequested = false + + override fun onCreate() { + super.onCreate() + ensureChannel() + val initial = + buildNotification( + title = nativeString("OpenClaw Node"), + text = nativeString("Starting…"), + ) + startForegroundWithTypes(notification = initial) + } + + private fun startRuntimeIfNeeded(startId: Int) { + if (activeRuntime != null || runtimeRestoreJob?.isActive == true || disconnectRequested) return + val app = application as NodeApp + runtimeRestoreJob = + scope.launch(Dispatchers.Default) { + try { + restoreStickyRuntime( + createRuntime = app::ensureBackgroundRuntime, + disconnectRequested = { disconnectRequested }, + disconnectRuntime = NodeRuntime::disconnect, + ) { restoredRuntime -> + withContext(Dispatchers.Main) { + runtimeRestoreJob = null + if (disconnectRequested) { + false + } else { + activeRuntime = restoredRuntime + observeRuntime(restoredRuntime) + true + } + } + } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + Log.e("OpenClawNodeService", "Failed to restore node runtime", err) + withContext(Dispatchers.Main) { + runtimeRestoreJob = null + if (!disconnectRequested && !stopSelfResult(startId)) { + startRuntimeIfNeeded(latestStartId) + } + } + } + } + } + + private fun observeRuntime(runtime: NodeRuntime) { + // Keep the connection tuple atomic, then split connection and capture work so notification text + // can update without restarting runtime-owned connection work. + notificationJob = + scope.launch { + val notificationStates = + combine( + combine( + runtime.gatewayConnectionDisplay, + runtime.serverName, + runtime.voiceCaptureMode, + runtime.locationMode, + ) { connection, server, mode, _ -> + VoiceNotificationBase( + status = connection.statusText, + server = server, + connected = connection.isConnected, + mode = mode, + ) + }, + combine( + runtime.micEnabled, + runtime.micIsListening, + runtime.talkModeListening, + runtime.talkModeSpeaking, + ) { micEnabled, micListening, talkListening, talkSpeaking -> + VoiceNotificationCapture( + micEnabled = micEnabled, + micListening = micListening, + talkListening = talkListening, + talkSpeaking = talkSpeaking, + ) + }, + ) { base, capture -> + VoiceNotificationState(base = base, capture = capture) + } + refreshNotificationOnLocaleChanges( + states = notificationStates, + localeChanges = nativeLocaleChanges, + ).collect { update -> + ensureChannelForLocaleRevision(update.localeRevision) + val state = update.state + voiceCaptureMode = state.mode + val title = + when { + state.connected && state.mode == VoiceCaptureMode.TalkMode -> + nativeString("OpenClaw Node · Talk") + state.connected -> nativeString("OpenClaw Node · Connected") + else -> nativeString("OpenClaw Node") + } + val displayStatus = gatewayConnectionStatusForDisplay(state.status) + val text = + (state.server?.let { nativeString("\$status · \$server", displayStatus, it) } ?: displayStatus) + + voiceNotificationSuffix( + mode = state.mode, + manualMicEnabled = state.capture.micEnabled, + manualMicListening = state.capture.micListening, + talkListening = state.capture.talkListening, + talkSpeaking = state.capture.talkSpeaking, + ) + + startForegroundWithTypes( + notification = buildNotification(title = title, text = text), + ) + } + } + } + + private var channelLocaleRevision: Long? = null + + private fun ensureChannelForLocaleRevision(localeRevision: Long) { + if (channelLocaleRevision == localeRevision) return + ensureChannel() + channelLocaleRevision = localeRevision + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + latestStartId = maxOf(latestStartId, startId) + when (intent?.action) { + ACTION_STOP -> { + startSuppressed.set(true) + disconnectRequested = true + runtimeRestoreJob?.cancel() + runtimeRestoreJob = null + notificationJob?.cancel() + notificationJob = null + activeRuntime?.disconnect() + activeRuntime = null + (application as NodeApp).disconnectRuntimeAsync() + stopSelfResult(startId) + return START_NOT_STICKY + } + ACTION_RESUME -> { + startSuppressed.set(false) + disconnectRequested = false + } + ACTION_SET_VOICE_CAPTURE_MODE -> { + voiceCaptureMode = intent.getStringExtra(EXTRA_VOICE_CAPTURE_MODE).toVoiceCaptureMode() + startForegroundWithTypes( + notification = + buildNotification( + title = nativeString("OpenClaw Node"), + text = + if (voiceCaptureMode == VoiceCaptureMode.TalkMode) { + nativeString("Talk mode active") + } else { + nativeString("Connected") + }, + ), + ) + } + } + if (disconnectRequested || startSuppressed.get()) { + // A STOP can lose stopSelfResult to a newer queued start. Let the newest + // start id close the service instead of leaving a disconnected FGS alive. + stopSelfResult(startId) + return START_NOT_STICKY + } + // START_STICKY recreates the service in a fresh process and calls this with a null intent. + startRuntimeIfNeeded(startId) + // Keep running; connection is managed by NodeRuntime (auto-reconnect + manual). + return START_STICKY + } + + override fun onDestroy() { + notificationJob?.cancel() + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?) = null + + private fun ensureChannel() { + val mgr = getSystemService(NotificationManager::class.java) + val channel = + NotificationChannel( + CHANNEL_ID, + nativeString("Connection"), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = nativeString("OpenClaw node connection status") + setShowBadge(false) + } + mgr.createNotificationChannel(channel) + } + + private fun buildNotification( + title: String, + text: String, + ): Notification { + val launchPending = mainActivityPendingIntent(this, requestCode = 1) + val visibleText = text + backgroundLocationNotificationSuffix(isBackgroundLocationActive()) + + val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP) + val stopPending = + PendingIntent.getService( + this, + 2, + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(visibleText) + .setContentIntent(launchPending) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(0, nativeString("Disconnect"), stopPending) + .build() + } + + private fun startForegroundWithTypes(notification: Notification) { + val serviceTypes = + foregroundServiceTypes( + voiceMode = voiceCaptureMode, + backgroundLocationActive = isBackgroundLocationActive(), + ) + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes) + } + + private fun isBackgroundLocationActive(): Boolean { + if (!SensitiveFeatureConfig.backgroundLocationEnabled) return false + if ((application as NodeApp).prefs.locationMode.value != LocationMode.Always) return false + val fineGranted = + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseGranted = + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val backgroundGranted = + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + return (fineGranted || coarseGranted) && backgroundGranted + } + + companion object { + private const val CHANNEL_ID = "connection" + private const val NOTIFICATION_ID = 1 + + private const val ACTION_STOP = "ai.openclaw.app.action.STOP" + private const val ACTION_RESUME = "ai.openclaw.app.action.RESUME" + private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE" + private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE" + private val startSuppressed = AtomicBoolean(false) + + fun start(context: Context) { + if (startSuppressed.get()) return + val intent = Intent(context, NodeForegroundService::class.java) + context.startForegroundService(intent) + } + + fun stop(context: Context) { + startSuppressed.set(true) + val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) + context.startService(intent) + } + + internal fun resume( + context: Context, + startNow: Boolean, + ) { + startSuppressed.set(false) + if (!startNow) return + val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_RESUME) + context.startForegroundService(intent) + } + + fun setVoiceCaptureMode( + context: Context, + mode: VoiceCaptureMode, + ) { + if (startSuppressed.get()) return + val intent = + Intent(context, NodeForegroundService::class.java) + .setAction(ACTION_SET_VOICE_CAPTURE_MODE) + .putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name) + if (mode == VoiceCaptureMode.TalkMode) { + // Microphone foreground service type must be declared before Talk capture starts. + ContextCompat.startForegroundService(context, intent) + } else { + context.startService(intent) + } + } + } +} + +/** Restores process-local state after Android recreates a sticky service in a fresh process. */ +internal suspend fun restoreStickyRuntime( + createRuntime: () -> T, + disconnectRequested: () -> Boolean, + disconnectRuntime: (T) -> Unit, + activateRuntime: suspend (T) -> Boolean, +) { + // A queued recovery may begin after STOP; do not construct process state once + // disconnect has already won. The post-create check still closes the race during construction. + if (disconnectRequested()) return + val runtime = createRuntime() + var activated = false + try { + if (!disconnectRequested()) { + activated = activateRuntime(runtime) + } + } finally { + // Ownership transfers only after activation. Stop/cancellation during the + // dispatcher hop must disconnect the recovered runtime instead of leaking it. + if (!activated) { + disconnectRuntime(runtime) + } + } +} + +internal fun foregroundServiceTypes( + voiceMode: VoiceCaptureMode, + backgroundLocationActive: Boolean, +): Int { + val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + val voiceTypes = + when (voiceMode) { + VoiceCaptureMode.Off -> base + VoiceCaptureMode.ManualMic, + VoiceCaptureMode.TalkMode, + -> base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + return if (backgroundLocationActive) { + voiceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } else { + voiceTypes + } +} + +internal fun backgroundLocationNotificationSuffix(active: Boolean): String = + if (active) { + nativeString(" · Location: Always") + } else { + "" + } + +internal fun voiceNotificationSuffix( + mode: VoiceCaptureMode, + manualMicEnabled: Boolean, + manualMicListening: Boolean, + talkListening: Boolean, + talkSpeaking: Boolean, +): String = + when (mode) { + VoiceCaptureMode.TalkMode -> + when { + talkSpeaking -> nativeString(" · Talk: Speaking") + talkListening -> nativeString(" · Talk: Listening") + else -> nativeString(" · Talk: On") + } + VoiceCaptureMode.ManualMic -> + if (manualMicEnabled) { + if (manualMicListening) { + nativeString(" · Mic: Listening") + } else { + nativeString(" · Mic: Pending") + } + } else { + "" + } + VoiceCaptureMode.Off -> "" + } + +private fun String?.toVoiceCaptureMode(): VoiceCaptureMode = + VoiceCaptureMode.entries.firstOrNull { + it.name == this + } ?: VoiceCaptureMode.Off + +/** Connection fields that drive foreground notification title/body text. */ +private data class VoiceNotificationBase( + val status: String, + val server: String?, + val connected: Boolean, + val mode: VoiceCaptureMode, +) + +/** Voice capture fields that affect foreground-service type and suffix. */ +private data class VoiceNotificationCapture( + val micEnabled: Boolean, + val micListening: Boolean, + val talkListening: Boolean, + val talkSpeaking: Boolean, +) + +/** Aggregated notification state from runtime flows. */ +private data class VoiceNotificationState( + val base: VoiceNotificationBase, + val capture: VoiceNotificationCapture, +) { + val status: String + get() = base.status + val server: String? + get() = base.server + val connected: Boolean + get() = base.connected + val mode: VoiceCaptureMode + get() = base.mode +} + +/** Re-emits stable runtime state when app-owned notification copy changes locale. */ +internal data class LocaleAwareNotificationState( + val state: T, + val localeRevision: Long, +) + +internal fun refreshNotificationOnLocaleChanges( + states: Flow, + localeChanges: Flow, +): Flow> = + combine(states, localeChanges) { state, localeRevision -> + LocaleAwareNotificationState(state = state, localeRevision = localeRevision) + } diff --git a/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/app/src/main/java/ai/openclaw/app/NodeRuntime.kt new file mode 100644 index 0000000..cd49a6f --- /dev/null +++ b/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -0,0 +1,9054 @@ +package ai.openclaw.app + +import ai.openclaw.app.chat.AndroidClientDatabases +import ai.openclaw.app.chat.BackgroundTask +import ai.openclaw.app.chat.ChatActiveRunPresentation +import ai.openclaw.app.chat.ChatCacheScope +import ai.openclaw.app.chat.ChatCommandEntry +import ai.openclaw.app.chat.ChatCommandOutbox +import ai.openclaw.app.chat.ChatComposerOwner +import ai.openclaw.app.chat.ChatController +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatOutboxItem +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatQuestionPrompt +import ai.openclaw.app.chat.ChatSessionDeletion +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatSwarmGroup +import ai.openclaw.app.chat.ChatThinkingLevelSelection +import ai.openclaw.app.chat.ChatTranscriptAnchorState +import ai.openclaw.app.chat.ChatTranscriptCache +import ai.openclaw.app.chat.ChatWidgetResource +import ai.openclaw.app.chat.ChatWidgetSurface +import ai.openclaw.app.chat.ChatWidgetSurfaceUrls +import ai.openclaw.app.chat.ChatWidgetUrlResolver +import ai.openclaw.app.chat.GatewayDefaultAgentOwner +import ai.openclaw.app.chat.MainSessionBinding +import ai.openclaw.app.chat.MessageSpeechClient +import ai.openclaw.app.chat.MessageSpeechController +import ai.openclaw.app.chat.MessageSpeechState +import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.chat.SessionBranch +import ai.openclaw.app.chat.SessionForkResult +import ai.openclaw.app.chat.SessionRewindResult +import ai.openclaw.app.chat.SystemSpeechSpeaker +import ai.openclaw.app.gateway.DeviceAuthEntry +import ai.openclaw.app.gateway.DeviceAuthStore +import ai.openclaw.app.gateway.DeviceIdentityStore +import ai.openclaw.app.gateway.GatewayDiscovery +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayEvent +import ai.openclaw.app.gateway.GatewayMediaKind +import ai.openclaw.app.gateway.GatewayMethod +import ai.openclaw.app.gateway.GatewayRegistryEntry +import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import ai.openclaw.app.gateway.GatewayRequestDefinitiveFailure +import ai.openclaw.app.gateway.GatewayRequestNotEnqueued +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewayTlsProbeFailure +import ai.openclaw.app.gateway.GatewayTlsProbeResult +import ai.openclaw.app.gateway.GatewayTlsTrustDecision +import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary +import ai.openclaw.app.gateway.NetworkMonitor +import ai.openclaw.app.gateway.NodeEventSendOutcome +import ai.openclaw.app.gateway.decideGatewayTlsTrust +import ai.openclaw.app.gateway.formatGatewayAuthority +import ai.openclaw.app.gateway.isGatewayTlsSystemTrustCandidate +import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId +import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprintInput +import ai.openclaw.app.gateway.parseChatSendAck +import ai.openclaw.app.gateway.probeGatewayTlsFingerprint +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.nativeLocaleChanges +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.resolveOptionalNativeText +import ai.openclaw.app.i18n.verbatimText +import ai.openclaw.app.node.A2UIHandler +import ai.openclaw.app.node.CalendarHandler +import ai.openclaw.app.node.CallLogHandler +import ai.openclaw.app.node.CameraCaptureManager +import ai.openclaw.app.node.CameraHandler +import ai.openclaw.app.node.CanvasController +import ai.openclaw.app.node.ConnectionManager +import ai.openclaw.app.node.ContactsHandler +import ai.openclaw.app.node.DEFAULT_SEAM_COLOR_ARGB +import ai.openclaw.app.node.DebugHandler +import ai.openclaw.app.node.DeviceHandler +import ai.openclaw.app.node.DeviceNotificationListenerService +import ai.openclaw.app.node.InvokeDispatcher +import ai.openclaw.app.node.LocationCaptureManager +import ai.openclaw.app.node.LocationHandler +import ai.openclaw.app.node.MobileUiHandler +import ai.openclaw.app.node.MotionHandler +import ai.openclaw.app.node.NodePresenceAliveBeacon +import ai.openclaw.app.node.NotificationsHandler +import ai.openclaw.app.node.PhotosHandler +import ai.openclaw.app.node.Quad +import ai.openclaw.app.node.SmsHandler +import ai.openclaw.app.node.SmsManager +import ai.openclaw.app.node.SystemHandler +import ai.openclaw.app.node.TalkHandler +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import ai.openclaw.app.node.invokeErrorFromThrowable +import ai.openclaw.app.node.parseHexColorArgb +import ai.openclaw.app.node.readAndroidPermissionSnapshot +import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction +import ai.openclaw.app.systemagent.SystemAgentChatController +import ai.openclaw.app.systemagent.SystemAgentChatState +import ai.openclaw.app.systemagent.SystemAgentGatewayAccess +import ai.openclaw.app.voice.AndroidOnDeviceVoiceWakeRecognizer +import ai.openclaw.app.voice.GatewayTranscriptionSession +import ai.openclaw.app.voice.MicCaptureManager +import ai.openclaw.app.voice.PreviewVoiceWakeRecognizer +import ai.openclaw.app.voice.TalkAudioPlayer +import ai.openclaw.app.voice.TalkModeManager +import ai.openclaw.app.voice.TalkPttOnceStart +import ai.openclaw.app.voice.TalkPttStopPayload +import ai.openclaw.app.voice.VoiceConversationEntry +import ai.openclaw.app.voice.VoiceConversationRole +import ai.openclaw.app.voice.VoiceWakeManager +import ai.openclaw.app.voice.VoiceWakeMatch +import ai.openclaw.app.voice.VoiceWakePreferences +import ai.openclaw.app.voice.VoiceWakeSuppressionReason +import ai.openclaw.app.wear.WearProxyAgent +import ai.openclaw.app.wear.WearProxyBridge +import ai.openclaw.app.wear.WearProxyController +import ai.openclaw.app.wear.WearProxyGatewayException +import ai.openclaw.app.wear.WearProxyModel +import ai.openclaw.app.wear.WearRealtimeAttemptOwner +import ai.openclaw.app.wear.WearRealtimeTalkController +import ai.openclaw.app.wear.wearConnectionFailure +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearRealtimeTalkCodec +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.SystemClock +import android.util.Base64 +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.webkit.WebViewFeature +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import java.util.Collections +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +private const val MAX_PENDING_NOTIFICATION_EVENTS = 128 +private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L +private const val CRON_RUN_TRACKING_POLL_MS = 2_000L +private const val CRON_JOBS_PAGE_SIZE = 200 +private const val CRON_JOBS_MAX_PAGES = 100 +private const val CRON_JOBS_MAX_COUNT = CRON_JOBS_PAGE_SIZE * CRON_JOBS_MAX_PAGES +private const val CRON_JOBS_SNAPSHOT_MAX_ATTEMPTS = 3 +private const val OperatorAdminScope = "operator.admin" +private const val OperatorPairingScope = "operator.pairing" + +private fun execApprovalOutcomeUnknownMessage(): String = nativeText("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.").source + +private fun execApprovalStillPendingMessage(): String = nativeText("The Gateway still shows this approval as pending. Review it before trying again.").source + +private fun execApprovalLoadDetailsFailureMessage(): String = nativeText("Could not load approval details. Refresh and try again.").source + +private fun execApprovalLoadFailureMessage(): String = nativeText("Could not load approvals.").source + +private fun execApprovalResolveFailureMessage(): String = nativeText("Could not resolve approval. Refresh and try again.").source + +internal typealias GatewayDataRequestOverride = + suspend (stableId: String, method: String, paramsJson: String?) -> String + +internal suspend fun startWearRealtimeTalkWhileCurrent( + owner: WearRealtimeAttemptOwner, + isCurrent: suspend (WearRealtimeAttemptOwner) -> Boolean, + start: suspend (onSessionActivated: () -> Unit) -> Boolean, + stop: suspend (WearRealtimeAttemptOwner) -> Unit, +): Boolean { + if (!isCurrent(owner)) return false + var relayStarted = false + var committed = false + try { + val startReturned = + start { + // The controller invokes this synchronously at activation, before a + // canceled caller can lose the successful suspend result. + relayStarted = true + } + if (!startReturned || !isCurrent(owner)) return false + committed = true + return true + } finally { + // Relay creation suspends outside the channel registry. Never leave a late + // session alive when replacement or cancellation wins before commit. + if (relayStarted && !committed) { + withContext(NonCancellable) { + stop(owner) + } + } + } +} + +private class ExecApprovalWriteOutcomeUnknown : IllegalStateException("approval resolve response was not authoritative") + +private class GatewayApprovalRpcUnavailable : IllegalStateException("Gateway approval RPC catalog is inconsistent") + +data class GatewayDevicePairingCapabilities( + val canList: Boolean = false, + val canApprove: Boolean = false, + val canReject: Boolean = false, + val canRemove: Boolean = false, +) { + val canManage: Boolean + get() = canList && (canApprove || canReject || canRemove) + + internal fun supports(action: GatewayDevicePairingAction): Boolean = + canList && + when (action) { + GatewayDevicePairingAction.Approve -> canApprove + GatewayDevicePairingAction.Reject -> canReject + GatewayDevicePairingAction.Remove -> canRemove + } +} + +internal fun selectGatewayDevicePairingCapabilities( + methods: Set, + scopes: List, +): GatewayDevicePairingCapabilities { + // Extending the limited mobile bootstrap profile with pairing is a gateway-side + // product decision; this UI only reflects hello-granted scopes and methods. + val hasPairingScope = scopes.any { it == OperatorPairingScope || it == OperatorAdminScope } + if (!hasPairingScope) return GatewayDevicePairingCapabilities() + val hasAdminScope = OperatorAdminScope in scopes + return GatewayDevicePairingCapabilities( + canList = "device.pair.list" in methods, + canApprove = "device.pair.approve" in methods, + canReject = "device.pair.reject" in methods, + canRemove = hasAdminScope && "device.pair.remove" in methods, + ) +} + +/** + * Mirrors the gateway's non-admin approval checks for the requested access set. + * See src/gateway/server-methods/devices.ts:268-331 and src/infra/device-pairing.ts:854-873. + */ +internal fun canApproveGatewayDevicePairing( + capabilities: GatewayDevicePairingCapabilities, + callerScopes: List, + pending: GatewayPendingDeviceSummary, +): Boolean { + if (!capabilities.supports(GatewayDevicePairingAction.Approve)) return false + val roles = + pending.roles + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + val scopes = + pending.scopes + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + if (scopes.any { scope -> roles.none { role -> roleAllowsScope(role, scope) } }) return false + + val grantedScopes = callerScopes.map(String::trim).filter(String::isNotEmpty).toSet() + if (OperatorAdminScope in grantedScopes) return true + if (roles.any { it != "operator" }) return false + return scopes.all { scope -> operatorScopeAllowed(scope, grantedScopes) } +} + +private fun roleAllowsScope( + role: String, + scope: String, +): Boolean = + if (role == "operator") { + scope.startsWith("operator.") + } else { + scope.startsWith("$role.") + } + +private fun operatorScopeAllowed( + requestedScope: String, + grantedScopes: Set, +): Boolean = + when (requestedScope) { + "operator.read" -> "operator.read" in grantedScopes || "operator.write" in grantedScopes + "operator.write" -> "operator.write" in grantedScopes + else -> requestedScope in grantedScopes + } + +enum class GatewayDevicePairingAction( + internal val method: String, + internal val idKey: String, + internal val successNotice: NativeText, +) { + Approve("device.pair.approve", "requestId", nativeText("Device approved.")), + Reject("device.pair.reject", "requestId", nativeText("Pairing request rejected.")), + Remove("device.pair.remove", "deviceId", nativeText("Paired device removed.")), +} + +data class GatewayDevicePairingMutation( + val action: GatewayDevicePairingAction, + val targetId: String, +) + +internal sealed interface GatewayDevicePairingMutationOutcome { + data object Approved : GatewayDevicePairingMutationOutcome + + data object Rejected : GatewayDevicePairingMutationOutcome + + data object Removed : GatewayDevicePairingMutationOutcome + + data object NotVerified : GatewayDevicePairingMutationOutcome +} + +internal fun verifyGatewayDevicePairingMutation( + mutation: GatewayDevicePairingMutation, + expectedDeviceId: String, + mutationAccepted: Boolean, + pending: List, + paired: List, +): GatewayDevicePairingMutationOutcome = + if (!mutationAccepted) { + GatewayDevicePairingMutationOutcome.NotVerified + } else { + when (mutation.action) { + GatewayDevicePairingAction.Approve -> + if ( + pending.none { it.requestId == mutation.targetId } && + paired.any { it.deviceId == expectedDeviceId } + ) { + GatewayDevicePairingMutationOutcome.Approved + } else { + GatewayDevicePairingMutationOutcome.NotVerified + } + GatewayDevicePairingAction.Reject -> + if (pending.none { it.requestId == mutation.targetId }) { + GatewayDevicePairingMutationOutcome.Rejected + } else { + GatewayDevicePairingMutationOutcome.NotVerified + } + GatewayDevicePairingAction.Remove -> + if (paired.none { it.deviceId == mutation.targetId }) { + GatewayDevicePairingMutationOutcome.Removed + } else { + GatewayDevicePairingMutationOutcome.NotVerified + } + } + } + +internal fun buildGatewayDevicePairingMutationParams(mutation: GatewayDevicePairingMutation): JsonObject = buildJsonObject { put(mutation.action.idKey, JsonPrimitive(mutation.targetId)) } + +internal enum class SkillWorkshopGatewayAction( + val methodSuffix: String, + val expectedStatus: String, + val notice: NativeText, + val verb: NativeText, +) { + Apply("apply", "applied", nativeText("Proposal applied."), nativeText("apply")), + Reject("reject", "rejected", nativeText("Proposal rejected."), nativeText("reject")), + Quarantine("quarantine", "quarantined", nativeText("Proposal quarantined."), nativeText("quarantine")), +} + +internal fun skillWorkshopUnexpectedStatusText( + status: String?, + action: SkillWorkshopGatewayAction, +): NativeText { + val statusText = status?.takeIf { it.isNotBlank() }?.let(::verbatimText) ?: nativeText("unknown") + return nativeText( + "Gateway returned status '\$statusLabel' after \${action.verb}.", + statusText, + action.verb, + ) +} + +internal fun skillWorkshopActionFailureText(action: SkillWorkshopGatewayAction): NativeText = + nativeText( + "Could not \${action.verb} Skill Workshop proposal.", + action.verb, + ) + +internal data class PendingNotificationNodeEvent( + val event: String, + val payloadJson: String?, + val gatewayId: String? = null, +) + +private data class QueuedNotificationNodeEvent( + val generation: Long, + val event: PendingNotificationNodeEvent, +) + +internal class NotificationNodeEventOutbox( + private val capacity: Int = MAX_PENDING_NOTIFICATION_EVENTS, + private val isAuthorized: (PendingNotificationNodeEvent) -> Boolean = { true }, + private val isConnected: () -> Boolean = { true }, + private val deliveryIntervalMs: () -> Long = { 0L }, + private val nowEpochMs: () -> Long = System::currentTimeMillis, + private val sleep: suspend (Long) -> Unit = { delay(it) }, + private val invalidateConnection: () -> Unit = {}, + private val send: suspend (PendingNotificationNodeEvent) -> NodeEventSendOutcome, +) { + private val stateLock = Any() + private val generation = AtomicLong() + private val lastDeliveryAtMs = AtomicLong(-1L) + private val pending = ArrayDeque(capacity) + private val wakeDelivery = Channel(Channel.CONFLATED) + private var inFlight: QueuedNotificationNodeEvent? = null + + init { + require(capacity > 0) { "capacity must be positive" } + } + + fun enqueue(event: PendingNotificationNodeEvent) { + synchronized(stateLock) { + if (pending.size == capacity) pending.removeFirst() + pending.addLast(QueuedNotificationNodeEvent(generation = generation.get(), event = event)) + } + wakeDelivery.trySend(Unit) + } + + fun clear() { + synchronized(stateLock) { + clearLocked() + } + wakeDelivery.trySend(Unit) + } + + fun updatePolicy(update: () -> T): T { + val result = + synchronized(stateLock) { + // Admission checks share this lock, so the new policy is visible before the next generation. + update().also { clearLocked() } + } + wakeDelivery.trySend(Unit) + return result + } + + fun onConnected() { + wakeDelivery.trySend(Unit) + } + + suspend fun deliver() { + while (true) { + wakeDelivery.receive() + while (true) { + val queued = synchronized(stateLock) { pending.firstOrNull() } ?: break + if (queued.generation != generation.get() || !isAuthorized(queued.event)) { + synchronized(stateLock) { + if (pending.firstOrNull() === queued) pending.removeFirst() + } + continue + } + if (!isConnected()) break + if (!awaitDeliverySlot(queued)) continue + val admitted = + synchronized(stateLock) { + if ( + pending.firstOrNull() !== queued || + queued.generation != generation.get() || + !isAuthorized(queued.event) || + !isConnected() + ) { + false + } else { + pending.removeFirst() + inFlight = queued + true + } + } + if (!admitted) continue + + val outcome = send(queued.event) + synchronized(stateLock) { + if (inFlight === queued) inFlight = null + if (queued.generation == generation.get() && isAuthorized(queued.event)) { + when (outcome) { + NodeEventSendOutcome.COMPLETED -> lastDeliveryAtMs.set(nowEpochMs()) + NodeEventSendOutcome.DISCONNECTED -> { + // This outcome is rejected before send, so it is safe to retain for reconnect. + if (pending.size == capacity) pending.removeLast() + pending.addFirst(queued) + } + // Ambiguous failures may have reached the gateway: do not retry, but charge their rate slot. + NodeEventSendOutcome.FAILED -> lastDeliveryAtMs.set(nowEpochMs()) + } + } + } + if (outcome == NodeEventSendOutcome.DISCONNECTED) break + } + } + } + + private suspend fun awaitDeliverySlot(queued: QueuedNotificationNodeEvent): Boolean { + while (queued.generation == generation.get() && isAuthorized(queued.event)) { + val lastDelivery = lastDeliveryAtMs.get() + if (lastDelivery < 0L) return true + val waitMs = lastDelivery + deliveryIntervalMs().coerceAtLeast(0L) - nowEpochMs() + if (waitMs <= 0L) return true + // Short slices make policy/gateway invalidation responsive without charging stale quota. + sleep(minOf(waitMs, 250L)) + } + return false + } + + private fun clearLocked() { + // Only an admitted RPC needs transport invalidation; queued payloads have no socket side effect. + if (inFlight?.generation == generation.get()) invalidateConnection() + generation.incrementAndGet() + lastDeliveryAtMs.set(-1L) + pending.clear() + } +} + +/** + * Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state. + */ +data class GatewayConnectionProblem( + val code: String?, + val message: String, + val reason: String?, + val requestId: String?, + val recommendedNextStep: String?, + val pauseReconnect: Boolean, + val retryable: Boolean, + val clientMinProtocol: Int? = null, + val clientMaxProtocol: Int? = null, + val expectedProtocol: Int? = null, + val minimumProbeProtocol: Int? = null, +) { + val isPairingRequired: Boolean = code == "PAIRING_REQUIRED" + val canAutoRetry: Boolean = + isPairingRequired && + ( + retryable || + !pauseReconnect || + recommendedNextStep == "wait_then_retry" + ) +} + +data class GatewayConnectionDisplay( + val isConnected: Boolean, + val statusText: String, + val problem: GatewayConnectionProblem?, +) + +private const val GATEWAY_STATUS_OFFLINE = "Offline" +private const val GATEWAY_STATUS_CONNECTED = "Connected" +private const val GATEWAY_STATUS_NODE_OFFLINE = "Connected (node offline)" +private const val GATEWAY_STATUS_OPERATOR_OFFLINE = "Connected (operator offline)" + +private fun gatewayOperatorConnectionState(operator: String): String = "Connected (operator: $operator)" + +internal fun gatewayConnectionStatusForDisplay(statusText: String): String { + val status = statusText.trim() + return when { + status.isEmpty() || status == GATEWAY_STATUS_OFFLINE -> nativeString("Offline") + status == GATEWAY_STATUS_CONNECTED -> nativeString("Connected") + status == GATEWAY_STATUS_NODE_OFFLINE -> nativeString("Connected (node offline)") + status == GATEWAY_STATUS_OPERATOR_OFFLINE -> nativeString("Connected (operator offline)") + status == "Connecting…" -> nativeString("Connecting…") + status == "Reconnecting…" -> nativeString("Reconnecting…") + status == "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected." -> + nativeString("Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.") + status == "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry." -> + nativeString("Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.") + status == "Failed: couldn't reach the secure gateway endpoint for this host." -> + nativeString("Failed: couldn't reach the secure gateway endpoint for this host.") + status.startsWith("Connected (operator: ") && status.endsWith(")") -> + nativeString( + "Connected (operator: \$operator)", + status.removePrefix("Connected (operator: ").dropLast(1), + ) + else -> status + } +} + +private fun gatewayProblemAfterDisconnect( + problem: GatewayConnectionProblem?, + statusText: String, +): GatewayConnectionProblem? = + // Automatic bootstrap pairing retries need their approval guidance until success or a different failure. + problem?.takeIf { statusText == "Reconnecting…" && it.canAutoRetry } + +internal fun gatewayConnectionDisplay( + operatorConnected: Boolean, + nodeConnected: Boolean, + operatorStatusText: String, + nodeStatusText: String, + operatorProblem: GatewayConnectionProblem?, + nodeProblem: GatewayConnectionProblem?, +): GatewayConnectionDisplay { + val operator = operatorStatusText.trim() + val node = nodeStatusText.trim() + return when { + operatorConnected && nodeConnected -> GatewayConnectionDisplay(true, GATEWAY_STATUS_CONNECTED, null) + operatorConnected -> GatewayConnectionDisplay(true, GATEWAY_STATUS_NODE_OFFLINE, nodeProblem) + nodeConnected -> + GatewayConnectionDisplay( + isConnected = false, + statusText = + if (operator.isNotEmpty() && operator != "Offline") { + gatewayOperatorConnectionState(operator) + } else { + GATEWAY_STATUS_OPERATOR_OFFLINE + }, + problem = operatorProblem, + ) + operator.isNotBlank() && operator != "Offline" -> GatewayConnectionDisplay(false, operator, operatorProblem) + else -> GatewayConnectionDisplay(false, node, nodeProblem) + } +} + +private data class AndroidChatStores( + val transcriptCache: ChatTranscriptCache, + val commandOutbox: ChatCommandOutbox, + val clientDatabases: AndroidClientDatabases, + val externalTranscriptCache: ChatTranscriptCache? = null, +) + +internal enum class NodeRuntimeMode { + Live, + ScreenshotFixture, +} + +internal class SessionObserverVisibility( + private val isVisible: () -> Boolean, + private val captureLease: () -> GatewaySession.RequestLease?, +) { + private val mutex = Mutex() + private var appliedLease: GatewaySession.RequestLease? = null + private var appliedVisibility: Boolean? = null + + suspend fun sync() { + mutex.withLock { + val lease = captureLease() ?: return@withLock + val visible = isVisible() + // Socket-bound declarations must survive reconnect without duplicate + // foreground RPCs or leaking a queued update onto the next gateway. + if (appliedVisibility == visible && appliedLease?.isCurrent() == true) return@withLock + // A timeout can mean the Gateway applied this change but lost its reply. + // Invalidate the old confirmation first so the next sync cannot skip recovery. + appliedLease = null + appliedVisibility = null + lease.request( + GatewayMethod.SessionsObserverVisibility.rawValue, + """{"visible":$visible}""", + ) + appliedLease = lease + appliedVisibility = visible + } + } +} + +private fun openAndroidChatStores( + context: Context, + prefs: SecurePrefs, +): AndroidChatStores { + val databases = + AndroidClientDatabases.start( + context.applicationContext, + registeredGatewayIds = + prefs.gatewayRegistry.entries.value + .map { it.stableId } + .toSet(), + ) + return AndroidChatStores( + transcriptCache = databases.transcriptCache(), + commandOutbox = databases.commandOutbox(), + clientDatabases = databases, + ) +} + +private fun openAndroidChatStores( + context: Context, + prefs: SecurePrefs, + transcriptCache: ChatTranscriptCache, +): AndroidChatStores { + val databases = + AndroidClientDatabases.start( + context.applicationContext, + registeredGatewayIds = + prefs.gatewayRegistry.entries.value + .map { it.stableId } + .toSet(), + ) + return AndroidChatStores( + transcriptCache = transcriptCache, + commandOutbox = databases.commandOutbox(), + clientDatabases = databases, + externalTranscriptCache = transcriptCache, + ) +} + +class NodeRuntime private constructor( + context: Context, + val prefs: SecurePrefs, + private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult, + chatStores: AndroidChatStores, + internal val mode: NodeRuntimeMode, + initialForeground: Boolean, + initialReconnectSuppressed: Boolean, +) { + private val chatTranscriptCache = chatStores.transcriptCache + private val chatCommandOutbox = chatStores.commandOutbox + private val clientDatabases = chatStores.clientDatabases + private val externalTranscriptCache = chatStores.externalTranscriptCache + private val gatewayAuthLifecycleLock = Any() + private var gatewayAuthResetInProgress = false + private var gatewayConnectOperationsInFlight = 0 + private var gatewayConnectOperationsDrained = CompletableDeferred(Unit) + + @Volatile private var connectingEndpointStableId: String? = null + private val gatewayDataScopeLock = Any() + private val gatewaySwitchMutex = Mutex() + private val inlineWidgetRefreshMutex = Mutex() + private val gatewayLifecycleIntentLock = Any() + private val gatewayLifecycleIntentSeq = AtomicLong() + private var gatewayDataGeneration = 0L + + private data class GatewayDataScope( + val stableId: String, + val generation: Long, + ) + + private data class GatewayMethodsSnapshot( + val approvalRpcFamily: GatewayApprovalRpcFamily, + val epoch: Long, + ) + + private class PendingExecApprovalWrite( + val stableId: String, + val id: String, + val decision: String, + // Captured at registration: canonical readback needs it after a refresh has + // already replaced the visible rows, or the legacy get parse drops the row. + val createdAtMs: Long?, + ) { + @Volatile var requestInFlight: Boolean = true + } + + private data class CronActionResult( + val message: NativeText, + val kind: GatewayCronNoticeKind, + val refresh: Boolean, + val deleted: Boolean = false, + ) + + constructor( + context: Context, + prefs: SecurePrefs = SecurePrefs(context.applicationContext), + tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint, + ) : this( + context = context, + prefs = prefs, + tlsFingerprintProbe = tlsFingerprintProbe, + chatStores = openAndroidChatStores(context, prefs), + mode = NodeRuntimeMode.Live, + initialForeground = true, + initialReconnectSuppressed = false, + ) + + internal constructor( + context: Context, + prefs: SecurePrefs, + initialForeground: Boolean, + ) : this( + context = context, + prefs = prefs, + tlsFingerprintProbe = ::probeGatewayTlsFingerprint, + chatStores = openAndroidChatStores(context, prefs), + mode = NodeRuntimeMode.Live, + initialForeground = initialForeground, + initialReconnectSuppressed = false, + ) + + internal constructor( + context: Context, + prefs: SecurePrefs, + mode: NodeRuntimeMode, + ) : this( + context = context, + prefs = prefs, + tlsFingerprintProbe = ::probeGatewayTlsFingerprint, + chatStores = openAndroidChatStores(context, prefs), + mode = mode, + initialForeground = true, + initialReconnectSuppressed = false, + ) + + internal constructor( + context: Context, + prefs: SecurePrefs, + chatTranscriptCache: ChatTranscriptCache, + ) : this( + context = context, + prefs = prefs, + tlsFingerprintProbe = ::probeGatewayTlsFingerprint, + chatStores = openAndroidChatStores(context, prefs, chatTranscriptCache), + mode = NodeRuntimeMode.Live, + initialForeground = true, + initialReconnectSuppressed = false, + ) + + companion object { + internal fun forGatewayAuthReset( + context: Context, + prefs: SecurePrefs, + ): NodeRuntime = + NodeRuntime( + context = context, + prefs = prefs, + tlsFingerprintProbe = ::probeGatewayTlsFingerprint, + chatStores = openAndroidChatStores(context, prefs), + mode = NodeRuntimeMode.Live, + initialForeground = true, + initialReconnectSuppressed = true, + ) + } + + /** + * Authentication material supplied by setup/manual connect flows before gateway session routing. + */ + data class GatewayConnectAuth( + val token: String?, + val bootstrapToken: String?, + val password: String?, + ) + + /** + * HTTP(S) page origin plus shared credentials for gateway-served Control UI pages. + * The values come from the same endpoint and auth material that the WS sessions use. + */ + data class GatewayControlPage( + val baseUrl: String, + val token: String?, + val password: String?, + val tlsFingerprintSha256: String?, + ) + + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val deviceAuthStore = DeviceAuthStore(prefs) + val canvas = CanvasController() + val camera = CameraCaptureManager(appContext) { prefs.preferredCameraFacing.value } + val location = LocationCaptureManager(appContext) + val sms = SmsManager(appContext) + private val json = Json { ignoreUnknownKeys = true } + + private val voiceWakeManager = + VoiceWakeManager( + context = appContext, + scope = scope, + recognizer = + when (mode) { + NodeRuntimeMode.Live -> AndroidOnDeviceVoiceWakeRecognizer(appContext) + NodeRuntimeMode.ScreenshotFixture -> PreviewVoiceWakeRecognizer() + }, + initialTriggerWords = VoiceWakePreferences.defaultTriggerWords, + onCommand = ::sendVoiceWakeCommand, + ) + val voiceWakeAvailable: StateFlow = MutableStateFlow(voiceWakeManager.isAvailable).asStateFlow() + val voiceWakeEnabled: StateFlow = prefs.voiceWakeEnabled + val voiceWakeWords: StateFlow> = prefs.voiceWakeWords + val voiceWakeIsListening: StateFlow = voiceWakeManager.isListening + val voiceWakeStatusText: StateFlow = voiceWakeManager.statusText + val voiceWakeLastTriggeredCommand: StateFlow = voiceWakeManager.lastTriggeredCommand + private val voiceWakeWordsSaveSeq = AtomicLong(0) + private val voiceWakeWordsLock = Any() + private var voiceWakeWordsRevision = 0L + private var voiceWakeWordsGatewayStableId: String? = null + private val _voiceWakeWordsSaving = MutableStateFlow(false) + val voiceWakeWordsSaving: StateFlow = _voiceWakeWordsSaving.asStateFlow() + private val _voiceWakeWordsNoticeText = MutableStateFlow(null) + val voiceWakeWordsNoticeText: StateFlow = _voiceWakeWordsNoticeText.resolveOptionalNativeText() + + private val externalAudioCaptureActive = MutableStateFlow(false) + private val _voiceCaptureMode = MutableStateFlow(VoiceCaptureMode.Off) + val voiceCaptureMode: StateFlow = _voiceCaptureMode.asStateFlow() + private val _activeAudioInputDevicePreference = MutableStateFlow(null) + val activeAudioInputDevicePreference: StateFlow = _activeAudioInputDevicePreference.asStateFlow() + + private val discovery = GatewayDiscovery(appContext, scope = scope) + val gateways: StateFlow> = discovery.gateways + val discoveryStatusText: StateFlow = discovery.statusText + + private val identityStore = DeviceIdentityStore.withPrefs(appContext, prefs) + private var connectedEndpoint: GatewayEndpoint? = null + private var activeGatewayAuth: GatewayConnectAuth? = null + + private val cameraHandler: CameraHandler = + CameraHandler( + appContext = appContext, + camera = camera, + setCameraAudioCaptureActive = ::setCameraAudioCaptureActive, + showCameraHud = ::showCameraHud, + invokeErrorFromThrowable = { invokeErrorFromThrowable(it) }, + ) + + private val debugHandler: DebugHandler = + DebugHandler( + appContext = appContext, + identityStore = identityStore, + ) + + private val locationHandler: LocationHandler = + LocationHandler( + appContext = appContext, + location = location, + json = json, + isForeground = { _isForeground.value }, + locationMode = { locationMode.value }, + backgroundLocationEnabled = { SensitiveFeatureConfig.backgroundLocationEnabled }, + locationPreciseEnabled = { locationPreciseEnabled.value }, + ) + + private val permissionSnapshot = { + readAndroidPermissionSnapshot( + context = appContext, + smsEnabled = SensitiveFeatureConfig.smsEnabled, + callLogEnabled = SensitiveFeatureConfig.callLogEnabled, + photosEnabled = SensitiveFeatureConfig.photosEnabled, + backgroundLocationEnabled = SensitiveFeatureConfig.backgroundLocationEnabled, + ) + } + + private val deviceHandler: DeviceHandler = + DeviceHandler.withPermissionSnapshot( + appContext = appContext, + smsEnabled = SensitiveFeatureConfig.smsEnabled, + callLogEnabled = SensitiveFeatureConfig.callLogEnabled, + photosEnabled = SensitiveFeatureConfig.photosEnabled, + permissionSnapshot = permissionSnapshot, + ) + + private val notificationsHandler: NotificationsHandler = + NotificationsHandler( + appContext = appContext, + ) + + private val systemHandler: SystemHandler = + SystemHandler( + appContext = appContext, + ) + + private val photosHandler: PhotosHandler = + PhotosHandler( + appContext = appContext, + ) + + private val contactsHandler: ContactsHandler = + ContactsHandler( + appContext = appContext, + ) + + private val calendarHandler: CalendarHandler = + CalendarHandler( + appContext = appContext, + ) + + private val callLogHandler: CallLogHandler = + CallLogHandler( + appContext = appContext, + ) + + private val motionHandler: MotionHandler = + MotionHandler( + appContext = appContext, + ) + + private val smsHandlerImpl: SmsHandler = + SmsHandler( + sms = sms, + ) + + private val mobileUiHandler = MobileUiHandler() + private var lastMobileUiConnected = mobileUiHandler.isConnected.value + + private val a2uiHandler: A2UIHandler = + A2UIHandler( + canvas = canvas, + json = json, + ) + + private val connectionManager: ConnectionManager = + ConnectionManager( + prefs = prefs, + cameraEnabled = { cameraEnabled.value }, + locationMode = { locationMode.value }, + motionActivityAvailable = { motionHandler.isActivityAvailable() }, + motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, + sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() }, + readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() }, + smsSearchPossible = { SensitiveFeatureConfig.smsEnabled && sms.hasTelephonyFeature() }, + callLogAvailable = { SensitiveFeatureConfig.callLogEnabled }, + photosAvailable = { SensitiveFeatureConfig.photosEnabled }, + installedAppsSharingEnabled = { installedAppsSharingEnabled.value }, + voiceWakeAvailable = { + voiceWakeManager.isAvailable && + hasRecordAudioPermission() && + isVoiceWakeWordsReadyForCurrentGateway() + }, + mobileUiAvailable = { + SensitiveFeatureConfig.accessibilityControlEnabled && mobileUiHandler.isConnected.value + }, + inlineWidgetsAvailable = { WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE) }, + permissionSnapshot = permissionSnapshot, + manualTls = { endpoint -> + prefs.gatewayRegistry.entries.value + .firstOrNull { it.stableId == endpoint.stableId } + ?.tls ?: manualTls.value + }, + ) + private var lastNodePermissions = connectionManager.buildPermissions() + private var lastVoiceWakeCapabilityEnabled = isVoiceWakeCapabilityEnabled() + + private val invokeDispatcher: InvokeDispatcher = + InvokeDispatcher( + canvas = canvas, + cameraHandler = cameraHandler, + locationHandler = locationHandler, + deviceHandler = deviceHandler, + notificationsHandler = notificationsHandler, + systemHandler = systemHandler, + talkHandler = + object : TalkHandler { + override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStart() + + override suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStop() + + override suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttCancel() + + override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttOnce() + }, + photosHandler = photosHandler, + contactsHandler = contactsHandler, + calendarHandler = calendarHandler, + motionHandler = motionHandler, + smsHandler = smsHandlerImpl, + a2uiHandler = a2uiHandler, + debugHandler = debugHandler, + callLogHandler = callLogHandler, + mobileUiHandler = mobileUiHandler, + isForeground = { _isForeground.value }, + cameraEnabled = { cameraEnabled.value }, + locationEnabled = { locationMode.value != LocationMode.Off }, + sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() }, + readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() }, + smsFeatureEnabled = { SensitiveFeatureConfig.smsEnabled }, + smsTelephonyAvailable = { sms.hasTelephonyFeature() }, + callLogAvailable = { SensitiveFeatureConfig.callLogEnabled }, + photosAvailable = { SensitiveFeatureConfig.photosEnabled }, + installedAppsSharingEnabled = { installedAppsSharingEnabled.value }, + debugBuild = { BuildConfig.DEBUG }, + onCanvasA2uiPush = { + _canvasA2uiHydrated.value = true + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + }, + onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, + motionActivityAvailable = { motionHandler.isActivityAvailable() }, + motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, + mobileUiAvailable = { + SensitiveFeatureConfig.accessibilityControlEnabled && mobileUiHandler.isConnected.value + }, + ) + + /** + * Pending TLS trust decision when a gateway certificate is new or has changed. + */ + data class GatewayTrustPrompt( + val endpoint: GatewayEndpoint, + val fingerprintSha256: String?, + val auth: GatewayConnectAuth, + val previousFingerprintSha256: String? = null, + val probeFailure: GatewayTlsProbeFailure? = null, + val systemTrustAvailable: Boolean = false, + ) + + data class VoiceE2eSliceResult( + val mode: String, + val status: String, + val userText: String?, + val assistantText: String?, + ) + + data class VoiceE2eResult( + val normal: VoiceE2eSliceResult?, + val realtime: VoiceE2eSliceResult?, + ) + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + private val _gatewayControlPage = MutableStateFlow(null) + val gatewayControlPage: StateFlow = _gatewayControlPage.asStateFlow() + private val _nodeConnected = MutableStateFlow(false) + val nodeConnected: StateFlow = _nodeConnected.asStateFlow() + private val _nodeCapabilityApproval = MutableStateFlow(GatewayNodeCapabilityApproval.Loading) + val nodeCapabilityApproval: StateFlow = _nodeCapabilityApproval.asStateFlow() + + private val _gatewayConnectionDisplay = MutableStateFlow(GatewayConnectionDisplay(false, GATEWAY_STATUS_OFFLINE, null)) + val gatewayConnectionDisplay: StateFlow = _gatewayConnectionDisplay.asStateFlow() + private val _statusText = MutableStateFlow(GATEWAY_STATUS_OFFLINE) + val statusText: StateFlow = _statusText.asStateFlow() + private val _gatewayConnectionProblem = MutableStateFlow(null) + val gatewayConnectionProblem: StateFlow = _gatewayConnectionProblem.asStateFlow() + private val _operatorScopes = MutableStateFlow>(emptyList()) + val operatorScopes: StateFlow> = _operatorScopes.asStateFlow() + val operatorAdminScopeAvailable: StateFlow = + operatorScopes + .map { scopes -> scopes.any { it == OperatorAdminScope } } + .stateIn(scope, SharingStarted.Eagerly, false) + + private val _pendingGatewayTrust = MutableStateFlow(null) + val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() + private val connectAttemptSeq = AtomicLong(0) + + /** + * Builds the node-owned session key from stable device identity plus optional active agent. + */ + private fun resolveNodeMainSessionKey(agentId: String? = null): String { + val deviceId = identityStore.loadOrCreate().deviceId + return buildNodeMainSessionKey(deviceId, agentId) + } + + private val _mainSessionKey = MutableStateFlow(resolveNodeMainSessionKey()) + val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() + + private val cameraHudSeq = AtomicLong(0) + private val _cameraHud = MutableStateFlow(null) + val cameraHud: StateFlow = _cameraHud.asStateFlow() + + private val _canvasA2uiHydrated = MutableStateFlow(false) + val canvasA2uiHydrated: StateFlow = _canvasA2uiHydrated.asStateFlow() + private val _canvasRehydratePending = MutableStateFlow(false) + val canvasRehydratePending: StateFlow = _canvasRehydratePending.asStateFlow() + private val _canvasRehydrateErrorText = MutableStateFlow(null) + val canvasRehydrateErrorText: StateFlow = _canvasRehydrateErrorText.resolveOptionalNativeText() + + private val _serverName = MutableStateFlow(null) + val serverName: StateFlow = _serverName.asStateFlow() + + private val _remoteAddress = MutableStateFlow(null) + val remoteAddress: StateFlow = _remoteAddress.asStateFlow() + + private val _gatewayVersion = MutableStateFlow(null) + val gatewayVersion: StateFlow = _gatewayVersion.asStateFlow() + + private val _gatewayUpdateAvailable = MutableStateFlow(null) + val gatewayUpdateAvailable: StateFlow = _gatewayUpdateAvailable.asStateFlow() + + private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB) + val seamColorArgb: StateFlow = _seamColorArgb.asStateFlow() + private val _modelCatalog = MutableStateFlow>(emptyList()) + val modelCatalog: StateFlow> = _modelCatalog.asStateFlow() + private val _providerModelCatalog = MutableStateFlow>(emptyList()) + val providerModelCatalog: StateFlow> = _providerModelCatalog.asStateFlow() + private val _providerModelCatalogRefreshing = MutableStateFlow(false) + val providerModelCatalogRefreshing: StateFlow = _providerModelCatalogRefreshing.asStateFlow() + private val _providerModelCatalogErrorText = MutableStateFlow(null) + val providerModelCatalogErrorText: StateFlow = _providerModelCatalogErrorText.resolveOptionalNativeText() + private val providerModelCatalogRefreshGuard = LatestGatewayRefreshGuard() + private val _modelAuthProviders = MutableStateFlow>(emptyList()) + val modelAuthProviders: StateFlow> = _modelAuthProviders.asStateFlow() + private val _modelCatalogRefreshing = MutableStateFlow(false) + val modelCatalogRefreshing: StateFlow = _modelCatalogRefreshing.asStateFlow() + private val _modelCatalogErrorText = MutableStateFlow(null) + val modelCatalogErrorText: StateFlow = _modelCatalogErrorText.resolveOptionalNativeText() + private val _talkSetupReadiness = MutableStateFlow(GatewayTalkSetupReadiness.unverified()) + val talkSetupReadiness: StateFlow = _talkSetupReadiness.asStateFlow() + private val _gatewayDefaultAgentId = MutableStateFlow(null) + val gatewayDefaultAgentId: StateFlow = _gatewayDefaultAgentId.asStateFlow() + private val gatewayDefaultAgentRevision = AtomicLong(0) + private var gatewayDefaultAgentStableId: String? = null + + private fun updateGatewayDefaultAgentId(agentId: String?) { + val normalized = agentId?.trim()?.ifEmpty { null } + val ownerStableId = normalized?.let { chatCacheGatewayId() } + if (_gatewayDefaultAgentId.value == normalized && gatewayDefaultAgentStableId == ownerStableId) return + // Revision first: a send may observe either side of the value write, but never a new + // owner paired with the previous epoch during an A -> B -> A transition. + gatewayDefaultAgentRevision.incrementAndGet() + _gatewayDefaultAgentId.value = normalized + gatewayDefaultAgentStableId = ownerStableId + chat.onDefaultAgentChanged(normalized) + } + + private val _gatewayAgents = MutableStateFlow>(emptyList()) + val gatewayAgents: StateFlow> = _gatewayAgents.asStateFlow() + + // Preserve an explicit user choice across metadata refreshes. Gateway reconnects + // clear it so the newly connected gateway's canonical main agent wins again. + @Volatile private var selectedChatAgentId: String? = null + private val _cronStatus = MutableStateFlow(GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) + val cronStatus: StateFlow = _cronStatus.asStateFlow() + private val _cronJobs = MutableStateFlow>(emptyList()) + val cronJobs: StateFlow> = _cronJobs.asStateFlow() + private val _cronRefreshing = MutableStateFlow(false) + val cronRefreshing: StateFlow = _cronRefreshing.asStateFlow() + private val _cronErrorText = MutableStateFlow(null) + val cronErrorText: StateFlow = _cronErrorText.resolveOptionalNativeText() + private val _cronJobDetailState = MutableStateFlow(GatewayCronJobDetailState.Idle) + val cronJobDetailState: StateFlow = _cronJobDetailState.asStateFlow() + private val _cronRunHistoryState = MutableStateFlow(GatewayCronRunHistoryState.Idle) + val cronRunHistoryState: StateFlow = _cronRunHistoryState.asStateFlow() + private val _cronActionState = MutableStateFlow(GatewayCronActionState.Idle) + val cronActionState: StateFlow = _cronActionState.asStateFlow() + private val _pendingCronRunJobIds = MutableStateFlow>(emptySet()) + val pendingCronRunJobIds: StateFlow> = _pendingCronRunJobIds.asStateFlow() + private val cronJobDetailRequestGuard = CronJobDetailRequestGuard() + private val cronRunHistoryRequestGuard = CronJobDetailRequestGuard() + private val cronRefreshGuard = LatestGatewayRefreshGuard() + private val cronActionMutex = Mutex() + private val pendingCronRunRegistry = PendingCronRunRegistry() + private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) + val usageSummary: StateFlow = _usageSummary.asStateFlow() + private val _usageRefreshing = MutableStateFlow(false) + val usageRefreshing: StateFlow = _usageRefreshing.asStateFlow() + private val _usageErrorText = MutableStateFlow(null) + val usageErrorText: StateFlow = _usageErrorText.resolveOptionalNativeText() + private val _skillsSummary = MutableStateFlow(GatewaySkillsSummary(skills = emptyList())) + val skillsSummary: StateFlow = _skillsSummary.asStateFlow() + private val _skillsRefreshing = MutableStateFlow(false) + val skillsRefreshing: StateFlow = _skillsRefreshing.asStateFlow() + private val _skillsErrorText = MutableStateFlow(null) + val skillsErrorText: StateFlow = _skillsErrorText.resolveOptionalNativeText() + private val _clawHubSkillMethodsAvailable = MutableStateFlow(false) + val clawHubSkillMethodsAvailable: StateFlow = _clawHubSkillMethodsAvailable.asStateFlow() + private val systemAgentChatSupported = MutableStateFlow(null) + private val _skillMutationKeys = MutableStateFlow>(emptySet()) + val skillMutationKeys: StateFlow> = _skillMutationKeys.asStateFlow() + private val _clawHubSkillSearchState = MutableStateFlow(GatewayClawHubSkillSearchState()) + val clawHubSkillSearchState: StateFlow = + _clawHubSkillSearchState.asStateFlow() + private val clawHubSkillSearchSeq = AtomicLong(0) + private val clawHubSkillReviewSeq = AtomicLong(0) + private val clawHubSkillInstallMutex = Mutex() + private val _skillWorkshopSummary = MutableStateFlow(GatewaySkillWorkshopSummary(proposals = emptyList())) + val skillWorkshopSummary: StateFlow = _skillWorkshopSummary.asStateFlow() + private val _skillWorkshopRefreshing = MutableStateFlow(false) + val skillWorkshopRefreshing: StateFlow = _skillWorkshopRefreshing.asStateFlow() + private val _skillWorkshopErrorText = MutableStateFlow(null) + val skillWorkshopErrorText: StateFlow = _skillWorkshopErrorText.resolveOptionalNativeText() + private val _skillWorkshopNoticeText = MutableStateFlow(null) + val skillWorkshopNoticeText: StateFlow = _skillWorkshopNoticeText.resolveOptionalNativeText() + private val _skillWorkshopInspectingProposalId = MutableStateFlow(null) + val skillWorkshopInspectingProposalId: StateFlow = _skillWorkshopInspectingProposalId.asStateFlow() + private val _skillWorkshopMutatingProposalId = MutableStateFlow(null) + val skillWorkshopMutatingProposalId: StateFlow = _skillWorkshopMutatingProposalId.asStateFlow() + private val skillWorkshopListSeq = AtomicLong(0) + private val skillWorkshopInspectSeq = AtomicLong(0) + private val skillWorkshopMutationSeq = AtomicLong(0) + private val _nodesDevicesSummary = + MutableStateFlow( + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ), + ) + val nodesDevicesSummary: StateFlow = _nodesDevicesSummary.asStateFlow() + private val _nodesDevicesRefreshing = MutableStateFlow(false) + val nodesDevicesRefreshing: StateFlow = _nodesDevicesRefreshing.asStateFlow() + private val _nodesDevicesErrorText = MutableStateFlow(null) + val nodesDevicesErrorText: StateFlow = _nodesDevicesErrorText.resolveOptionalNativeText() + private val _nodesDevicesNoticeText = MutableStateFlow(null) + val nodesDevicesNoticeText: StateFlow = _nodesDevicesNoticeText.resolveOptionalNativeText() + private val _devicePairingCapabilities = MutableStateFlow(GatewayDevicePairingCapabilities()) + val devicePairingCapabilities: StateFlow = + _devicePairingCapabilities.asStateFlow() + private val _devicePairingMutation = MutableStateFlow(null) + val devicePairingMutation: StateFlow = _devicePairingMutation.asStateFlow() + private val devicePairingMutationLock = Any() + private val nodeApprovalRefreshGuard = LatestGatewayRefreshGuard() + private val _execApprovals = MutableStateFlow>(emptyList()) + val execApprovals: StateFlow> = _execApprovals.asStateFlow() + private val _execApprovalsRefreshing = MutableStateFlow(false) + val execApprovalsRefreshing: StateFlow = _execApprovalsRefreshing.asStateFlow() + private val _execApprovalsErrorText = MutableStateFlow(null) + val execApprovalsErrorText: StateFlow = _execApprovalsErrorText.asStateFlow() + private val _execApprovalsNotice = MutableStateFlow(null) + val execApprovalsNotice: StateFlow = _execApprovalsNotice.asStateFlow() + private val execApprovalsRefreshSeq = AtomicLong(0) + private val execApprovalsStateLock = Any() + private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap()) + private val pendingExecApprovalWrites = mutableMapOf() + + // Each hello pins one approval RPC family. The epoch prevents an old socket's + // response from publishing into a replacement socket on the same stable endpoint. + private val gatewayMethodsLock = Any() + private var gatewayApprovalRpcFamily = GatewayApprovalRpcFamily.Unavailable + private var gatewayMethodsEpoch = 0L + + @Volatile internal var gatewayDataRequestOverrideForTests: GatewayDataRequestOverride? = null + + @Volatile internal var gatewayDataRequestTimeoutObserverForTests: ((method: String, timeoutMs: Long) -> Unit)? = null + + @Volatile internal var clawHubSkillInstallBeforeClaimObserverForTests: (() -> Unit)? = null + private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList())) + val channelsSummary: StateFlow = _channelsSummary.asStateFlow() + private val _channelsRefreshing = MutableStateFlow(false) + val channelsRefreshing: StateFlow = _channelsRefreshing.asStateFlow() + private val _channelsErrorText = MutableStateFlow(null) + val channelsErrorText: StateFlow = _channelsErrorText.resolveOptionalNativeText() + private val _dreamingSummary = MutableStateFlow(GatewayDreamingSummary()) + val dreamingSummary: StateFlow = _dreamingSummary.asStateFlow() + private val _dreamingRefreshing = MutableStateFlow(false) + val dreamingRefreshing: StateFlow = _dreamingRefreshing.asStateFlow() + private val _dreamingErrorText = MutableStateFlow(null) + val dreamingErrorText: StateFlow = _dreamingErrorText.resolveOptionalNativeText() + private val _healthLogsSummary = MutableStateFlow(GatewayHealthLogsSummary()) + val healthLogsSummary: StateFlow = _healthLogsSummary.asStateFlow() + private val _healthLogsRefreshing = MutableStateFlow(false) + val healthLogsRefreshing: StateFlow = _healthLogsRefreshing.asStateFlow() + private val _healthLogsErrorText = MutableStateFlow(null) + val healthLogsErrorText: StateFlow = _healthLogsErrorText.resolveOptionalNativeText() + + private val _isForeground = MutableStateFlow(initialForeground) + val isForeground: StateFlow = _isForeground.asStateFlow() + + private data class TalkPttOwnership( + val captureId: String, + val epoch: Long, + ) + + private data class VoiceWakeSuppressionUpdate( + val reason: VoiceWakeSuppressionReason, + val suppressed: Boolean, + val revision: Long, + ) + + private val voiceLifecycleEpoch = AtomicLong() + private val voiceCaptureOwnershipEpoch = AtomicLong() + private val talkPttCommandEpoch = AtomicLong() + private val talkPttOwnership = AtomicReference() + + // Keep ownership epochs and their service/capture state transitions atomic. + // Otherwise stale PTT cleanup can pass its epoch check before a UI mode change. + private val voiceCaptureOwnershipLock = Any() + private var voiceWakeSuppressionRevision = 0L + private var voiceNoteOwnsMic = false + private var dictationOwnsMic = false + private var cameraAudioOwnsMic = false + private val voiceReplySpeechDepth = AtomicInteger(0) + private val voiceCapturePreparationMutex = Mutex() + + private var didAutoRequestCanvasRehydrate = false + private val canvasRehydrateSeq = AtomicLong(0) + + @Volatile private var nodePresenceAliveLastSuccessAtMs: Long? = null + private var operatorConnected = false + private var operatorStatusText: String = "Offline" + private var nodeStatusText: String = "Offline" + private var operatorConnectionProblem: GatewayConnectionProblem? = null + private var nodeConnectionProblem: GatewayConnectionProblem? = null + private val gatewayStatusLock = Any() + + private val operatorSession: GatewaySession = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { hello -> + recordConnectedGateway() + _serverName.value = hello.serverName + _remoteAddress.value = hello.remoteAddress + _gatewayVersion.value = hello.serverVersion + _gatewayUpdateAvailable.value = hello.updateAvailable + replaceGatewayMethods(hello.methods) + val operatorScopes = normalizeOperatorScopes(hello.authScopes) + _operatorScopes.value = operatorScopes + _devicePairingCapabilities.value = + selectGatewayDevicePairingCapabilities(hello.methods, operatorScopes) + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + val mainSessionKey = + prepareMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey)) + // Create/adopt before history refresh; this keeps the first connected read on the + // device-owned session without changing the shipped key or its existing transcript. + chat.onGatewayConnected(mainSessionBinding(mainSessionKey)) + refreshGatewayControlPage() + updateStatus { + operatorConnectionProblem = null + operatorConnected = true + operatorStatusText = "Connected" + } + // Method and scope snapshots are synchronous above; refresh only after both so + // this route cannot inherit readiness from the connection it replaced. + systemAgentChatController.refresh(startIfNeeded = false) + micCapture.onGatewayConnectionChanged(true) + wearProxyBridge()?.publishConnection(connected = true, status = "Connected") + scope.launch { + subscribeOperatorSessionEvents() + refreshWakeWordsFromGateway() + refreshExecApprovalsFromGateway() + refreshHomeCanvasOverviewIfConnected() + if (voiceReplySpeakerLazy.isInitialized()) { + voiceReplySpeaker.refreshConfig() + } + } + }, + onDisconnected = { message -> + if (wearRealtimeTalkControllerLazy.isInitialized()) wearRealtimeTalkController.abort() + clearOperatorGatewayState(retirePendingCronRuns = false) + chat.applyMainSessionKey(resolveMainSessionKey()) + chat.onDisconnected(message) + val wearFailure = wearConnectionFailure(operatorConnectionProblem?.code, message) + updateStatus { + operatorConnected = false + operatorStatusText = message + operatorConnectionProblem = gatewayProblemAfterDisconnect(operatorConnectionProblem, message) + } + systemAgentChatController.refresh(startIfNeeded = false) + micCapture.onGatewayConnectionChanged(false) + wearProxyBridge()?.publishConnection( + connected = false, + status = message, + failure = wearFailure, + ) + }, + onConnectFailure = { error, pauseReconnect -> + if (wearRealtimeTalkControllerLazy.isInitialized()) wearRealtimeTalkController.abort() + val problem = gatewayConnectionProblem(error, pauseReconnect) + updateStatus { + operatorConnected = false + operatorStatusText = problem.message + operatorConnectionProblem = problem + } + systemAgentChatController.refresh(startIfNeeded = false) + micCapture.onGatewayConnectionChanged(false) + wearProxyBridge()?.publishConnection( + connected = false, + status = problem.message, + failure = wearConnectionFailure(problem.code, problem.message), + ) + }, + onEvent = { event, payloadJson -> + handleGatewayEvent(event, payloadJson) + }, + customHeadersProvider = prefs::loadGatewayCustomHeaders, + ) + + private val sessionObserverVisibility = + SessionObserverVisibility( + isVisible = { _isForeground.value }, + captureLease = { operatorSession.captureRequestLease() }, + ) + + private val systemAgentChatController by lazy { + SystemAgentChatController( + scope = scope, + access = { + SystemAgentGatewayAccess( + connected = operatorConnected, + hasAdminScope = _operatorScopes.value.any { it == OperatorAdminScope }, + supportsMethod = systemAgentChatSupported.value, + gatewayId = + when (mode) { + NodeRuntimeMode.Live -> operatorSession.currentEndpointStableId() + NodeRuntimeMode.ScreenshotFixture -> AndroidScreenshotFixture.gatewayId + }, + ) + }, + captureLease = { gatewayId -> + when (mode) { + NodeRuntimeMode.Live -> operatorSession.captureRequestLease(gatewayId) + NodeRuntimeMode.ScreenshotFixture -> + GatewaySession.RequestLease(endpointStableId = AndroidScreenshotFixture.gatewayId) { method, paramsJson, _ -> + AndroidScreenshotFixture.request(method, paramsJson) + } + } + }, + json = json, + ) + } + internal val systemAgentChatState: StateFlow + get() = systemAgentChatController.state + + private data class SecondaryOperatorRuntime( + val endpoint: GatewayEndpoint, + val session: GatewaySession, + ) + + private val secondaryOperatorSessions = ConcurrentHashMap() + private val _backgroundGatewayStatuses = MutableStateFlow>(emptyMap()) + val backgroundGatewayStatuses: StateFlow> = _backgroundGatewayStatuses.asStateFlow() + + private val wearProxyController by lazy { + WearProxyController( + requestGateway = ::requestWearGateway, + isGatewayConnected = operatorSession::isReady, + gatewayStatusText = { synchronized(gatewayStatusLock) { operatorStatusText } }, + activeAgentId = { + resolveAgentIdFromMainSessionKey(mainSessionKey.value) ?: gatewayDefaultAgentId.value + }, + activeSessionKey = { chatSessionKey.value }, + selectedModelRef = { chatSelectedModelRef.value }, + agents = { + gatewayAgents.value.selectableAgents().map { agent -> + WearProxyAgent( + id = agent.id, + name = agent.name, + emoji = agent.emoji, + ) + } + }, + selectGatewayAgent = { agentId -> + if (gatewayAgents.value.selectableAgents().none { agent -> agent.id == agentId }) { + false + } else { + selectChatAgent(agentId) + true + } + }, + models = { + chatModelCatalog.value + .asSequence() + .filter { model -> model.available != false } + .map { model -> + val provider = model.provider.trim() + val ref = + if (provider.isEmpty() || model.id.startsWith("$provider/")) { + model.id + } else { + "$provider/${model.id}" + } + WearProxyModel(ref = ref, name = model.name) + }.toList() + }, + selectSessionModel = { sessionKey, modelRef -> + chat.setSessionModelAwait(sessionKey = sessionKey, modelRef = modelRef) + }, + connectGateway = { refreshGatewayConnection() }, + disconnectGateway = { disconnect() }, + startRealtimeTalk = { nodeId, sessionKey, attemptId, language, attemptScopedAudio -> + if (startWearRealtimeTalk(nodeId, sessionKey, attemptId, language, attemptScopedAudio)) wearRealtimeTalkSnapshot.value else null + }, + stopRealtimeTalk = { nodeId, attemptId -> + if (stopWearRealtimeTalk(nodeId, attemptId)) wearRealtimeTalkSnapshot.value else null + }, + ) + } + + internal suspend fun handleWearProxyRequest( + sourceNodeId: String, + request: WearMessage.Request, + ): WearMessage.Response = wearProxyController.handle(request, sourceNodeId) + + private suspend fun requestWearGateway( + method: String, + params: JsonObject, + ): JsonElement { + val lease = + operatorSession.captureRequestLease() + ?: throw WearProxyGatewayException("unavailable", "Phone gateway is offline") + val response = + try { + lease.request(method, params.toString()) + } catch (err: GatewayRequestRejected) { + throw WearProxyGatewayException(err.gatewayError.code, err.gatewayError.message) + } catch (_: GatewayRequestNotEnqueued) { + throw WearProxyGatewayException("unavailable", "Phone gateway is offline") + } catch (_: GatewayRequestOutcomeUnknown) { + throw WearProxyGatewayException("unavailable", "Phone gateway request outcome is unknown") + } + return try { + json.parseToJsonElement(response) + } catch (_: Throwable) { + throw WearProxyGatewayException("invalid_response", "$method returned invalid JSON") + } + } + + private fun wearProxyBridge(): WearProxyBridge? = (appContext as? NodeApp)?.wearProxyBridge + + private fun clearOperatorGatewayState(retirePendingCronRuns: Boolean) { + invalidateNodeCapabilityApprovalState() + _serverName.value = null + _remoteAddress.value = null + _gatewayVersion.value = null + _gatewayUpdateAvailable.value = null + replaceGatewayMethods(emptySet()) + _operatorScopes.value = emptyList() + _devicePairingCapabilities.value = GatewayDevicePairingCapabilities() + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + _gatewayAgents.value = emptyList() + selectedChatAgentId = null + _modelCatalog.value = emptyList() + providerModelCatalogRefreshGuard.invalidate() + _providerModelCatalog.value = emptyList() + _providerModelCatalogRefreshing.value = false + _providerModelCatalogErrorText.value = null + _modelAuthProviders.value = emptyList() + _modelCatalogRefreshing.value = false + _modelCatalogErrorText.value = null + _talkSetupReadiness.value = GatewayTalkSetupReadiness.unverified() + voiceWakeWordsSaveSeq.incrementAndGet() + _voiceWakeWordsSaving.value = false + _voiceWakeWordsNoticeText.value = null + cronRefreshGuard.invalidate() + _cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null) + _cronJobs.value = emptyList() + _cronRefreshing.value = false + _cronErrorText.value = null + cronJobDetailRequestGuard.cancel { _cronJobDetailState.value = GatewayCronJobDetailState.Idle } + cronRunHistoryRequestGuard.cancel { _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle } + _cronActionState.value = GatewayCronActionState.Idle + if (retirePendingCronRuns) { + pendingCronRunRegistry.clear { _pendingCronRunJobIds.value = it } + } + _usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList()) + _usageRefreshing.value = false + _usageErrorText.value = null + _skillsSummary.value = GatewaySkillsSummary(skills = emptyList()) + _skillsRefreshing.value = false + _skillsErrorText.value = null + _skillMutationKeys.value = emptySet() + clawHubSkillSearchSeq.incrementAndGet() + clawHubSkillReviewSeq.incrementAndGet() + _clawHubSkillSearchState.value = GatewayClawHubSkillSearchState() + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(proposals = emptyList()) + _skillWorkshopRefreshing.value = false + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = null + skillWorkshopListSeq.incrementAndGet() + skillWorkshopInspectSeq.incrementAndGet() + skillWorkshopMutationSeq.incrementAndGet() + _nodesDevicesSummary.value = + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ) + _nodesDevicesRefreshing.value = false + _nodesDevicesErrorText.value = null + _nodesDevicesNoticeText.value = null + synchronized(devicePairingMutationLock) { + _devicePairingMutation.value = null + } + invalidateExecApprovalRefreshes() + resolvedExecApprovalIds.clear() + if (retirePendingCronRuns) { + synchronized(execApprovalsStateLock) { pendingExecApprovalWrites.clear() } + } + _execApprovals.value = emptyList() + _execApprovalsRefreshing.value = false + _execApprovalsErrorText.value = null + _execApprovalsNotice.value = null + _channelsSummary.value = GatewayChannelsSummary(channels = emptyList()) + _channelsRefreshing.value = false + _channelsErrorText.value = null + _dreamingSummary.value = GatewayDreamingSummary() + _dreamingRefreshing.value = false + _dreamingErrorText.value = null + _healthLogsSummary.value = GatewayHealthLogsSummary() + _healthLogsRefreshing.value = false + _healthLogsErrorText.value = null + } + + private suspend fun subscribeOperatorSessionEvents() { + try { + operatorSession.request(GatewayMethod.SessionsSubscribe.rawValue, null) + } catch (err: Throwable) { + Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}") + } + syncSessionObserverVisibility() + } + + private suspend fun syncSessionObserverVisibility() { + try { + sessionObserverVisibility.sync() + } catch (err: Throwable) { + Log.d( + "OpenClawRuntime", + "sessions.observer.visibility failed: ${err.message ?: err::class.java.simpleName}", + ) + } + } + + private val nodeSession = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { + recordConnectedGateway() + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + updateStatus { + nodeConnectionProblem = null + _nodeConnected.value = true + nodeStatusText = "Connected" + } + notificationOutbox.onConnected() + resetLocalCanvas() + publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect) + val endpoint = connectedEndpoint + val auth = activeGatewayAuth + if (operatorConnected) { + scope.launch { refreshNodesDevicesFromGateway() } + } else if (endpoint != null && auth != null) { + maybeStartOperatorSessionAfterNodeConnect(endpoint, auth) + } + }, + onDisconnected = { message -> + invalidateNodeCapabilityApprovalState() + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + updateStatus { + _nodeConnected.value = false + nodeStatusText = message + nodeConnectionProblem = gatewayProblemAfterDisconnect(nodeConnectionProblem, message) + } + resetLocalCanvas() + }, + onConnectFailure = { error, pauseReconnect -> + updateStatus { + nodeConnectionProblem = gatewayConnectionProblem(error, pauseReconnect) + } + if (operatorConnected && nodeConnectFailureNeedsApprovalRefresh(error)) { + scope.launch { refreshNodesDevicesFromGateway() } + } + }, + onEvent = ::handleNodeGatewayEvent, + onInvoke = { req -> + invokeDispatcher.handleInvoke(req.command, req.paramsJson) + }, + onTlsFingerprint = { stableId, fingerprint -> + prefs.saveGatewayTlsFingerprint(stableId, fingerprint) + }, + customHeadersProvider = prefs::loadGatewayCustomHeaders, + ) + + /** + * Triggers an immediate gateway reconnect when Android reports a validated transport + * restore, instead of waiting for the time-based backoff slot in [GatewaySession]. + * Each session keeps ownership of desired-connection and auth-pause decisions. + */ + private val networkMonitor = NetworkMonitor(appContext, ::retryGatewaySessionsAfterNetworkRestore) + + private fun retryGatewaySessionsAfterNetworkRestore() { + launchGatewayLifecycle { + operatorSession.retryAfterNetworkRestore() + nodeSession.retryAfterNetworkRestore() + } + } + + private val notificationOutbox: NotificationNodeEventOutbox by lazy { + NotificationNodeEventOutbox( + isAuthorized = ::isNotificationEventStillAuthorized, + isConnected = nodeSession::isReady, + deliveryIntervalMs = ::notificationDeliveryIntervalMs, + invalidateConnection = nodeSession::reconnect, + send = { pending -> + nodeSession.sendNodeEventWithOutcomeForEndpoint( + expectedEndpointStableId = pending.gatewayId, + event = pending.event, + payloadJson = pending.payloadJson, + ) + }, + ) + } + + private fun notificationDeliveryIntervalMs(): Long { + val maxEvents = + prefs.notificationForwardingMaxEventsPerMinute.value + .coerceAtLeast(1) + .toLong() + return (60_000L + maxEvents - 1L) / maxEvents + } + + private fun isNotificationEventStillAuthorized(event: PendingNotificationNodeEvent): Boolean { + if (event.event != "notifications.changed") return false + if (!DeviceNotificationListenerService.isAccessEnabled(appContext)) return false + val payload = + runCatching { event.payloadJson?.let(json::parseToJsonElement).asObjectOrNull() } + .getOrNull() + ?: return false + val packageName = payload["packageName"].asStringOrNull()?.trim().orEmpty() + if (packageName.isEmpty()) return false + val policy = prefs.getNotificationForwardingPolicy(appPackageName = appContext.packageName) + if (event.gatewayId != null && event.gatewayId != prefs.gatewayRegistry.activeStableId.value) return false + val eventSessionKey = payload["sessionKey"].asStringOrNull()?.trim()?.ifEmpty { null } + return policy.enabled && + policy.sessionKey == eventSessionKey && + policy.allowsPackage(packageName) && + !policy.isWithinQuietHours(nowEpochMs = System.currentTimeMillis()) + } + + init { + if (mode == NodeRuntimeMode.Live) { + scope.launch { notificationOutbox.deliver() } + DeviceNotificationListenerService.setNodeEventSink { event, payloadJson -> + notificationOutbox.enqueue( + PendingNotificationNodeEvent( + event = event, + payloadJson = payloadJson, + gatewayId = prefs.gatewayRegistry.activeStableId.value, + ), + ) + } + } + } + + private val chatSessionDeletionListenerSequence = AtomicLong() + private val chatSessionDeletionListeners = ConcurrentHashMap Unit>() + + internal fun addChatSessionDeletionListener(listener: (ChatSessionDeletion) -> Unit): () -> Unit { + val id = chatSessionDeletionListenerSequence.incrementAndGet() + chatSessionDeletionListeners[id] = listener + return { chatSessionDeletionListeners.remove(id) } + } + + private fun publishChatSessionDeletion(deletion: ChatSessionDeletion) { + chatSessionDeletionListeners.values.forEach { listener -> listener(deletion) } + } + + private val chat: ChatController = + when (mode) { + NodeRuntimeMode.Live -> + ChatController( + scope = scope, + session = operatorSession, + json = json, + transcriptCache = chatTranscriptCache, + cacheScope = ::chatCacheScope, + currentDefaultAgentId = { gatewayDefaultAgentId.value }, + currentDefaultAgentRevision = gatewayDefaultAgentRevision::get, + commandOutbox = chatCommandOutbox, + recordModelRecent = prefs::recordModelRecent, + onSessionDeleted = ::publishChatSessionDeletion, + onOfflineDefaultAgentRestored = ::syncMainSessionKey, + ) + NodeRuntimeMode.ScreenshotFixture -> + ChatController( + scope = scope, + json = json, + requestGateway = AndroidScreenshotFixture::request, + ) + }.also { + it.applyMainSessionKey(_mainSessionKey.value) + } + + private val messageSpeechControllerLazy = + lazy { + MessageSpeechController( + scope = scope, + synthesizer = MessageSpeechClient(session = operatorSession, json = json), + player = TalkAudioPlayer(appContext), + localSpeech = SystemSpeechSpeaker(appContext), + ).also { controller -> + scope.launch { + controller.state.collect { state -> + voiceWakeManager.setSuppressed(VoiceWakeSuppressionReason.MessageSpeech, state != null) + } + } + } + } + private val messageSpeechController: MessageSpeechController + get() = messageSpeechControllerLazy.value + internal val messageSpeechState: StateFlow + get() = messageSpeechController.state + + /** + * Stable per-gateway scope for the offline chat cache; resolved per call so cached transcripts + * never leak across gateways. Null (nothing paired/configured) disables cache reads and writes. + */ + private fun chatCacheGatewayId(): String? { + connectedEndpoint?.stableId?.let { return it } + return prefs.gatewayRegistry.activeStableId.value + } + + private fun chatCacheScope(): ChatCacheScope? = + chatCacheGatewayId()?.let { gatewayId -> + ChatCacheScope(gatewayId = gatewayId, connectionGeneration = connectAttemptSeq.get()) + } + + private val voiceReplySpeakerLazy: Lazy = + lazy { + // Reuse the existing TalkMode speech engine for native Android TTS playback + // without enabling the legacy talk capture loop. + TalkModeManager( + context = appContext, + scope = scope, + session = operatorSession, + isConnected = { gatewayConnectionDisplay.value.isConnected }, + gatewayStableId = { connectedEndpoint?.stableId }, + onBeforeSpeak = { + acquireVoiceReplySpeechSuppression() + micCapture.pauseForTts() + }, + onAfterSpeak = { + try { + micCapture.resumeAfterTts() + } finally { + releaseVoiceReplySpeechSuppression() + } + }, + ).also { speaker -> + speaker.setPlaybackEnabled(prefs.speakerEnabled.value) + } + } + private val voiceReplySpeaker: TalkModeManager + get() = voiceReplySpeakerLazy.value + + private val micCapture: MicCaptureManager by lazy { + MicCaptureManager( + context = appContext, + scope = scope, + preferredAudioInputDevice = { prefs.preferredAudioInputDevice.value }, + onAppliedAudioInputChanged = { key -> + if (_voiceCaptureMode.value == VoiceCaptureMode.ManualMic) { + _activeAudioInputDevicePreference.value = key + } + }, + createTranscriptionSession = { + val gatewayId = connectedEndpoint?.stableId ?: error("not connected") + val params = + buildJsonObject { + put("mode", JsonPrimitive("transcription")) + put("transport", JsonPrimitive("gateway-relay")) + put("brain", JsonPrimitive("none")) + } + val response = + operatorSession.requestForEndpoint( + gatewayId, + "talk.session.create", + params.toString(), + timeoutMs = 15_000, + ) + GatewayTranscriptionSession( + id = parseTalkSessionId(response), + gatewayId = gatewayId, + ) + }, + appendTranscriptionAudio = { session, audio, onError -> + val params = + buildJsonObject { + put("sessionId", JsonPrimitive(session.id)) + put("audioBase64", JsonPrimitive(Base64.encodeToString(audio, Base64.NO_WRAP))) + put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime())) + } + operatorSession.sendRequestFrameForEndpoint( + session.gatewayId, + "talk.session.appendAudio", + params.toString(), + timeoutMs = 8_000, + ) { error -> onError(error.message) } + }, + closeTranscriptionSession = { session -> + val params = buildJsonObject { put("sessionId", JsonPrimitive(session.id)) } + operatorSession.requestForEndpoint( + session.gatewayId, + "talk.session.close", + params.toString(), + timeoutMs = 5_000, + ) + }, + sendToGateway = { message, onRunIdKnown -> + val gatewayId = connectedEndpoint?.stableId ?: error("not connected") + val idempotencyKey = UUID.randomUUID().toString() + // Notify MicCaptureManager of the idempotency key *before* the network + // call so pendingRunId is set before any chat events can arrive. + onRunIdKnown(idempotencyKey) + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) + put("message", JsonPrimitive(message)) + put("thinking", JsonPrimitive(chatThinkingLevel.value)) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(idempotencyKey)) + } + val response = operatorSession.requestForEndpoint(gatewayId, "chat.send", params.toString()) + val ack = parseChatSendAck(json, response) + ack.copy(runId = ack.runId ?: idempotencyKey) + }, + refreshAfterTerminalSuccess = { + chat.refresh() + }, + speakAssistantReply = { text -> + // Voice-tab replies should speak through the dedicated reply speaker. + // Relying on talkMode.ttsOnAllResponses here can drop playback if the + // chat-event path misses the terminal event for this turn. + voiceReplySpeaker.speakAssistantReply(text) + }, + ) + } + + val micStatusText: StateFlow + get() = micCapture.statusText + + val micLiveTranscript: StateFlow + get() = micCapture.liveTranscript + + val micIsListening: StateFlow + get() = micCapture.isListening + + val micEnabled: StateFlow + get() = micCapture.micEnabled + + val micCooldown: StateFlow + get() = micCapture.micCooldown + + val micQueuedMessages: StateFlow> + get() = micCapture.queuedMessages + + val micConversation: StateFlow> + get() = micCapture.conversation + + val micInputLevel: StateFlow + get() = micCapture.inputLevel + + val micIsSending: StateFlow + get() = micCapture.isSending + + private val talkMode: TalkModeManager by lazy { + TalkModeManager( + context = appContext, + scope = scope, + session = operatorSession, + isConnected = { gatewayConnectionDisplay.value.isConnected }, + gatewayStableId = { connectedEndpoint?.stableId }, + preferredAudioInputDevice = { prefs.preferredAudioInputDevice.value }, + onAppliedAudioInputChanged = { key -> + if (_voiceCaptureMode.value == VoiceCaptureMode.TalkMode) { + _activeAudioInputDevicePreference.value = key + } + }, + onBeforeSpeak = { micCapture.pauseForTts() }, + onAfterSpeak = { micCapture.resumeAfterTts() }, + onStoppedByRelay = { finishTalkModeAfterRelayClose() }, + ) + } + + val talkModeEnabled: StateFlow + get() = talkMode.isEnabled + + val talkModeListening: StateFlow + get() = talkMode.isListening + + val talkModeSpeaking: StateFlow + get() = talkMode.isSpeaking + + val talkInputLevel: StateFlow + get() = talkMode.inputLevel + + val talkOutputLevel: StateFlow + get() = talkMode.outputLevel + + val talkSpeechActive: StateFlow + get() = talkMode.speechActive + + val talkAwaitingAgent: StateFlow + get() = talkMode.awaitingAgent + + val talkModeStatusText: StateFlow + get() = talkMode.statusText + + val talkModeConversation: StateFlow> + get() = talkMode.conversation + + private val wearRealtimeLifecycleMutex = Mutex() + + private val wearRealtimeTalkControllerLazy: Lazy = + lazy { + WearRealtimeTalkController( + scope = scope, + isConnected = { gatewayConnectionDisplay.value.isConnected }, + requestGateway = { method, paramsJson, timeoutMs -> + val gatewayId = connectedEndpoint?.stableId ?: error("Gateway not connected") + operatorSession.requestForEndpoint(gatewayId, method, paramsJson, timeoutMs) + }, + sendGatewayFrame = { method, paramsJson, timeoutMs, onError -> + val gatewayId = connectedEndpoint?.stableId ?: error("Gateway not connected") + operatorSession.sendRequestFrameForEndpoint(gatewayId, method, paramsJson, timeoutMs) { error -> + onError(error.message) + } + }, + sendWatchFrame = { owner, type, payload -> + val app = appContext as? NodeApp ?: error("Wear channel owner is unavailable") + app.wearRealtimeChannels.send(owner, type, payload) + }, + onSnapshot = { snapshot -> + wearProxyBridge()?.publishTalk(WearRealtimeTalkCodec.encode(snapshot)) + }, + onForceCloseWatchChannel = { owner -> + scope.launch { + (appContext as? NodeApp)?.wearRealtimeChannels?.close(owner) + } + }, + ) + } + + private val wearRealtimeTalkController: WearRealtimeTalkController + get() = wearRealtimeTalkControllerLazy.value + + internal val wearRealtimeTalkSnapshot: StateFlow + get() = wearRealtimeTalkController.snapshot + + internal suspend fun startWearRealtimeTalk( + nodeId: String, + sessionKey: String, + attemptId: String, + language: String?, + attemptScopedAudio: Boolean, + ): Boolean { + if (talkModeEnabled.value || micEnabled.value || micCooldown.value) return false + val app = appContext as? NodeApp ?: return false + val claim = + app.wearRealtimeChannels.claim( + nodeId = nodeId, + attemptId = attemptId, + attemptScopedAudio = attemptScopedAudio, + ) ?: return false + val owner = claim.owner + val resolvedLanguage = talkMode.resolveRealtimeLanguageHint(language) + var started = false + return try { + started = + wearRealtimeLifecycleMutex.withLock { + if (talkModeEnabled.value || micEnabled.value || micCooldown.value) { + return@withLock false + } + startWearRealtimeTalkWhileCurrent( + owner = owner, + isCurrent = app.wearRealtimeChannels::isCurrent, + start = { onSessionActivated -> + wearRealtimeTalkController.start( + owner = owner, + sessionKey = sessionKey, + language = resolvedLanguage, + onSessionActivated = onSessionActivated, + ) + }, + stop = { staleOwner -> + wearRealtimeTalkController.stop(staleOwner) + }, + ) + } + started + } finally { + if (!started && claim.newlyAcquired) app.wearRealtimeChannels.release(owner) + } + } + + internal suspend fun stopWearRealtimeTalk( + nodeId: String? = null, + attemptId: String? = null, + ): Boolean = + wearRealtimeLifecycleMutex.withLock { + // The watch closes its channel after receiving the stop response. Closing + // here races the response and makes a normal stop look like link failure. + wearRealtimeTalkController.stop(nodeId, attemptId) + } + + internal suspend fun stopWearRealtimeTalk(owner: WearRealtimeAttemptOwner): Boolean = + wearRealtimeLifecycleMutex.withLock { + wearRealtimeTalkController.stop(owner) + } + + internal fun appendWearRealtimeAudio( + owner: WearRealtimeAttemptOwner, + payload: ByteArray, + ) { + if (wearRealtimeTalkControllerLazy.isInitialized()) { + wearRealtimeTalkController.appendAudio(owner, payload) + } + } + + private fun syncMainSessionKey(agentId: String?) { + val resolvedKey = resolveNodeMainSessionKey(agentId) + talkMode.setMainSessionKey(resolvedKey) + if (_mainSessionKey.value == resolvedKey) return + _mainSessionKey.value = resolvedKey + if (operatorConnected) { + chat.prepareMainSessionKey(resolvedKey) + chat.onGatewayConnected(mainSessionBinding(resolvedKey)) + } else { + chat.applyMainSessionKey(resolvedKey) + } + updateHomeCanvasState() + } + + private fun prepareMainSessionKey(agentId: String?): String { + val resolvedKey = resolveNodeMainSessionKey(agentId) + // Always push into TalkMode so a lazy instance cannot retain the "main" alias. + talkMode.setMainSessionKey(resolvedKey) + if (_mainSessionKey.value != resolvedKey) { + _mainSessionKey.value = resolvedKey + updateHomeCanvasState() + } + chat.prepareMainSessionKey(resolvedKey) + return resolvedKey + } + + private fun selectMainSessionKey(agentId: String) { + val resolvedKey = resolveNodeMainSessionKey(agentId) + talkMode.setMainSessionKey(resolvedKey) + _mainSessionKey.value = resolvedKey + chat.prepareAndSelectMainSessionKey(resolvedKey) + chat.onGatewayConnected(mainSessionBinding(resolvedKey)) + updateHomeCanvasState() + } + + private fun mainSessionBinding(sessionKey: String): MainSessionBinding = + MainSessionBinding( + key = sessionKey, + label = buildAndroidAppSessionLabel(prefs.displayName.value, identityStore.loadOrCreate().deviceId), + ) + + private fun updateStatus(update: () -> Unit = {}) { + synchronized(gatewayStatusLock) { + update() + // Select and publish text plus diagnostics atomically; operator and node callbacks run concurrently. + val display = + gatewayConnectionDisplay( + operatorConnected = operatorConnected, + nodeConnected = _nodeConnected.value, + operatorStatusText = operatorStatusText, + nodeStatusText = nodeStatusText, + operatorProblem = operatorConnectionProblem, + nodeProblem = nodeConnectionProblem, + ) + _gatewayConnectionDisplay.value = display + _isConnected.value = display.isConnected + _statusText.value = display.statusText + _gatewayConnectionProblem.value = display.problem + } + updateHomeCanvasState() + } + + private fun setStandaloneGatewayStatus(statusText: String) { + synchronized(gatewayStatusLock) { + val display = GatewayConnectionDisplay(operatorConnected, statusText, null) + _gatewayConnectionDisplay.value = display + _isConnected.value = display.isConnected + _statusText.value = display.statusText + _gatewayConnectionProblem.value = display.problem + } + updateHomeCanvasState() + } + + private fun gatewayConnectionProblem( + error: GatewaySession.ErrorShape, + pauseReconnect: Boolean, + ): GatewayConnectionProblem { + val details = error.details + return GatewayConnectionProblem( + code = details?.code ?: error.code, + message = error.message, + reason = details?.reason, + requestId = details?.requestId, + recommendedNextStep = details?.recommendedNextStep, + pauseReconnect = pauseReconnect || details?.pauseReconnect == true, + retryable = details?.retryable == true, + clientMinProtocol = details?.clientMinProtocol, + clientMaxProtocol = details?.clientMaxProtocol, + expectedProtocol = details?.expectedProtocol, + minimumProbeProtocol = details?.minimumProbeProtocol, + ) + } + + private fun resolveMainSessionKey(): String { + val trimmed = _mainSessionKey.value.trim() + return if (trimmed.isEmpty()) "main" else trimmed + } + + private fun resetLocalCanvas() { + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + canvas.navigate("") + } + + private fun launchGatewayRefresh(refresh: suspend () -> Unit) { + if (mode != NodeRuntimeMode.ScreenshotFixture) scope.launch { refresh() } + } + + fun refreshHomeCanvasOverviewIfConnected() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return + if (!operatorConnected) { + updateHomeCanvasState() + return + } + scope.launch { + refreshBrandingFromGateway() + refreshAgentsFromGateway() + refreshModelCatalogFromGateway() + refreshProviderModelsFromGateway() + refreshTalkSetupReadinessFromGateway() + refreshCronFromGateway() + refreshUsageFromGateway() + refreshSkillsFromGateway() + refreshNodesDevicesFromGateway() + refreshChannelsFromGateway() + refreshDreamingFromGateway() + refreshHealthLogsFromGateway() + } + } + + fun refreshModelCatalog() = launchGatewayRefresh { refreshModelCatalogFromGateway() } + + fun refreshProviderModels() = launchGatewayRefresh { refreshProviderModelsFromGateway() } + + fun refreshTalkSetupReadiness() = launchGatewayRefresh { refreshTalkSetupReadinessFromGateway() } + + fun refreshAgents() = launchGatewayRefresh { refreshAgentsFromGateway() } + + fun refreshCronJobs() = launchGatewayRefresh { refreshCronFromGateway() } + + fun loadCronJobDetail(id: String) { + val detailRequest = cronJobDetailRequestGuard.begin(id) ?: return + val historyRequest = cronRunHistoryRequestGuard.begin(detailRequest.id) ?: return + _cronJobDetailState.value = GatewayCronJobDetailState.Loading(detailRequest.id) + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(historyRequest.id) + if (mode == NodeRuntimeMode.ScreenshotFixture) { + applyScreenshotCronDetail(detailRequest = detailRequest, historyRequest = historyRequest) + return + } + scope.launch { loadCronJobDetailFromGateway(detailRequest) } + scope.launch { loadCronRunHistoryFromGateway(historyRequest) } + } + + fun refreshCronRunHistory(id: String) { + val request = cronRunHistoryRequestGuard.begin(id) ?: return + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(request.id) + if (mode == NodeRuntimeMode.ScreenshotFixture) { + publishScreenshotCronHistory(request) + return + } + scope.launch { loadCronRunHistoryFromGateway(request) } + } + + fun clearCronJobDetail() { + cronJobDetailRequestGuard.cancel { + _cronJobDetailState.value = GatewayCronJobDetailState.Idle + } + cronRunHistoryRequestGuard.cancel { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle + } + } + + fun dismissCronActionNotice(id: String) { + val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return + val notice = _cronActionState.value as? GatewayCronActionState.Notice + if (notice?.id == jobId) { + _cronActionState.value = GatewayCronActionState.Idle + } + } + + fun runCronJob(id: String) { + val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return + if (pendingCronRunRegistry.contains(jobId)) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = nativeText("This automation already has a queued run."), + kind = GatewayCronNoticeKind.Warning, + ) + return + } + launchCronAction(id = jobId, action = GatewayCronAction.Run) { gatewayScope, actionJobId -> + val response = + requestGatewayData( + gatewayScope, + "cron.run", + buildJsonObject { + put("id", JsonPrimitive(actionJobId)) + put("mode", JsonPrimitive("force")) + }.toString(), + ) + when (val outcome = parseGatewayCronRunOutcome(json.parseToJsonElement(response).asObjectOrNull())) { + is GatewayCronRunOutcome.Started -> { + outcome.runId?.let { runId -> + var trackingStarted = false + publishGatewayData(gatewayScope) { + trackingStarted = + pendingCronRunRegistry.begin(actionJobId, runId) { + _pendingCronRunJobIds.value = it + } + } + if (trackingStarted) { + trackQueuedCronRun(gatewayScope = gatewayScope, jobId = actionJobId, runId = runId) + } + } + CronActionResult( + message = if (outcome.runId == null) nativeText("Automation started.") else nativeText("Automation run queued."), + kind = GatewayCronNoticeKind.Success, + refresh = cronRunShouldRefresh(outcome), + ) + } + is GatewayCronRunOutcome.Skipped -> + CronActionResult( + message = outcome.reason.messageText, + kind = GatewayCronNoticeKind.Warning, + refresh = cronRunShouldRefresh(outcome), + ) + GatewayCronRunOutcome.Rejected -> + CronActionResult( + message = nativeText("Gateway rejected the automation run."), + kind = GatewayCronNoticeKind.Error, + refresh = false, + ) + null -> error("Gateway returned an invalid cron run result.") + } + } + } + + fun setCronJobEnabled( + id: String, + enabled: Boolean, + ) { + launchCronAction( + id = id, + action = if (enabled) GatewayCronAction.Enable else GatewayCronAction.Disable, + ) { gatewayScope, jobId -> + requestGatewayData( + gatewayScope, + "cron.update", + buildJsonObject { + put("id", JsonPrimitive(jobId)) + put( + "patch", + buildJsonObject { + put("enabled", JsonPrimitive(enabled)) + }, + ) + }.toString(), + ) + CronActionResult( + message = if (enabled) nativeText("Automation enabled.") else nativeText("Automation paused."), + kind = GatewayCronNoticeKind.Success, + refresh = true, + ) + } + } + + fun updateCronJob( + original: GatewayCronJobDetail, + edit: GatewayCronJobEdit, + ) { + launchCronAction(id = original.id, action = GatewayCronAction.Save) { gatewayScope, _ -> + try { + requestGatewayData( + gatewayScope, + "cron.update", + buildCronUpdateParams(original = original, edit = edit), + ) + } catch (err: GatewayRequestRejected) { + if (!isCronJobRevisionConflict(err.gatewayError)) throw err + reloadCronJobIfSelected(original.id) + return@launchCronAction CronActionResult( + message = nativeText("This automation changed on the gateway. Review the latest version before saving again."), + kind = GatewayCronNoticeKind.Warning, + refresh = false, + ) + } + CronActionResult( + message = nativeText("Automation updated."), + kind = GatewayCronNoticeKind.Success, + refresh = true, + ) + } + } + + fun deleteCronJob(id: String) { + launchCronAction(id = id, action = GatewayCronAction.Delete) { gatewayScope, jobId -> + requestGatewayData( + gatewayScope, + "cron.remove", + buildJsonObject { put("id", JsonPrimitive(jobId)) }.toString(), + ) + CronActionResult( + message = nativeText("Automation deleted."), + kind = GatewayCronNoticeKind.Success, + refresh = true, + deleted = true, + ) + } + } + + fun refreshUsage() = launchGatewayRefresh { refreshUsageFromGateway() } + + fun refreshSkills() = launchGatewayRefresh { refreshSkillsFromGateway() } + + fun setSkillEnabled( + skillKey: String, + enabled: Boolean, + ) { + val normalized = skillKey.trim() + if (normalized.isEmpty()) return + scope.launch { setSkillEnabledOnGateway(normalized, enabled) } + } + + fun searchClawHubSkills(query: String) { + scope.launch { searchClawHubSkillsFromGateway(query) } + } + + fun reviewClawHubSkillInstall(skill: GatewayClawHubSkillSummary) { + if (skill.slug.isBlank()) return + scope.launch { reviewClawHubSkillInstallFromGateway(skill.copy(slug = skill.slug.trim())) } + } + + fun dismissClawHubSkillInstallReview() { + clawHubSkillReviewSeq.incrementAndGet() + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy(reviewingSlug = null, installReview = null) + } + + internal fun installClawHubSkill( + slug: String, + acknowledgeClawHubRisk: Boolean = false, + version: String? = null, + ): Job? { + val normalized = slug.trim() + if (normalized.isEmpty()) return null + return scope.launch { + installClawHubSkillFromGateway( + slug = normalized, + acknowledgeClawHubRisk = acknowledgeClawHubRisk, + version = version, + ) + } + } + + fun clearClawHubSkillMessage() { + clawHubSkillReviewSeq.incrementAndGet() + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + reviewingSlug = null, + installReview = null, + acknowledgeSlug = null, + acknowledgeVersion = null, + errorText = null, + messageText = null, + ) + } + + fun refreshSkillWorkshopProposals(agentId: String? = null) { + scope.launch { + refreshSkillWorkshopProposalsFromGateway(agentId = agentId) + } + } + + fun resetSkillWorkshopAgentScope(agentId: String? = null) { + val normalizedAgentId = normalizeSkillWorkshopAgentId(agentId) + skillWorkshopListSeq.incrementAndGet() + skillWorkshopInspectSeq.incrementAndGet() + skillWorkshopMutationSeq.incrementAndGet() + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = normalizedAgentId, proposals = emptyList()) + _skillWorkshopRefreshing.value = false + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = null + } + + fun inspectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + val normalized = proposalId.trim() + if (normalized.isEmpty()) return + scope.launch { + inspectSkillWorkshopProposalFromGateway(proposalId = normalized, agentId = agentId) + } + } + + fun applySkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Apply) + } + + fun rejectSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Reject) + } + + fun quarantineSkillWorkshopProposal( + proposalId: String, + agentId: String? = null, + ) { + mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Quarantine) + } + + private fun mutateSkillWorkshopProposal( + proposalId: String, + agentId: String?, + action: SkillWorkshopGatewayAction, + ) { + val normalized = proposalId.trim() + if (normalized.isEmpty()) return + scope.launch { + mutateSkillWorkshopProposalOnGateway(proposalId = normalized, agentId = agentId, action = action) + } + } + + fun clearSkillWorkshopMessage() { + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + } + + fun refreshNodesDevices() = launchGatewayRefresh { refreshNodesDevicesFromGateway() } + + fun approveDevicePairing( + requestId: String, + deviceId: String, + ) { + startDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Approve, requestId), + expectedDeviceId = deviceId, + ) + } + + fun rejectDevicePairing(requestId: String) { + startDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Reject, requestId), + expectedDeviceId = "", + ) + } + + fun removePairedDevice(deviceId: String) { + startDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Remove, deviceId), + expectedDeviceId = deviceId, + ) + } + + private fun startDevicePairingMutation( + mutation: GatewayDevicePairingMutation, + expectedDeviceId: String, + ) { + if (mode == NodeRuntimeMode.ScreenshotFixture) return + if (mutation.targetId.isBlank()) return + if (mutation.action == GatewayDevicePairingAction.Approve && expectedDeviceId.isBlank()) return + // Capture the gateway scope at claim time: the ids were validated against the gateway the + // user is looking at, and a reconnect/switch before the coroutine runs must not let the + // request (especially Remove, where deviceIds recur across gateways) reach a replacement. + val gatewayScope = captureGatewayDataScope() ?: return + synchronized(devicePairingMutationLock) { + if (_devicePairingMutation.value != null) return + if (!_devicePairingCapabilities.value.supports(mutation.action)) return + _devicePairingMutation.value = mutation + } + scope.launch { + mutateDevicePairingOnGateway(gatewayScope, mutation, expectedDeviceId) + } + } + + fun refreshExecApprovals() = launchGatewayRefresh { refreshExecApprovalsFromGateway() } + + fun resolveExecApproval( + id: String, + decision: String, + ) { + val exactId = id.takeIf(::isWellFormedGatewayApprovalId) + val normalizedDecision = normalizeGatewayExecApprovalDecision(decision) + if (exactId == null || normalizedDecision == null) return + scope.launch { + resolveExecApprovalOnGateway(id = exactId, decision = normalizedDecision) + } + } + + fun dismissExecApprovalsNotice(expected: GatewayExecApprovalNotice) { + // Atomic conditional clear: not every notice publisher holds execApprovalsStateLock + // (refreshExecApprovalFromGateway's terminal branch), so a locked check-then-clear + // could still let a stale dismiss clobber a freshly published replacement. + _execApprovalsNotice.compareAndSet(expected, null) + } + + fun refreshChannels() = launchGatewayRefresh { refreshChannelsFromGateway() } + + fun refreshDreaming() = launchGatewayRefresh { refreshDreamingFromGateway() } + + fun refreshHealthLogs() = launchGatewayRefresh { refreshHealthLogsFromGateway() } + + fun requestCanvasRehydrate( + source: String = "manual", + force: Boolean = true, + ) { + val gatewayId = connectedEndpoint?.stableId + scope.launch { + if (gatewayId == null || !_nodeConnected.value) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = nativeText("Node offline. Reconnect and retry.") + return@launch + } + if (!force && didAutoRequestCanvasRehydrate) return@launch + didAutoRequestCanvasRehydrate = true + val requestId = canvasRehydrateSeq.incrementAndGet() + _canvasRehydratePending.value = true + _canvasRehydrateErrorText.value = null + + val sessionKey = resolveMainSessionKey() + val prompt = + "Restore canvas now for session=$sessionKey source=$source. " + + "If existing A2UI state exists, replay it immediately. " + + "If not, create and render a compact mobile-friendly dashboard in Canvas." + val sent = + nodeSession.sendNodeEventForEndpoint( + expectedEndpointStableId = gatewayId, + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(prompt)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + if (!sent) { + if (!force) { + didAutoRequestCanvasRehydrate = false + } + if (canvasRehydrateSeq.get() == requestId) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = nativeText("Failed to request restore. Tap to retry.") + } + Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable") + return@launch + } + scope.launch { + delay(20_000) + if (canvasRehydrateSeq.get() != requestId) return@launch + if (!_canvasRehydratePending.value) return@launch + if (_canvasA2uiHydrated.value) return@launch + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = nativeText("No canvas update yet. Tap to retry.") + } + } + } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val onboardingCompleted: StateFlow = prefs.onboardingCompleted + + /** Clears setup credentials plus paired device tokens for both Android gateway roles. */ + suspend fun resetGatewaySetupAuth(stableId: String): Boolean = + gatewayLifecycleIntentSeq.incrementAndGet().let { intent -> + gatewaySwitchMutex.withLock { + if (intent != gatewayLifecycleIntentSeq.get()) false else resetGatewaySetupAuthLocked(stableId) + } + } + + private suspend fun resetGatewaySetupAuthLocked(stableId: String): Boolean { + val connectOperationsDrained = + synchronized(gatewayAuthLifecycleLock) { + if (gatewayAuthResetInProgress) { + null + } else { + gatewayAuthResetInProgress = true + gatewayConnectOperationsDrained + } + } + ?: return false + return try { + connectOperationsDrained.await() + if (connectedEndpoint?.stableId == stableId) { + disconnectAndJoin() + } + if (connectingEndpointStableId == stableId) { + connectAttemptSeq.incrementAndGet() + connectingEndpointStableId = null + _pendingGatewayTrust.value = null + chat.onGatewayScopeChanging(retireRunState = true) + } + drainIdleGatewaySessionTails() + // A deliberate disconnect retains reconnect ownership. Authentication replacement does not. + chat.onGatewayScopeChanging(retireRunState = true) + // Replacing authentication retires the old identity even when the endpoint is unchanged. + // Purge only that gateway; ordinary switches retain every gateway's offline state. + val cacheCleared = + runCatching { + chat.clearGatewayCache(stableId) { + clientDatabases.commitGatewayRemoval(stableId, requireCacheRemoval = true) + externalTranscriptCache?.clearGateway(stableId) + } + }.onFailure { err -> + Log.e("OpenClawRuntime", "Failed to purge gateway chat data before auth reset", err) + setStandaloneGatewayStatus("Failed: couldn't clear offline chat data. Retry sign out.") + }.isSuccess + if (!cacheCleared) return false + prefs.clearGatewayCredentials(stableId) + val deviceId = identityStore.loadOrCreate().deviceId + deviceAuthStore.clearToken(stableId, deviceId, "node") + deviceAuthStore.clearToken(stableId, deviceId, "operator") + true + } finally { + synchronized(gatewayAuthLifecycleLock) { gatewayAuthResetInProgress = false } + } + } + + /** Persists onboarding state; callers decide whether runtime startup is needed first. */ + fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) + + val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId + val pairedGateways: StateFlow> = prefs.gatewayRegistry.entries + val activeGatewayStableId: StateFlow = prefs.gatewayRegistry.activeStableId + val connectedGatewayStableIds: StateFlow> = prefs.gatewayRegistry.connectedStableIds + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val installedAppsSharingEnabled: StateFlow = prefs.installedAppsSharingEnabled + val notificationForwardingEnabled: StateFlow = prefs.notificationForwardingEnabled + val notificationForwardingMode: StateFlow = + prefs.notificationForwardingMode + val notificationForwardingPackages: StateFlow> = prefs.notificationForwardingPackages + val notificationForwardingQuietHoursEnabled: StateFlow = + prefs.notificationForwardingQuietHoursEnabled + val notificationForwardingQuietStart: StateFlow = prefs.notificationForwardingQuietStart + val notificationForwardingQuietEnd: StateFlow = prefs.notificationForwardingQuietEnd + val notificationForwardingMaxEventsPerMinute: StateFlow = + prefs.notificationForwardingMaxEventsPerMinute + val notificationForwardingSessionKey: StateFlow = prefs.notificationForwardingSessionKey + + private var didAutoConnect = false + + @Volatile private var preferredGatewayReconnectSuppressed = initialReconnectSuppressed + private val secondaryGatewayConnectionsEnabled = MutableStateFlow(!initialReconnectSuppressed) + + val chatSessionKey: StateFlow = chat.sessionKey + val chatSessionOwnerAgentId: StateFlow = chat.sessionOwnerAgentId + internal val gatewayComposerDefaultAgentOwner: StateFlow = chat.composerDefaultAgentOwner + val chatSessionId: StateFlow = chat.sessionId + val chatMessages: StateFlow> = chat.messages + val chatTranscriptAnchor: StateFlow = chat.transcriptAnchor + val chatHistoryLoading: StateFlow = chat.historyLoading + val chatError: StateFlow = chat.errorText + val chatHealthOk: StateFlow = chat.healthOk + val chatThinkingLevel: StateFlow = chat.thinkingLevel + val chatThinkingLevelSelection: StateFlow = chat.thinkingLevelSelection + val chatSelectedModelRef: StateFlow = chat.selectedModelRef + val chatModelCatalog: StateFlow> = chat.modelCatalog + val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText + val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls + val chatQuestions: StateFlow> = chat.questions + val chatPlanSteps: StateFlow> = chat.planSteps + val chatSessions: StateFlow> = chat.sessions + val chatSwarmGroups: StateFlow> = chat.swarmGroups + val chatSessionBranches: StateFlow> = chat.sessionBranches + val chatSessionBranchesLoading: StateFlow = chat.sessionBranchesLoading + val chatSessionBranchSwitching: StateFlow = chat.sessionBranchSwitching + val pendingRunCount: StateFlow = chat.pendingRunCount + internal val chatSelectedActiveRunPresentation: StateFlow = + chat.selectedActiveRunPresentation + val chatCommands: StateFlow> = chat.commands + val chatOutboxItems: StateFlow> = chat.outboxItems + val chatOutboxPresentationRestored: StateFlow = chat.outboxPresentationRestored + + suspend fun listBackgroundTasks(agentId: String): List = chat.listBackgroundTasks(agentId) + + suspend fun getBackgroundTask(taskId: String): BackgroundTask = chat.getBackgroundTask(taskId) + + fun retryChatOutboxCommand(id: String) = chat.retryOutboxCommand(id) + + fun deleteChatOutboxCommand(id: String) = chat.deleteOutboxCommand(id) + + fun resolveChatQuestion( + id: String, + answers: Map>, + ) = chat.resolveQuestion(id, answers) + + fun skipChatQuestion(id: String) = chat.skipQuestion(id) + + private fun applyScreenshotFixture() { + check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" } + _serverName.value = "OpenClaw Gateway" + _remoteAddress.value = "Mac Studio on local network" + _gatewayVersion.value = BuildConfig.VERSION_NAME + updateGatewayDefaultAgentId("main") + _gatewayAgents.value = AndroidScreenshotFixture.agents + _modelCatalog.value = AndroidScreenshotFixture.models + _providerModelCatalog.value = AndroidScreenshotFixture.models + _modelAuthProviders.value = AndroidScreenshotFixture.providers + _talkSetupReadiness.value = + GatewayTalkSetupReadiness( + realtimeTalk = GatewayTalkSetupState.Ready(GatewayTalkProvider("openai", "OpenAI")), + dictation = GatewayTalkSetupState.Ready(GatewayTalkProvider("openai", "OpenAI")), + ) + _cronStatus.value = + GatewayCronStatus( + enabled = true, + jobs = 1, + nextWakeAtMs = 1_783_641_600_000, + ) + _cronJobs.value = parseScreenshotCronJobs() + _operatorScopes.value = listOf(OperatorAdminScope) + systemAgentChatSupported.value = true + _nodesDevicesSummary.value = AndroidScreenshotFixture.nodes + _channelsSummary.value = AndroidScreenshotFixture.channels + _nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Approved + _mainSessionKey.value = AndroidScreenshotFixture.mainSessionKey + chat.applyMainSessionKey(AndroidScreenshotFixture.mainSessionKey) + updateStatus { + operatorConnected = true + operatorStatusText = "Connected" + _nodeConnected.value = true + nodeStatusText = "Connected" + operatorConnectionProblem = null + nodeConnectionProblem = null + } + systemAgentChatController.refresh(startIfNeeded = false) + chat.refreshSessions(limit = 20) + } + + private fun parseScreenshotCronJobs(): List { + // Screenshot mode parses gateway-shaped fixtures so UI navigation covers the live data contract. + val list = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.list", null)) + .asObjectOrNull() + return parseCronJobs(list?.get("jobs") as? JsonArray) + } + + private fun applyScreenshotCronDetail( + detailRequest: CronJobDetailRequest, + historyRequest: CronJobDetailRequest, + ) { + val detail = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.get", cronJobGetParams(detailRequest.id))) + .asObjectOrNull() + ?.let(::parseGatewayCronJobDetail) + ?.takeIf { it.id == detailRequest.id } + cronJobDetailRequestGuard.publishIfCurrent(detailRequest) { + _cronJobDetailState.value = + detail?.let(GatewayCronJobDetailState::Loaded) + ?: GatewayCronJobDetailState.Error(detailRequest.id, nativeText("Gateway returned an invalid automation.")) + } + publishScreenshotCronHistory(historyRequest) + } + + private fun publishScreenshotCronHistory(request: CronJobDetailRequest) { + val history = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.runs", cronJobGetParams(request.id))) + .asObjectOrNull() + val runs = parseGatewayCronRunHistory(history?.get("entries") as? JsonArray) + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loaded(id = request.id, runs = runs) + } + } + + init { + if (mode == NodeRuntimeMode.Live) { + if (initialForeground && prefs.voiceMicEnabled.value) { + setVoiceCaptureMode(VoiceCaptureMode.ManualMic, persistManualMic = false) + } else if (!initialForeground && prefs.voiceMicEnabled.value) { + // Process recovery without an Activity must not revive microphone capture. + prefs.setVoiceMicEnabled(false) + } + + scope.launch(Dispatchers.Default) { + gateways.collect { list -> + seedLastDiscoveredGateway(list) + autoConnectIfNeeded() + } + } + scope.launch(Dispatchers.Default) { + combine( + prefs.gatewayRegistry.entries, + prefs.gatewayRegistry.connectedStableIds, + prefs.gatewayRegistry.activeStableId, + gateways, + combine(_isForeground, secondaryGatewayConnectionsEnabled) { foreground, enabled -> + foreground && enabled + }, + ) { entries, connectedIds, activeId, discovered, shouldRun -> + BackgroundGatewayFleetSnapshot(entries, connectedIds, activeId, discovered, shouldRun) + }.distinctUntilChanged() + .collect(::reconcileBackgroundGatewayFleet) + } + } else { + applyScreenshotFixture() + } + + if (mode == NodeRuntimeMode.Live) { + invalidateVoiceWakeWordsForGateway() + scope.launch { + mobileUiHandler.isConnected.collect { connected -> + if (connected == lastMobileUiConnected) return@collect + lastMobileUiConnected = connected + refreshNodeSurfaceAfterSettingsChange() + } + } + } + reconcileVoiceWakeCaptureSuppression() + voiceWakeManager.setForeground(initialForeground) + voiceWakeManager.setEnabled(prefs.voiceWakeEnabled.value) + scope.launch { + micCapture.micCooldown.collect { + // Manual capture drains partial audio for two seconds after its toggle + // turns off. Resume Voice Wake only after that capture owner releases. + reconcileVoiceWakeCaptureSuppression() + } + } + + scope.launch { + combine( + canvasDebugStatusEnabled, + statusText, + serverName, + remoteAddress, + ) { debugEnabled, status, server, remote -> + Quad(debugEnabled, status, server, remote) + }.distinctUntilChanged() + .collect { (debugEnabled, status, server, remote) -> + canvas.setDebugStatusEnabled(debugEnabled) + if (!debugEnabled) return@collect + canvas.setDebugStatus(status, server ?: remote) + } + } + + scope.launch { + nativeLocaleChanges.drop(1).collect { + updateHomeCanvasState() + } + } + + scope.launch { + chatModelCatalog.drop(1).distinctUntilChanged().collect { + // Chat metadata arrives after the connection event. Invalidate the Watch snapshot so + // its Home model picker cannot stay empty until the user refreshes manually. + if (operatorSession.isReady()) wearProxyBridge()?.publishResync() + } + } + + updateHomeCanvasState() + } + + /** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */ + fun setForeground(value: Boolean) { + val visibilityChanged = _isForeground.value != value + _isForeground.value = value + voiceWakeManager.setForeground(value) + if (mode == NodeRuntimeMode.ScreenshotFixture) return + if (visibilityChanged) { + scope.launch { + syncSessionObserverVisibility() + } + } + if (!value) { + voiceLifecycleEpoch.incrementAndGet() + } + if (value) { + refreshNodePermissionSurface() + refreshVoiceWakeCapabilitySurfaceIfChanged() + reconnectPreferredGatewayOnForeground() + scope.launch { + refreshExecApprovalsFromGateway() + } + } else { + stopMessageSpeech() + stopActiveVoiceSession() + publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true) + } + } + + private fun publishNodePresenceAliveBeacon( + trigger: NodePresenceAliveBeacon.Trigger, + throttleRecentSuccess: Boolean = false, + ) { + val gatewayId = connectedEndpoint?.stableId ?: return + scope.launch { + sendNodePresenceAliveBeacon( + gatewayId = gatewayId, + trigger = trigger, + throttleRecentSuccess = throttleRecentSuccess, + ) + } + } + + private suspend fun sendNodePresenceAliveBeacon( + gatewayId: String, + trigger: NodePresenceAliveBeacon.Trigger, + throttleRecentSuccess: Boolean, + ) { + if (!_nodeConnected.value) return + val nowMs = System.currentTimeMillis() + if ( + throttleRecentSuccess && + NodePresenceAliveBeacon.shouldSkipRecentSuccess( + nowMs = nowMs, + lastSuccessAtMs = nodePresenceAliveLastSuccessAtMs, + ) + ) { + return + } + + val client = connectionManager.buildClientInfo(clientId = "openclaw-android", clientMode = "node") + val payloadJson = + NodePresenceAliveBeacon.makePayloadJson( + trigger = trigger, + sentAtMs = nowMs, + displayName = client.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: "Android", + version = client.version, + platform = NodePresenceAliveBeacon.androidPlatformMetadata(), + deviceFamily = client.deviceFamily, + modelIdentifier = client.modelIdentifier, + ) + val result = + nodeSession.sendNodeEventDetailedForEndpoint( + expectedEndpointStableId = gatewayId, + event = NodePresenceAliveBeacon.EVENT_NAME, + payloadJson = payloadJson, + ) + if (!result.ok) return + val response = NodePresenceAliveBeacon.decodeResponse(result.payloadJson) + if (response?.handled == true) { + nodePresenceAliveLastSuccessAtMs = nowMs + } else { + Log.d( + "OpenClawNode", + "node.presence.alive not handled: ${NodePresenceAliveBeacon.sanitizeReasonForLog(response?.reason)}", + ) + } + } + + private fun seedLastDiscoveredGateway(list: List) { + if (list.isEmpty()) return + if (lastDiscoveredStableId.value.trim().isNotEmpty()) return + prefs.setLastDiscoveredStableId(list.first().stableId) + } + + private data class BackgroundGatewayFleetSnapshot( + val entries: List, + val connectedIds: List, + val activeId: String?, + val discovered: List, + val shouldRun: Boolean, + ) + + private suspend fun reconcileBackgroundGatewayFleet(snapshot: BackgroundGatewayFleetSnapshot) { + val plan = + backgroundGatewayFleetPlan( + entries = snapshot.entries, + connectedIds = snapshot.connectedIds, + activeId = snapshot.activeId, + foreground = snapshot.shouldRun, + existingStableIds = secondaryOperatorSessions.keys.toList(), + ) { entry -> + resolveRegistryEndpoint(entry, snapshot.discovered) + } + + for (stableId in plan.disconnectStableIds) { + secondaryOperatorSessions.remove(stableId)?.session?.disconnectAndJoin() + updateBackgroundGatewayStatus(stableId, null) + } + + for ((stableId, endpoint) in plan.resolvedEndpoints) { + val existing = secondaryOperatorSessions[stableId] + if (existing?.endpoint == endpoint) continue + existing?.session?.disconnectAndJoin() + val auth = resolveGatewayConnectAuth(endpoint) + val storedOperatorEntry = loadStoredRoleDeviceAuthEntry(endpoint, "operator") + val operatorAuth = resolveOperatorSessionConnectAuth(auth, storedOperatorEntry?.token) + if (operatorAuth == null) { + updateBackgroundGatewayStatus(stableId, "Needs setup") + secondaryOperatorSessions.remove(stableId) + continue + } + val session = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { + prefs.gatewayRegistry.markConnected(stableId, System.currentTimeMillis()) + updateBackgroundGatewayStatus(stableId, "Connected") + }, + onDisconnected = { message -> updateBackgroundGatewayStatus(stableId, message) }, + onConnectFailure = { error, _ -> updateBackgroundGatewayStatus(stableId, error.message) }, + // Secondary sessions retain authenticated presence only. Focused UI state and + // capability commands remain exclusively owned by the active runtime sessions. + onEvent = { _, _ -> }, + customHeadersProvider = prefs::loadGatewayCustomHeaders, + ) + secondaryOperatorSessions[stableId] = SecondaryOperatorRuntime(endpoint, session) + updateBackgroundGatewayStatus(stableId, "Connecting…") + val usesStoredOperatorDeviceToken = + operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token) + session.connect( + endpoint, + operatorAuth.token, + operatorAuth.bootstrapToken, + operatorAuth.password, + connectionManager.buildOperatorConnectOptions( + scopes = + operatorConnectScopesForAuth( + usesStoredDeviceToken = usesStoredOperatorDeviceToken, + storedOperatorScopes = storedOperatorEntry?.scopes, + ), + ), + connectionManager.resolveTlsParams(endpoint), + ) + } + } + + private fun resolveRegistryEndpoint( + entry: GatewayRegistryEntry, + discovered: List = gateways.value, + ): GatewayEndpoint? { + return when (entry.kind) { + GatewayRegistryEntryKind.MANUAL -> manualGatewayEndpoint(entry) + GatewayRegistryEntryKind.DISCOVERED -> { + val endpoint = discovered.firstOrNull { it.stableId == entry.stableId } ?: return null + val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty() + endpoint.takeIf { storedFingerprint.isNotEmpty() } + } + } + } + + private fun updateBackgroundGatewayStatus( + stableId: String, + status: String?, + ) { + synchronized(secondaryOperatorSessions) { + _backgroundGatewayStatuses.value = + if (status == null) { + _backgroundGatewayStatuses.value - stableId + } else { + _backgroundGatewayStatuses.value + (stableId to status) + } + } + } + + private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? { + val entry = prefs.gatewayRegistry.activeEntry() ?: return null + return resolveRegistryEndpoint(entry) + } + + suspend fun switchToGateway(stableId: String): Boolean { + val entry = + prefs.gatewayRegistry.entries.value + .firstOrNull { it.stableId == stableId } ?: return false + val endpoint = + when (entry.kind) { + GatewayRegistryEntryKind.MANUAL -> manualGatewayEndpoint(entry) ?: return false + GatewayRegistryEntryKind.DISCOVERED -> + gateways.value.firstOrNull { it.stableId == stableId } + ?: run { + setStandaloneGatewayStatus("Gateway not currently discoverable") + return false + } + } + return connectSwitchingGateway(endpoint) + } + + fun setGatewayConnectionEnabled( + stableId: String, + enabled: Boolean, + ) { + if (enabled) secondaryGatewayConnectionsEnabled.value = true + prefs.gatewayRegistry.setConnectionEnabled(stableId, enabled) + } + + suspend fun connectSwitchingGateway( + endpoint: GatewayEndpoint, + explicitAuth: GatewayConnectAuth? = null, + ): Boolean { + preferredGatewayReconnectSuppressed = false + secondaryGatewayConnectionsEnabled.value = true + val intent = gatewayLifecycleIntentSeq.incrementAndGet() + return gatewaySwitchMutex.withLock { + if (intent != gatewayLifecycleIntentSeq.get()) return@withLock false + val currentStableId = + connectedEndpoint?.stableId + ?: connectingEndpointStableId + ?: prefs.gatewayRegistry.activeStableId.value + if (currentStableId != null && currentStableId != endpoint.stableId) { + disconnectAndJoin() + } + if (prefs.gatewayRegistry.entries.value + .any { it.stableId == endpoint.stableId } + ) { + prefs.gatewayRegistry.setActive(endpoint.stableId) + } + val started = + synchronized(gatewayLifecycleIntentLock) { + if (intent != gatewayLifecycleIntentSeq.get()) { + false + } else { + beginConnect(endpoint, resolveGatewayConnectAuth(endpoint, explicitAuth)) + true + } + } + if (!started) return@withLock false + chat.restoreSelectedGatewayOfflineState() + true + } + } + + private fun autoConnectIfNeeded() { + if (preferredGatewayReconnectSuppressed) return + if (didAutoConnect) return + if (gatewayConnectionDisplay.value.isConnected) return + val endpoint = resolvePreferredGatewayEndpoint() ?: return + // Only attempt the stored preferred gateway once per runtime lifetime; users + // can still reconnect explicitly from the UI after a failed auto attempt. + didAutoConnect = true + // Cold-start fallback only: discovery can emit late, so atomically claim the very first + // lifecycle intent. If any explicit connect/disconnect/switch intent already exists, stand + // down permanently instead of overriding the user's decision with a stale auto-connect. + if (!gatewayLifecycleIntentSeq.compareAndSet(0L, 1L)) return + launchConnect(endpoint, explicitAuth = null) + } + + private fun reconnectPreferredGatewayOnForeground() { + if (preferredGatewayReconnectSuppressed) return + if (gatewayConnectionDisplay.value.isConnected) return + if (_pendingGatewayTrust.value != null) return + if (connectedEndpoint != null) { + refreshGatewayConnection() + return + } + resolvePreferredGatewayEndpoint()?.let(::connect) + } + + /** + * Reconnect a live node only when Android authority changed since its last connect. + */ + fun refreshNodePermissionSurface() { + val permissions = connectionManager.buildPermissions() + if (permissions == lastNodePermissions) return + refreshNodeSurfaceAfterSettingsChange() + } + + fun setDisplayName(value: String) { + prefs.setDisplayName(value) + } + + fun setCameraEnabled(value: Boolean) { + if (prefs.cameraEnabled.value == value) return + prefs.setCameraEnabled(value) + refreshNodeSurfaceAfterSettingsChange() + } + + fun setLocationMode(mode: LocationMode) { + if (prefs.locationMode.value == mode) return + prefs.setLocationMode(mode) + refreshNodeSurfaceAfterSettingsChange() + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.setLocationPreciseEnabled(value) + } + + fun setPreventSleep(value: Boolean) { + prefs.setPreventSleep(value) + } + + fun setManualEnabled(value: Boolean) { + prefs.setManualEnabled(value) + } + + fun setManualHost(value: String) { + prefs.setManualHost(value) + } + + fun setManualPort(value: Int) { + prefs.setManualPort(value) + } + + fun setManualTls(value: Boolean) { + prefs.setManualTls(value) + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + prefs.setCanvasDebugStatusEnabled(value) + } + + fun grantInstalledAppsDisclosureConsent() { + if (prefs.installedAppsSharingEnabled.value) return + prefs.grantInstalledAppsDisclosureConsent() + refreshNodeSurfaceAfterSettingsChange() + } + + fun revokeInstalledAppsDisclosureConsent() { + if (!prefs.installedAppsSharingEnabled.value) return + prefs.revokeInstalledAppsDisclosureConsent() + refreshNodeSurfaceAfterSettingsChange() + } + + fun setNotificationForwardingEnabled(value: Boolean) { + if (prefs.notificationForwardingEnabled.value == value) return + notificationOutbox.updatePolicy { prefs.setNotificationForwardingEnabled(value) } + } + + fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) { + if (prefs.notificationForwardingMode.value == mode) return + notificationOutbox.updatePolicy { prefs.setNotificationForwardingMode(mode) } + } + + fun setNotificationForwardingPackages(packages: List) { + val normalized = packages.map(String::trim).filter(String::isNotEmpty).toSet() + if (prefs.notificationForwardingPackages.value == normalized) return + notificationOutbox.updatePolicy { prefs.setNotificationForwardingPackages(normalized.toList()) } + } + + fun setNotificationForwardingQuietHours( + enabled: Boolean, + start: String, + end: String, + ): Boolean { + if (!enabled) { + if (!prefs.notificationForwardingQuietHoursEnabled.value) return true + return notificationOutbox.updatePolicy { + prefs.setNotificationForwardingQuietHours(enabled = false, start = start, end = end) + } + } + val normalizedStart = normalizeLocalHourMinute(start) ?: return false + val normalizedEnd = normalizeLocalHourMinute(end) ?: return false + val unchanged = + prefs.notificationForwardingQuietHoursEnabled.value && + prefs.notificationForwardingQuietStart.value == normalizedStart && + prefs.notificationForwardingQuietEnd.value == normalizedEnd + if (unchanged) return true + return notificationOutbox.updatePolicy { + prefs.setNotificationForwardingQuietHours( + enabled = true, + start = normalizedStart, + end = normalizedEnd, + ) + } + } + + fun setNotificationForwardingMaxEventsPerMinute(value: Int) { + val normalized = value.coerceAtLeast(1) + if (prefs.notificationForwardingMaxEventsPerMinute.value == normalized) return + notificationOutbox.updatePolicy { + prefs.setNotificationForwardingMaxEventsPerMinute(normalized) + } + } + + fun setNotificationForwardingSessionKey(value: String?) { + val normalized = value?.trim()?.takeIf(String::isNotEmpty) + if (prefs.notificationForwardingSessionKey.value == normalized) return + notificationOutbox.updatePolicy { prefs.setNotificationForwardingSessionKey(normalized) } + } + + fun setVoiceScreenActive(active: Boolean) { + if (mode == NodeRuntimeMode.ScreenshotFixture) return + if (!active) { + stopManualVoiceSession() + } else { + refreshTalkSetupReadiness() + } + // Don't re-enable on active=true; mic toggle drives that + } + + fun setMicEnabled(value: Boolean) { + setVoiceCaptureMode(if (value) VoiceCaptureMode.ManualMic else VoiceCaptureMode.Off) + } + + internal fun tryAcquireVoiceNoteMic(): Boolean { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + if (voiceNoteOwnsMic || dictationOwnsMic || !isVoiceCaptureModeActive(VoiceCaptureMode.Off)) return false + voiceNoteOwnsMic = true + createVoiceWakeSuppressionUpdateLocked(VoiceWakeSuppressionReason.VoiceNote, true) + } + applyVoiceWakeSuppression(suppressionUpdate) + return true + } + + internal fun releaseVoiceNoteMic() { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + voiceNoteOwnsMic = false + createVoiceWakeSuppressionUpdateLocked(VoiceWakeSuppressionReason.VoiceNote, false) + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + internal fun tryAcquireDictationMic(): Boolean { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + if ( + dictationOwnsMic || + voiceNoteOwnsMic || + cameraAudioOwnsMic || + !isVoiceCaptureModeActive(VoiceCaptureMode.Off) + ) { + return false + } + dictationOwnsMic = true + createVoiceWakeSuppressionUpdateLocked(VoiceWakeSuppressionReason.Dictation, true) + } + applyVoiceWakeSuppression(suppressionUpdate) + return true + } + + internal fun releaseDictationMic() { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + dictationOwnsMic = false + createVoiceWakeSuppressionUpdateLocked(VoiceWakeSuppressionReason.Dictation, false) + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + fun cancelMicCapture() { + micCapture.cancelMicCapture() + setVoiceCaptureMode(VoiceCaptureMode.Off, persistManualMic = false) + prefs.setVoiceMicEnabled(false) + } + + fun setTalkModeEnabled(value: Boolean) { + setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off) + } + + private suspend fun handleTalkPttStart(): GatewaySession.InvokeResult = + runTalkPttCommand { + talkMode.finishingPushToTalkCaptureId?.let { + return@runTalkPttCommand GatewaySession.InvokeResult.error( + code = "PTT_BUSY", + message = "PTT_BUSY: previous push-to-talk turn is still finishing", + ) + } + val lifecycleEpoch = voiceLifecycleEpoch.get() + val commandEpoch = talkPttCommandEpoch.get() + if (!_isForeground.value) { + val payload = talkMode.beginPushToTalk(allowNewCapture = false) + return@runTalkPttCommand GatewaySession.InvokeResult.ok(payload.toJson()) + } + val payload = + withPreparedTalkPttCommand(lifecycleEpoch, commandEpoch) { ownershipEpoch -> + val started = + talkMode.beginPushToTalk( + allowNewCapture = true, + canStartCapture = { + _isForeground.value && + voiceLifecycleEpoch.get() == lifecycleEpoch && + talkPttCommandEpoch.get() == commandEpoch && + voiceCaptureOwnershipEpoch.get() == ownershipEpoch + }, + ) + recordTalkPttOwnership(captureId = started.captureId, ownershipEpoch = ownershipEpoch) + started + } + GatewaySession.InvokeResult.ok(payload.toJson()) + } + + private suspend fun handleTalkPttStop(): GatewaySession.InvokeResult = + runTalkPttCommand { + val payload = stopPreparedTalkPttCapture { talkMode.endPushToTalk() } + GatewaySession.InvokeResult.ok(payload.toJson()) + } + + private suspend fun handleTalkPttCancel(): GatewaySession.InvokeResult = + runTalkPttCommand { + val payload = stopPreparedTalkPttCapture { talkMode.cancelPushToTalk() } + GatewaySession.InvokeResult.ok(payload.toJson()) + } + + private suspend fun handleTalkPttOnce(): GatewaySession.InvokeResult = + runTalkPttCommand { + currentTalkPttOnceBusy()?.let { busy -> + return@runTalkPttCommand GatewaySession.InvokeResult.ok(busy.payload.toJson()) + } + val lifecycleEpoch = voiceLifecycleEpoch.get() + val commandEpoch = talkPttCommandEpoch.get() + val start = + withPreparedTalkPttCommand( + lifecycleEpoch = lifecycleEpoch, + commandEpoch = commandEpoch, + beforePrepare = ::currentTalkPttOnceBusy, + ) { ownershipEpoch -> + val started = + talkMode.beginPushToTalkOnce( + canStartCapture = { + _isForeground.value && + voiceLifecycleEpoch.get() == lifecycleEpoch && + talkPttCommandEpoch.get() == commandEpoch && + voiceCaptureOwnershipEpoch.get() == ownershipEpoch + }, + ) + when (started) { + is TalkPttOnceStart.Busy -> cleanupFailedTalkCapture(ownershipEpoch) + is TalkPttOnceStart.Started -> + recordTalkPttOwnership(captureId = started.captureId, ownershipEpoch = ownershipEpoch) + } + started + } + val payload = + try { + talkMode.awaitPushToTalkOnce(start) + } finally { + if (start is TalkPttOnceStart.Started) { + finishTalkCaptureIfIdleAfterPreparation(start.captureId) + } + } + GatewaySession.InvokeResult.ok(payload.toJson()) + } + + private fun currentTalkPttOnceBusy(): TalkPttOnceStart.Busy? { + val captureId = talkMode.activePushToTalkCaptureId ?: talkMode.finishingPushToTalkCaptureId ?: return null + return TalkPttOnceStart.Busy( + TalkPttStopPayload(captureId = captureId, transcript = null, status = "busy"), + ) + } + + private suspend fun withPreparedTalkPttCommand( + lifecycleEpoch: Long, + commandEpoch: Long, + beforePrepare: () -> T? = { null }, + block: suspend (ownershipEpoch: Long) -> T, + ): T = + voiceCapturePreparationMutex.withLock { + // Preparation suspends while gateway config loads. Serialize ownership so + // a stale command cannot clean up a newer command before capture starts. + if ( + !_isForeground.value || + voiceLifecycleEpoch.get() != lifecycleEpoch || + talkPttCommandEpoch.get() != commandEpoch + ) { + throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground") + } + beforePrepare()?.let { return@withLock it } + val ownershipEpoch = prepareTalkCapture(lifecycleEpoch, commandEpoch) + try { + if ( + !_isForeground.value || + voiceLifecycleEpoch.get() != lifecycleEpoch || + talkPttCommandEpoch.get() != commandEpoch || + voiceCaptureOwnershipEpoch.get() != ownershipEpoch + ) { + throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground") + } + block(ownershipEpoch) + } catch (err: Throwable) { + cleanupFailedTalkCapture(ownershipEpoch) + throw err + } + } + + private suspend fun runTalkPttCommand(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult = + try { + block() + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + GatewaySession.InvokeResult.error(code = code, message = message) + } + + private suspend fun prepareTalkCapture( + lifecycleEpoch: Long, + commandEpoch: Long, + ): Long { + // Publish preparation on Main with lifecycle shutdown. After this block + // yields, preparation must not write capture state that backgrounding cleared. + val (ownershipEpoch, suppressionUpdate) = + withContext(Dispatchers.Main) { + synchronized(voiceCaptureOwnershipLock) { + if ( + !_isForeground.value || + voiceLifecycleEpoch.get() != lifecycleEpoch || + talkPttCommandEpoch.get() != commandEpoch + ) { + throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground") + } + if (voiceNoteOwnsMic) { + throw IllegalStateException("MIC_BUSY: voice note recording is active") + } + if (dictationOwnsMic) { + throw IllegalStateException("MIC_BUSY: dictation is active") + } + if (cameraAudioOwnsMic) { + throw IllegalStateException("MIC_BUSY: camera audio recording is active") + } + if (!hasRecordAudioPermission()) { + throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + } + val epoch = voiceCaptureOwnershipEpoch.incrementAndGet() + val update = setExternalAudioCaptureActiveLocked(true) + micCapture.setMicEnabled(false) + stopVoicePlayback() + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.TalkMode) + talkMode.ttsOnAllResponses = true + talkMode.setPlaybackEnabled(speakerEnabled.value) + epoch to update + } + } + applyVoiceWakeSuppression(suppressionUpdate) + try { + talkMode.refreshConfig() + return ownershipEpoch + } catch (err: Throwable) { + cleanupFailedTalkCapture(ownershipEpoch) + throw err + } + } + + private fun cleanupFailedTalkCapture(ownershipEpoch: Long) { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + // TalkModeManager owns capture-scoped cancellation. A stale invoke must not + // tear down a newer capture after a background/foreground transition. + if (voiceCaptureOwnershipEpoch.get() == ownershipEpoch) { + talkMode.activePushToTalkCaptureId?.let { captureId -> + // An idempotent retry can fail while the original capture remains live. + // Transfer preparation ownership so its eventual stop still cleans up. + talkPttOwnership.set(TalkPttOwnership(captureId = captureId, epoch = ownershipEpoch)) + return + } + } + finishTalkCaptureIfIdleUnderOwnershipLock(ownershipEpoch) + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + private fun recordTalkPttOwnership( + captureId: String, + ownershipEpoch: Long, + ) { + synchronized(voiceCaptureOwnershipLock) { + if (voiceCaptureOwnershipEpoch.get() == ownershipEpoch) { + talkPttOwnership.set(TalkPttOwnership(captureId = captureId, epoch = ownershipEpoch)) + } + } + } + + private suspend fun finishTalkCaptureIfIdleAfterPreparation(captureId: String) { + withContext(NonCancellable) { + voiceCapturePreparationMutex.withLock { + finishTalkCaptureIfIdleLocked(captureId) + } + } + } + + private suspend fun stopPreparedTalkPttCapture( + stopCapture: suspend () -> TalkPttStopPayload, + ): TalkPttStopPayload { + // Preparation can suspend on gateway config. Invalidate it before waiting, + // while later starts queue behind this stop with the new command epoch. + talkPttCommandEpoch.incrementAndGet() + return withContext(NonCancellable) { + voiceCapturePreparationMutex.withLock { + val payload = stopCapture() + finishTalkCaptureIfIdleLocked(payload.captureId) + payload + } + } + } + + private fun finishTalkCaptureIfIdleLocked(captureId: String) { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + val ownership = talkPttOwnership.get() + if (ownership?.captureId != captureId || !talkPttOwnership.compareAndSet(ownership, null)) return + finishTalkCaptureIfIdleUnderOwnershipLock(ownership.epoch) + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + private fun finishTalkCaptureIfIdleUnderOwnershipLock(ownershipEpoch: Long): VoiceWakeSuppressionUpdate? { + if (ownershipEpoch == 0L || voiceCaptureOwnershipEpoch.get() != ownershipEpoch) return null + if (!talkMode.isEnabled.value && !talkMode.isListening.value && !talkMode.isSpeaking.value) { + talkMode.ttsOnAllResponses = false + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off) + return setExternalAudioCaptureActiveLocked(false) + } + return null + } + + private fun finishTalkModeAfterRelayClose() { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + if (_voiceCaptureMode.value != VoiceCaptureMode.TalkMode) return + talkPttCommandEpoch.incrementAndGet() + voiceCaptureOwnershipEpoch.incrementAndGet() + _voiceCaptureMode.value = VoiceCaptureMode.Off + talkMode.ttsOnAllResponses = false + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off) + setExternalAudioCaptureActiveLocked(false) + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + val speakerEnabled: StateFlow + get() = prefs.speakerEnabled + + val preferredCameraFacing: StateFlow + get() = prefs.preferredCameraFacing + + val preferredAudioInputDevice: StateFlow + get() = prefs.preferredAudioInputDevice + + fun setSpeakerEnabled(value: Boolean) { + prefs.setSpeakerEnabled(value) + if (voiceReplySpeakerLazy.isInitialized()) { + voiceReplySpeaker.setPlaybackEnabled(value) + } + // Keep TalkMode in sync so any active Talk playback also respects speaker mute. + talkMode.setPlaybackEnabled(value) + } + + fun setPreferredCameraFacing(value: String) { + prefs.setPreferredCameraFacing(value) + } + + fun setPreferredAudioInputDevice(value: String?) { + prefs.setPreferredAudioInputDevice(value) + } + + fun setVoiceWakeEnabled(value: Boolean) { + if (value && !voiceWakeManager.isAvailable) return + if (prefs.voiceWakeEnabled.value == value) return + prefs.setVoiceWakeEnabled(value) + voiceWakeManager.setEnabled(value) + refreshVoiceWakeCapabilitySurfaceIfChanged() + } + + fun setVoiceWakeWords(words: List) { + val sanitized = VoiceWakePreferences.sanitizeTriggerWords(words) + if (mode == NodeRuntimeMode.ScreenshotFixture) { + prefs.setVoiceWakeWords(sanitized) + voiceWakeManager.updateTriggerWords(sanitized) + _voiceWakeWordsNoticeText.value = nativeText("Wake words saved") + return + } + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null) { + _voiceWakeWordsNoticeText.value = nativeText("Connect to a Gateway to save wake words") + return + } + if (!isVoiceWakeWordsReadyFor(gatewayScope.stableId)) { + _voiceWakeWordsNoticeText.value = nativeText("Connect to a Gateway to save wake words") + return + } + val saveSeq = voiceWakeWordsSaveSeq.incrementAndGet() + val requestRevision = currentVoiceWakeWordsRevision() + _voiceWakeWordsSaving.value = true + _voiceWakeWordsNoticeText.value = null + scope.launch { + var published = false + try { + val response = + requestGatewayData( + gatewayScope, + GatewayMethod.VoicewakeSet.rawValue, + buildJsonObject { + put("triggers", JsonArray(sanitized.map(::JsonPrimitive))) + }.toString(), + ) + val canonical = parseVoiceWakeWords(response) ?: error("voicewake.set returned invalid triggers") + published = + publishGatewayData(gatewayScope) { + if (saveSeq == voiceWakeWordsSaveSeq.get()) { + applyAuthoritativeVoiceWakeWords( + words = canonical, + gatewayStableId = gatewayScope.stableId, + expectedRevision = requestRevision, + ) + _voiceWakeWordsSaving.value = false + _voiceWakeWordsNoticeText.value = nativeText("Wake words saved") + } + } + } catch (_: CancellationException) { + // Gateway-scope retirement owns state reset; never publish the old response. + } catch (err: Throwable) { + Log.d("OpenClawRuntime", "voicewake.set failed: ${err.message ?: err::class.java.simpleName}") + if (saveSeq == voiceWakeWordsSaveSeq.get() && isGatewayDataScopeCurrent(gatewayScope)) { + _voiceWakeWordsSaving.value = false + _voiceWakeWordsNoticeText.value = nativeText("Could not save wake words") + } + } finally { + if (!published && saveSeq == voiceWakeWordsSaveSeq.get() && !isGatewayDataScopeCurrent(gatewayScope)) { + _voiceWakeWordsSaving.value = false + _voiceWakeWordsNoticeText.value = null + } + } + } + } + + fun refreshVoiceWakePermission() { + voiceWakeManager.refreshPermission() + refreshVoiceWakeCapabilitySurfaceIfChanged() + } + + private fun isVoiceWakeCapabilityEnabled(): Boolean = + prefs.voiceWakeEnabled.value && + voiceWakeManager.isAvailable && + hasRecordAudioPermission() && + isVoiceWakeWordsReadyForCurrentGateway() + + private fun refreshVoiceWakeCapabilitySurfaceIfChanged() { + val enabled = isVoiceWakeCapabilityEnabled() + if (enabled == lastVoiceWakeCapabilityEnabled) return + lastVoiceWakeCapabilityEnabled = enabled + refreshNodeSurfaceAfterSettingsChange() + } + + suspend fun runVoiceE2e( + mode: String, + transcript: String, + realtimeAssistantText: String, + timeoutMs: Long, + ): VoiceE2eResult { + if (!BuildConfig.DEBUG) { + throw IllegalStateException("voice e2e is debug-only") + } + if (!gatewayConnectionDisplay.value.isConnected) { + throw IllegalStateException("gateway not connected") + } + if (!hasRecordAudioPermission()) { + throw IllegalStateException("microphone permission missing") + } + + val normalizedMode = mode.trim().lowercase().ifEmpty { "both" } + val runNormal = normalizedMode == "both" || normalizedMode == "normal" || normalizedMode == "dictation" + val runRealtime = normalizedMode == "both" || normalizedMode == "realtime" || normalizedMode == "talk" + if (!runNormal && !runRealtime) { + throw IllegalArgumentException("unknown voice e2e mode: $mode") + } + + val previousSpeakerEnabled = speakerEnabled.value + setSpeakerEnabled(false) + var completed = false + return try { + VoiceE2eResult( + normal = + if (runNormal) { + runNormalVoiceE2e(transcript = transcript, timeoutMs = timeoutMs) + } else { + null + }, + realtime = + if (runRealtime) { + runRealtimeVoiceE2e( + transcript = transcript, + assistantText = realtimeAssistantText, + timeoutMs = timeoutMs, + ) + } else { + null + }, + ).also { completed = true } + } finally { + if (!completed) { + stopActiveVoiceSession() + } + setSpeakerEnabled(previousSpeakerEnabled) + } + } + + private suspend fun runNormalVoiceE2e( + transcript: String, + timeoutMs: Long, + ): VoiceE2eSliceResult { + stopActiveVoiceSession() + setVoiceCaptureMode(VoiceCaptureMode.ManualMic) + micCapture.submitTranscribedMessage(transcript) + awaitVoiceConversation(timeoutMs = timeoutMs) { + micCapture.conversation.value.any { it.role == VoiceConversationRole.Assistant && !it.isStreaming } + } + val entries = micCapture.conversation.value + return VoiceE2eSliceResult( + mode = "normal", + status = micCapture.statusText.value, + userText = entries.lastOrNull { it.role == VoiceConversationRole.User }?.text, + assistantText = entries.lastOrNull { it.role == VoiceConversationRole.Assistant }?.text, + ) + } + + private suspend fun runRealtimeVoiceE2e( + transcript: String, + assistantText: String, + timeoutMs: Long, + ): VoiceE2eSliceResult { + stopActiveVoiceSession() + setVoiceCaptureMode(VoiceCaptureMode.TalkMode) + talkMode.runE2eRealtimeTurn( + userText = transcript, + assistantText = assistantText, + timeoutMs = timeoutMs, + ) + awaitVoiceConversation(timeoutMs = timeoutMs) { + val entries = talkMode.conversation.value + entries.any { it.role == VoiceConversationRole.User && !it.isStreaming } && + entries.any { it.role == VoiceConversationRole.Assistant && !it.isStreaming } + } + val entries = talkMode.conversation.value + return VoiceE2eSliceResult( + mode = "realtime", + status = talkMode.statusText.value, + userText = entries.lastOrNull { it.role == VoiceConversationRole.User }?.text, + assistantText = entries.lastOrNull { it.role == VoiceConversationRole.Assistant }?.text, + ) + } + + private suspend fun awaitVoiceConversation( + timeoutMs: Long, + ready: () -> Boolean, + ) { + withTimeout(timeoutMs) { + while (!ready()) { + delay(100L) + } + } + } + + private fun setVoiceCaptureMode( + mode: VoiceCaptureMode, + persistManualMic: Boolean = true, + ) { + var startAfterSuppression: VoiceCaptureMode? = null + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + if (mode != VoiceCaptureMode.Off && (voiceNoteOwnsMic || dictationOwnsMic)) return + if (mode != VoiceCaptureMode.Off && cameraAudioOwnsMic) return + talkPttCommandEpoch.incrementAndGet() + voiceCaptureOwnershipEpoch.incrementAndGet() + val permissionDenied = mode.requiresMicrophonePermission && !hasRecordAudioPermission() + val captureMode = if (permissionDenied) VoiceCaptureMode.Off else mode + if (permissionDenied) prefs.setVoiceMicEnabled(false) + if (_voiceCaptureMode.value == captureMode && isVoiceCaptureModeActive(captureMode)) return + talkPttOwnership.set(null) + _voiceCaptureMode.value = captureMode + _activeAudioInputDevicePreference.value = null + when (captureMode) { + VoiceCaptureMode.Off -> { + talkMode.ttsOnAllResponses = false + talkMode.stopAllCapture() + stopVoicePlayback() + micCapture.setMicEnabled(false) + if (persistManualMic) { + prefs.setVoiceMicEnabled(false) + } + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off) + setExternalAudioCaptureActiveLocked(false) + } + + VoiceCaptureMode.ManualMic -> { + talkMode.ttsOnAllResponses = false + talkMode.stopAllCapture() + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.ManualMic) + if (persistManualMic) { + prefs.setVoiceMicEnabled(true) + } + // Tapping mic on interrupts any active TTS (barge-in). + stopVoicePlayback() + scope.launch { talkMode.refreshConfig() } + startAfterSuppression = VoiceCaptureMode.ManualMic + setExternalAudioCaptureActiveLocked(true) + } + + VoiceCaptureMode.TalkMode -> { + if (persistManualMic) { + prefs.setVoiceMicEnabled(false) + } + micCapture.setMicEnabled(false) + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.TalkMode) + talkMode.ttsOnAllResponses = true + talkMode.setPlaybackEnabled(speakerEnabled.value) + scope.launch { talkMode.refreshConfig() } + talkMode.stopAllCapture() + startAfterSuppression = VoiceCaptureMode.TalkMode + setExternalAudioCaptureActiveLocked(true) + } + } + } + applyVoiceWakeSuppression(suppressionUpdate) + synchronized(voiceCaptureOwnershipLock) { + when (startAfterSuppression) { + VoiceCaptureMode.ManualMic -> { + if (_voiceCaptureMode.value == VoiceCaptureMode.ManualMic && externalAudioCaptureActive.value) { + micCapture.setMicEnabled(true) + } + } + VoiceCaptureMode.TalkMode -> { + if (_voiceCaptureMode.value == VoiceCaptureMode.TalkMode && externalAudioCaptureActive.value) { + talkMode.setEnabled(true) + } + } + VoiceCaptureMode.Off, + null, + -> Unit + } + } + } + + private fun stopManualVoiceSession() { + if (_voiceCaptureMode.value != VoiceCaptureMode.ManualMic) return + setVoiceCaptureMode(VoiceCaptureMode.Off) + } + + private fun stopActiveVoiceSession() { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + talkPttCommandEpoch.incrementAndGet() + voiceCaptureOwnershipEpoch.incrementAndGet() + talkPttOwnership.set(null) + talkMode.ttsOnAllResponses = false + talkMode.stopAllCapture() + stopVoicePlayback() + micCapture.setMicEnabled(false) + prefs.setVoiceMicEnabled(false) + NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off) + _voiceCaptureMode.value = VoiceCaptureMode.Off + setExternalAudioCaptureActiveLocked(false) + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + private fun setExternalAudioCaptureActiveLocked(active: Boolean): VoiceWakeSuppressionUpdate { + externalAudioCaptureActive.value = active + return createVoiceCaptureSuppressionUpdateLocked() + } + + internal fun setCameraAudioCaptureActive(active: Boolean): Boolean { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + if (active) { + if ( + cameraAudioOwnsMic || + voiceNoteOwnsMic || + dictationOwnsMic || + !isVoiceCaptureModeActive(VoiceCaptureMode.Off) + ) { + return false + } + cameraAudioOwnsMic = true + } else { + cameraAudioOwnsMic = false + } + createVoiceWakeSuppressionUpdateLocked(VoiceWakeSuppressionReason.Camera, active) + } + applyVoiceWakeSuppression(suppressionUpdate) + return true + } + + private fun acquireVoiceReplySpeechSuppression() { + if (voiceReplySpeechDepth.incrementAndGet() == 1) { + voiceWakeManager.setSuppressed(VoiceWakeSuppressionReason.VoiceReplySpeech, true) + } + } + + private fun releaseVoiceReplySpeechSuppression() { + while (true) { + val depth = voiceReplySpeechDepth.get() + if (depth == 0) return + if (!voiceReplySpeechDepth.compareAndSet(depth, depth - 1)) continue + if (depth == 1) { + voiceWakeManager.setSuppressed(VoiceWakeSuppressionReason.VoiceReplySpeech, false) + } + return + } + } + + private fun reconcileVoiceWakeCaptureSuppression() { + val suppressionUpdate = + synchronized(voiceCaptureOwnershipLock) { + createVoiceCaptureSuppressionUpdateLocked() + } + applyVoiceWakeSuppression(suppressionUpdate) + } + + private fun createVoiceCaptureSuppressionUpdateLocked(): VoiceWakeSuppressionUpdate = + createVoiceWakeSuppressionUpdateLocked( + reason = VoiceWakeSuppressionReason.VoiceCapture, + suppressed = externalAudioCaptureActive.value || micCapture.micCooldown.value, + ) + + private fun createVoiceWakeSuppressionUpdateLocked( + reason: VoiceWakeSuppressionReason, + suppressed: Boolean, + ): VoiceWakeSuppressionUpdate { + voiceWakeSuppressionRevision += 1 + return VoiceWakeSuppressionUpdate( + reason = reason, + suppressed = suppressed, + revision = voiceWakeSuppressionRevision, + ) + } + + private fun applyVoiceWakeSuppression(update: VoiceWakeSuppressionUpdate?) { + if (update == null) return + // Versioned application happens after ownership unlock. This avoids a main + // looper lock inversion while preventing an older release from winning. + voiceWakeManager.setSuppressed( + reason = update.reason, + suppressed = update.suppressed, + revision = update.revision, + ) + } + + private fun stopVoicePlayback() { + talkMode.stopTts() + if (voiceReplySpeakerLazy.isInitialized()) { + voiceReplySpeaker.stopTts() + } + } + + private val VoiceCaptureMode.requiresMicrophonePermission: Boolean + get() = this == VoiceCaptureMode.ManualMic || this == VoiceCaptureMode.TalkMode + + private fun isVoiceCaptureModeActive(mode: VoiceCaptureMode): Boolean = + when (mode) { + VoiceCaptureMode.Off -> + !cameraAudioOwnsMic && + !externalAudioCaptureActive.value && + !micCapture.micEnabled.value && + !micCapture.micCooldown.value && + !talkMode.isEnabled.value && + talkMode.activePushToTalkCaptureId == null + VoiceCaptureMode.ManualMic -> + externalAudioCaptureActive.value && + micCapture.micEnabled.value && + !talkMode.isEnabled.value && + talkMode.activePushToTalkCaptureId == null + VoiceCaptureMode.TalkMode -> + externalAudioCaptureActive.value && + !micCapture.micEnabled.value && + talkMode.isEnabled.value && + talkMode.activePushToTalkCaptureId == null + } + + fun refreshGatewayConnection() { + preferredGatewayReconnectSuppressed = false + secondaryGatewayConnectionsEnabled.value = true + gatewayLifecycleIntentSeq.incrementAndGet() + launchGatewayLifecycle { + val endpoint = connectedEndpoint + if (endpoint == null) { + val preferred = resolvePreferredGatewayEndpoint() + if (preferred == null) { + setStandaloneGatewayStatus("Failed: no saved gateway endpoint") + } else { + prepareGatewayTarget(preferred) + beginConnect(preferred, resolveGatewayConnectAuth(preferred)) + } + return@launchGatewayLifecycle + } + updateStatus { + operatorStatusText = "Connecting…" + operatorConnectionProblem = null + } + connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(endpoint), reconnect = true) + } + } + + private fun refreshNodeSurfaceAfterSettingsChange() { + launchGatewayLifecycle { + if (preferredGatewayReconnectSuppressed) return@launchGatewayLifecycle + val endpoint = connectedEndpoint ?: return@launchGatewayLifecycle + connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(endpoint), reconnect = true) + } + } + + private fun launchGatewayLifecycle(block: () -> Unit) { + val intent = gatewayLifecycleIntentSeq.get() + val guardedBlock = { + synchronized(gatewayLifecycleIntentLock) { + if (intent == gatewayLifecycleIntentSeq.get()) block() + } + } + if (gatewaySwitchMutex.tryLock()) { + try { + guardedBlock() + } finally { + gatewaySwitchMutex.unlock() + } + } else { + scope.launch { gatewaySwitchMutex.withLock { guardedBlock() } } + } + } + + private fun connectWithAuth( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + reconnect: Boolean = false, + beforeConnect: () -> Unit = {}, + ): Boolean = + runGatewayConnectOperation { + beforeConnect() + activeGatewayAuth = auth + val tls = connectionManager.resolveTlsParams(endpoint) + val storedOperatorEntry = loadStoredRoleDeviceAuthEntry(endpoint, "operator") + refreshGatewayControlPage(endpoint, auth, storedOperatorEntry?.token) + val usesStoredOperatorDeviceToken = + operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token) + val operatorAuth = + resolveOperatorSessionConnectAuth( + auth = auth, + storedOperatorToken = storedOperatorEntry?.token, + ) + if (operatorAuth == null) { + updateStatus { + operatorConnected = false + operatorStatusText = "Offline" + operatorConnectionProblem = null + } + operatorSession.disconnect() + } else { + operatorSession.connect( + endpoint, + operatorAuth.token, + operatorAuth.bootstrapToken, + operatorAuth.password, + connectionManager.buildOperatorConnectOptions( + scopes = + operatorConnectScopesForAuth( + usesStoredDeviceToken = usesStoredOperatorDeviceToken, + storedOperatorScopes = storedOperatorEntry?.scopes, + ), + ), + tls, + ) + } + val nodeConnectOptions = connectionManager.buildNodeConnectOptions() + lastNodePermissions = nodeConnectOptions.permissions + nodeSession.connect( + endpoint, + auth.token, + auth.bootstrapToken, + auth.password, + nodeConnectOptions, + tls, + ) + if (reconnect && operatorAuth != null) { + operatorSession.reconnect() + } + if (reconnect) { + nodeSession.reconnect() + } + } + + // Auth reset waits for claimed connection starts before disconnecting. Session calls stay outside + // this monitor because GatewaySession invokes callbacks while holding its own lifecycle monitor. + private fun runGatewayConnectOperation(block: () -> Unit): Boolean { + val claimed = + synchronized(gatewayAuthLifecycleLock) { + if (gatewayAuthResetInProgress) { + false + } else { + if (gatewayConnectOperationsInFlight == 0) { + gatewayConnectOperationsDrained = CompletableDeferred() + } + gatewayConnectOperationsInFlight += 1 + true + } + } + if (!claimed) return false + try { + block() + return true + } finally { + val drained = + synchronized(gatewayAuthLifecycleLock) { + gatewayConnectOperationsInFlight -= 1 + gatewayConnectOperationsDrained.takeIf { gatewayConnectOperationsInFlight == 0 } + } + drained?.complete(Unit) + } + } + + private fun beginConnect( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + ) { + synchronized(gatewayAuthLifecycleLock) { + if (gatewayAuthResetInProgress) return + } + // A user-selected connect target must never inherit notification content from another gateway. + if (gatewayDefaultAgentStableId?.let { it != endpoint.stableId } == true) { + updateGatewayDefaultAgentId(null) + } + notificationOutbox.clear() + invalidateNodeCapabilityApprovalState() + val connectAttemptId = connectAttemptSeq.incrementAndGet() + connectingEndpointStableId = endpoint.stableId + chat.onGatewayScopeChanging() + _pendingGatewayTrust.value = null + val tls = connectionManager.resolveTlsParams(endpoint) + if (tls?.required == true) { + val storedFingerprint = tls.expectedFingerprint + setStandaloneGatewayStatus("Verify gateway TLS fingerprint…") + scope.launch { + val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port) + if (!isCurrentConnectAttempt(connectAttemptId)) return@launch + when ( + val decision = + decideGatewayTlsTrust( + storedFingerprint = storedFingerprint, + systemTrustCandidate = isGatewayTlsSystemTrustCandidate(endpoint.host), + probeResult = tlsProbe, + ) + ) { + GatewayTlsTrustDecision.SystemTrusted -> { + // Automatic platform trust only applies where no user-accepted pin exists. + // Replacing a pin always requires explicit confirmation in the trust prompt. + registerGateway(endpoint, setActive = true) + connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId) + } + is GatewayTlsTrustDecision.PinnedTrust -> + connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId) + is GatewayTlsTrustDecision.PromptRequired -> { + decision.probeFailure?.let { setStandaloneGatewayStatus(gatewayTlsProbeFailureMessage(it)) } + publishGatewayTrustPromptIfCurrent( + connectAttemptId = connectAttemptId, + prompt = + GatewayTrustPrompt( + endpoint = endpoint, + fingerprintSha256 = decision.fingerprintSha256, + auth = auth, + previousFingerprintSha256 = decision.previousFingerprintSha256, + probeFailure = decision.probeFailure, + systemTrustAvailable = decision.systemTrustAvailable, + ), + ) + } + is GatewayTlsTrustDecision.Failed -> { + connectingEndpointStableId = null + setStandaloneGatewayStatus(gatewayTlsProbeFailureMessage(decision.reason)) + } + } + } + return + } + + connectAfterTlsCheckLocked(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId) + } + + private fun isCurrentConnectAttempt(connectAttemptId: Long): Boolean = connectAttemptSeq.get() == connectAttemptId + + private fun publishGatewayTrustPromptIfCurrent( + connectAttemptId: Long, + prompt: GatewayTrustPrompt, + ): Boolean = + synchronized(gatewayAuthLifecycleLock) { + if (gatewayAuthResetInProgress || !isCurrentConnectAttempt(connectAttemptId)) { + false + } else { + _pendingGatewayTrust.value = prompt + true + } + } + + private fun refreshGatewayControlPage( + endpoint: GatewayEndpoint? = connectedEndpoint, + auth: GatewayConnectAuth? = activeGatewayAuth, + storedOperatorToken: String? = endpoint?.let { loadStoredRoleDeviceAuthEntry(it, "operator")?.token }, + ) { + if (endpoint == null) { + _gatewayControlPage.value = null + return + } + val pageAuth = resolveGatewayControlPageAuth(auth ?: resolveGatewayConnectAuth(endpoint), storedOperatorToken) + _gatewayControlPage.value = + GatewayControlPage( + baseUrl = gatewayControlPageBaseUrl(endpoint), + token = pageAuth.token, + password = pageAuth.password, + tlsFingerprintSha256 = gatewayControlPageTlsFingerprint(prefs, endpoint), + ) + } + + private fun connectAfterTlsCheck( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + connectAttemptId: Long, + ) { + launchGatewayLifecycle { connectAfterTlsCheckLocked(endpoint, auth, connectAttemptId) } + } + + private fun connectAfterTlsCheckLocked( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + connectAttemptId: Long, + ) { + if (!isCurrentConnectAttempt(connectAttemptId)) return + connectWithAuth(endpoint = endpoint, auth = auth) { + connectedEndpoint = endpoint + connectingEndpointStableId = null + updateStatus { + operatorConnectionProblem = null + nodeConnectionProblem = null + operatorStatusText = "Connecting…" + nodeStatusText = "Connecting…" + } + } + } + + fun connect(endpoint: GatewayEndpoint) { + preferredGatewayReconnectSuppressed = false + secondaryGatewayConnectionsEnabled.value = true + gatewayLifecycleIntentSeq.incrementAndGet() + launchConnect(endpoint, explicitAuth = null) + } + + fun connect( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + ) { + preferredGatewayReconnectSuppressed = false + secondaryGatewayConnectionsEnabled.value = true + gatewayLifecycleIntentSeq.incrementAndGet() + launchConnect(endpoint, explicitAuth = auth) + } + + private fun launchConnect( + endpoint: GatewayEndpoint, + explicitAuth: GatewayConnectAuth?, + ) { + launchGatewayLifecycle { + prepareGatewayTarget(endpoint) + beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(endpoint, explicitAuth)) + } + } + + private fun prepareGatewayTarget(endpoint: GatewayEndpoint) { + if (connectedEndpoint?.stableId?.let { it != endpoint.stableId } == true) { + // Close both sockets before changing routes so stale callbacks cannot cross the handoff. + disconnect(retireRunState = true) + } + if (prefs.gatewayRegistry.entries.value + .any { it.stableId == endpoint.stableId } + ) { + prefs.gatewayRegistry.setActive(endpoint.stableId) + } + } + + internal fun resolveGatewayConnectAuth( + endpoint: GatewayEndpoint, + explicitAuth: GatewayConnectAuth? = null, + ): GatewayConnectAuth = + explicitAuth + ?: prefs.loadGatewayCredentials(endpoint.stableId).let { credentials -> + GatewayConnectAuth( + token = credentials.token, + bootstrapToken = credentials.bootstrapToken, + password = credentials.password, + ) + } + + fun acceptGatewayTrustPrompt(manualFingerprint: String? = null) { + val prompt = _pendingGatewayTrust.value ?: return + val acceptedFingerprint = + normalizeGatewayTlsFingerprintInput( + prompt.fingerprintSha256 ?: manualFingerprint ?: return, + ) ?: return + gatewayLifecycleIntentSeq.incrementAndGet() + launchGatewayLifecycle { + if (_pendingGatewayTrust.value != prompt) return@launchGatewayLifecycle + _pendingGatewayTrust.value = null + prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, acceptedFingerprint) + registerGateway(prompt.endpoint, setActive = true) + beginConnect(endpoint = prompt.endpoint, auth = prompt.auth) + } + } + + fun useSystemGatewayTrustPrompt() { + val prompt = _pendingGatewayTrust.value ?: return + if (!prompt.systemTrustAvailable) return + gatewayLifecycleIntentSeq.incrementAndGet() + launchGatewayLifecycle { + if (_pendingGatewayTrust.value != prompt) return@launchGatewayLifecycle + _pendingGatewayTrust.value = null + prefs.clearGatewayTlsFingerprint(prompt.endpoint.stableId) + registerGateway(prompt.endpoint, setActive = true) + beginConnect(endpoint = prompt.endpoint, auth = prompt.auth) + } + } + + fun declineGatewayTrustPrompt() { + gatewayLifecycleIntentSeq.incrementAndGet() + launchGatewayLifecycle { + _pendingGatewayTrust.value = null + connectingEndpointStableId = null + setStandaloneGatewayStatus("Offline") + } + } + + private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure): String = + when (failure) { + GatewayTlsProbeFailure.TLS_UNAVAILABLE -> + nativeText( + "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", + ).source + GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT -> + nativeText( + "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", + ).source + GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE -> + nativeText("Failed: couldn't reach the secure gateway endpoint for this host.").source + } + + private fun hasRecordAudioPermission(): Boolean = + ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + + fun connectManual() { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isEmpty() || port <= 0 || port > 65535) { + setStandaloneGatewayStatus("Failed: invalid manual host/port") + return + } + connect( + GatewayEndpoint.manual( + host = host, + port = port, + tlsEnabled = manualTls.value, + ), + ) + } + + private fun loadStoredRoleDeviceAuthEntry( + endpoint: GatewayEndpoint, + role: String, + ): DeviceAuthEntry? { + val deviceId = identityStore.loadOrCreate().deviceId + return deviceAuthStore.loadEntry(endpoint.stableId, deviceId, role) + } + + private fun maybeStartOperatorSessionAfterNodeConnect( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + ) { + val selectedGatewayId = connectedEndpoint?.stableId ?: connectingEndpointStableId + if (selectedGatewayId != null && selectedGatewayId != endpoint.stableId) return + runGatewayConnectOperation { + if (operatorConnected) return@runGatewayConnectOperation + val storedOperatorEntry = loadStoredRoleDeviceAuthEntry(endpoint, "operator") + val usesStoredOperatorDeviceToken = + operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token) + val operatorAuth = + resolveOperatorSessionConnectAuth( + auth = auth, + storedOperatorToken = storedOperatorEntry?.token, + ) ?: return@runGatewayConnectOperation + updateStatus { + operatorStatusText = "Connecting…" + operatorConnectionProblem = null + } + operatorSession.connect( + endpoint, + operatorAuth.token, + operatorAuth.bootstrapToken, + operatorAuth.password, + connectionManager.buildOperatorConnectOptions( + scopes = + operatorConnectScopesForAuth( + usesStoredDeviceToken = usesStoredOperatorDeviceToken, + storedOperatorScopes = storedOperatorEntry?.scopes, + ), + ), + connectionManager.resolveTlsParams(endpoint), + ) + } + } + + fun disconnect() = disconnectGatewayLifecycle(retireRunState = false) + + fun prepareForGatewaySetup() = disconnectGatewayLifecycle(retireRunState = true) + + private fun disconnectGatewayLifecycle(retireRunState: Boolean) { + synchronized(gatewayLifecycleIntentLock) { + preferredGatewayReconnectSuppressed = true + secondaryGatewayConnectionsEnabled.value = false + gatewayLifecycleIntentSeq.incrementAndGet() + disconnectSecondaryGatewayConnections() + disconnect(retireRunState) + } + } + + private fun disconnectSecondaryGatewayConnections() { + val sessions = secondaryOperatorSessions.values.map { it.session } + secondaryOperatorSessions.clear() + _backgroundGatewayStatuses.value = emptyMap() + sessions.forEach { it.disconnect() } + } + + private fun disconnect(retireRunState: Boolean) { + if (wearRealtimeTalkControllerLazy.isInitialized()) wearRealtimeTalkController.abort() + prepareDisconnect(retireRunState) + operatorSession.disconnect() + nodeSession.disconnect() + } + + suspend fun forgetGateway(stableId: String): Boolean = + gatewayLifecycleIntentSeq.incrementAndGet().let { intent -> + gatewaySwitchMutex.withLock { + if (intent != gatewayLifecycleIntentSeq.get()) false else forgetGatewayLocked(stableId) + } + } + + private suspend fun forgetGatewayLocked(stableId: String): Boolean { + val normalized = stableId.trim() + if (normalized.isEmpty()) return false + secondaryOperatorSessions.remove(normalized)?.session?.disconnectAndJoin() + updateBackgroundGatewayStatus(normalized, null) + val wasActive = prefs.gatewayRegistry.activeStableId.value == normalized + val connectOperationsDrained = + synchronized(gatewayAuthLifecycleLock) { + if (gatewayAuthResetInProgress) { + null + } else { + gatewayAuthResetInProgress = true + gatewayConnectOperationsDrained + } + } + ?: return false + return try { + connectOperationsDrained.await() + if (connectedEndpoint?.stableId == normalized) { + disconnectAndJoin() + } else if (connectingEndpointStableId == normalized) { + connectAttemptSeq.incrementAndGet() + connectingEndpointStableId = null + _pendingGatewayTrust.value = null + chat.onGatewayScopeChanging(retireRunState = true) + } else if (wasActive) { + prepareDisconnect(retireRunState = true) + } + drainIdleGatewaySessionTails() + val removalStaged = + runCatching { clientDatabases.stageGatewayRemoval(normalized) } + .onFailure { err -> + Log.e("OpenClawRuntime", "Failed to stage forgotten gateway data removal", err) + setStandaloneGatewayStatus("Failed: couldn't prepare offline gateway cleanup. Retry forget.") + }.isSuccess + if (!removalStaged) return false + val authRetired = + runCatching { + val deviceId = identityStore.loadOrCreate().deviceId + deviceAuthStore.clearToken(normalized, deviceId, "node") + deviceAuthStore.clearToken(normalized, deviceId, "operator") + prefs.clearGatewayCredentials(normalized) + prefs.clearGatewayCustomHeaders(normalized) + prefs.clearGatewayTlsFingerprint(normalized) + prefs.clearNotificationForwardingSessionKey(normalized) + }.onFailure { err -> + runCatching { clientDatabases.cancelGatewayRemoval(normalized) } + Log.e("OpenClawRuntime", "Failed to retire forgotten gateway authentication", err) + setStandaloneGatewayStatus("Failed: couldn't clear saved gateway authentication. Retry forget.") + }.isSuccess + if (!authRetired) return false + val cacheCleared = + runCatching { + chat.clearGatewayCache(normalized) { + clientDatabases.commitGatewayRemoval(normalized, requireCacheRemoval = true) + externalTranscriptCache?.clearGateway(normalized) + } + }.onFailure { err -> + Log.e("OpenClawRuntime", "Failed to purge forgotten gateway chat data", err) + setStandaloneGatewayStatus("Failed: couldn't clear offline gateway data. Retry forget.") + }.isSuccess + if (!cacheCleared) return false + // Publish registry removal only after auth, durable state, and transcripts are gone. Any + // earlier failure leaves this signed-out registration available for an idempotent retry. + val registryRemoved = + runCatching { + check(prefs.gatewayRegistry.remove(normalized)) { "Failed to persist gateway registry removal" } + }.fold( + onSuccess = { true }, + onFailure = { err -> + runCatching { clientDatabases.cancelGatewayRemoval(normalized) } + Log.e("OpenClawRuntime", "Failed to commit forgotten gateway registry removal", err) + setStandaloneGatewayStatus("Failed: couldn't remove the saved gateway. Retry forget.") + false + }, + ) + if (!registryRemoved) return false + true + } finally { + synchronized(gatewayAuthLifecycleLock) { gatewayAuthResetInProgress = false } + } + } + + private fun recordConnectedGateway() { + val endpoint = connectedEndpoint ?: return + registerGateway(endpoint, setActive = true) + prefs.gatewayRegistry.markConnected(endpoint.stableId, System.currentTimeMillis()) + } + + private fun registerGateway( + endpoint: GatewayEndpoint, + setActive: Boolean, + ) { + val existing = + prefs.gatewayRegistry.entries.value + .firstOrNull { it.stableId == endpoint.stableId } + val entry = gatewayRegistryEntry(endpoint, existing) + prefs.gatewayRegistry.upsert(entry) + if (setActive) prefs.gatewayRegistry.setActive(endpoint.stableId) + } + + private suspend fun disconnectAndJoin() { + prepareDisconnect(retireRunState = true) + // Both sockets close before either reconnect loop is joined, so no authenticated role stays + // live while reset waits for the other role's terminal callback. + coroutineScope { + launch { operatorSession.disconnectAndJoin() } + launch { nodeSession.disconnectAndJoin() } + } + } + + private suspend fun drainIdleGatewaySessionTails() { + if (connectedEndpoint != null || connectingEndpointStableId != null) return + coroutineScope { + launch { operatorSession.disconnectAndJoin() } + launch { nodeSession.disconnectAndJoin() } + } + } + + private fun prepareDisconnect(retireRunState: Boolean) { + notificationOutbox.clear() + connectAttemptSeq.incrementAndGet() + synchronized(gatewayDataScopeLock) { + gatewayDataGeneration += 1 + clearOperatorGatewayState(retirePendingCronRuns = true) + } + if (retireRunState) updateGatewayDefaultAgentId(null) + invalidateVoiceWakeWordsForGateway() + chat.onGatewayScopeChanging(retireRunState) + stopMessageSpeech() + micCapture.onGatewayScopeChanging() + stopActiveVoiceSession() + talkMode.onGatewayScopeChanging() + if (voiceReplySpeakerLazy.isInitialized()) { + voiceReplySpeaker.onGatewayScopeChanging() + } + if (retireRunState) { + val defaultMainSessionKey = resolveNodeMainSessionKey() + _mainSessionKey.value = defaultMainSessionKey + talkMode.setMainSessionKey(defaultMainSessionKey) + } + connectedEndpoint = null + connectingEndpointStableId = null + _gatewayControlPage.value = null + activeGatewayAuth = null + updateStatus { + operatorConnected = false + _nodeConnected.value = false + operatorStatusText = "Offline" + nodeStatusText = "Offline" + operatorConnectionProblem = null + nodeConnectionProblem = null + } + _pendingGatewayTrust.value = null + } + + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { + val gatewayId = connectedEndpoint?.stableId + scope.launch { + val trimmed = payloadJson.trim() + if (trimmed.isEmpty()) return@launch + + val root = + try { + json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch + } catch (_: Throwable) { + return@launch + } + + val userActionObj = (root["userAction"] as? JsonObject) ?: root + val actionId = + (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { + java.util.UUID + .randomUUID() + .toString() + } + val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch + + val surfaceId = + (userActionObj["surfaceId"] as? JsonPrimitive) + ?.content + ?.trim() + .orEmpty() + .ifEmpty { "main" } + val sourceComponentId = + (userActionObj["sourceComponentId"] as? JsonPrimitive) + ?.content + ?.trim() + .orEmpty() + .ifEmpty { "-" } + val contextJson = (userActionObj["context"] as? JsonObject)?.toString() + + val sessionKey = resolveMainSessionKey() + val message = + OpenClawCanvasA2UIAction.formatAgentMessage( + actionName = name, + sessionKey = sessionKey, + surfaceId = surfaceId, + sourceComponentId = sourceComponentId, + host = displayName.value, + instanceId = instanceId.value.lowercase(), + contextJson = contextJson, + ) + + val connected = _nodeConnected.value + var error: String? = null + if (connected && gatewayId != null) { + val sent = + nodeSession.sendNodeEventForEndpoint( + expectedEndpointStableId = gatewayId, + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(message)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + put("key", JsonPrimitive(actionId)) + }.toString(), + ) + if (!sent) { + error = "send failed" + } + } else { + error = "gateway not connected" + } + + try { + canvas.eval( + OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( + actionId = actionId, + ok = connected && error == null, + error = error, + ), + ) + } catch (_: Throwable) { + // ignore + } + } + } + + fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = a2uiHandler.isTrustedCanvasActionUrl(rawUrl) + + internal suspend fun resolveInlineWidgetResource( + path: String, + failedResource: ChatWidgetResource?, + ): ChatWidgetResource? { + fun GatewaySession.currentWidgetSurface(): ChatWidgetSurface? = + currentCanvasHostRoute()?.let { route -> + ChatWidgetSurface( + url = route.url, + tlsFingerprintSha256 = route.tlsFingerprintSha256, + ) + } + + fun currentSurfaceUrls(): ChatWidgetSurfaceUrls = + ChatWidgetSurfaceUrls( + node = nodeSession.currentWidgetSurface(), + operator = operatorSession.currentWidgetSurface(), + ) + + // Initial loads may use the operator fallback; failures rotate the preferred live route. + if (failedResource == null) return ChatWidgetUrlResolver.resolvePreferred(currentSurfaceUrls(), path, excluding = null) + return inlineWidgetRefreshMutex.withLock { + // Serialize both role sessions so sibling widgets cannot invalidate each other's new token. + ChatWidgetUrlResolver.resolveAfterFailure( + target = path, + failedResource = failedResource, + currentSurfaceUrls = ::currentSurfaceUrls, + refreshNodeSurface = { observedUrl -> + nodeSession.refreshCanvasHostRouteIfCurrent(observedUrl)?.let { route -> + ChatWidgetSurface( + url = route.url, + tlsFingerprintSha256 = route.tlsFingerprintSha256, + ) + } + }, + refreshOperatorSurface = { observedUrl -> + operatorSession.refreshCanvasHostRouteIfCurrent(observedUrl)?.let { route -> + ChatWidgetSurface( + url = route.url, + tlsFingerprintSha256 = route.tlsFingerprintSha256, + ) + } + }, + ) + } + } + + internal suspend fun loadChatImageArtifact(artifactId: String) = chat.loadImageArtifact(artifactId) + + internal suspend fun loadChatMediaArtifact( + artifactId: String, + kind: GatewayMediaKind, + playbackRendition: Boolean, + ) = chat.loadMediaArtifact(artifactId, kind, playbackRendition) + + fun loadChat( + sessionKey: String, + ownerAgentId: String? = null, + ) { + val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } + chat.load(key, ownerAgentId) + } + + fun refreshChat() { + chat.refresh() + } + + fun refreshChatSessions( + limit: Int? = null, + archived: Boolean = false, + ) { + chat.refreshSessions(limit = limit, archived = archived) + } + + suspend fun patchChatSession( + key: String, + ownerAgentId: String? = null, + label: String? = null, + clearLabel: Boolean = false, + category: String? = null, + clearCategory: Boolean = false, + pinned: Boolean? = null, + archived: Boolean? = null, + unread: Boolean? = null, + ) { + chat.patchSession( + key = key, + ownerAgentId = ownerAgentId, + label = label, + clearLabel = clearLabel, + category = category, + clearCategory = clearCategory, + pinned = pinned, + archived = archived, + unread = unread, + ) + } + + suspend fun renameChatSessionGroup( + from: String, + to: String, + ) { + chat.renameSessionGroup(from = from, to = to) + } + + suspend fun dissolveChatSessionGroup(group: String) { + chat.dissolveSessionGroup(group) + } + + internal suspend fun deleteChatSession( + key: String, + ownerAgentId: String?, + ): ChatSessionDeletion? = chat.deleteSession(key, ownerAgentId) + + suspend fun forkChatSession( + parentKey: String, + ownerAgentId: String? = null, + ): String? = chat.forkSession(parentKey, ownerAgentId) + + suspend fun rewindChatAtEntry(entryId: String): SessionRewindResult? = chat.rewindSessionAtEntryResult(chatSessionKey.value, entryId) + + suspend fun forkChatAtEntry(entryId: String): SessionForkResult? = chat.forkSessionAtEntry(chatSessionKey.value, entryId) + + suspend fun refreshChatSessionBranches(): Boolean = chat.refreshSessionBranches() + + suspend fun switchChatSessionBranch(leafEntryId: String): Boolean = chat.switchSessionBranch(chatSessionKey.value, leafEntryId) + + fun setChatThinkingLevel(level: String) { + chat.setThinkingLevel(level) + } + + fun setChatSessionModel( + sessionKey: String, + modelRef: String?, + ) { + chat.setSessionModel(sessionKey = sessionKey, modelRef = modelRef) + } + + fun switchChatSession( + sessionKey: String, + ownerAgentId: String? = null, + ) { + stopMessageSpeech() + chat.switchSession(sessionKey, ownerAgentId) + } + + internal fun refreshSystemAgentChat() { + systemAgentChatController.refresh() + } + + internal fun clearSystemAgentChatInput() { + systemAgentChatController.clearInputForBackground() + } + + internal fun sendSystemAgentChatInput() { + systemAgentChatController.sendInput() + } + + internal fun setSystemAgentChatInput(value: String) { + systemAgentChatController.setInput(value) + } + + internal fun answerSystemAgentQuestion( + messageId: String, + optionLabel: String, + ) { + systemAgentChatController.answerQuestion(messageId, optionLabel) + } + + internal fun skipSystemAgentQuestion(messageId: String) { + systemAgentChatController.skipQuestion(messageId) + } + + internal fun restartSystemAgentChat() { + systemAgentChatController.restart() + } + + internal fun consumeSystemAgentChatHandoff() = systemAgentChatController.openHandoff() + + fun selectChatAgent(agentId: String) { + val normalizedAgentId = agentId.trim() + if (normalizedAgentId.isEmpty()) return + stopMessageSpeech() + // Agent selection owns every main-session consumer; switching chat alone would + // leave Talk mode and the home canvas bound to the previous agent. + selectedChatAgentId = normalizedAgentId + selectMainSessionKey(normalizedAgentId) + } + + suspend fun fetchChatSessionList( + search: String?, + archived: Boolean, + ): List = chat.fetchSessionList(search = search, archived = archived) + + fun abortChat() { + chat.abort() + } + + fun startNewChat(worktree: Boolean = false) { + stopMessageSpeech() + chat.startNewChat(worktree = worktree) + } + + fun toggleMessageSpeech( + messageId: String, + text: String, + ) { + messageSpeechController.toggle(messageId = messageId, text = text) + } + + fun stopMessageSpeech() { + if (messageSpeechControllerLazy.isInitialized()) messageSpeechController.stop() + } + + fun sendChat( + message: String, + thinking: String, + attachments: List, + ) { + chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) + } + + suspend fun sendChatAwaitAcceptance( + message: String, + thinking: String, + attachments: List, + ): Boolean = chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments) + + internal fun canSendForOwner(owner: ChatComposerOwner): Boolean = chat.canSendForOwner(owner) + + internal suspend fun sendChatForOwnerAwaitAcceptance( + owner: ChatComposerOwner, + message: String, + thinking: String, + attachments: List, + idempotencyKey: String, + ): Boolean = + chat.sendMessageForOwnerAwaitAcceptance( + message = message, + thinkingLevel = thinking, + attachments = attachments, + expectedOwner = owner, + idempotencyKey = idempotencyKey, + ) + + internal suspend fun wasChatOutboxCommandAdmitted(id: String): Boolean = chat.wasOutboxCommandAdmitted(id) + + fun refreshChatCommands() { + chat.refreshCommands() + } + + private fun handleGatewayEvent( + event: String, + payloadJson: String?, + ) { + if (event == "update.available") { + _gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson) + } + if (event == GatewayEvent.VoicewakeChanged.rawValue) { + applyVoiceWakeWords(payloadJson) + } + handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson) + micCapture.handleGatewayEvent(event, payloadJson) + talkMode.handleGatewayEvent(event, payloadJson) + if (wearRealtimeTalkControllerLazy.isInitialized()) { + wearRealtimeTalkController.handleGatewayEvent(event, payloadJson) + } + chat.handleGatewayEvent(event, payloadJson) + if (event == "chat" && !payloadJson.isNullOrBlank()) { + runCatching { json.parseToJsonElement(payloadJson) } + .getOrNull() + ?.let { wearProxyBridge()?.publishChat(it) } + } + } + + private fun handleNodeGatewayEvent( + event: String, + payloadJson: String?, + ) { + if (event != GatewayEvent.VoicewakeChanged.rawValue) return + val endpointStableId = nodeSession.currentEndpointStableId() ?: return + applyNodeVoiceWakeWords(endpointStableId, payloadJson) { + nodeSession.currentEndpointStableId() == endpointStableId + } + } + + internal fun applyNodeVoiceWakeWords( + endpointStableId: String, + payloadJson: String?, + isCurrentConnection: () -> Boolean, + ) { + val gatewayScope = captureGatewayDataScope()?.takeIf { it.stableId == endpointStableId } ?: return + val words = parseVoiceWakeWords(payloadJson) ?: return + var applied = false + publishGatewayData(gatewayScope) { + if (isCurrentConnection()) { + applied = applyAuthoritativeVoiceWakeWords(words, gatewayStableId = gatewayScope.stableId) + } + } + if (applied) resumeVoiceWakeAfterGatewayWords(gatewayScope) + } + + private suspend fun refreshWakeWordsFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + val requestRevision = currentVoiceWakeWordsRevision() + try { + val words = parseVoiceWakeWords(requestGatewayData(gatewayScope, GatewayMethod.VoicewakeGet.rawValue, "{}")) ?: return + var applied = false + publishGatewayData(gatewayScope) { + applied = + applyAuthoritativeVoiceWakeWords( + words = words, + gatewayStableId = gatewayScope.stableId, + expectedRevision = requestRevision, + ) + } + if (applied) resumeVoiceWakeAfterGatewayWords(gatewayScope) + } catch (_: CancellationException) { + // A replacement Gateway owns the next refresh. + } catch (err: Throwable) { + Log.d("OpenClawRuntime", "voicewake.get failed: ${err.message ?: err::class.java.simpleName}") + } + } + + private fun applyVoiceWakeWords(payloadJson: String?) { + val gatewayScope = captureGatewayDataScope() ?: return + val words = parseVoiceWakeWords(payloadJson) ?: return + var applied = false + publishGatewayData(gatewayScope) { + applied = applyAuthoritativeVoiceWakeWords(words, gatewayStableId = gatewayScope.stableId) + } + if (applied) resumeVoiceWakeAfterGatewayWords(gatewayScope) + } + + private fun currentVoiceWakeWordsRevision(): Long = synchronized(voiceWakeWordsLock) { voiceWakeWordsRevision } + + private fun applyAuthoritativeVoiceWakeWords( + words: List, + gatewayStableId: String, + expectedRevision: Long? = null, + ): Boolean = + synchronized(voiceWakeWordsLock) { + if (expectedRevision != null && expectedRevision != voiceWakeWordsRevision) return@synchronized false + voiceWakeWordsRevision += 1 + voiceWakeWordsGatewayStableId = gatewayStableId + prefs.setVoiceWakeWords(words) + voiceWakeManager.updateTriggerWords(words) + true + } + + private fun invalidateVoiceWakeWordsForGateway() { + synchronized(voiceWakeWordsLock) { + voiceWakeWordsRevision += 1 + voiceWakeWordsGatewayStableId = null + prefs.setVoiceWakeWords(VoiceWakePreferences.defaultTriggerWords) + voiceWakeManager.updateTriggerWords(VoiceWakePreferences.defaultTriggerWords) + } + voiceWakeManager.setSuppressed(VoiceWakeSuppressionReason.GatewaySync, true) + refreshVoiceWakeCapabilitySurfaceIfChanged() + } + + private fun resumeVoiceWakeAfterGatewayWords(gatewayScope: GatewayDataScope) { + if (!isGatewayDataScopeCurrent(gatewayScope) || !isVoiceWakeWordsReadyFor(gatewayScope.stableId)) return + voiceWakeManager.setSuppressed(VoiceWakeSuppressionReason.GatewaySync, false) + refreshVoiceWakeCapabilitySurfaceIfChanged() + } + + private fun isVoiceWakeWordsReadyForCurrentGateway(): Boolean = connectedEndpoint?.stableId?.let(::isVoiceWakeWordsReadyFor) == true + + private fun isVoiceWakeWordsReadyFor(gatewayStableId: String): Boolean = synchronized(voiceWakeWordsLock) { voiceWakeWordsGatewayStableId == gatewayStableId } + + private fun parseVoiceWakeWords(payloadJson: String?): List? = + runCatching { + payloadJson + ?.let(json::parseToJsonElement) + ?.asObjectOrNull() + ?.get("triggers") + ?.let { it as? JsonArray } + ?.mapNotNull { it.asStringOrNull() } + ?.let(VoiceWakePreferences::sanitizeTriggerWords) + }.getOrNull() + + private suspend fun sendVoiceWakeCommand(match: VoiceWakeMatch): Boolean { + val gatewayId = connectedEndpoint?.stableId ?: return false + if (!isVoiceWakeWordsReadyFor(gatewayId)) return false + if (!_nodeConnected.value) return false + val payload = + buildJsonObject { + put("eventId", JsonPrimitive(UUID.randomUUID().toString())) + put("text", JsonPrimitive(match.command)) + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) + } + return nodeSession.sendNodeEventForEndpoint( + expectedEndpointStableId = gatewayId, + event = "voice.transcript", + payloadJson = payload.toString(), + ) + } + + private fun handleExecApprovalGatewayEvent( + event: String, + payloadJson: String?, + ) { + when (event) { + "exec.approval.requested" -> { + val approvalId = parseExecApprovalEventId(payloadJson) + approvalId?.let { id -> + resolvedExecApprovalIds.remove(id) + synchronized(execApprovalsStateLock) { + if (_execApprovalsNotice.value?.approvalId == id) { + _execApprovalsNotice.value = null + } + } + } + scope.launch { + if (approvalId == null) { + refreshExecApprovalsFromGateway() + } else { + refreshExecApprovalFromGateway(approvalId) + } + } + } + "exec.approval.resolved" -> { + val approvalId = parseExecApprovalEventId(payloadJson) ?: return + val methodsSnapshot = captureGatewayMethods() + when (methodsSnapshot.approvalRpcFamily) { + GatewayApprovalRpcFamily.Canonical -> { + // Resolve events can race the local request or come from another surface. + // Canonical readback preserves the durable winner across that race. + scope.launch { refreshExecApprovalFromGateway(approvalId) } + } + GatewayApprovalRpcFamily.Legacy, + GatewayApprovalRpcFamily.Unavailable, + -> { + val terminal = parseGatewayExecApprovalResolvedEventTerminal(payloadJson ?: return, json) + synchronized(execApprovalsStateLock) { + if (terminal != null && _execApprovals.value.any { it.id == approvalId }) { + _execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(terminal) + } + // Noncanonical peers cannot prove terminal state by readback. The + // authenticated event is the fail-closed tombstone for this exact ID. + markExecApprovalResolved(approvalId) + } + } + } + } + } + } + + private fun parseExecApprovalEventId(payloadJson: String?): String? = + try { + payloadJson + ?.let { json.parseToJsonElement(it).asObjectOrNull() } + ?.get("id") + ?.let { it as? JsonPrimitive } + ?.takeIf { it.isString } + ?.content + ?.takeIf(::isWellFormedGatewayApprovalId) + } catch (_: Throwable) { + null + } + + private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? { + return try { + val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } + val update = root?.get("updateAvailable").asObjectOrNull() ?: return null + GatewayUpdateAvailableSummary( + currentVersion = update["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + latestVersion = update["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + channel = update["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + ) + } catch (_: Throwable) { + null + } + } + + private fun parseTalkSessionId(response: String): String { + val root = json.parseToJsonElement(response).asObjectOrNull() + val sessionId = + root?.get("transcriptionSessionId").asStringOrNull() + ?: root?.get("sessionId").asStringOrNull() + if (sessionId.isNullOrBlank()) { + throw IllegalStateException("talk.session.create returned no session id") + } + return sessionId + } + + private fun captureGatewayDataScope(): GatewayDataScope? = + synchronized(gatewayDataScopeLock) { + connectedEndpoint?.stableId?.let { GatewayDataScope(it, gatewayDataGeneration) } + } + + private suspend fun requestGatewayData( + gatewayScope: GatewayDataScope, + method: String, + paramsJson: String?, + timeoutMs: Long = 15_000, + ): String { + gatewayDataRequestTimeoutObserverForTests?.invoke(method, timeoutMs) + val response = + gatewayDataRequestOverrideForTests?.invoke(gatewayScope.stableId, method, paramsJson) + ?: operatorSession.requestForEndpoint(gatewayScope.stableId, method, paramsJson, timeoutMs) + if (!isGatewayDataScopeCurrent(gatewayScope)) throw CancellationException("gateway scope changed") + return response + } + + private suspend fun requestGatewayApprovalData( + gatewayScope: GatewayDataScope, + methodsSnapshot: GatewayMethodsSnapshot, + method: String, + paramsJson: String?, + preserveWriteFailureAcrossEpoch: Boolean = false, + ): String { + if (!isGatewayMethodsSnapshotCurrent(methodsSnapshot)) { + if (preserveWriteFailureAcrossEpoch) { + throw GatewayRequestNotEnqueued("gateway connection changed before request") + } + throw CancellationException("gateway connection changed") + } + return try { + val response = requestGatewayData(gatewayScope, method, paramsJson) + if (!isGatewayMethodsSnapshotCurrent(methodsSnapshot)) { + throw CancellationException("gateway connection changed") + } + response + } catch (err: Throwable) { + if (!isGatewayMethodsSnapshotCurrent(methodsSnapshot)) { + // A registered write owner makes definitive and ambiguous failures safe + // to classify after a same-endpoint reconnect; successes still read back. + if ( + preserveWriteFailureAcrossEpoch && + (err is GatewayRequestDefinitiveFailure || err is GatewayRequestOutcomeUnknown) + ) { + throw err + } + throw CancellationException("gateway connection changed") + } + throw err + } + } + + private fun isGatewayDataScopeCurrent(gatewayScope: GatewayDataScope): Boolean = + synchronized(gatewayDataScopeLock) { + gatewayScope.generation == gatewayDataGeneration && connectedEndpoint?.stableId == gatewayScope.stableId + } + + private inline fun publishGatewayData( + gatewayScope: GatewayDataScope, + publish: () -> Unit, + ): Boolean = + synchronized(gatewayDataScopeLock) { + if (gatewayScope.generation != gatewayDataGeneration || connectedEndpoint?.stableId != gatewayScope.stableId) { + false + } else { + publish() + true + } + } + + private suspend fun refreshGatewaySummary( + summary: MutableStateFlow, + refreshing: MutableStateFlow, + errorText: MutableStateFlow, + disconnectedSummary: T, + failureText: NativeText, + fetch: suspend (GatewayDataScope) -> T, + ): Boolean { + val gatewayScope = captureGatewayDataScope() ?: return false + publishGatewayData(gatewayScope) { + refreshing.value = true + errorText.value = null + } + if (!operatorConnected) { + summary.value = disconnectedSummary + refreshing.value = false + return false + } + return try { + val nextSummary = fetch(gatewayScope) + publishGatewayData(gatewayScope) { summary.value = nextSummary } + true + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { errorText.value = failureText } + false + } finally { + publishGatewayData(gatewayScope) { refreshing.value = false } + } + } + + /** Publishes approval state only while the response's operator socket still owns the method catalog. */ + private inline fun publishGatewayApprovalData( + gatewayScope: GatewayDataScope, + methodsSnapshot: GatewayMethodsSnapshot, + publish: () -> Unit, + ): Boolean { + var approvalPublished = false + val scopePublished = + publishGatewayData(gatewayScope) { + // Lock order stays gateway data -> method catalog -> approval state. The + // explicit disconnect path already takes the first two in this order. + synchronized(gatewayMethodsLock) { + if (methodsSnapshot.epoch == gatewayMethodsEpoch) { + publish() + approvalPublished = true + } + } + } + return scopePublished && approvalPublished + } + + private inline fun publishCronRefresh( + gatewayScope: GatewayDataScope, + refreshGeneration: Long, + crossinline publish: () -> Unit, + ): Boolean = + publishGatewayData(gatewayScope) { + cronRefreshGuard.publishIfCurrent(refreshGeneration) { publish() } + } + + private inline fun publishProviderModelRefresh( + gatewayScope: GatewayDataScope, + refreshGeneration: Long, + crossinline publish: () -> Unit, + ): Boolean = + publishGatewayData(gatewayScope) { + providerModelCatalogRefreshGuard.publishIfCurrent(refreshGeneration) { publish() } + } + + private suspend fun refreshBrandingFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + if (!gatewayConnectionDisplay.value.isConnected) return + try { + val res = requestGatewayData(gatewayScope, "config.get", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() + val config = root?.get("config").asObjectOrNull() + val ui = config?.get("ui").asObjectOrNull() + val raw = ui?.get("seamColor").asStringOrNull()?.trim() + val parsed = parseHexColorArgb(raw) + publishGatewayData(gatewayScope) { + _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB + updateHomeCanvasState() + } + } catch (_: Throwable) { + // ignore + } + } + + /** Lists one directory of the active agent's workspace (read-only RPC). */ + suspend fun listWorkspaceFiles( + path: String?, + offset: Int? = null, + ): GatewayWorkspaceListing { + val params = + buildJsonObject { + put("agentId", JsonPrimitive(workspaceAgentId())) + if (!path.isNullOrEmpty()) put("path", JsonPrimitive(path)) + if (offset != null && offset > 0) put("offset", JsonPrimitive(offset)) + } + val res = operatorSession.request("agents.workspace.list", params.toString()) + return parseWorkspaceListing(json.parseToJsonElement(res)) + ?: throw IllegalStateException("agents.workspace.list returned no listing") + } + + /** Fetches one workspace file preview (UTF-8 text or base64 image). */ + suspend fun fetchWorkspaceFile(path: String): GatewayWorkspaceFile { + val params = + buildJsonObject { + put("agentId", JsonPrimitive(workspaceAgentId())) + put("path", JsonPrimitive(path)) + } + val res = operatorSession.request("agents.workspace.get", params.toString(), timeoutMs = 30_000) + return parseWorkspaceFile(json.parseToJsonElement(res)) + ?: throw IllegalStateException("agents.workspace.get returned no file") + } + + private fun workspaceAgentId(): String = resolveActiveAgentId().ifEmpty { "main" } + + private suspend fun refreshAgentsFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + if (!operatorConnected) return + try { + val res = requestGatewayData(gatewayScope, "agents.list", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return + val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty() + val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull()) + val agents = parseGatewayAgentSummaries(root) + + publishGatewayData(gatewayScope) { + updateGatewayDefaultAgentId(defaultAgentId) + _gatewayAgents.value = agents + val selectedAgentId = selectedChatAgentId?.takeIf { id -> agents.any { it.id == id } } + selectedChatAgentId = selectedAgentId + syncMainSessionKey(selectedAgentId ?: resolveAgentIdFromMainSessionKey(mainKey) ?: gatewayDefaultAgentId.value) + updateHomeCanvasState() + } + } catch (_: Throwable) { + // ignore + } + } + + private suspend fun refreshModelCatalogFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + publishGatewayData(gatewayScope) { + _modelCatalogRefreshing.value = true + _modelCatalogErrorText.value = null + } + if (!operatorConnected) { + _modelCatalog.value = emptyList() + _modelAuthProviders.value = emptyList() + _modelCatalogRefreshing.value = false + return + } + try { + val modelsRes = requestGatewayData(gatewayScope, "models.list", "{}") + val modelsRoot = json.parseToJsonElement(modelsRes).asObjectOrNull() + val models = parseGatewayModels(modelsRoot?.get("models") as? JsonArray) + publishGatewayData(gatewayScope) { + _modelCatalog.value = models + } + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { _modelCatalogErrorText.value = nativeText("Could not load provider catalog.") } + } finally { + publishGatewayData(gatewayScope) { _modelCatalogRefreshing.value = false } + } + } + + private suspend fun refreshProviderModelsFromGateway() { + val refreshGeneration = providerModelCatalogRefreshGuard.begin() + val gatewayScope = captureGatewayDataScope() ?: return + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + _providerModelCatalogRefreshing.value = true + _providerModelCatalogErrorText.value = null + } + if (!operatorConnected) { + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + _providerModelCatalog.value = emptyList() + _modelAuthProviders.value = emptyList() + _providerModelCatalogRefreshing.value = false + } + return + } + try { + try { + val models = requestProviderModelCatalog(gatewayScope) + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + _providerModelCatalog.value = models + } + } catch (err: Throwable) { + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + _providerModelCatalogErrorText.value = + if (err is ProviderModelConfigUnsupported) { + nativeText("Update your Gateway to view provider model config.") + } else { + nativeText("Could not load provider model config.") + } + } + } + + // Keep readiness independent from the additive provider-config view so + // older Gateways still populate provider status while prompting an upgrade. + try { + val providers = requestModelAuthProviders(gatewayScope) + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + _modelAuthProviders.value = providers + } + } catch (_: Throwable) { + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + if (_providerModelCatalogErrorText.value == null) { + _providerModelCatalogErrorText.value = + nativeText("Provider models loaded, but readiness is unavailable.") + } + } + } + } finally { + publishProviderModelRefresh(gatewayScope, refreshGeneration) { + _providerModelCatalogRefreshing.value = false + } + } + } + + private suspend fun requestProviderModelCatalog(gatewayScope: GatewayDataScope): List { + val modelsRes = + requestProviderModelConfig { paramsJson -> + requestGatewayData(gatewayScope, "models.list", paramsJson) + } + val modelsRoot = json.parseToJsonElement(modelsRes).asObjectOrNull() + return parseGatewayModels(modelsRoot?.get("models") as? JsonArray) + } + + private suspend fun requestModelAuthProviders(gatewayScope: GatewayDataScope): List { + val authRes = requestGatewayData(gatewayScope, "models.authStatus", "{}") + val authRoot = json.parseToJsonElement(authRes).asObjectOrNull() + return parseGatewayModelProviders(authRoot?.get("providers") as? JsonArray) + } + + private suspend fun refreshTalkSetupReadinessFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + if (!operatorConnected) { + _talkSetupReadiness.value = GatewayTalkSetupReadiness.unverified() + return + } + val readiness = + try { + val response = requestGatewayData(gatewayScope, "talk.catalog", "{}") + parseGatewayTalkSetupReadiness(json.parseToJsonElement(response).asObjectOrNull()) + } catch (_: Throwable) { + GatewayTalkSetupReadiness.unverified(GatewayTalkSetupIssue.CatalogLoadFailed) + } + publishGatewayData(gatewayScope) { _talkSetupReadiness.value = readiness } + } + + private suspend fun refreshCronFromGateway() { + val refreshGeneration = cronRefreshGuard.begin() + val gatewayScope = captureGatewayDataScope() ?: return + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronRefreshing.value = true + _cronErrorText.value = null + } + if (!operatorConnected) { + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null) + _cronJobs.value = emptyList() + _cronRefreshing.value = false + } + return + } + try { + val statusRes = requestGatewayData(gatewayScope, "cron.status", "{}") + val statusRoot = json.parseToJsonElement(statusRes).asObjectOrNull() + val status = + GatewayCronStatus( + enabled = statusRoot.boolean("enabled"), + jobs = statusRoot.long("jobs")?.toInt() ?: 0, + nextWakeAtMs = statusRoot.long("nextWakeAtMs"), + ) + + var snapshot: List? = null + repeat(CRON_JOBS_SNAPSHOT_MAX_ATTEMPTS) { + if (snapshot == null) snapshot = requestCronJobsSnapshot(gatewayScope) + } + val jobs = + requireNotNull(snapshot) { + "Gateway cron jobs changed repeatedly while loading." + } + val sortedJobs = + jobs.sortedWith( + compareBy { it.nextRunAtMs == null } + .thenBy { it.nextRunAtMs ?: Long.MAX_VALUE } + .thenBy { it.id }, + ) + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronStatus.value = status + _cronJobs.value = sortedJobs + } + } catch (_: Throwable) { + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronErrorText.value = nativeText("Could not load automations.") + } + } finally { + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronRefreshing.value = false + } + } + } + + private suspend fun requestCronJobsSnapshot( + gatewayScope: GatewayDataScope, + ): List? { + val jobs = mutableListOf() + val jobIds = mutableSetOf() + var offset = 0 + var complete = false + var pageCount = 0 + var expectedTotal: Long? = null + var expectedSnapshotRevision: String? = null + var snapshotRevisionSupported: Boolean? = null + while (pageCount < CRON_JOBS_MAX_PAGES && !complete) { + pageCount += 1 + val listParams = + buildJsonObject { + put("includeDisabled", JsonPrimitive(true)) + put("limit", JsonPrimitive(CRON_JOBS_PAGE_SIZE)) + put("offset", JsonPrimitive(offset)) + // nextRunAtMs changes as jobs execute; name plus the server's id tie-breaker + // keeps offsets stable while paging, then we restore scheduler order below. + put("sortBy", JsonPrimitive("name")) + put("sortDir", JsonPrimitive("asc")) + }.toString() + val listRes = requestGatewayData(gatewayScope, "cron.list", listParams) + val listRoot = json.parseToJsonElement(listRes).asObjectOrNull() + val rawJobs = listRoot?.get("jobs") as? JsonArray + val pageJobs = parseCronJobs(rawJobs) + val total = + requireNotNull(listRoot.long("total")) { + "Gateway did not return a cron jobs total." + } + require(total in 0L..CRON_JOBS_MAX_COUNT.toLong()) { + "Gateway returned an invalid cron jobs total." + } + if (expectedTotal != null && total != expectedTotal) return null + expectedTotal = total + val snapshotRevision = + (listRoot?.get("snapshotRevision") as? JsonPrimitive) + ?.contentOrNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + val pageSupportsSnapshotRevision = snapshotRevision != null + if ( + snapshotRevisionSupported != null && + snapshotRevisionSupported != pageSupportsSnapshotRevision + ) { + return null + } + snapshotRevisionSupported = pageSupportsSnapshotRevision + if (expectedSnapshotRevision != null && snapshotRevision != expectedSnapshotRevision) return null + expectedSnapshotRevision = snapshotRevision + for (job in pageJobs) { + // Offset pages are separately locked by the Gateway. A mutation between + // calls can shift a boundary; discard the partial snapshot and retry. + if (!jobIds.add(job.id)) return null + } + jobs += pageJobs + require(jobs.size <= CRON_JOBS_MAX_COUNT) { "Gateway returned too many cron jobs." } + require(total >= jobs.size.toLong()) { + "Gateway returned an invalid cron jobs total." + } + val nextOffset = nextCronJobsPageOffset(listRoot, offset, rawJobs?.size ?: 0) + if (nextOffset == null) { + complete = true + break + } + require(nextOffset <= CRON_JOBS_MAX_COUNT) { "Gateway returned too many cron jobs." } + offset = nextOffset + } + require(complete) { "Gateway returned too many cron job pages." } + return jobs.takeIf { it.size.toLong() == expectedTotal } + } + + private suspend fun loadCronJobDetailFromGateway(request: CronJobDetailRequest) { + val gatewayScope = captureGatewayDataScope() ?: return + if (!operatorConnected) { + cronJobDetailRequestGuard.publishIfCurrent(request) { + _cronJobDetailState.value = GatewayCronJobDetailState.Error(request.id, nativeText("Connect the gateway to inspect automations.")) + } + return + } + try { + val res = requestGatewayData(gatewayScope, "cron.get", cronJobGetParams(request.id)) + val root = json.parseToJsonElement(res).asObjectOrNull() + cronJobDetailRequestGuard.publishIfCurrent(request) { + _cronJobDetailState.value = + parseGatewayCronJobDetail(root)?.let(GatewayCronJobDetailState::Loaded) + ?: GatewayCronJobDetailState.Error(request.id, nativeText("Gateway returned an invalid automation.")) + } + } catch (_: Throwable) { + cronJobDetailRequestGuard.publishIfCurrent(request) { + _cronJobDetailState.value = GatewayCronJobDetailState.Error(request.id, nativeText("Could not load automation.")) + } + } + } + + private suspend fun loadCronRunHistoryFromGateway(request: CronJobDetailRequest) { + val gatewayScope = captureGatewayDataScope() ?: return + if (!operatorConnected) { + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = + GatewayCronRunHistoryState.Error( + id = request.id, + message = nativeString("Connect the gateway to inspect automation run history."), + ) + } + return + } + try { + val response = + requestGatewayData( + gatewayScope, + "cron.runs", + buildJsonObject { + put("id", JsonPrimitive(request.id)) + put("limit", JsonPrimitive(20)) + put("sortDir", JsonPrimitive("desc")) + }.toString(), + ) + val root = json.parseToJsonElement(response).asObjectOrNull() + val runs = parseGatewayCronRunHistory(root?.get("entries") as? JsonArray) + publishGatewayData(gatewayScope) { + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loaded(id = request.id, runs = runs) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = + GatewayCronRunHistoryState.Error( + id = request.id, + message = nativeString("Could not load automation run history."), + ) + } + } + } + } + + private fun launchCronAction( + id: String, + action: GatewayCronAction, + perform: suspend (GatewayDataScope, String) -> CronActionResult, + ) { + val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return + if (!operatorAdminScopeAvailable.value) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = nativeText("Cron changes require operator.admin access."), + kind = GatewayCronNoticeKind.Error, + ) + return + } + if (!operatorConnected) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = nativeText("Connect the gateway to manage automations."), + kind = GatewayCronNoticeKind.Error, + ) + return + } + if (_cronActionState.value is GatewayCronActionState.Running) return + // One mutating RPC at a time keeps button taps and programmatic calls from racing. + if (!cronActionMutex.tryLock()) { + if (_cronActionState.value !is GatewayCronActionState.Running) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = nativeText("Another cron action is still finishing."), + kind = GatewayCronNoticeKind.Warning, + ) + } + return + } + // Publish ownership before returning to Compose so Activity recreation can + // distinguish a retained Save from dead pending state after process death. + val actionScope = captureGatewayDataScope() + if (actionScope == null) { + cronActionMutex.unlock() + return + } + val started = + publishGatewayData(actionScope) { + _cronActionState.value = GatewayCronActionState.Running(id = jobId, action = action) + } + if (!started) { + cronActionMutex.unlock() + return + } + scope.launch { + var completionState: GatewayCronActionState.Notice? = null + try { + val result = perform(actionScope, jobId) + if (result.deleted) { + clearDeletedCronSelection(jobId) + } + if (result.refresh) { + refreshCronFromGateway() + if (!result.deleted) reloadCronJobIfSelected(jobId) + } + completionState = + GatewayCronActionState.Notice( + id = jobId, + message = result.message, + kind = result.kind, + deleted = result.deleted, + ) + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + val message = + err.message + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::verbatimText) + ?: nativeText("Cron action failed.") + completionState = + GatewayCronActionState.Notice( + id = jobId, + message = message, + kind = GatewayCronNoticeKind.Error, + ) + } finally { + cronActionMutex.unlock() + val notice = completionState + if (notice != null) { + publishGatewayData(actionScope) { + _cronActionState.value = notice + } + } + } + } + } + + private fun reloadCronJobIfSelected(jobId: String) { + // Ownership checks and loading publication stay under each guard's lock; + // navigation that wins afterward invalidates these requests before publish. + val detailRequest = + cronJobDetailRequestGuard.beginIfCurrent(jobId) { request -> + _cronJobDetailState.value = GatewayCronJobDetailState.Loading(request.id) + } + val historyRequest = + cronRunHistoryRequestGuard.beginIfCurrent(jobId) { request -> + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(request.id) + } + detailRequest?.let { scope.launch { loadCronJobDetailFromGateway(it) } } + historyRequest?.let { scope.launch { loadCronRunHistoryFromGateway(it) } } + } + + private fun clearDeletedCronSelection(jobId: String) { + // A completed delete can race navigation to another job. Clear only state + // still owned by the deleted id so the newer detail/history survives. + cronJobDetailRequestGuard.cancelIfCurrent(jobId) { + _cronJobDetailState.value = GatewayCronJobDetailState.Idle + } + cronRunHistoryRequestGuard.cancelIfCurrent(jobId) { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle + } + } + + private fun trackQueuedCronRun( + gatewayScope: GatewayDataScope, + jobId: String, + runId: String, + ) { + // cron.run acknowledges before lane admission. Track its exact run-log id + // so only this job stays deduped until terminal evidence or scope retirement. + scope.launch { + var completedRun: GatewayCronRunSummary? = null + while (isGatewayDataScopeCurrent(gatewayScope) && completedRun == null) { + completedRun = + try { + val response = + requestGatewayData( + gatewayScope, + "cron.runs", + buildJsonObject { + put("id", JsonPrimitive(jobId)) + put("runId", JsonPrimitive(runId)) + put("limit", JsonPrimitive(1)) + put("sortDir", JsonPrimitive("desc")) + }.toString(), + ) + val root = json.parseToJsonElement(response).asObjectOrNull() + parseGatewayCronRunHistory(root?.get("entries") as? JsonArray) + .firstOrNull { it.runId == runId } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + if (!isGatewayDataScopeCurrent(gatewayScope)) return@launch + null + } + if (completedRun == null) delay(CRON_RUN_TRACKING_POLL_MS) + } + if (!isGatewayDataScopeCurrent(gatewayScope)) return@launch + val terminalRun = completedRun ?: return@launch + + var pendingCleared = false + val scopeCurrent = + publishGatewayData(gatewayScope) { + pendingCleared = + pendingCronRunRegistry.finish(jobId, runId) { + _pendingCronRunJobIds.value = it + } + } + if (!scopeCurrent || !pendingCleared) return@launch + + refreshCronFromGateway() + reloadCronJobIfSelected(jobId) + publishGatewayData(gatewayScope) { + val currentAction = _cronActionState.value + val canPublish = + currentAction == GatewayCronActionState.Idle || + (currentAction is GatewayCronActionState.Notice && currentAction.id == jobId) + if (canPublish) { + _cronActionState.value = cronRunCompletionNotice(jobId, terminalRun.status) + } + } + } + } + + private suspend fun refreshUsageFromGateway() = + refreshGatewaySummary( + summary = _usageSummary, + refreshing = _usageRefreshing, + errorText = _usageErrorText, + disconnectedSummary = GatewayUsageSummary(updatedAtMs = null, providers = emptyList()), + failureText = nativeText("Could not load usage."), + ) { gatewayScope -> + val root = json.parseToJsonElement(requestGatewayData(gatewayScope, "usage.status", "{}")).asObjectOrNull() + GatewayUsageSummary( + updatedAtMs = root.long("updatedAt"), + providers = parseUsageProviders(root?.get("providers") as? JsonArray), + ) + } + + private suspend fun refreshSkillsFromGateway(): Boolean = + refreshGatewaySummary( + summary = _skillsSummary, + refreshing = _skillsRefreshing, + errorText = _skillsErrorText, + disconnectedSummary = GatewaySkillsSummary(skills = emptyList()), + failureText = nativeText("Could not load skills."), + ) { gatewayScope -> + val root = json.parseToJsonElement(requestGatewayData(gatewayScope, "skills.status", "{}")).asObjectOrNull() + GatewaySkillsSummary( + managedSkillsDirAvailable = + root + ?.get("managedSkillsDir") + .asStringOrNull() + ?.trim() + ?.isNotEmpty() == true, + skills = parseSkillSummaries(root?.get("skills") as? JsonArray), + ) + } + + private suspend fun setSkillEnabledOnGateway( + skillKey: String, + enabled: Boolean, + ) { + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillsErrorText.value = nativeText("Connect the gateway to update skills.") + return + } + if (!operatorAdminScopeAvailable.value) { + _skillsErrorText.value = nativeText("This gateway connection needs operator.admin to update skills.") + return + } + publishGatewayData(gatewayScope) { + _skillMutationKeys.value = _skillMutationKeys.value + skillKey + _skillsErrorText.value = null + } + try { + requestGatewayData(gatewayScope, "skills.update", skillEnabledParams(skillKey, enabled)) + refreshSkillsFromGateway() + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + _skillsErrorText.value = + nativeText(if (enabled) "Could not enable skill." else "Could not disable skill.") + } + } finally { + publishGatewayData(gatewayScope) { + _skillMutationKeys.value = _skillMutationKeys.value - skillKey + } + } + } + + private suspend fun searchClawHubSkillsFromGateway(query: String) { + val normalized = query.trim() + val searchSeq = clawHubSkillSearchSeq.incrementAndGet() + clawHubSkillReviewSeq.incrementAndGet() + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _clawHubSkillSearchState.value = + GatewayClawHubSkillSearchState( + query = normalized, + errorText = nativeString("Connect the gateway to search ClawHub skills."), + ) + return + } + if (!clawHubSkillMethodsAvailable.value) { + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy(errorText = CLAWHUB_SKILL_GATEWAY_UNAVAILABLE) + } + return + } + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + query = normalized, + searching = true, + results = emptyList(), + reviewingSlug = null, + installReview = null, + acknowledgeSlug = null, + acknowledgeVersion = null, + errorText = null, + messageText = null, + ) + } + try { + val response = requestGatewayData(gatewayScope, "skills.search", clawHubSearchParams(normalized)) + val results = parseClawHubSearchResults(response, json) + publishGatewayData(gatewayScope) { + if (clawHubSkillSearchSeq.get() == searchSeq) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + searching = false, + results = results, + messageText = if (results.isEmpty()) "No ClawHub skills matched." else null, + ) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (clawHubSkillSearchSeq.get() == searchSeq) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + searching = false, + errorText = nativeString("Could not search ClawHub skills."), + ) + } + } + } + } + + private suspend fun reviewClawHubSkillInstallFromGateway(skill: GatewayClawHubSkillSummary) { + val reviewSeq = clawHubSkillReviewSeq.incrementAndGet() + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + errorText = nativeString("Connect the gateway to inspect ClawHub skills."), + ) + return + } + if (!clawHubSkillMethodsAvailable.value) { + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy(errorText = CLAWHUB_SKILL_GATEWAY_UNAVAILABLE) + } + return + } + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + reviewingSlug = skill.slug, + installReview = null, + acknowledgeSlug = null, + acknowledgeVersion = null, + errorText = null, + messageText = null, + ) + } + try { + val response = requestGatewayData(gatewayScope, "skills.detail", clawHubDetailParams(skill.slug)) + val review = parseClawHubInstallReview(response, skill, json) + publishGatewayData(gatewayScope) { + if (clawHubSkillReviewSeq.get() == reviewSeq) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + reviewingSlug = null, + installReview = review, + errorText = + if (review == null) { + "ClawHub did not return an installable version for ${skill.slug}." + } else { + null + }, + ) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (clawHubSkillReviewSeq.get() == reviewSeq) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + reviewingSlug = null, + errorText = + nativeString("Could not load ClawHub details for \${skill.slug}.", skill.slug), + ) + } + } + } + } + + private suspend fun installClawHubSkillFromGateway( + slug: String, + acknowledgeClawHubRisk: Boolean, + version: String?, + ) { + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + errorText = nativeString("Connect the gateway to install ClawHub skills."), + ) + return + } + if (!clawHubSkillMethodsAvailable.value) { + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy(errorText = CLAWHUB_SKILL_GATEWAY_UNAVAILABLE) + } + return + } + if (!operatorAdminScopeAvailable.value) { + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + errorText = + nativeString( + "This gateway connection needs operator.admin to install ClawHub skills.", + ), + ) + } + return + } + clawHubSkillInstallBeforeClaimObserverForTests?.invoke() + val claimed = + clawHubSkillInstallMutex.withLock { + var published = false + // Gateway switches reset this shared UI state while installs can wait + // on the mutex. Claim under the scope lock so stale work cannot leak in. + publishGatewayData(gatewayScope) { + val current = _clawHubSkillSearchState.value + if (slug !in current.installingSlugs) { + _clawHubSkillSearchState.value = + current.copy(installingSlugs = current.installingSlugs + slug) + published = true + } + } + published + } + if (!claimed) return + val attemptedVersion = version?.trim()?.takeIf(String::isNotEmpty) + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + installReview = null, + acknowledgeSlug = null, + acknowledgeVersion = null, + errorText = null, + messageText = null, + ) + } + try { + val response = + requestGatewayData( + gatewayScope, + "skills.install", + clawHubInstallParams(slug, attemptedVersion, acknowledgeClawHubRisk), + timeoutMs = CLAWHUB_INSTALL_REQUEST_TIMEOUT_MS, + ) + val root = json.parseToJsonElement(response).asObjectOrNull() + val message = + root + ?.get("message") + .asStringOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty) + val warning = + root + ?.get("warning") + .asStringOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty) + val refreshed = refreshSkillsFromGateway() + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + messageText = + formatClawHubInstallMessage( + message ?: "Installed $slug.", + listOfNotNull( + warning, + if (refreshed) null else "Installed, but the skills list could not be refreshed.", + ).joinToString("\n").ifBlank { null }, + ), + ) + } + } catch (err: CancellationException) { + throw err + } catch (_: GatewayRequestOutcomeUnknown) { + val confirmed = refreshAndConfirmClawHubInstall(gatewayScope, slug, attemptedVersion) + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + errorText = if (confirmed) null else clawHubInstallOutcomeUnknownMessage(slug), + messageText = if (confirmed) "Installed $slug." else null, + ) + } + } catch (err: GatewayRequestRejected) { + val confirmed = refreshAndConfirmClawHubInstall(gatewayScope, slug, attemptedVersion) + val rejection = if (confirmed) null else clawHubInstallRejection(err.gatewayError, attemptedVersion) + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + acknowledgeSlug = if (rejection?.requiresAcknowledgement == true) slug else null, + acknowledgeVersion = rejection?.acknowledgeVersion, + errorText = rejection?.let { formatClawHubInstallMessage(it.message, it.warning) }, + messageText = if (confirmed) "Installed $slug." else null, + ) + } + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + errorText = nativeString("Could not install \${slug} from ClawHub.", slug), + ) + } + } finally { + releaseClawHubInstallClaim(slug, gatewayScope) + } + } + + private suspend fun refreshAndConfirmClawHubInstall( + gatewayScope: GatewayDataScope, + slug: String, + version: String?, + ): Boolean { + val exactVersion = version ?: return false + if (!refreshSkillsFromGateway() || !isGatewayDataScopeCurrent(gatewayScope)) return false + return isClawHubSkillInstalled(_skillsSummary.value.skills, slug, exactVersion) + } + + private suspend fun releaseClawHubInstallClaim( + slug: String, + gatewayScope: GatewayDataScope? = null, + ) { + clawHubSkillInstallMutex.withLock { + val release = { + _clawHubSkillSearchState.value = + _clawHubSkillSearchState.value.copy( + installingSlugs = _clawHubSkillSearchState.value.installingSlugs - slug, + ) + } + if (gatewayScope == null) release() else publishGatewayData(gatewayScope, release) + } + } + + private suspend fun refreshSkillWorkshopProposalsFromGateway(agentId: String?) { + val listSeq = skillWorkshopListSeq.incrementAndGet() + val requestAgentId = normalizeSkillWorkshopAgentId(agentId) + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList()) + _skillWorkshopRefreshing.value = false + _skillWorkshopErrorText.value = nativeText("Connect the gateway to load Skill Workshop proposals.") + return + } + publishGatewayData(gatewayScope) { + _skillWorkshopRefreshing.value = true + _skillWorkshopErrorText.value = null + if (_skillWorkshopSummary.value.agentId != requestAgentId) { + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList()) + _skillWorkshopNoticeText.value = null + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = null + skillWorkshopInspectSeq.incrementAndGet() + skillWorkshopMutationSeq.incrementAndGet() + } + } + try { + val res = + requestGatewayData( + gatewayScope, + "skills.proposals.list", + skillWorkshopParams(agentId = agentId).toString(), + ) + val root = json.parseToJsonElement(res).asObjectOrNull() + val previousById = + _skillWorkshopSummary.value + .takeIf { it.agentId == requestAgentId } + ?.proposals + ?.associateBy { it.id } + .orEmpty() + val proposals = parseSkillWorkshopProposals(root?.get("proposals") as? JsonArray, previousById) + publishGatewayData(gatewayScope) { + if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = proposals) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopErrorText.value = nativeText("Could not load Skill Workshop proposals.") + } + } + } finally { + publishGatewayData(gatewayScope) { + if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopRefreshing.value = false + } + } + } + } + + private suspend fun inspectSkillWorkshopProposalFromGateway( + proposalId: String, + agentId: String?, + ) { + var inspectSeq = 0L + val requestAgentId = normalizeSkillWorkshopAgentId(agentId) + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillWorkshopErrorText.value = nativeText("Connect the gateway to inspect Skill Workshop proposals.") + return + } + var inspectStarted = false + val scopeCurrent = + publishGatewayData(gatewayScope) { + val currentSummary = _skillWorkshopSummary.value + if ( + currentSummary.agentId == requestAgentId && + currentSummary.proposals.any { it.id == proposalId } && + _skillWorkshopMutatingProposalId.value == null + ) { + inspectStarted = true + inspectSeq = skillWorkshopInspectSeq.incrementAndGet() + _skillWorkshopInspectingProposalId.value = proposalId + _skillWorkshopErrorText.value = null + } + } + if (!scopeCurrent || !inspectStarted) { + return + } + try { + val res = + requestGatewayData( + gatewayScope, + "skills.proposals.inspect", + skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(), + ) + val root = json.parseToJsonElement(res).asObjectOrNull() + val previous = + _skillWorkshopSummary.value + .takeIf { it.agentId == requestAgentId } + ?.proposals + ?.firstOrNull { it.id == proposalId } + val inspected = + parseSkillWorkshopProposalInspect(root, previous) + ?: throw IllegalStateException("skills.proposals.inspect returned no proposal") + publishGatewayData(gatewayScope) { + val currentSummary = _skillWorkshopSummary.value + if ( + skillWorkshopInspectSeq.get() == inspectSeq && + currentSummary.agentId == requestAgentId && + currentSummary.proposals.any { it.id == proposalId } + ) { + _skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(inspected) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopErrorText.value = nativeText("Could not inspect Skill Workshop proposal.") + } + } + } finally { + publishGatewayData(gatewayScope) { + if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopInspectingProposalId.value = null + } + } + } + } + + private suspend fun mutateSkillWorkshopProposalOnGateway( + proposalId: String, + agentId: String?, + action: SkillWorkshopGatewayAction, + ) { + var mutationSeq = 0L + val requestAgentId = normalizeSkillWorkshopAgentId(agentId) + if (!operatorAdminScopeAvailable.value) { + _skillWorkshopErrorText.value = nativeText("Skill Workshop proposal actions require operator.admin scope.") + return + } + val gatewayScope = captureGatewayDataScope() + if (gatewayScope == null || !operatorConnected) { + _skillWorkshopErrorText.value = nativeText("Connect the gateway to update Skill Workshop proposals.") + return + } + var mutationStarted = false + val scopeCurrent = + publishGatewayData(gatewayScope) { + val currentSummary = _skillWorkshopSummary.value + if ( + currentSummary.agentId == requestAgentId && + currentSummary.proposals.any { it.id == proposalId } && + _skillWorkshopMutatingProposalId.value == null + ) { + mutationStarted = true + mutationSeq = skillWorkshopMutationSeq.incrementAndGet() + // A lifecycle action supersedes any older detail read. Without this + // guard, a late inspect response can restore the pre-action status. + skillWorkshopInspectSeq.incrementAndGet() + _skillWorkshopInspectingProposalId.value = null + _skillWorkshopMutatingProposalId.value = proposalId + _skillWorkshopErrorText.value = null + _skillWorkshopNoticeText.value = null + } + } + if (!scopeCurrent || !mutationStarted) { + return + } + try { + val res = + requestGatewayData( + gatewayScope, + "skills.proposals.${action.methodSuffix}", + skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(), + ) + val updatedProposal = + parseSkillWorkshopProposalActionResult( + root = json.parseToJsonElement(res).asObjectOrNull(), + previous = + _skillWorkshopSummary.value + .takeIf { it.agentId == requestAgentId } + ?.proposals + ?.firstOrNull { it.id == proposalId }, + ) + var mutationConfirmed = false + publishGatewayData(gatewayScope) { + if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + if (updatedProposal?.status == action.expectedStatus) { + _skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(updatedProposal) + _skillWorkshopNoticeText.value = action.notice + mutationConfirmed = true + } else { + _skillWorkshopErrorText.value = skillWorkshopUnexpectedStatusText(updatedProposal?.status, action) + } + } + } + if (!mutationConfirmed) return + var refreshStillCurrent = false + publishGatewayData(gatewayScope) { + refreshStillCurrent = + skillWorkshopMutationSeq.get() == mutationSeq && + _skillWorkshopSummary.value.agentId == requestAgentId + } + if (refreshStillCurrent) { + refreshSkillWorkshopProposalsFromGateway(agentId = agentId) + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopErrorText.value = skillWorkshopActionFailureText(action) + } + } + } finally { + publishGatewayData(gatewayScope) { + if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) { + _skillWorkshopMutatingProposalId.value = null + } + } + } + } + + private fun normalizeSkillWorkshopAgentId(agentId: String?): String = agentId?.trim().orEmpty() + + private fun skillWorkshopParams( + agentId: String?, + proposalId: String? = null, + ): JsonObject = + buildJsonObject { + val normalizedAgentId = agentId?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedAgentId != null) put("agentId", JsonPrimitive(normalizedAgentId)) + val normalizedProposalId = proposalId?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedProposalId != null) put("proposalId", JsonPrimitive(normalizedProposalId)) + } + + private suspend fun mutateDevicePairingOnGateway( + gatewayScope: GatewayDataScope, + mutation: GatewayDevicePairingMutation, + expectedDeviceId: String, + ) { + publishGatewayData(gatewayScope) { + _nodesDevicesErrorText.value = null + _nodesDevicesNoticeText.value = null + } + try { + // A missing item alone is ambiguous: another operator may have resolved it. + // Require the exact write acknowledgement plus the canonical list terminal state. + var definitiveFailure: NativeText? = null + val mutationAccepted = + try { + val response = + requestGatewayData( + gatewayScope = gatewayScope, + method = mutation.action.method, + paramsJson = buildGatewayDevicePairingMutationParams(mutation).toString(), + ) + val result = json.parseToJsonElement(response).asObjectOrNull() + when (mutation.action) { + GatewayDevicePairingAction.Approve -> + result?.get("requestId").asStringOrNull()?.trim() == mutation.targetId && + result + ?.get("device") + .asObjectOrNull() + ?.get("deviceId") + .asStringOrNull() + ?.trim() == + expectedDeviceId + GatewayDevicePairingAction.Reject -> + result?.get("requestId").asStringOrNull()?.trim() == mutation.targetId + GatewayDevicePairingAction.Remove -> + result?.get("deviceId").asStringOrNull()?.trim() == mutation.targetId + } + } catch (err: CancellationException) { + throw err + } catch (err: GatewayRequestRejected) { + definitiveFailure = verbatimText(err.gatewayError.message) + false + } catch (err: GatewayRequestDefinitiveFailure) { + definitiveFailure = verbatimText(err.message ?: "Gateway request failed.") + false + } catch (_: GatewayRequestOutcomeUnknown) { + false + } catch (_: Throwable) { + false + } + + val devicesRoot = + try { + val response = requestGatewayData(gatewayScope, "device.pair.list", "{}") + json.parseToJsonElement(response).asObjectOrNull() + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + null + } + val pending = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray) + val paired = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray) + val hasCanonicalList = + devicesRoot?.get("pending") is JsonArray && devicesRoot["paired"] is JsonArray + val outcome = + if (hasCanonicalList) { + verifyGatewayDevicePairingMutation( + mutation = mutation, + expectedDeviceId = expectedDeviceId, + mutationAccepted = mutationAccepted, + pending = pending, + paired = paired, + ) + } else { + GatewayDevicePairingMutationOutcome.NotVerified + } + publishGatewayData(gatewayScope) { + if (hasCanonicalList) { + // Claim the generation only with the canonical post-mutation list in hand and only + // while this gateway scope is still current: older refreshes that read pre-mutation + // state are invalidated, a stale mutation from a previous gateway cannot touch the + // new gateway's refresh, and the mutation takes over the refreshing flag it displaced. + val refreshGeneration = nodeApprovalRefreshGuard.begin() + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodesDevicesSummary.value = + _nodesDevicesSummary.value.copy( + pendingDevices = pending, + pairedDevices = paired, + devicePairingAvailable = true, + ) + _nodesDevicesRefreshing.value = false + } + } + if (definitiveFailure != null) { + _nodesDevicesErrorText.value = definitiveFailure + } else if (outcome == GatewayDevicePairingMutationOutcome.NotVerified) { + _nodesDevicesErrorText.value = nativeText("Could not verify the device pairing change. Refresh and try again.") + } else { + _nodesDevicesNoticeText.value = mutation.action.successNotice + } + } + } finally { + publishGatewayData(gatewayScope) { + synchronized(devicePairingMutationLock) { + if (_devicePairingMutation.value == mutation) { + _devicePairingMutation.value = null + } + } + } + } + } + + private suspend fun refreshNodesDevicesFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + val refreshGeneration = nodeApprovalRefreshGuard.begin() + var refreshStarted = false + val currentScope = + publishGatewayData(gatewayScope) { + refreshStarted = + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodesDevicesRefreshing.value = true + _nodesDevicesErrorText.value = null + _nodesDevicesNoticeText.value = null + _nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds() + val pendingFallback = _nodeCapabilityApproval.value.withoutExactRequestId() + if (pendingFallback != null) { + _nodeCapabilityApproval.value = pendingFallback + } else if ( + _nodeCapabilityApproval.value !is GatewayNodeCapabilityApproval.PendingApproval && + _nodeCapabilityApproval.value !is GatewayNodeCapabilityApproval.PendingReapproval + ) { + _nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading + } + } + } + if (!currentScope || !refreshStarted) return + if (!operatorConnected) { + publishGatewayData(gatewayScope) { + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading + _nodesDevicesSummary.value = + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ) + _nodesDevicesRefreshing.value = false + } + } + return + } + try { + val nodesRes = requestGatewayData(gatewayScope, "node.list", "{}") + val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull() + val nodes = parseGatewayNodeList(nodesRoot) + val selfNodeId = identityStore.loadOrCreate().deviceId + val approval = + currentNodeCapabilityApproval( + nodes = nodes, + selfNodeId = selfNodeId, + ) + val selfNodeConnected = nodes.firstOrNull { it.id == selfNodeId }?.connected == true + var approvalPublished = false + val scopePublished = + publishGatewayData(gatewayScope) { + approvalPublished = + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodeCapabilityApproval.value = approval + } + } + if (!scopePublished || !approvalPublished) { + return + } + publishGatewayData(gatewayScope) { + if (selfNodeConnected && !_nodeConnected.value) { + updateStatus { + nodeConnectionProblem = null + _nodeConnected.value = true + nodeStatusText = "Connected" + } + } + } + scheduleNodeApprovalCommandRefresh(gatewayScope, refreshGeneration, approval) + val devicesRoot = + if (_devicePairingCapabilities.value.canList) { + try { + val devicesRes = requestGatewayData(gatewayScope, "device.pair.list", "{}") + json.parseToJsonElement(devicesRes).asObjectOrNull() + } catch (_: Throwable) { + null + } + } else { + null + } + publishGatewayData(gatewayScope) { + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodesDevicesSummary.value = + GatewayNodesDevicesSummary( + nodes = nodes, + pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray), + pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray), + devicePairingAvailable = devicesRoot != null, + ) + } + } + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodesDevicesErrorText.value = nativeText("Could not load nodes and devices.") + } + } + } finally { + publishGatewayData(gatewayScope) { + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodesDevicesRefreshing.value = false + } + } + } + } + + private fun scheduleNodeApprovalCommandRefresh( + gatewayScope: GatewayDataScope, + refreshGeneration: Long, + approval: GatewayNodeCapabilityApproval, + ) { + val fallback = approval.withoutExactRequestId() ?: return + scope.launch { + delay(NODE_APPROVAL_COMMAND_FRESH_MS) + // Pairing request IDs expire on the Gateway. Age out cached commands before rechecking so + // recovery never leaves an old exact ID visible when a refresh fails or races disconnect. + var approvalPublished = false + val scopePublished = + publishGatewayData(gatewayScope) { + approvalPublished = + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodeCapabilityApproval.value = fallback + _nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds() + } + } + if (scopePublished && approvalPublished && operatorConnected) { + refreshNodesDevicesFromGateway() + } + } + } + + private suspend fun refreshExecApprovalsFromGateway() { + val gatewayScope = captureGatewayDataScope() ?: return + val refreshGeneration = + synchronized(execApprovalsStateLock) { + execApprovalsRefreshSeq.incrementAndGet() + } + publishGatewayData(gatewayScope) { + _execApprovalsRefreshing.value = true + _execApprovalsErrorText.value = null + // The terminal notice reports an outcome the reviewer has not acknowledged yet. + // Refresh must not wipe it; it clears on user dismissal, a replacement terminal + // notice, a re-requested approval with the same id, or gateway teardown. + } + if (!operatorConnected) { + publishGatewayData(gatewayScope) { + if (execApprovalsRefreshSeq.get() == refreshGeneration) { + _execApprovals.value = emptyList() + _execApprovalsRefreshing.value = false + } + } + return + } + try { + // TODO(#103505): replace legacy full-request discovery with the sanitized + // session approval lifecycle projection before removing this list seam. + val res = requestGatewayData(gatewayScope, "exec.approval.list", "{}") + val existing = _execApprovals.value.associateBy { it.id } + val terminalApprovals = mutableListOf() + val rows = + parseGatewayExecApprovalListPayload(res, json) + .filterNot { it.id in resolvedExecApprovalIds } + .mapNotNull { row -> + val methodsSnapshot = captureGatewayMethods() + val lookup = + try { + fetchExecApprovalDetailFromGateway( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + id = row.id, + createdAtMs = row.createdAtMs ?: System.currentTimeMillis(), + ) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + null + } + if (lookup is GatewayExecApprovalSnapshot.Terminal) { + terminalApprovals.add(lookup) + return@mapNotNull null + } + val hydrated = + (lookup as? GatewayExecApprovalSnapshot.Pending)?.summary + ?: row.copy(errorText = execApprovalLoadDetailsFailureMessage()) + val current = existing[row.id] + val pendingWrite = pendingExecApprovalWrite(row.id, gatewayScope.stableId) + if (current == null) { + hydrated.copy( + resolvingDecision = pendingWrite?.decision, + errorText = if (pendingWrite == null) hydrated.errorText else execApprovalOutcomeUnknownMessage(), + ) + } else { + hydrated.copy( + resolvingDecision = current.resolvingDecision ?: pendingWrite?.decision, + errorText = + current.errorText + ?: if (pendingWrite?.requestInFlight == false) { + execApprovalOutcomeUnknownMessage() + } else { + hydrated.errorText + }, + ) + } + } + publishExecApprovalsIfCurrent( + gatewayScope = gatewayScope, + refreshGeneration = refreshGeneration, + rows = rows, + terminalApprovals = terminalApprovals, + ) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + if (execApprovalsRefreshSeq.get() == refreshGeneration) { + _execApprovalsErrorText.value = execApprovalLoadFailureMessage() + } + } + } finally { + publishGatewayData(gatewayScope) { + if (execApprovalsRefreshSeq.get() == refreshGeneration) { + _execApprovalsRefreshing.value = false + } + } + } + reconcilePendingExecApprovalWrites(gatewayScope) + } + + private suspend fun refreshExecApprovalFromGateway(id: String) { + val gatewayScope = captureGatewayDataScope() ?: return + if (!operatorConnected) return + if (id in resolvedExecApprovalIds) return + try { + val current = _execApprovals.value.firstOrNull { it.id == id } + val methodsSnapshot = captureGatewayMethods() + val lookup = + fetchExecApprovalDetailFromGateway( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + id = id, + createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(), + ) + when (lookup) { + is GatewayExecApprovalSnapshot.Pending -> + publishGatewayApprovalData(gatewayScope, methodsSnapshot) { + if (id !in resolvedExecApprovalIds) { + invalidateExecApprovalRefreshes() + val pendingWrite = pendingExecApprovalWrite(id, gatewayScope.stableId) + upsertExecApproval( + lookup.summary.copy( + resolvingDecision = current?.resolvingDecision ?: pendingWrite?.decision, + errorText = + current?.errorText + ?: pendingWrite + ?.takeIf { current == null || !it.requestInFlight } + ?.let { execApprovalOutcomeUnknownMessage() }, + ), + ) + } + } + is GatewayExecApprovalSnapshot.Terminal -> + publishGatewayApprovalData(gatewayScope, methodsSnapshot) { + if (_execApprovals.value.any { it.id == id }) { + _execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(lookup) + } + markExecApprovalResolved(id) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + if (isGatewayDataScopeCurrent(gatewayScope)) { + refreshExecApprovalsFromGateway() + } + } + } + + private suspend fun fetchExecApprovalDetailFromGateway( + gatewayScope: GatewayDataScope, + methodsSnapshot: GatewayMethodsSnapshot, + id: String, + createdAtMs: Long?, + ): GatewayExecApprovalSnapshot = + when (methodsSnapshot.approvalRpcFamily) { + GatewayApprovalRpcFamily.Canonical -> + fetchUnifiedExecApprovalDetail( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + id = id, + ) + GatewayApprovalRpcFamily.Legacy -> { + val params = buildGatewayExecApprovalGetParams(id).toString() + val response = + requestGatewayApprovalData( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + method = "exec.approval.get", + paramsJson = params, + ) + parseLegacyGatewayExecApprovalGetPayload( + payloadJson = response, + json = json, + expectedId = id, + createdAtMs = createdAtMs, + ) ?: error("Malformed exec.approval.get response") + } + GatewayApprovalRpcFamily.Unavailable -> throw GatewayApprovalRpcUnavailable() + } + + private suspend fun resolveExecApprovalOnGateway( + id: String, + decision: String, + ) { + val gatewayScope = captureGatewayDataScope() ?: return + val methodsSnapshot = captureGatewayMethods() + var registeredWrite: PendingExecApprovalWrite? = null + val scopeCurrent = + publishGatewayApprovalData(gatewayScope, methodsSnapshot) { + synchronized(execApprovalsStateLock) { + if (!operatorConnected || id in resolvedExecApprovalIds) return@synchronized + val currentRows = _execApprovals.value + if (currentRows.none { it.id == id && it.resolvingDecision == null }) return@synchronized + if (pendingExecApprovalWrites.containsKey(id)) return@synchronized + val pendingWrite = + PendingExecApprovalWrite( + gatewayScope.stableId, + id, + decision, + currentRows.firstOrNull { it.id == id }?.createdAtMs, + ) + pendingExecApprovalWrites[id] = pendingWrite + registeredWrite = pendingWrite + invalidateExecApprovalRefreshes() + _execApprovals.value = + currentRows.map { row -> + if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row + } + // Do not clear the notice here: it reports a different approval's terminal + // outcome (a same-id write cannot start after its terminal notice retired the + // row) and must stay visible until the user acknowledges it. + } + } + val pendingWrite = registeredWrite + if (!scopeCurrent || pendingWrite == null) return + try { + val resolution = submitExecApprovalResolution(gatewayScope, methodsSnapshot, id, decision) + markExecApprovalWriteRequestFinished(pendingWrite) + publishGatewayApprovalData(gatewayScope, methodsSnapshot) { + synchronized(execApprovalsStateLock) { + if (pendingExecApprovalWrites[id] !== pendingWrite || id in resolvedExecApprovalIds) return@synchronized + // `applied=false` carries the canonical winner from another surface. + _execApprovalsNotice.value = gatewayExecApprovalResolutionNotice(resolution) + markExecApprovalResolved(id) + } + } + if (pendingExecApprovalWrite(id, gatewayScope.stableId) === pendingWrite) { + reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite) + } + } catch (err: CancellationException) { + markExecApprovalWriteRequestFinished(pendingWrite) + reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite) + throw err + } catch (_: GatewayRequestNotEnqueued) { + handleExecApprovalResolveFailure( + gatewayScope = gatewayScope, + pendingWrite = pendingWrite, + outcomeUnknown = false, + ) + } catch (err: GatewayRequestRejected) { + if ( + methodsSnapshot.approvalRpcFamily == GatewayApprovalRpcFamily.Legacy && + isGatewayExecApprovalAlreadyResolved(err.gatewayError) + ) { + // Mirror the success path: the rejection settled the request, so mark it + // finished first. The epoch-guarded publish below can be skipped by a methods + // epoch bump, and a write left requestInFlight would never reconcile. + markExecApprovalWriteRequestFinished(pendingWrite) + handleLegacyExecApprovalAlreadyResolved(gatewayScope, methodsSnapshot, pendingWrite) + if (pendingExecApprovalWrite(id, gatewayScope.stableId) === pendingWrite) { + // A same-endpoint method-catalog replacement rejects stale publishes but does + // not invalidate the write owner. Read current canonical state so the card + // cannot remain frozen until a later manual refresh. + reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite) + } + } else { + handleExecApprovalResolveFailure( + gatewayScope = gatewayScope, + pendingWrite = pendingWrite, + outcomeUnknown = false, + ) + } + } catch (_: GatewayApprovalRpcUnavailable) { + handleExecApprovalResolveFailure( + gatewayScope = gatewayScope, + pendingWrite = pendingWrite, + outcomeUnknown = false, + ) + } catch (_: Throwable) { + handleExecApprovalResolveFailure( + gatewayScope = gatewayScope, + pendingWrite = pendingWrite, + outcomeUnknown = true, + ) + reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite) + } + } + + private suspend fun submitExecApprovalResolution( + gatewayScope: GatewayDataScope, + methodsSnapshot: GatewayMethodsSnapshot, + id: String, + decision: String, + ): GatewayExecApprovalResolution = + when (methodsSnapshot.approvalRpcFamily) { + GatewayApprovalRpcFamily.Canonical -> { + val params = buildGatewayExecApprovalResolveParams(id, decision).toString() + val response = + requestGatewayApprovalData( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + method = "approval.resolve", + paramsJson = params, + preserveWriteFailureAcrossEpoch = true, + ) + parseGatewayExecApprovalResolvePayload( + payloadJson = response, + json = json, + expectedId = id, + expectedDecision = decision, + ) ?: throw ExecApprovalWriteOutcomeUnknown() + } + GatewayApprovalRpcFamily.Legacy -> { + val legacyParams = + buildJsonObject { + put("id", JsonPrimitive(id)) + put("decision", JsonPrimitive(decision)) + }.toString() + val legacyResponse = + requestGatewayApprovalData( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + method = "exec.approval.resolve", + paramsJson = legacyParams, + preserveWriteFailureAcrossEpoch = true, + ) + if (!parseLegacyGatewayExecApprovalResolvePayload(legacyResponse, json)) { + throw ExecApprovalWriteOutcomeUnknown() + } + val terminal = + legacyGatewayExecApprovalTerminal(id, decision) + ?: throw ExecApprovalWriteOutcomeUnknown() + GatewayExecApprovalResolution( + applied = false, + approval = terminal, + attribution = GatewayExecApprovalResolutionAttribution.Unknown, + ) + } + GatewayApprovalRpcFamily.Unavailable -> throw GatewayApprovalRpcUnavailable() + } + + private fun isGatewayExecApprovalAlreadyResolved(error: GatewaySession.ErrorShape): Boolean = error.code == "INVALID_REQUEST" && error.details?.reason == "APPROVAL_ALREADY_RESOLVED" + + private fun handleLegacyExecApprovalAlreadyResolved( + gatewayScope: GatewayDataScope, + methodsSnapshot: GatewayMethodsSnapshot, + pendingWrite: PendingExecApprovalWrite, + ) { + publishGatewayApprovalData(gatewayScope, methodsSnapshot) { + synchronized(execApprovalsStateLock) { + val id = pendingWrite.id + if (pendingExecApprovalWrites[id] !== pendingWrite) return@synchronized + if (_execApprovals.value.any { it.id == id }) { + _execApprovalsNotice.value = gatewayExecApprovalPriorResolutionNotice(id) + } + // The legacy rejection proves only that another verdict won. Retire the + // exact card without inventing that unavailable winner's decision. + markExecApprovalResolved(id) + } + } + } + + private fun handleExecApprovalResolveFailure( + gatewayScope: GatewayDataScope, + pendingWrite: PendingExecApprovalWrite, + outcomeUnknown: Boolean, + ) { + publishGatewayData(gatewayScope) { + synchronized(execApprovalsStateLock) { + val id = pendingWrite.id + if (pendingExecApprovalWrites[id] !== pendingWrite) return@synchronized + if (!outcomeUnknown) { + pendingExecApprovalWrites.remove(id) + } else { + pendingWrite.requestInFlight = false + } + invalidateExecApprovalRefreshes() + if (!operatorConnected || id in resolvedExecApprovalIds || _execApprovals.value.none { it.id == id }) { + return@synchronized + } + val error = + if (outcomeUnknown) execApprovalOutcomeUnknownMessage() else execApprovalResolveFailureMessage() + _execApprovals.value = + _execApprovals.value.map { row -> + if (row.id == id) { + row.copy( + resolvingDecision = pendingWrite.decision.takeIf { outcomeUnknown }, + errorText = error, + ) + } else { + row + } + } + } + } + } + + private suspend fun reconcilePendingExecApprovalWrites(gatewayScope: GatewayDataScope) { + if (!operatorConnected) return + val pendingWrites = + synchronized(execApprovalsStateLock) { + pendingExecApprovalWrites.values + .filter { it.stableId == gatewayScope.stableId && !it.requestInFlight } + .toList() + } + pendingWrites.forEach { reconcileExecApprovalWriteOutcome(gatewayScope, it) } + } + + private suspend fun reconcileExecApprovalWriteOutcome( + gatewayScope: GatewayDataScope, + pendingWrite: PendingExecApprovalWrite, + ) { + val shouldReconcile = + synchronized(execApprovalsStateLock) { + operatorConnected && + pendingExecApprovalWrites[pendingWrite.id] === pendingWrite && + !pendingWrite.requestInFlight + } + if (!shouldReconcile) return + val methodsSnapshot = captureGatewayMethods() + val snapshot = + try { + fetchExecApprovalDetailFromGateway( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + id = pendingWrite.id, + createdAtMs = + pendingWrite.createdAtMs + ?: _execApprovals.value.firstOrNull { it.id == pendingWrite.id }?.createdAtMs, + ) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + return + } + publishGatewayApprovalData(gatewayScope, methodsSnapshot) { + synchronized(execApprovalsStateLock) { + if (!operatorConnected || pendingExecApprovalWrites[pendingWrite.id] !== pendingWrite) return@synchronized + when (snapshot) { + is GatewayExecApprovalSnapshot.Terminal -> { + _execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(snapshot) + markExecApprovalResolved(pendingWrite.id) + } + is GatewayExecApprovalSnapshot.Pending -> { + invalidateExecApprovalRefreshes() + pendingExecApprovalWrites.remove(pendingWrite.id) + val row = + snapshot.summary.copy( + resolvingDecision = null, + errorText = execApprovalStillPendingMessage(), + ) + val retained = _execApprovals.value.filterNot { it.id == pendingWrite.id } + val nextRows = + (retained + row) + .filterActiveExecApprovals() + .sortedBy { it.createdAtMs ?: Long.MAX_VALUE } + _execApprovals.value = nextRows + scheduleExecApprovalExpiryPrune(nextRows) + } + } + } + } + } + + private fun markExecApprovalWriteRequestFinished(pendingWrite: PendingExecApprovalWrite) { + synchronized(execApprovalsStateLock) { + if (pendingExecApprovalWrites[pendingWrite.id] === pendingWrite) { + pendingWrite.requestInFlight = false + } + } + } + + private suspend fun fetchUnifiedExecApprovalDetail( + gatewayScope: GatewayDataScope, + methodsSnapshot: GatewayMethodsSnapshot, + id: String, + ): GatewayExecApprovalSnapshot { + val params = buildGatewayExecApprovalGetParams(id).toString() + val response = + requestGatewayApprovalData( + gatewayScope = gatewayScope, + methodsSnapshot = methodsSnapshot, + method = "approval.get", + paramsJson = params, + ) + return parseGatewayExecApprovalGetPayload(response, json, expectedId = id) + ?: error("Malformed approval.get response") + } + + private fun replaceGatewayMethods(methods: Set) { + synchronized(gatewayMethodsLock) { + gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(methods) + _clawHubSkillMethodsAvailable.value = supportsClawHubSkillManagement(methods) + systemAgentChatSupported.value = GatewayMethod.OpenclawChat.rawValue in methods + gatewayMethodsEpoch += 1 + } + } + + private fun captureGatewayMethods(): GatewayMethodsSnapshot = + synchronized(gatewayMethodsLock) { + GatewayMethodsSnapshot( + approvalRpcFamily = gatewayApprovalRpcFamily, + epoch = gatewayMethodsEpoch, + ) + } + + private fun isGatewayMethodsSnapshotCurrent(snapshot: GatewayMethodsSnapshot): Boolean = synchronized(gatewayMethodsLock) { snapshot.epoch == gatewayMethodsEpoch } + + private fun pendingExecApprovalWrite( + id: String, + stableId: String, + ): PendingExecApprovalWrite? = + synchronized(execApprovalsStateLock) { + pendingExecApprovalWrites[id]?.takeIf { it.stableId == stableId } + } + + private fun upsertExecApproval(row: GatewayExecApprovalSummary) { + synchronized(execApprovalsStateLock) { + if (!operatorConnected || row.id in resolvedExecApprovalIds) return + if (row.isExpiredExecApproval()) return + val rows = _execApprovals.value + val replaced = rows.any { it.id == row.id } + val nextRows = + ( + if (replaced) { + rows.map { current -> + if (current.id == row.id) { + row.copy( + resolvingDecision = current.resolvingDecision ?: row.resolvingDecision, + errorText = current.errorText ?: row.errorText, + ) + } else { + current + } + } + } else { + rows + row + } + ).filterActiveExecApprovals() + .sortedBy { it.createdAtMs ?: Long.MAX_VALUE } + _execApprovals.value = nextRows + scheduleExecApprovalExpiryPrune(nextRows) + } + } + + private fun invalidateExecApprovalRefreshes() { + synchronized(execApprovalsStateLock) { + execApprovalsRefreshSeq.incrementAndGet() + _execApprovalsRefreshing.value = false + } + } + + private fun markExecApprovalResolved(id: String) { + synchronized(execApprovalsStateLock) { + resolvedExecApprovalIds.add(id) + pendingExecApprovalWrites.remove(id) + invalidateExecApprovalRefreshes() + _execApprovals.value = _execApprovals.value.filterNot { it.id == id } + } + } + + private fun publishExecApprovalsIfCurrent( + gatewayScope: GatewayDataScope, + refreshGeneration: Long, + rows: List, + terminalApprovals: List, + ) { + publishGatewayData(gatewayScope) { + synchronized(execApprovalsStateLock) { + if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) { + val visibleIds = _execApprovals.value.mapTo(mutableSetOf()) { it.id } + val pendingWriteIds = + pendingExecApprovalWrites.values + .filter { it.stableId == gatewayScope.stableId } + .mapTo(mutableSetOf()) { it.id } + terminalApprovals.lastOrNull { it.id in visibleIds || it.id in pendingWriteIds }?.let { terminal -> + _execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(terminal) + } + val terminalIds = terminalApprovals.map { it.id } + resolvedExecApprovalIds.addAll(terminalIds) + terminalIds.forEach(pendingExecApprovalWrites::remove) + val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals() + _execApprovals.value = nextRows + scheduleExecApprovalExpiryPrune(nextRows) + } + } + } + } + + private fun scheduleExecApprovalExpiryPrune(rows: List) { + val now = System.currentTimeMillis() + val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return + scope.launch { + delay((nextExpiry - now + 250).coerceAtLeast(0)) + pruneExpiredExecApprovals() + } + } + + private fun pruneExpiredExecApprovals() { + synchronized(execApprovalsStateLock) { + _execApprovals.value = _execApprovals.value.filterActiveExecApprovals() + } + } + + private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true + + private fun List.filterActiveExecApprovals( + nowMs: Long = System.currentTimeMillis(), + ): List = filterNot { it.isExpiredExecApproval(nowMs) } + + private fun invalidateNodeCapabilityApprovalState() { + val refreshGeneration = nodeApprovalRefreshGuard.begin() + nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading + _nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds() + _nodesDevicesRefreshing.value = false + } + } + + private suspend fun refreshChannelsFromGateway() = + refreshGatewaySummary( + summary = _channelsSummary, + refreshing = _channelsRefreshing, + errorText = _channelsErrorText, + disconnectedSummary = GatewayChannelsSummary(channels = emptyList()), + failureText = nativeText("Could not load channels."), + ) { gatewayScope -> + val response = requestGatewayData(gatewayScope, "channels.status", """{"probe":false,"timeoutMs":8000}""") + val root = json.parseToJsonElement(response).asObjectOrNull() + GatewayChannelsSummary( + updatedAtMs = root.long("ts"), + partial = root.boolean("partial"), + warnings = parseStringArray(root?.get("warnings") as? JsonArray), + channels = parseChannelSummaries(root), + ) + } + + private suspend fun refreshDreamingFromGateway() = + refreshGatewaySummary( + summary = _dreamingSummary, + refreshing = _dreamingRefreshing, + errorText = _dreamingErrorText, + disconnectedSummary = GatewayDreamingSummary(), + failureText = nativeText("Could not load dreaming."), + ) { gatewayScope -> + val statusResponse = requestGatewayData(gatewayScope, "doctor.memory.status", "{}") + val statusRoot = json.parseToJsonElement(statusResponse).asObjectOrNull() + val diaryResponse = requestGatewayData(gatewayScope, "doctor.memory.dreamDiary", "{}") + val diaryRoot = json.parseToJsonElement(diaryResponse).asObjectOrNull() + parseDreamingSummary(dreaming = statusRoot?.get("dreaming").asObjectOrNull(), diary = diaryRoot) + } + + private suspend fun refreshHealthLogsFromGateway() = + refreshGatewaySummary( + summary = _healthLogsSummary, + refreshing = _healthLogsRefreshing, + errorText = _healthLogsErrorText, + disconnectedSummary = GatewayHealthLogsSummary(), + failureText = nativeText("Could not load gateway logs."), + ) { gatewayScope -> + val response = requestGatewayData(gatewayScope, "logs.tail", """{"limit":40,"maxBytes":65536}""") + val root = json.parseToJsonElement(response).asObjectOrNull() + val lines = (root?.get("lines") as? JsonArray)?.mapNotNull { it.asStringOrNull() }.orEmpty() + GatewayHealthLogsSummary( + fileName = + root + ?.get("file") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.substringAfterLast('/') + ?.substringAfterLast('\\'), + cursor = root.long("cursor"), + truncated = root.boolean("truncated"), + entries = lines.map { parseGatewayLogEntry(it) }, + ) + } + + private fun parseGatewayLogEntry(line: String): GatewayLogEntry { + val sanitizedLine = sanitizeGatewayLogText(line) + val root = + try { + json.parseToJsonElement(line).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return GatewayLogEntry( + time = null, + level = null, + subsystem = null, + message = sanitizedLine.trim().ifEmpty { "Empty log entry" }, + raw = sanitizedLine, + ) + val meta = root["_meta"].asObjectOrNull() + val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull() + val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull()) + val contextCandidate = root["0"].asStringOrNull() ?: meta?.get("name").asStringOrNull() + val contextObject = parseMaybeJsonObject(contextCandidate) + val subsystem = + contextObject?.get("subsystem").asStringOrNull() + ?: contextObject?.get("module").asStringOrNull() + ?: contextCandidate?.takeIf { it.length < 80 && contextObject == null } + val contextMessage = if (contextObject == null) root["0"].asStringOrNull() else null + val message = + root["1"].asStringOrNull() + ?: root["2"].asStringOrNull() + ?: contextMessage + ?: root["message"].asStringOrNull() + ?: line + val normalizedMessage = + sanitizeGatewayLogText(message) + .trim() + .replace(Regex("\\s+"), " ") + .takeUtf16Safe(240) + .ifEmpty { "Log entry" } + return GatewayLogEntry( + time = time, + level = level, + subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() }, + message = normalizedMessage, + raw = sanitizedLine, + ) + } + + private fun parseMaybeJsonObject(value: String?): JsonObject? { + val trimmed = value?.trim().orEmpty() + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null + return try { + json.parseToJsonElement(trimmed).asObjectOrNull() + } catch (_: Throwable) { + null + } + } + + private fun normalizeLogLevel(value: String?): String? { + val level = value?.trim()?.lowercase().orEmpty() + return if (level in setOf("trace", "debug", "info", "warn", "error", "fatal")) level else null + } + + private fun parseGatewayModelProviders(providers: JsonArray?): List = + providers + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["provider"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return@mapNotNull null + GatewayModelProviderSummary( + id = id, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: providerDisplayName(id), + status = obj["status"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown", + profileCount = ((obj["profiles"] as? JsonArray)?.size ?: 0), + ) + }.orEmpty() + + private fun parseCronJobs(jobs: JsonArray?): List = + jobs + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + val name = obj["name"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty() || name.isEmpty()) return@mapNotNull null + val schedule = obj["schedule"].asObjectOrNull() + val state = obj["state"].asObjectOrNull() + val payload = obj["payload"].asObjectOrNull() + GatewayCronJobSummary( + id = id, + name = name, + enabled = obj.boolean("enabled"), + scheduleLabel = cronScheduleLabel(schedule), + promptPreview = cronPayloadPreview(payload), + nextRunAtMs = state.long("nextRunAtMs"), + lastRunStatus = cronJobLastRunStatus(state), + ) + }.orEmpty() + + private fun parseUsageProviders(providers: JsonArray?): List = + providers + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val displayName = obj["displayName"].asStringOrNull()?.trim().orEmpty() + if (displayName.isEmpty()) return@mapNotNull null + GatewayUsageProviderSummary( + displayName = displayName, + plan = obj["plan"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + error = obj["error"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + windows = parseUsageWindows(obj["windows"] as? JsonArray), + ) + }.orEmpty() + + private fun parseUsageWindows(windows: JsonArray?): List = + windows + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val label = obj["label"].asStringOrNull()?.trim().orEmpty() + if (label.isEmpty()) return@mapNotNull null + GatewayUsageWindowSummary( + label = label, + usedPercent = obj.double("usedPercent") ?: 0.0, + resetAtMs = obj.long("resetAt"), + ) + }.orEmpty() + + private fun parseSkillSummaries(skills: JsonArray?): List = + skills + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val name = obj["name"].asStringOrNull()?.trim().orEmpty() + if (name.isEmpty()) return@mapNotNull null + val missing = obj["missing"].asObjectOrNull() + val clawHub = obj["clawhub"].asObjectOrNull() + GatewaySkillSummary( + skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name, + name = name, + description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown", + emoji = obj["emoji"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + disabled = obj.boolean("disabled"), + eligible = obj.boolean("eligible"), + blockedByAllowlist = obj.boolean("blockedByAllowlist"), + blockedByAgentFilter = obj.boolean("blockedByAgentFilter"), + bundled = obj.boolean("bundled"), + missingCount = skillMissingCount(missing), + installCount = (obj["install"] as? JsonArray)?.size ?: 0, + clawHubSlug = + clawHub + ?.get("slug") + .asStringOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty), + clawHubValid = clawHub?.boolean("valid") == true, + clawHubOwnerHandle = + clawHub + ?.get("ownerHandle") + .asStringOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty), + clawHubInstalledVersion = + clawHub + ?.get("installedVersion") + .asStringOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty), + ) + }.orEmpty() + + private fun parseSkillWorkshopProposals( + proposals: JsonArray?, + previousById: Map, + ): List { + val parsed = + proposals?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj.skillWorkshopString("id") ?: return@mapNotNull null + val previous = previousById[id] + val updatedAt = obj.skillWorkshopString("updatedAt").orEmpty() + GatewaySkillWorkshopProposal( + id = id, + kind = obj.skillWorkshopString("kind") ?: "proposal", + status = obj.skillWorkshopString("status") ?: "pending", + title = obj.skillWorkshopString("title") ?: obj.skillWorkshopString("skillName") ?: id, + description = obj.skillWorkshopString("description"), + skillName = obj.skillWorkshopString("skillName") ?: id, + skillKey = obj.skillWorkshopString("skillKey") ?: id, + createdAt = obj.skillWorkshopString("createdAt").orEmpty(), + updatedAt = updatedAt, + scanState = obj.skillWorkshopString("scanState"), + content = previous?.content?.takeIf { previous.updatedAt == updatedAt }, + supportFiles = previous?.supportFiles?.takeIf { previous.updatedAt == updatedAt }.orEmpty(), + ) + } + return parsed.orEmpty().sortedByDescending { it.updatedAt } + } + + private fun parseSkillWorkshopProposalInspect( + root: JsonObject?, + previous: GatewaySkillWorkshopProposal?, + ): GatewaySkillWorkshopProposal? { + val source = root ?: return null + val record = source["record"].asObjectOrNull() ?: return null + val id = record.skillWorkshopString("id") ?: previous?.id ?: return null + val target = record["target"].asObjectOrNull() + val updatedAt = record.skillWorkshopString("updatedAt").orEmpty() + return GatewaySkillWorkshopProposal( + id = id, + kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal", + status = record.skillWorkshopString("status") ?: previous?.status ?: "pending", + title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id, + description = record.skillWorkshopString("description") ?: previous?.description, + skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id, + skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id, + createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(), + updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() }, + scanState = record.skillWorkshopString("scanState") ?: previous?.scanState, + content = stripSkillWorkshopFrontmatter(source["content"].asStringOrNull().orEmpty()), + supportFiles = parseSkillWorkshopSupportFiles(source["supportFiles"] as? JsonArray), + ) + } + + private fun parseSkillWorkshopProposalActionResult( + root: JsonObject?, + previous: GatewaySkillWorkshopProposal?, + ): GatewaySkillWorkshopProposal? { + val record = + root?.get("record").asObjectOrNull() + ?: root?.takeIf { it.skillWorkshopString("status") != null } + ?: return null + val id = record.skillWorkshopString("id") ?: previous?.id ?: return null + val target = record["target"].asObjectOrNull() + val updatedAt = record.skillWorkshopString("updatedAt").orEmpty() + return GatewaySkillWorkshopProposal( + id = id, + kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal", + status = record.skillWorkshopString("status") ?: previous?.status ?: "pending", + title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id, + description = record.skillWorkshopString("description") ?: previous?.description, + skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id, + skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id, + createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(), + updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() }, + scanState = + record["scan"].asObjectOrNull()?.skillWorkshopString("state") + ?: record.skillWorkshopString("scanState") + ?: previous?.scanState, + content = previous?.content, + supportFiles = previous?.supportFiles.orEmpty(), + ) + } + + private fun parseSkillWorkshopSupportFiles(files: JsonArray?): List { + val parsed = + files?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val path = obj.skillWorkshopString("path") ?: return@mapNotNull null + GatewaySkillWorkshopSupportFile( + path = path, + content = obj["content"].asStringOrNull()?.takeIf { it.isNotEmpty() }, + ) + } + return parsed.orEmpty() + } + + private fun stripSkillWorkshopFrontmatter(content: String): String { + val withoutFrontmatter = content.replace(Regex("(?s)^---\\r?\\n.*?\\r?\\n---\\r?\\n?"), "") + return withoutFrontmatter.trim() + } + + private fun JsonObject.skillWorkshopString(key: String): String? = + get(key) + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + + private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 } + + private fun parsePendingDevices(devices: JsonArray?): List = + devices + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val requestId = obj["requestId"].asStringOrNull()?.trim().orEmpty() + val deviceId = obj["deviceId"].asStringOrNull()?.trim().orEmpty() + if (requestId.isEmpty() || deviceId.isEmpty()) return@mapNotNull null + GatewayPendingDeviceSummary( + requestId = requestId, + deviceId = deviceId, + publicKey = obj["publicKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + platform = obj["platform"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + clientId = obj["clientId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + clientMode = obj["clientMode"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + browserOrigin = obj["browserOrigin"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + roles = parseDeviceRoles(obj), + scopes = parseStringArray(obj["scopes"] as? JsonArray), + requestedAtMs = obj.long("ts"), + repair = obj.boolean("isRepair"), + ) + }.orEmpty() + + private fun parsePairedDevices(devices: JsonArray?): List = + devices + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val deviceId = obj["deviceId"].asStringOrNull()?.trim().orEmpty() + if (deviceId.isEmpty()) return@mapNotNull null + GatewayPairedDeviceSummary( + deviceId = deviceId, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + roles = parseDeviceRoles(obj), + scopes = parseStringArray(obj["scopes"] as? JsonArray), + tokens = parseDeviceTokens(obj["tokens"] as? JsonArray), + approvedAtMs = obj.long("approvedAtMs"), + ) + }.orEmpty() + + private fun parseDeviceRoles(device: JsonObject): List { + val roles = parseStringArray(device["roles"] as? JsonArray) + if (roles.isNotEmpty()) return roles + return listOfNotNull(device["role"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }) + } + + private fun parseDeviceTokens(tokens: JsonArray?): List = + tokens + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val role = obj["role"].asStringOrNull()?.trim().orEmpty() + if (role.isEmpty()) return@mapNotNull null + GatewayDeviceTokenSummary( + role = role, + scopes = parseStringArray(obj["scopes"] as? JsonArray), + revoked = obj.long("revokedAtMs") != null, + updatedAtMs = obj.long("rotatedAtMs") ?: obj.long("createdAtMs") ?: obj.long("lastUsedAtMs"), + ) + }.orEmpty() + + private fun parseChannelSummaries(root: JsonObject?): List { + val order = parseStringArray(root?.get("channelOrder") as? JsonArray) + val labels = parseStringMap(root?.get("channelLabels").asObjectOrNull()) + val channels = root?.get("channels").asObjectOrNull() + val accounts = root?.get("channelAccounts").asObjectOrNull() + val ids = (order + channels.orEmpty().keys + accounts.orEmpty().keys).distinct() + return ids + .map { id -> + val summary = channels?.get(id).asObjectOrNull() + val accountRows = parseChannelAccounts(accounts?.get(id) as? JsonArray) + GatewayChannelSummary( + id = id, + label = labels[id] ?: channelDisplayLabel(id), + accountCount = accountRows.size, + enabled = summary.boolean("enabled") || accountRows.any { it.enabled }, + configured = summary.boolean("configured") || accountRows.any { it.configured }, + linked = summary.boolean("linked") || accountRows.any { it.linked }, + running = summary.boolean("running") || accountRows.any { it.running }, + connected = summary.boolean("connected") || accountRows.any { it.connected }, + error = + summary + ?.get("lastError") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: accountRows.firstNotNullOfOrNull { it.error }, + ) + }.sortedWith(compareByDescending { it.enabled || it.configured }.thenBy { it.label.lowercase() }) + } + + private fun parseChannelAccounts(accounts: JsonArray?): List = + accounts + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val accountId = obj["accountId"].asStringOrNull()?.trim().orEmpty() + if (accountId.isEmpty()) return@mapNotNull null + GatewayChannelAccountSummary( + enabled = obj.boolean("enabled"), + configured = obj.boolean("configured"), + linked = obj.boolean("linked"), + running = obj.boolean("running"), + connected = obj.boolean("connected"), + error = + obj["lastError"] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() }, + ) + }.orEmpty() + + private fun parseStringMap(map: JsonObject?): Map = + map + ?.mapNotNull { (key, value) -> + value + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { key to it } + }?.toMap() + .orEmpty() + + private fun parseDreamingSummary( + dreaming: JsonObject?, + diary: JsonObject?, + ): GatewayDreamingSummary { + val diaryContent = diary?.get("content").asStringOrNull() + val entries = if (diary.boolean("found")) parseDreamDiaryEntries(diaryContent) else emptyList() + val timezone = + dreaming + ?.get("timezone") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + val storeHealthy = + dreaming + ?.get("storeError") + .asStringOrNull() + ?.trim() + .isNullOrEmpty() + val phaseSignalHealthy = + dreaming + ?.get("phaseSignalError") + .asStringOrNull() + ?.trim() + .isNullOrEmpty() + return GatewayDreamingSummary( + enabled = dreaming.boolean("enabled"), + timezone = timezone, + shortTermCount = dreaming.long("shortTermCount")?.toInt() ?: 0, + groundedSignalCount = dreaming.long("groundedSignalCount")?.toInt() ?: 0, + totalSignalCount = dreaming.long("totalSignalCount")?.toInt() ?: 0, + promotedToday = dreaming.long("promotedToday")?.toInt() ?: 0, + promotedTotal = dreaming.long("promotedTotal")?.toInt() ?: 0, + nextRunAtMs = dreamingNextRunAtMs(dreaming), + storeHealthy = storeHealthy, + phaseSignalHealthy = phaseSignalHealthy, + diaryFound = diary.boolean("found"), + diaryEntries = entries, + diaryEntryCount = entries.size, + ) + } + + private fun dreamingNextRunAtMs(dreaming: JsonObject?): Long? { + val phases = dreaming?.get("phases").asObjectOrNull() + return listOf("light", "deep", "rem") + .mapNotNull { phase -> phases?.get(phase).asObjectOrNull().long("nextRunAtMs") } + .minOrNull() + } + + private fun parseDreamDiaryEntries(content: String?): List { + val raw = content?.trim().orEmpty() + if (raw.isEmpty()) return emptyList() + val body = raw.substringAfter("", raw).substringBefore("") + return body + .split(Regex("\\n---\\n")) + .mapNotNull(::parseGatewayDreamDiaryEntry) + .asReversed() + .take(4) + } + + private fun parseStringArray(items: JsonArray?): List = + items + ?.mapNotNull { item -> item.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } } + .orEmpty() + + private fun cronScheduleLabel(schedule: JsonObject?): NativeText = + when (schedule?.get("kind").asStringOrNull()) { + "at" -> nativeText("One time") + "every" -> schedule.long("everyMs")?.let(::cronIntervalText) ?: nativeText("Repeating") + "cron" -> + schedule + ?.get("expr") + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::verbatimText) + ?: nativeText("Cron") + else -> nativeText("Scheduled") + } + + private fun cronIntervalText(everyMs: Long): NativeText { + val minutes = everyMs / 60_000L + val hours = minutes / 60L + val days = hours / 24L + return when { + days >= 1 && hours % 24L == 0L -> nativeText("Every \${days}d", days) + hours >= 1 && minutes % 60L == 0L -> nativeText("Every \${hours}h", hours) + minutes >= 1 -> nativeText("Every \${minutes}m", minutes) + else -> nativeText("Repeating") + } + } + + private fun cronPayloadPreview(payload: JsonObject?): NativeText { + val text = + when (payload?.get("kind").asStringOrNull()) { + "systemEvent" -> payload?.get("text").asStringOrNull() + "agentTurn" -> payload?.get("message").asStringOrNull() + else -> null + } + return text + ?.trim() + ?.replace(Regex("\\s+"), " ") + ?.takeIf { it.isNotEmpty() } + ?.let(::verbatimText) + ?: nativeText("No prompt") + } + + private fun updateHomeCanvasState() { + val payload = + try { + json.encodeToString(makeHomeCanvasPayload()) + } catch (_: Throwable) { + null + } + canvas.updateHomeCanvasState(payload) + } + + private fun makeHomeCanvasPayload(): HomeCanvasPayload { + val state = resolveHomeCanvasGatewayState() + val gatewayName = normalized(_serverName.value) + val gatewayAddress = normalized(_remoteAddress.value) + val gatewayLabel = gatewayName ?: gatewayAddress ?: nativeString("Gateway") + val activeAgentId = resolveActiveAgentId() + val agents = homeCanvasAgents(activeAgentId) + + return when (state) { + HomeCanvasGatewayState.Connected -> + HomeCanvasPayload( + gatewayState = "connected", + eyebrow = nativeString("Connected to \$gatewayLabel", gatewayLabel), + title = nativeString("Your agents are ready"), + subtitle = + nativeString("This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep."), + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = agents.firstOrNull { it.isActive }?.badge ?: "OC", + activeAgentCaption = nativeString("Selected on this phone"), + agentCount = agents.size, + agents = agents.take(6), + footer = nativeString("The overview refreshes on reconnect and when this screen opens."), + ) + HomeCanvasGatewayState.Connecting -> + HomeCanvasPayload( + gatewayState = "connecting", + eyebrow = nativeString("Reconnecting"), + title = nativeString("OpenClaw is syncing back up"), + subtitle = + nativeString("The gateway session is coming back online. Agent shortcuts should settle automatically in a moment."), + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = "OC", + activeAgentCaption = nativeString("Gateway session in progress"), + agentCount = agents.size, + agents = agents.take(4), + footer = nativeString("If the gateway is reachable, reconnect should complete without intervention."), + ) + HomeCanvasGatewayState.Error, HomeCanvasGatewayState.Offline -> + HomeCanvasPayload( + gatewayState = if (state == HomeCanvasGatewayState.Error) "error" else "offline", + eyebrow = nativeString("Welcome to OpenClaw"), + title = nativeString("Your phone stays quiet until it is needed"), + subtitle = + nativeString("Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops."), + gatewayLabel = gatewayLabel, + activeAgentName = nativeString("Main"), + activeAgentBadge = "OC", + activeAgentCaption = nativeString("Connect to load your agents"), + agentCount = agents.size, + agents = agents.take(4), + footer = nativeString("When connected, the gateway can wake the phone with a silent push instead of holding an always-on session."), + ) + } + } + + private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState { + val display = gatewayConnectionDisplay.value + val lower = display.statusText.trim().lowercase() + return when { + display.isConnected -> HomeCanvasGatewayState.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting + lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error + else -> HomeCanvasGatewayState.Offline + } + } + + private fun resolveActiveAgentId(): String { + val mainKey = _mainSessionKey.value.trim() + if (mainKey.startsWith("agent:")) { + val agentId = mainKey.removePrefix("agent:").substringBefore(':').trim() + if (agentId.isNotEmpty()) return agentId + } + return gatewayDefaultAgentId.value?.trim().orEmpty() + } + + private fun resolveActiveAgentName(activeAgentId: String): String { + if (activeAgentId.isNotEmpty()) { + gatewayAgents.value.firstOrNull { it.id == activeAgentId }?.let { agent -> + return normalized(agent.name) ?: agent.id + } + return activeAgentId + } + return gatewayAgents.value.firstOrNull()?.let { normalized(it.name) ?: it.id } ?: nativeString("Main") + } + + private fun homeCanvasAgents(activeAgentId: String): List { + val defaultAgentId = gatewayDefaultAgentId.value?.trim().orEmpty() + return gatewayAgents.value + .map { agent -> + val isActive = activeAgentId.isNotEmpty() && agent.id == activeAgentId + val isDefault = defaultAgentId.isNotEmpty() && agent.id == defaultAgentId + HomeCanvasAgentCard( + id = agent.id, + name = normalized(agent.name) ?: agent.id, + badge = homeCanvasBadge(agent), + caption = + when { + isActive -> nativeString("Active on this phone") + isDefault -> nativeString("Default agent") + else -> nativeString("Ready") + }, + isActive = isActive, + ) + }.sortedWith(compareByDescending { it.isActive }.thenBy { it.name.lowercase() }) + } + + private fun homeCanvasBadge(agent: GatewayAgentSummary): String { + val emoji = normalized(agent.emoji) + if (emoji != null) return emoji + val initials = + (normalized(agent.name) ?: agent.id) + .split(' ', '-', '_') + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { token -> token.uppercaseFirstGraphemeOrNull() } + .joinToString("") + return if (initials.isNotEmpty()) initials else "OC" + } + + private fun normalized(value: String?): String? { + val trimmed = value?.trim().orEmpty() + return trimmed.ifEmpty { null } + } + + private fun showCameraHud( + message: String, + kind: CameraHudKind, + autoHideMs: Long? = null, + ) { + val token = cameraHudSeq.incrementAndGet() + _cameraHud.value = CameraHudState(token = token, kind = kind, message = message) + + if (autoHideMs != null && autoHideMs > 0) { + scope.launch { + delay(autoHideMs) + if (_cameraHud.value?.token == token) _cameraHud.value = null + } + } + } +} + +internal fun resolveOperatorSessionConnectAuth( + auth: NodeRuntime.GatewayConnectAuth, + storedOperatorToken: String?, +): NodeRuntime.GatewayConnectAuth? { + val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() } + if (explicitToken != null) { + return NodeRuntime.GatewayConnectAuth( + token = explicitToken, + bootstrapToken = null, + password = null, + ) + } + + val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() } + if (explicitPassword != null) { + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = explicitPassword, + ) + } + + val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() } + if (storedToken != null) { + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = null, + ) + } + + val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() } + if (explicitBootstrapToken != null) { + return null + } + + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = null, + ) +} + +internal fun resolveGatewayControlPageAuth( + auth: NodeRuntime.GatewayConnectAuth, + storedOperatorToken: String?, +): NodeRuntime.GatewayConnectAuth { + val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() } + if (explicitToken != null) { + return NodeRuntime.GatewayConnectAuth( + token = explicitToken, + bootstrapToken = null, + password = null, + ) + } + + val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() } + if (explicitPassword != null) { + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = explicitPassword, + ) + } + + val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() } + if (storedToken != null) { + return NodeRuntime.GatewayConnectAuth( + token = storedToken, + bootstrapToken = null, + password = null, + ) + } + + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = null, + ) +} + +internal fun operatorSessionUsesStoredDeviceToken( + auth: NodeRuntime.GatewayConnectAuth, + storedOperatorToken: String?, +): Boolean { + val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() } + if (storedToken == null) return false + val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() } + val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() } + return explicitToken == null && explicitPassword == null +} + +internal fun operatorConnectScopesForAuth( + usesStoredDeviceToken: Boolean, + storedOperatorScopes: List?, +): List { + if (usesStoredDeviceToken && storedOperatorScopes != null) { + return ConnectionManager.operatorScopesForStoredDeviceToken(storedOperatorScopes) + } + return ConnectionManager.nativeClientOperatorScopes +} + +internal fun normalizeOperatorScopes(scopes: List): List = + scopes + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .sorted() + +internal fun backgroundGatewayStableIds( + entries: List, + connectedIds: List, + activeId: String?, + foreground: Boolean, +): List { + if (!foreground) return emptyList() + val registered = entries.mapTo(mutableSetOf()) { it.stableId } + return connectedIds.distinct().filter { it != activeId && it in registered } +} + +internal data class BackgroundGatewayFleetPlan( + val disconnectStableIds: List, + val resolvedEndpoints: Map, +) + +internal fun backgroundGatewayFleetPlan( + entries: List, + connectedIds: List, + activeId: String?, + foreground: Boolean, + existingStableIds: List, + resolveEndpoint: (GatewayRegistryEntry) -> GatewayEndpoint?, +): BackgroundGatewayFleetPlan { + val desiredStableIds = + backgroundGatewayStableIds( + entries = entries, + connectedIds = connectedIds, + activeId = activeId, + foreground = foreground, + ) + val desiredSet = desiredStableIds.toSet() + val entriesByStableId = entries.associateBy(GatewayRegistryEntry::stableId) + val resolvedEndpoints = + desiredStableIds + .mapNotNull { stableId -> + val entry = entriesByStableId[stableId] ?: return@mapNotNull null + resolveEndpoint(entry)?.let { stableId to it } + }.toMap() + + // Discovery gaps remove the current route from resolvedEndpoints, but the desired ID remains. + // Disconnect only when the user disables, forgets, or focuses the gateway. + return BackgroundGatewayFleetPlan( + disconnectStableIds = existingStableIds.filterNot(desiredSet::contains), + resolvedEndpoints = resolvedEndpoints, + ) +} + +internal fun manualGatewayEndpoint(entry: GatewayRegistryEntry): GatewayEndpoint? { + if (entry.kind != GatewayRegistryEntryKind.MANUAL) return null + val normalizedHost = entry.host?.trim().orEmpty() + val normalizedPort = entry.port ?: return null + if (normalizedHost.isEmpty() || normalizedPort !in 1..65535) return null + return GatewayEndpoint.manual( + host = normalizedHost, + port = normalizedPort, + tlsEnabled = entry.tls, + ) +} + +internal fun gatewayRegistryEntry( + endpoint: GatewayEndpoint, + existing: GatewayRegistryEntry?, +): GatewayRegistryEntry = + if (endpoint.stableId.startsWith("manual|")) { + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = endpoint.name, + host = endpoint.host, + port = endpoint.port, + tls = endpoint.tlsEnabled, + lastConnectedAtMs = existing?.lastConnectedAtMs ?: 0L, + ) + } else { + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.DISCOVERED, + name = endpoint.name, + tls = true, + lastConnectedAtMs = existing?.lastConnectedAtMs ?: 0L, + ) + } + +/** HTTP(S) origin serving the connected gateway's Control UI pages. */ +internal fun gatewayControlPageBaseUrl(endpoint: GatewayEndpoint): String { + val scheme = if (endpoint.tlsEnabled) "https" else "http" + return "$scheme://${formatGatewayAuthority(endpoint.host, endpoint.port)}" +} + +private enum class HomeCanvasGatewayState { + Connected, + Connecting, + Error, + Offline, +} + +data class GatewayModelSummary( + val id: String, + val name: String, + val provider: String, + val available: Boolean?, + val supportsVision: Boolean, + val supportsAudio: Boolean, + val supportsVideo: Boolean, + val supportsDocuments: Boolean, + val supportsReasoning: Boolean, + val contextTokens: Long?, +) + +internal fun parseGatewayModels(models: JsonArray?): List = + models + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return@mapNotNull null + val provider = obj["provider"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id.substringBefore('/', "default") + val inputTypes = (obj["input"] as? JsonArray)?.mapNotNull { it.asStringOrNull()?.trim()?.lowercase() }?.toSet().orEmpty() + GatewayModelSummary( + id = id, + name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id, + provider = provider, + available = obj.optionalBoolean("available"), + supportsVision = "image" in inputTypes, + supportsAudio = "audio" in inputTypes, + supportsVideo = "video" in inputTypes, + supportsDocuments = "document" in inputTypes, + supportsReasoning = obj["reasoning"].toString().trim() == "true", + contextTokens = obj["contextTokens"].toString().toLongOrNull() ?: obj["contextWindow"].toString().toLongOrNull(), + ) + }.orEmpty() + +internal class ProviderModelConfigUnsupported : Exception() + +internal suspend fun requestProviderModelConfig(request: suspend (String) -> String): String = + try { + request("""{"view":"provider-config"}""") + } catch (err: GatewayRequestRejected) { + if (err.gatewayError.code != "INVALID_REQUEST") throw err + throw ProviderModelConfigUnsupported() + } + +data class GatewayModelProviderSummary( + val id: String, + val displayName: String, + val status: String, + val profileCount: Int, +) + +data class GatewayCronStatus( + val enabled: Boolean, + val jobs: Int, + val nextWakeAtMs: Long?, +) + +data class GatewayCronJobSummary( + val id: String, + val name: String, + val enabled: Boolean, + val scheduleLabel: NativeText, + val promptPreview: NativeText, + val nextRunAtMs: Long?, + val lastRunStatus: String?, +) + +data class GatewayUsageSummary( + val updatedAtMs: Long?, + val providers: List, +) + +data class GatewayUsageProviderSummary( + val displayName: String, + val plan: String?, + val error: String?, + val windows: List, +) + +data class GatewayUsageWindowSummary( + val label: String, + val usedPercent: Double, + val resetAtMs: Long?, +) + +data class GatewaySkillsSummary( + val managedSkillsDirAvailable: Boolean = false, + val skills: List, +) + +data class GatewaySkillWorkshopSummary( + val agentId: String = "", + val proposals: List, +) { + fun withProposal(proposal: GatewaySkillWorkshopProposal): GatewaySkillWorkshopSummary = + copy( + proposals = + (proposals.filterNot { it.id == proposal.id } + proposal) + .sortedByDescending { it.updatedAt }, + ) +} + +data class GatewaySkillWorkshopProposal( + val id: String, + val kind: String, + val status: String, + val title: String, + val description: String?, + val skillName: String, + val skillKey: String, + val createdAt: String, + val updatedAt: String, + val scanState: String?, + val content: String? = null, + val supportFiles: List = emptyList(), +) + +data class GatewaySkillWorkshopSupportFile( + val path: String, + val content: String?, +) + +data class GatewaySkillSummary( + val skillKey: String, + val name: String, + val description: String?, + val source: String, + val emoji: String?, + val disabled: Boolean, + val eligible: Boolean, + val blockedByAllowlist: Boolean, + val blockedByAgentFilter: Boolean, + val bundled: Boolean, + val missingCount: Int, + val installCount: Int, + val clawHubSlug: String? = null, + val clawHubValid: Boolean = false, + val clawHubOwnerHandle: String? = null, + val clawHubInstalledVersion: String? = null, +) + +data class GatewayNodesDevicesSummary( + val nodes: List, + val pendingDevices: List, + val pairedDevices: List, + val devicePairingAvailable: Boolean = true, +) + +enum class GatewayNodeApprovalState { + Loading, + Unsupported, + Approved, + PendingApproval, + PendingReapproval, + Unapproved, +} + +/** Current phone approval state; only pending variants can carry an approval target. */ +sealed interface GatewayNodeCapabilityApproval { + data object Loading : GatewayNodeCapabilityApproval + + data object Unsupported : GatewayNodeCapabilityApproval + + data object Approved : GatewayNodeCapabilityApproval + + data class PendingApproval( + val requestId: String?, + ) : GatewayNodeCapabilityApproval + + data class PendingReapproval( + val requestId: String?, + ) : GatewayNodeCapabilityApproval + + data object Unapproved : GatewayNodeCapabilityApproval +} + +internal fun GatewayNodeCapabilityApproval.withoutExactRequestId(): GatewayNodeCapabilityApproval? = + when (this) { + is GatewayNodeCapabilityApproval.PendingApproval -> + requestId?.let { GatewayNodeCapabilityApproval.PendingApproval(requestId = null) } + is GatewayNodeCapabilityApproval.PendingReapproval -> + requestId?.let { GatewayNodeCapabilityApproval.PendingReapproval(requestId = null) } + else -> null + } + +internal fun GatewayNodesDevicesSummary.withoutExactApprovalRequestIds(): GatewayNodesDevicesSummary = copy(nodes = nodes.map { node -> node.copy(pendingRequestId = null) }) + +/** Prevents an older gateway response from publishing after a newer refresh begins. */ +internal class LatestGatewayRefreshGuard { + private val lock = Any() + private var generation = 0L + + fun begin(): Long = + synchronized(lock) { + generation += 1 + generation + } + + fun invalidate() { + begin() + } + + fun publishIfCurrent( + refreshGeneration: Long, + publish: () -> Unit, + ): Boolean = + synchronized(lock) { + if (refreshGeneration != generation) return@synchronized false + publish() + true + } +} + +internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalState = + when (raw?.trim()?.lowercase()) { + null, "" -> GatewayNodeApprovalState.Loading + "approved" -> GatewayNodeApprovalState.Approved + "pending-approval" -> GatewayNodeApprovalState.PendingApproval + "pending-reapproval" -> GatewayNodeApprovalState.PendingReapproval + "unapproved" -> GatewayNodeApprovalState.Unapproved + else -> GatewayNodeApprovalState.Loading + } + +internal fun nodeConnectFailureNeedsApprovalRefresh(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "PAIRING_REQUIRED" + +internal fun currentNodeCapabilityApproval( + nodes: List, + selfNodeId: String, +): GatewayNodeCapabilityApproval { + val node = nodes.firstOrNull { it.id == selfNodeId } ?: return GatewayNodeCapabilityApproval.Loading + return when (node.approvalState) { + GatewayNodeApprovalState.Loading -> GatewayNodeCapabilityApproval.Loading + GatewayNodeApprovalState.Unsupported -> GatewayNodeCapabilityApproval.Unsupported + GatewayNodeApprovalState.Approved -> GatewayNodeCapabilityApproval.Approved + GatewayNodeApprovalState.PendingApproval -> + GatewayNodeCapabilityApproval.PendingApproval( + normalizeGatewayApprovalRequestId(node.pendingRequestId), + ) + GatewayNodeApprovalState.PendingReapproval -> + GatewayNodeCapabilityApproval.PendingReapproval( + normalizeGatewayApprovalRequestId(node.pendingRequestId), + ) + GatewayNodeApprovalState.Unapproved -> GatewayNodeCapabilityApproval.Unapproved + } +} + +internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? { + val obj = item.asObjectOrNull() ?: return null + val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return null + return GatewayNodeSummary( + id = id, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + paired = obj.boolean("paired"), + connected = obj.boolean("connected"), + // Only an omitted field identifies a legacy gateway; malformed and future values stay fail-closed. + approvalState = + if (obj.containsKey("approvalState")) { + parseGatewayNodeApprovalState(obj["approvalState"].asStringOrNull()) + } else { + GatewayNodeApprovalState.Unsupported + }, + pendingRequestId = normalizeGatewayApprovalRequestId(obj["pendingRequestId"].asStringOrNull()), + capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray), + commands = parseGatewayStringArray(obj["commands"] as? JsonArray), + ) +} + +internal fun parseGatewayNodeList(root: JsonObject?): List { + if (root == null) return emptyList() + val seen = mutableSetOf() + val result = mutableListOf() + + fun append(nodes: JsonArray?) { + for (node in nodes?.mapNotNull(::parseGatewayNodeSummary).orEmpty()) { + if (seen.add(node.id)) { + result.add(node) + } + } + } + + append(root["nodes"] as? JsonArray) + append(root["pending"] as? JsonArray) + append(root["paired"] as? JsonArray) + return result +} + +data class GatewayNodeSummary( + val id: String, + val displayName: String?, + val remoteIp: String?, + val version: String?, + val deviceFamily: String?, + val paired: Boolean, + val connected: Boolean, + val approvalState: GatewayNodeApprovalState, + val pendingRequestId: String?, + val capabilities: List, + val commands: List, +) + +data class GatewayPendingDeviceSummary( + val requestId: String, + val deviceId: String, + val publicKey: String? = null, + val displayName: String?, + val platform: String? = null, + val deviceFamily: String? = null, + val clientId: String? = null, + val clientMode: String? = null, + val browserOrigin: String? = null, + val remoteIp: String?, + val roles: List, + val scopes: List, + val requestedAtMs: Long?, + val repair: Boolean, +) + +data class GatewayPairedDeviceSummary( + val deviceId: String, + val displayName: String?, + val remoteIp: String?, + val roles: List, + val scopes: List, + val tokens: List, + val approvedAtMs: Long?, +) + +data class GatewayDeviceTokenSummary( + val role: String, + val scopes: List, + val revoked: Boolean, + val updatedAtMs: Long?, +) + +data class GatewayChannelsSummary( + val updatedAtMs: Long? = null, + val partial: Boolean = false, + val warnings: List = emptyList(), + val channels: List, +) + +data class GatewayChannelSummary( + val id: String, + val label: String, + val accountCount: Int, + val enabled: Boolean, + val configured: Boolean, + val linked: Boolean, + val running: Boolean, + val connected: Boolean, + val error: String?, +) + +private data class GatewayChannelAccountSummary( + val enabled: Boolean, + val configured: Boolean, + val linked: Boolean, + val running: Boolean, + val connected: Boolean, + val error: String?, +) + +data class GatewayDreamingSummary( + val enabled: Boolean = false, + val timezone: String? = null, + val shortTermCount: Int = 0, + val groundedSignalCount: Int = 0, + val totalSignalCount: Int = 0, + val promotedToday: Int = 0, + val promotedTotal: Int = 0, + val nextRunAtMs: Long? = null, + val storeHealthy: Boolean = true, + val phaseSignalHealthy: Boolean = true, + val diaryFound: Boolean = false, + val diaryEntries: List = emptyList(), + val diaryEntryCount: Int = 0, +) + +data class GatewayDreamDiaryEntry( + val date: NativeText, + val text: String, +) + +internal fun parseGatewayDreamDiaryEntry(block: String): GatewayDreamDiaryEntry? { + val lines = block.trim().lines() + val date = + lines + .firstOrNull { line -> + val trimmed = line.trim() + trimmed.length > 2 && trimmed.startsWith("*") && trimmed.endsWith("*") + }?.trim() + ?.trim('*') + ?.takeIf { it.isNotEmpty() } + val text = + lines + .map { it.trim() } + .filter { line -> line.isNotEmpty() && !line.startsWith("#") && !line.startsWith("") + } + + data object ProcessingInstruction : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains("?>") + } + + data object Declaration : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains('>') + } + + data object Cdata : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains("]]>") + } + + data class Element( + val tag: String, + ) : RawHtmlContext { + override fun closes(line: String): Boolean = line.lowercase(Locale.US).contains("") + } + + companion object { + fun opening(line: String): RawHtmlContext? { + val trimmed = line.trimStart() + val lowercased = trimmed.lowercase(Locale.US) + if (trimmed.startsWith(" + "تعذّر تجهيز مرفق للإرسال." + "الميكروفون متوقف" + "عرض تنبيهات OpenClaw" + "نشاط المحادثة" + "كامل" + "تمت الموافقة والحفظ." + "عرض سجل المكالمات الأخيرة" + "1 قيد الانتظار" + "مرفق غير مدعوم" + "0 = دقيق" + "اتصل بـ Gateway للبحث في المحادثات." + "%1$s حسابات" + "تتطلب تغييرات Cron الإذن operator.admin. لا تمنحه رموز الإعداد عمدًا. أعد الاتصال باستخدام الرمز المميز المشترك أو كلمة المرور الخاصة بـ Gateway لطلب وصول المسؤول. إذا ظل هذا الجهاز يفتقر إليه، فوافق على ترقية النطاق المعلّقة من عميل مسؤول حالي." + "Apply Patch" + "القرص" + "إلغاء كتم مكبر الصوت" + "مرات التخطي المتتالية" + "لا يحتوي هذا المجلد على أي ملفات بعد." + "غير متصل" + "افحص حالة المهارات المثبّتة وأدِرها." + "فشل" + "الوكيل الافتراضي" + "الكاميرا" + "إزالة من المجموعة" + "جارٍ البحث" + "متوقف مؤقتًا لتشغيل الصوت" + "سيتحقق Gateway من هذا الإصدار بعينه عبر ClawHub قبل التنزيل. إذا كان الإصدار يتطلب إقرارًا صريحًا بالمخاطر، فسيعرض Android تحذير Gateway قبل إعادة المحاولة." + "يستخدم رمز الإعداد معرّف نطاق IPv6. استخدم عنوان IPv6 غير محدد النطاق أو اسم مضيف LAN." + "مرفق" + "اضبط كلمات التنبيه والتحدث والتشغيل." + "جارٍ الاستماع (PTT)" + "تم رفض المقترح." + "إظهار الشريط الجانبي" + "المستخدم" + "%1$s · %2$s" + "الحد الأدنى" + "رفض" + "الوكيل النشط" + "مهمة واحدة مجدولة" + "لا يوجد رد" + "تم تحديد %1$s" + "مصفوفة JSON للأمر argv" + "تعذّرت قراءة هذه الصورة. اختر لقطة شاشة واضحة أو صورة لرمز QR من openclaw qr." + "تعذّر %1$s مقترح ورشة عمل Skills." + "تمت الإجابة في مكان آخر" + "سجّل Gateway الموافقة مرة واحدة." + "status" + "لا يتحقق OpenClaw من الموقع إلا عندما يطلب Gateway المقترن ذلك. في شاشة Android التالية، اختر %1$s للسماح بعمليات التحقق أثناء عمل التطبيق في الخلفية." + "رفض" + "التباين" + "استبدال إعداد Gateway؟" + "تعذر تحميل عمليات التشغيل الآلي." + "أنت" + "الميكروفون المدمج" + "الواجهة" + "لا توجد مقترحات" + "المحادثة الرئيسية" + "فتح الدردشة" + "إجراءات إقران الأجهزة غير متاحة في جلسة Gateway هذه. شغّل openclaw devices list على مضيف Gateway وأدِر الطلب من هناك. الموافقة على إمكانات العقدة منفصلة، ولا تزال تستخدم nodes approve <request id>." + "طلب إجراء" + "list pins" + "اتصل بـ Gateway لتحميل مقترحات ورشة Skills." + "لم يتم قبول رمز الإعداد" + "تسجيل الخروج" + "موفر النسخ النصي في الوقت الفعلي غير مهيأ." + "إظهار تطبيقات النظام" + "حدّث Gateway لعرض إعدادات نماذج موفّر الخدمة." + "جارٍ إرسال الإملاء" + "افحص هذا المقترح لتحميل محتواه بتنسيق Markdown." + "افتح OpenClaw واسأل %1$s" + "الاستدلال" + "العميل" + "مطبق" + "فيديو" + "تمت ترقيته" + "متصل" + "النطاقات" + "موفر الصوت في الوقت الفعلي غير مهيأ." + "%1$s · %2$s" + "kick" + "أعاد Gateway مهمة آلية غير صالحة." + "معرّف المثيل" + "رمز Gateway مطلوب. أدخله مرة أخرى أو عدّل هذا الاتصال." + "المصدر" + "تحديث" + "%1$s في قائمة الانتظار" + "بدء الدردشة" + "تظل استدعاءات أدوات الدردشة المنتظرة في المحادثة النشطة ظاهرة هنا." + "مراجعة الشهادة مطلوبة" + "افتح سطح Canvas الحالي لفحصه أو التفاعل معه." + "تم تحديث التشغيل الآلي." + "لا توجد جلسات حديثة" + "برنامج نصي" + "حالة Gateway، وجاهزية عقدة الهاتف، وتدفق السجلات الأخير." + "فتح تفاصيل الأتمتة" + "بيئة التشغيل" + "عامل إضافي واحد" + "الوكيل %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "يرجى تفعيل %1$s في إعدادات Android للمتابعة." + "إصلاح" + "reactions" + "تم" + "الإصدار والتحديث" + "سيعرض OpenClaw الموافقات والمهام الفاشلة ومشكلات القنوات هنا." + "ميكروفون USB" + "عدد كبير جدًا من المشاركات في انتظار الإضافة." + "تم التخطي" + "استخدم عنوان LAN لكمبيوتر Gateway أو اسم مضيف آمن للوصول عن بُعد." + "تشغيل" + "جارٍ البحث في المحادثات" + "التحدث في الوقت الفعلي" + "· %1$s" + "يقوم OpenClaw بتحضير رد." + "تمت الموافقة مرة واحدة." + "إعداد المزوّد" + "بلا" + "تُحفَظ حمولات البرنامج النصي دون تغيير. استخدم CLI لتعديل هذا البرنامج النصي." + "دليل إعداد Android" + "تم حظر %1$s تطبيقات من إعادة التوجيه." + "الأجهزة المقترنة" + ":%1$s" + "%1$s معلّقة" + "اسم الجهاز" + "إرسال" + "الموقع" + "مثال America/New_York" + "الجلسة المستهدفة" + "مراجعة مهارة ClawHub" + "snapshot" + "هل تريد رفض طلب الاقتران من هذا الجهاز؟" + "الميكروفون المفضل" + "مضيف العقدة" + "المستوى" + "إغلاق منتقي التطبيقات" + "الصق رمز Gateway مميزًا مشتركًا أو رمزًا مميزًا صادرًا عن المشغّل." + "جميع الأنظمة تعمل بشكل طبيعي" + "تم نسخ تشخيصات Gateway" + "خطأ في الصوت" + "استبدال الإعداد" + "إجراءات سريعة" + "فشل الإرسال: فشلت الدردشة قبل بدء التشغيل؛ حاول مرة أخرى." + "الميكروفون" + "لا تزال الدردشة تتحقق من حالة Gateway." + "الموقع الدقيق" + "السماح مرة واحدة" + "+%1$s أخرى" + "thread create" + "محظور" + "كلمة أو عبارة التنبيه" + "يتطلب Gateway الموافقة على الجهاز" + "ميكروفون خارجي" + "%1$s/%2$s جاهزة" + "متصل (المشغّل غير متصل)" + "الإمكانية غير معتمدة" + "يؤدي هذا إلى إزالة عملية الأتمتة وجدولها نهائيًا من Gateway." + "جارٍ تحميل الصورة…" + "اتصال" + "الموافقة على وصول العقدة" + "إضافة Gateway" + "النسخ غير متاح: %1$s" + "صورة" + "التدفق" + "إغلاق معاينة الصورة" + "eval" + "آخر أمر: %1$s" + "اجعل نافذة طرفية مفتوحة على الجهاز الذي يشغّل OpenClaw." + "لا توجد عناصر مفقودة" + "يتطلب إخراج اللوحة اتصال Gateway نشطًا." + "%1$s · %2$s" + "معزول" + "© 2026 OpenClaw Foundation — ترخيص MIT." + "PDF" + "Conversations" + "توحيد الذاكرة ويوميات الأحلام." + "Create Goal" + "تغيّرت عملية الأتمتة هذه أثناء تعديلك لها. ارجع إلى أحدث إصدار في Gateway قبل الحفظ." + "عند الاتصال، يمكن لـ Gateway تنشيط الهاتف بإشعار صامت بدلًا من إبقاء جلسة دائمة التشغيل." + "وضع التنبيه" + "هل تريد إزالة الجهاز المقترن؟" + "نص حدث النظام" + "تعذّر نسخ صورة الأداة" + "لا" + "مسار اختياري" + "جارٍ إرسال الصوت في قائمة الانتظار" + "مدمج" + "hide" + "runs" + "كلمة مرور Gateway مطلوبة. أدخلها مرة أخرى أو عدّل هذا الاتصال." + "نص الحدث" + "النص المباشر" + "تعذّر تحميل إعدادات نماذج موفّر الخدمة." + "يُسمح لـ %1$s تطبيق بإعادة التوجيه." + "إعداد الصوت" + "إرفاق فيديو" + "صور إضافية مخفية: %1$s" + "هل تريد رفض طلب الاقتران؟" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "الناشر" + "التفريع من هنا" + " · المحادثة: جارٍ التحدث" + "تجاوز اختياري" + "افتراضي" + "الموافقة على الأمر" + "يستخدم التطبيق وGateway إصدارات بروتوكول غير متوافقة. حدّث OpenClaw على كليهما، ثم أعد المحاولة." + "تحديث الشاشة" + "قراءة الصور والوسائط الحديثة" + "استماع" + "متصل" + "مكتملة" + "تم إقران هاتفك بـ %1$s. تابع لإكمال الوصول إلى العقدة." + "المحادثة الحالية" + "التفكير %1$s" + "مكتوم" + "تقدّر OpenClaw شركاءها في مجتمع المصادر المفتوحة." + "مفتوح" + "جارٍ الاتصال بـ Gateway" + "يمكن للـ Gateway تغيير هذا المسار لكن لا يمكنه مسح مسار موجود." + "TTS" + "جارٍ الحفظ…" + "المرساة %1$s" + "search" + "تفعيل" + "افحص مهام Gateway المجدولة وأدِرها." + "سمح رد سابق بهذا الأمر مرة واحدة بالفعل." + "generate" + "استخدمه فقط على شبكة خاصة موثوقة." + "البحث في الإعدادات" + "التحدث مباشر" + "لم تتم تهيئة مصادقة Gateway. عدّل هذا الاتصال وحاول مرة أخرى." + "فشل: تم الوصول إلى نقطة نهاية آمنة، لكن انتهت مهلة التحقق من بصمة TLS. تحقق من Tailscale Serve أو TLS الخاص بالبوابة ثم أعد المحاولة." + "الخطوة 1" + "الإملاء" + "فتح منتقي التطبيقات" + "لا توجد موافقات معلّقة" + "edit" + "الاتصال بـ Gateway الخاص بك" + "أدخل رمز الإعداد من openclaw qr." + "التشخيصات" + "تبقى التطبيقات الأخرى دون مساس." + "التكسير" + "يؤدي هذا إلى حذف المحادثة ونصها نهائيًا." + "تمت الموافقة على الجهاز." + "تجري إعادة المحاولة تلقائيًا" + "تم نسخ صورة الأداة" + "%1$s أدوار" + "عنصر واحد مفقود" + "%1$s مجدولة" + "react" + "الوكلاء" + "اتصل بـ Gateway لتحميل عمليات الأتمتة." + "جارٍ إعادة الاتصال…" + "العودة إلى الإعداد" + "send" + "تعذّر اختبار الاتصال" + "التحقق والتثبيت" + "حوّل هذا الجهاز إلى عقدة OpenClaw آمنة للدردشة والصوت والكاميرا وأدوات الجهاز." + "إعداد يدوي" + "افتح الدردشة لبدء المحادثة الحالية أو استئنافها." + "تلميح: أوقف الاستماع لإرسال الجزء الملتقط." + "تخطي" + "فشل الطلب الصوتي" + "سيؤدي هذا إلى رفض \"%1$s\" وتحديث حالة ورشة Skills من Gateway." + "تكوين الصدفة" + "update" + "مشاركة" + "الكاميرا مفعّلة" + "تظهر Telegram وWhatsApp والبريد الإلكتروني والقنوات الأخرى هنا بعد الإعداد." + "خطأ في الشبكة" + "استكشاف برك المد والجزر" + "استعد لوحة الرسم الآن للجلسة=%1$s والمصدر=%2$s. إذا كانت هناك حالة A2UI موجودة، فأعِد تشغيلها فورًا. وإلا، فأنشئ واعرض لوحة معلومات مدمجة وملائمة للأجهزة المحمولة في لوحة الرسم." + "فشل البدء: %1$s" + "لم يُطلب" + "قم بتكوين موفّر %1$s على Gateway" + "kill" + "الموافقات" + "الملفات غير متاحة" + "تعليم كغير مقروء" + "العثور على الأشخاص وتفاصيل الاتصال" + "هوية الجهاز مطلوبة" + "محادثة OpenClaw" + "السماح بالوصول إلى مكتبة الصور." + "حلّ رد سابق هذه الموافقة بالفعل." + "لا توجد سلاسل محادثات حديثة" + "المهلة %1$sث" + "لا توجد نتائج مطابقة" + "قراءة إشعارات التطبيقات المحددة" + "التوفر غير معروف" + "إعداد المحادثة" + "إضافي" + "تم إقران Gateway. في انتظار صلاحية وصول المشغّل." + "إرفاق صورة" + "اختر ما يصل إلى OpenClaw." + "إعادة الموافقة على الإمكانية معلّقة" + "راجع العناصر المميزة" + "جارٍ الاستماع..." + "أطلعني على المستجدات" + "الرسالة" + "قراءة جهات الاتصال" + "مساحة تخزين المرفقات دون اتصال ممتلئة؛ احذف العناصر قيد الانتظار أولًا." + "مرة واحدة" + "إعادة تسمية" + "لم يتم العثور على قنوات." + "عرض الكل" + "جهاز جديد" + "Session Status" + "فتح معاينة الصورة" + "تغيّر فرع الجلسة؛ راجع هذه الرسالة وأعد المحاولة." + "close" + "يبدو أن هذا رمز إعداد. ارجع واختر إعداد Gateway، ثم استخدام رمز الإعداد." + "✦" + "الوكلاء والأتمتة" + "تطبيق" + "تم تخطي تشغيل المهمة الآلية." + "متابعة" + "المراقبة · %1$s من المهام المجدولة" + "تصفح" + "tabs" + "قيد الانتظار" + "التحدث: %1$s" + "read" + "تحديد النص" + "نشاط الحركة" + "description: %1$s" + "تشغيل الصوت" + "الوقت" + "غير موثّق" + "Yield" + "نسخ أمر الموافقة" + "إخراج الشاشة الحالي وسطح التطبيق التفاعلي." + "الخدمة متصلة" + "العرض" + "جاهز عندما تكون جاهزًا" + "تعذّر تحميل كتالوج موفّري الخدمة." + "يتحدث · بانتظار الرد" + "غير ممنوح" + "حفظ التغييرات" + "رفض Gateway تشغيل المهمة الآلية." + "Session Send" + "البحث في ClawHub" + "يسمح دائمًا بفحوصات الموقع المطلوبة أثناء وجود OpenClaw في الخلفية؛ يعرض Android ذلك في إشعار العقدة المستمر." + "حدث النظام" + "وصّل Gateway لعرض المزوّدين" + "نبضة الاتصال التالية" + "تم إقران Gateway. في انتظار الموافقة على إمكانات العقدة." + "التمليح" + "إغلاق اللوحة" + "كتابة جهات الاتصال" + "لا توجد مهارات مثبّتة تطابق هذا البحث." + "إعداد مزوّد المحادثة" + "Music Generation" + "إعدادات Talk" + "المراقبة · سلسلة محادثات واحدة" + "نص الحمولة" + "تعيين النص" + "الموافقة %1$s" + "لم يُرجع Gateway جاهزية %1$s" + "يوجد %1$s من النماذج المُعدّة. حدّث لإعادة التحقق من التوفر." + "Conversation Send" + "اللوحة" + "مزوّد واحد" + "تعذّرت قراءة شهادة Gateway تلقائيًا. الصق بصمة SHA-256 التي حصلت عليها من مضيف Gateway." + "فشل الإرسال: %1$s" + "الجسر" + "خطأ في التسليم" + "استخدم OpenClaw من هاتفك" + "المظهر" + "ورشة Skills" + "يتطلب رمزًا مميزًا" + "معاينة · %1$s" + "إذن استخدام الميكروفون مطلوب" + "اتصل بـ Gateway لتحميل مقترحات ورشة عمل Skills." + "جميع الأنظمة تعمل" + "يتعذر الوصول إلى Gateway" + "OC" + "تاريخ التحديث" + "متصل (العقدة غير متصلة)" + "الرئيسية" + "الإملاء يستمع" + "لا توجد محادثات مؤرشفة" + "اختر وافحص المساعدين المتاحين على Gateway هذا." + "وضع التحدث نشط" + "جارٍ العمل · عملية تشغيل نشطة واحدة" + "الموافقة والتمكين" + "تحديث Gateway مطلوب" + "نسخ الصورة" + "عنوان URL لـ Gateway" + "main أو isolated أو current أو session:<id>" + "الوسائط غير متوفرة" + "اتصل بالـ Gateway لفتح shell في مساحة عمل الوكيل." + "%1$s://%2$s:%3$s" + "تعذّر تحميل تفاصيل الموافقة. حدّث وحاول مرة أخرى." + "يمكنني التحقق من حالة Gateway، أو إصلاح الإعدادات، أو تغيير النماذج، أو ربط القنوات." + "Tool Call" + "المحادثات" + "Write" + "ابدأ بموجّه، أو استخدم الصوت." + "ي" + "فتح الإعدادات" + "جارٍ الرصد…" + "إنهاء المحادثة" + "آخر خطأ" + "راجع الإجراءات التي تحتاج إلى انتباهك." + "معطّلة لجميع الوكلاء." + "بدء المحادثة الصوتية" + "العودة إلى المهام في الخلفية" + "لا يزال إجراء cron آخر قيد الإنهاء." + "فترة التهدئة %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "الرسائل القصيرة" + "ميزة التعرّف على الكلام على الجهاز غير متاحة." + "برنامج نصي · للقراءة فقط" + "المرفقات كبيرة جدًا بحيث لا يمكن وضعها في قائمة الانتظار ضمن رسالة واحدة؛ أزل بعضها وحاول مجددًا." + "يوجد نموذج واحد مُعدّ. حدّث لإعادة التحقق من التوفر." + "الأداة غير متاحة" + "يلزم اتصال آمن لهذا المضيف." + "الأخيرة" + "لا توجد عمليات أتمتة مطابقة." + "يمكن للهاتف الوصول إلى Gateway" + "Gateway" + "انتهت الصلاحية" + "أعمال OpenClaw المجدولة من Gateway لديك." + "Sub-agent" + "في انتظار الموافقة على الجهاز" + "جارٍ تحميل سلسلة المحادثات" + "يقدّم Gateway هذا الآن شهادة موثوقة من هذا الجهاز." + "التداخل بالميلي ثانية" + "event create" + "مستند" + "إعداد Gateway" + "تشغيل الفيديو" + "المصادقة المحفوظة غير صالحة. أعد المصادقة أو أعد ضبط اتصال هذا Gateway." + "أثناء الاستخدام" + "screenshot" + "الرجوع إلى هنا" + "تعبير Cron، مثل 0 9 * * *" + "العودة إلى الصوت" + "تحدّث" + "التفاصيل" + "%1$s/%2$s متصلة" + "يُسمح لـ %1$s تطبيقات بإعادة التوجيه." + "الدردشة" + "يلزم الوصول إلى الميكروفون." + "العدو الجانبي" + "تحرير" + "ساعات الهدوء" + "نسخ التشخيصات" + "مجدول" + "إنشاء" + "تنتهي الصلاحية خلال %1$s" + "تجاهل" + "رفض المقترح؟" + "خطأ في التعرّف على الكلام (%1$s)" + "مشكلة" + "ابحث في بيانات السجل الوصفية. يتحقق Gateway من الثقة مرة أخرى قبل أي تنزيل." + "استخدم عنوان IP خاصًا بشبكة LAN للإعداد المحلي، أو فعّل Tailscale Serve / اعرض عنوان URL للبوابة باستخدام wss:// للوصول عن بُعد." + "جارٍ الإرسال…" + "الحساب %1$s" + "Suggest Task" + "بحث" + "جارٍ الاستماع" + "لم يتم تحميل الأتمتة." + "يتوفر تحديث لـ Gateway. شغّل التحديث من واجهة الويب أو CLI عندما تكون مستعدًا." + "قريبًا" + "لا توجد موافقات Gateway." + "المضيف" + "أضف كلمة تنبيه أو عبارة واحدة في كل حقل. ثم قل إحداها قبل أمرك." + "نسخ ثم إرسال" + "التشغيل في" + "إيقاف الصوت مؤقتًا" + "الوصول إلى جهاز Gateway" + "لا توجد معاينة" + "الأجهزة" + "OpenClaw لنظام Android." + "موافقة الإمكانية معلّقة" + "احفظ تعديلاتك أو تراجع عنها قبل تشغيل عملية الأتمتة هذه أو تفعيلها أو تعطيلها أو حذفها أو تحديثها." + "لا توجد عمليات أتمتة حتى الآن." + "تحتاج هذه المهارة إلى %1$s من عناصر الإعداد. يعرض Android ما هو مثبّت؛ وتظل تغييرات الإعداد/التكوين على سطح المكتب أو CLI." + "%1$s حديثة" + "القنوات" + "غير متزامن" + "نشط على هذا الهاتف" + "جارٍ التحقق من صلاحية الوصول إلى العقدة" + "المنطقة الزمنية" + "إجراءات الفحص والتطبيق في Skill Workshop" + "السماح دائمًا" + "present" + "ستظهر Skills المثبتة على Gateway هنا." + "ربما انتهت صلاحية الرمز أو تم إنشاؤه لـ Gateway آخر." + "الإذن مطلوب" + "إعدادات التشغيل الآلي غير صالحة." + "قائمة السماح" + "الإعداد والحالة والإصلاح" + "groups" + "المفتاح العام" + "حول" + "لم يتم العثور على رمز QR للإعداد في هذه الصورة. اختر رمز QR الذي أنشأه openclaw qr، أو أدخل رمز الإعداد يدويًا." + "permissions" + "وصّل Gateway لتحميل العُقد والأجهزة المقترنة." + "تبديل الفرع" + "لا توجد مهارات" + "تُشغَّل الردود بصوت عالٍ" + "تعليم كمقروء" + "موافقة العقدة قيد الانتظار" + "wake" + "%1$s مقترحات" + "تتطلب مصادقة Gateway تدخلك." + "تفاصيل الاتصال" + "بالميلي ثانية" + "التعرّف على الكلام" + "الوصف" + "المحادثات الحديثة" + "يرسل هاتفك هذه المعلومات إلى Gateway الخاص بك، وليس إلى خادم تديره OpenClaw. قد يُضمّنها Gateway في الطلبات المُرسَلة إلى موفر الذكاء الاصطناعي الذي اخترته." + "التسليم" + "كتم مكبر الصوت" + "%1$s قيد التشغيل · %2$s مكتمل · %3$s فشل" + "جارٍ فتح اتصال Gateway" + "المراقبة · %1$s من سلاسل المحادثات" + "اكتمل تشغيل المهمة الآلية." + "لا توجد تطبيقات مطابقة." + "إرسال إلى الدردشة" + "تم حذف التشغيل الآلي." + "تمكين" + "عمليات التشغيل الأخيرة" + "قم بمحاذاة رمز QR داخل المربع." + "تعذّر تحميل الموافقات." + "لقد وافقت" + "وصّل Gateway لتحميل جاهزية المزوّد." + "غير مقترن" + "انتهت صلاحية هذه الموافقة قبل أن يتم حلها." + "جارٍ الرصد خلال %1$s ثانية — انتقل إلى التطبيق المستهدف" + "مطالبة الوكيل" + "emoji list" + "متكرر" + "البحث في OpenClaw" + "%1$s قيد الانتظار" + "التعرّف على الكلام على الجهاز غير متاح" + "لا يوجد تطبيق يمكنه مشاركة هذه الرسالة" + "إغلاق البحث" + "الأمر المراد مراقبته" + "الحالة" + "مستمع الإشعارات" + "مكبر الصوت مكتوم" + "البحث في سلاسل المحادثات" + "موافق" + "تعذّر فتح دليل الإعداد." + "اسأل OpenClaw %1$s" + "Wait for Agents" + "العنوان" + "ستظهر هنا الأعمال المجدولة التي تم إنشاؤها على Gateway." + "يتم عرض أحدث جزء من السجل." + "استخدام رمز الإعداد" + "sticker" + "استخدم Gateway آمنًا عبر wss:// أو Tailscale Serve، وأنشئ رمز إعداد بوصول كامل في Control UI أو باستخدام openclaw qr، ثم امسحه ضوئيًا أو الصقه أدناه وأعد الاتصال لتمكين الإعدادات والترقيات." + "steer" + "محدد" + "يمكن لـ Android مسح رمز إعداد موجود أو لصقه، لكن هذه البوابة لا تتيح بعد إنشاء رمز الإعداد للتطبيق. أنشئ رمز QR/الرمز على مضيف البوابة باستخدام openclaw qr، ثم امسحه هنا أو الصق رمز الإعداد أدناه." + "حالة اللوحة" + "إصلاح الاتصال" + "حفظ الصورة" + "العقدة %1$s" + "كلمة مرور Gateway مطلوبة" + "Update Plan" + "إزالة المرفق" + "فشل تشغيل المهمة الآلية." + "حدود المزوّد وحالة الحصة." + "لم يتم تحميل كتالوج محادثات Gateway" + "هذا الـ Gateway" + "لا توجد عمليات تشغيل حديثة بعد." + "نموذج اللغة على الجهاز غير متاح" + "تتطلب لوحة المعلومات Gateway متصلاً" + "ستظهر المقترحات المطابقة هنا بعد أن تنشئ الوكلاء مسودات مهارات قابلة لإعادة الاستخدام." + "Session Search" + "OpenClaw يتحدث" + "مسح رمز QR" + "التطبيقات المحددة" + "التراجع عن التغييرات" + "تم نسخ أمر الموافقة" + "حالة التسليم" + "لم يتم قبول رمز QR" + "مركز أوامرك الصوتية." + "اختبار الاتصال" + "OPENCLAW" + "Web Fetch" + "الموجّه" + "هل تريد الموافقة على الجهاز؟" + "اتصل بـ Gateway لديك لفتح لوحة معلومات هذه الجلسة." + "هل تريد إزالة %1$s وبيانات الاعتماد المحفوظة الخاصة به من هذا الهاتف؟" + "يشير رمز QR إلى Gateway بعيد غير آمن. %1$s %2$s" + "سطح الشاشة جاهز" + "إقران Gateway" + "وصّل Gateway لتحميل القنوات." + "يتوقف مؤقتًا أثناء الأنشطة الصوتية الأخرى." + "النموذج" + "الصور" + "الصق رمز الإعداد" + "OpenClaw يتحدث" + "جارٍ الاتصال..." + " · الموقع: دائمًا" + "الرسائل: %1$s" + "الإبحار" + "التحميل من Gateway" + "text: %1$s" + "يحتاج إلى" + "rename group" + "جاهز" + "تنتظر المذكّرات أول إدخال لها." + "موافقة" + "الصفحة المباشرة" + "التشغيل الآلي قيد التشغيل بالفعل." + "إزالة عملية الأتمتة هذه بعد تشغيل ناجح لمرة واحدة." + "جاهز للدردشة والصوت" + "متصل (المشغّل: %1$s)" + "اكتمل إقران Gateway. وافق على هذا الهاتف كعقدة حتى يتمكن OpenClaw من استخدام إمكانات الجهاز التي تفعّلها." + "تم إيقاف الاستجابة" + "صورة" + "%1$s معلّق" + "لا توجد محادثات مطابقة" + "delete" + "التخطيط: مضغوط" + "channels" + "ممنوح" + "كل %1$s دقيقة" + "رمز مميز واحد" + "%1$s %2$s" + "التطبيقات المثبتة" + "قيد الانتظار" + "جارٍ تحضير الملاحظة الصوتية…" + "أبدًا" + "النظام الفرعي" + "عند انتهاء الأمر" + "الاتصال" + "تعذر تحميل سجل تشغيل عمليات الأتمتة." + "اسم الأتمتة" + "الخطوة 2" + "تشخيص" + "لم تكتمل بعض عمليات التحقق من حالة القنوات." + "pin" + "نسخ %1$s" + "مقترن" + "تعذر حفظ كلمات التنبيه" + "سيؤدي هذا إلى عزل \"%1$s\" وتحديث حالة ورشة Skills من Gateway." + "تسجيل ملاحظة صوتية" + "في قائمة الانتظار" + "تمت الإجابة" + "السماح بأدوات الكاميرا عند طلبها." + "المشكلات" + "التنبيه الصوتي" + "تم رفض طلب الاقتران." + "قبل %1$s ي" + "roles" + "Skills" + "أرشفة" + "العقدة غير متصلة. أعد الاتصال وحاول مجددًا." + "النظام" + "عنوان IP البعيد" + "غير مصنّف" + "تفاصيل الجدول" + "إمكانات الهاتف" + "غير متاح" + "لوحة المعلومات" + "لصق الرمز المميز" + "لا يوجد موفرون" + "بصمة SHA-256" + "لا توجد محادثات بعد" + "ميكروفون Bluetooth" + "الأخيرة" + "إعادة تسمية المحادثة" + "نتيجة الحل غير معروفة. تبقى الإجراءات معطلة حتى يتم التحقق من سجل Gateway." + "dialog" + "الاستماع إلى كلمات التنبيه" + "camera snap" + "جارٍ تجهيز التشغيل…" + "حدّد Gateway موفّرًا غير معروف %1$s" + "delete group" + "متابعة Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "وصّل Gateway لتحميل الوكلاء." + "رجوع" + "مشاركة الرسالة" + "أنشئ رمز QR." + "إعادة التشغيل" + "مكبر الصوت قيد التشغيل" + "حذف المجموعة؟" + "مفقود" + "البحث في المقترحات" + "stop" + "آمن (TLS)" + "لا توجد عُقد أو أجهزة مقترنة." + "متبقٍ %1$s%% %2$s" + "انتهت صلاحية رمز الإعداد" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "المذكّرات" + "notify" + "يظل هذا الهاتف في وضع السكون حتى يحتاج إليه Gateway، ثم يستيقظ ويُجري المزامنة ويعود إلى السكون." + "%1$s من النماذج المُعدّة" + "التراخيص" + "اتصل بـ Gateway للبحث عن مهارات ClawHub." + "مهارة" + "تغيّر اتصال Gateway. أعد تشغيل OpenClaw لإعادة الاتصال." + "معرّف الجهاز" + "لم يحدّد Gateway موفّر %1$s النشط" + "في الانتظار" + "تم حفظ كلمات التنبيه" + "الأقدم أولاً" + "الشاشة" + "قيد التشغيل منذ" + "معرّفات نطاق IPv6 غير مدعومة. استخدم عنوان IPv6 غير محدد النطاق أو اسم مضيف LAN." + "تم الإرسال — جارٍ تأكيد التسليم…" + "صوت" + "This gateway connection needs operator.admin to update skills." + "رمز الإعداد" + "الإقرار بتحذير Gateway والتثبيت" + "تحديث المحادثة" + "الفاصل الزمني" + "تتطلب إجراءات مقترحات ورشة عمل Skills نطاق operator.admin." + "الجلسات" + "إعادة تسمية…" + "وصّل Gateway لتحميل الأحلام." + "الإعداد" + "فتح Talk" + "poll" + "اتصل لتحميل وكلائك" + "role remove" + " · التحدث: جارٍ الاستماع" + "لم يُرجع ClawHub إصدارًا قابلًا للتثبيت لـ %1$s." + "الأمر" + "تم إلغاء هذه الموافقة قبل أن يتم حلها." + "الميكروفون قيد التشغيل · في انتظار Gateway" + "نص" + "يتم عرض %1$s من %2$s. حسّن البحث لعرض المزيد." + "الإصدار v%1$s متاح" + "%1$s://%2$s" + "%1$s... (حسنًا)" + "الموفرون والنماذج المُهيأة" + "جارٍ الاتصال…" + "اتصل بـ Gateway لحفظ كلمات التنبيه" + "فتح الملف الشخصي" + "ابدأ تشغيل Gateway الخاص بك." + "ساعدني في تحويل هذا الهدف إلى قائمة تحقق عملية: " + "مسح بحث الجلسات" + "المنفذ" + "أدخل رمز الإعداد" + "تعذر تحميل سجلات Gateway." + "%1$s مزوّدين جاهزين" + "وكلاؤك جاهزون" + "لم يتم تكوين أي موفّر %1$s على Gateway" + "جارٍ الاستماع لجولة واحدة" + "رصد" + "ميلي ثانية Epoch (اختياري)" + "لا توجد نماذج مُعدّة. حدّث لإعادة التحقق من التوفر." + "الإعدادات" + "الكاميرا الخلفية" + "approve" + "قبل أن تبدأ" + "تعذّر تحميل Skills." + "معطّل" + "ما زلنا بانتظار الموافقة" + "تعذّر تحميل المهام في الخلفية" + "تحقّق من أن OpenClaw يمكنه التحدث بوضوح على هذا الهاتف." + "جارٍ العمل · %1$s عمليات تشغيل نشطة" + "دليل عمل الأمر" + "اسم المجموعة" + "الاختيار من المعرض" + "Version %1$s" + "رجوع" + "Connect the gateway to update skills." + "الحذف بعد التشغيل" + "يشير رمز الإعداد إلى Gateway بعيد غير آمن. %1$s %2$s" + "Computer" + "Gateway غير متصل." + "Session Settings" + "صِل Gateway للبدء" + "إشعار أمان" + "إجابة أخرى" + "تجاهل تحذير الصورة المشتركة" + "قيّم Gateway إصدارًا مختلفًا من ClawHub. راجع Skill مرة أخرى قبل التثبيت." + "فتح وصول النظام" + "انتهت" + "الصورة غير متاحة" + "الإشعارات" + "يتطلب التطبيق والرفض والحجر الصحي نطاق operator.admin. أعد الاتصال باستخدام مصادقة gateway المشتركة أو وافق على ترقية نطاق جهاز operator.admin لتمكين إجراءات دورة الحياة." + "sticker upload" + "صيد الكركند" + "Messages to recover" + "openclaw devices approve %1$s" + "تفاصيل سجل Gateway قابلة للقراءة." + "راجع مقترحات Skills المُنشأة قبل أن تصبح مهارات فعّالة." + "مضمّن" + "%1$s متاح" + "موافقة العقدة معلّقة" + "Gateway قيد الانتظار" + "المصادقة مطلوبة" + "العُقد" + "إبقاء الجهاز مستيقظًا" + "OpenClaw يرد" + "المستندات" + "%1$s جاهز" + "لا توجد مخرجات بعد" + "لغة الجهاز غير مدعومة" + "في قائمة الانتظار — سيُرسل عند إعادة الاتصال" + "قبل %1$s د" + "الفرع الحالي" + "جارٍ التحقق من صلاحية الاقتران" + "وصول محدود إلى Gateway" + "جارٍ تشغيل الأدوات..." + "جارٍ التحقق من الموافقة…" + "التقاط الصور والمقاطع من هذا الهاتف" + "متصل وجاهز" + "إغلاق" + "حوّل هدفًا إلى قائمة تحقق قابلة للتنفيذ." + "يحتوي رمز الإعداد على عنوان URL غير صالح لـ Gateway." + "فعّل فقط الوصول الذي ترتاح إلى السماح لـ OpenClaw باستخدامه أثناء اتصال هذا الهاتف. يمكنك تغيير ذلك لاحقًا في إعدادات Android." + "الحساب" + "remove" + "كلمة المرور اختيارية" + "تحتاج مصادقة Gateway إلى مراجعة. تحقق من إعدادات Gateway، ثم أعد المحاولة." + "يستخدم رمز QR معرّف نطاق IPv6. استخدم عنوان IPv6 غير محدد النطاق أو اسم مضيف LAN." + "add" + "التكريل" + "سليم" + "اكتمل خلال %1$s" + "الوسائط" + "خيارات التثبيت" + "خلال %1$s ساعة" + "موافقة Gateway معلّقة. شغّل الأمر التالي على مضيف Gateway:" + "مطلوب وصول المسؤول" + "set groups" + "تثبيت النموذج" + "مسح البحث" + "ممكّنة للوكلاء المؤهلين." + "لا توجد محادثة حالية" + "bounds: %1$s" + "بعد %1$s" + "السماح للمجدول بتشغيل عملية الأتمتة هذه." + "%1$s مطبّق" + "لا توجد مذكرة أحلام بعد." + "تحديث المهام في الخلفية" + "لخّص سلاسل المحادثات الأخيرة والخطوات التالية." + "يعمل على الجهاز أثناء ظهور OpenClaw." + "%1$s يعمل" + "%1$s %2$s" + "خام" + "عمليات التشغيل" + "تشغيل الآن" + "فرع بدون عنوان" + "تم إعداده" + "camera list" + "1 مطبّق" + "camera clip" + "نعم" + "اختبار الصوت" + "محتجز" + "events" + "دليل العمل" + "الانتقال إلى الأحدث" + "السماح طوال الوقت" + "امسح رمز QR أو رمز الإعداد ضوئيًا" + "Installing" + "العُقد المباشرة، والهواتف المقترنة، وطلبات الأجهزة المعلّقة." + "لقطة: %1$s" + "سمح رد سابق بهذا الأمر بالفعل وحفظ الاختيار." + "الطلبات المعلّقة" + "تمت الموافقة" + "مساحة العمل" + "الصوت" + "جاهز للتحدث" + "Subagents" + "فشل: لم يتم اكتشاف أي نقطة نهاية آمنة للـ Gateway. فعّل TLS الخاص بـ Gateway أو Tailscale Serve، أو استخدم عنوان LAN خاصًا موثوقًا مع تحديد خيار غير مشفّر." + "الإشارات" + "الجلسة المستهدفة" + "سجّل Gateway رفضًا." + "قبول" + "اسأل OpenClaw أي شيء" + "أعد الاتصال للمتابعة" + "%1$s مقترنة" + "سيؤدي هذا إلى تطبيق \"%1$s\" وتحديث حالة ورشة Skills من Gateway." + "Gateway غير متصل" + "openclaw devices list" + "حالة اتصال عقدة OpenClaw" + "تبقى التنبيهات على هذا الهاتف." + "يمكن لـ OpenClaw تلقي التنبيهات المحددة." + "فتح الشاشة" + "إجراءات الدردشة" + "السماح بالتحكم في التطبيقات الأخرى؟" + "جارٍ الفحص" + "امسح رمز الإعداد ضوئيًا أو الصقه لإضافة Gateway آخر." + "Swarm" + "انتهت مهلة TLS" + "الجلسات الأخيرة" + "تمت إزالة الجهاز المقترن." + "تم إقران Gateway. جارٍ التحقق من الموافقة على إمكانات العقدة." + "الحركة" + "فشل إجراء cron." + "على كمبيوتر Gateway، شغّل:" + "البحث في الجلسات" + "تحديث السجلات" + "الصورة غير متاحة · اضغط لإعادة المحاولة" + "openclaw nodes approve %1$s" + "ملاحظة صوتية · %1$s" + "الاستخدام" + "استكشاف النوتيلوس" + "السياق %1$s%%" + "نسخ المطالبات الصوتية" + "كتم الصوت" + "ابدأ محادثة جديدة وستظهر هنا." + "مشكلة في الاتصال" + "متوسط" + "تفريع" + "تفعيل مكبر الصوت" + "نص حدث النظام" + "الترتيب: %1$s" + "%1$s قيد الانتظار" + "Image Generation" + "ملاحظة صوتية" + "لا شيء يتطلب انتباهك" + "يحتاج OpenClaw إلى أذونات %1$s للمتابعة." + "ميكروفون سماعة الرأس السلكية" + "الصفحات" + "تم التسليم" + "مستحق" + "تفاصيل Skill غير متاحة في حالة Skills الحالية." + "اختر ما يمكن لهذا الهاتف مشاركته." + "لدى هذه المهمة الآلية تشغيل في قائمة الانتظار بالفعل." + "اتصل بـ Gateway لإدارة عمليات الأتمتة." + "لم يحن موعد التشغيل الآلي بعد." + "لا توجد تفاصيل" + "الموافقة قيد التنفيذ.\nسيعيد OpenClaw الاتصال تلقائيًا." + "وصّل Gateway لعرض جاهزية الموفرين." + "في انتظار الاقتران" + "ابدأ محادثة أو واصلها" + "لا توجد مهام مجدولة" + "الرد على OpenClaw…" + "الحالة" + "عقدة OpenClaw · متصلة" + "نشط" + "عرض حالة تصحيح مشاركة الشاشة." + "لم يتم الإبلاغ عن أي حدود" + "إغلاق الماسح" + "كل %1$s يوم" + "مفعّل" + "التمكين وفتح الإعدادات" + "متصل وجاهز" + "Ask User" + "خطأ في الدردشة" + "التمرير للأمام" + "%1$s من %2$s" + "خطّط للعمل" + "console" + "إعادة المحاولة" + "ابدأ محادثة وستظهر محادثات OpenClaw النشطة هنا." + "تعذر تحميل التشغيل الآلي." + "Shell في مساحة عمل الوكيل" + "%1$s نشط" + "اختر أذونات الجهاز" + "المدة الأخيرة" + "الوكيل الافتراضي" + "%1$sس" + "المحادثة مباشرة" + "تعذر تثبيت %1$s من ClawHub." + "مرحبًا بك في OpenClaw" + "التحكم في التطبيقات الأخرى" + "فهرس الإشارات" + "أدخل السر…" + "%1$s:%2$s" + "أخبر OpenClaw أن %1$s" + "تم الاكتشاف" + "إخفاء الشريط الجانبي" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "تشغيل الصوت غير متوفر" + "تطبيق" + "تعذّر تحميل بيانات الاستخدام." + "أبقِ العقدة متاحة أثناء العمل النشط." + "التنبيه التالي" + "%1$s/%2$s" + "لا توجد إدخالات سجلات حديثة." + "Gateway يدوي" + "إعادة تسمية المجموعة" + "Update Goal" + "توفر المزوّد غير معروف" + "مقدّمو الخدمة" + "حذف المجموعة…" + "الحمولة" + "سجل المكالمات" + "Memory Search" + "%1$s مزوّدين" + "سياق الهاتف والخصوصية" + "%1$s/%2$s متصلة" + "%1$s %2$s" + "لا تزال استعادة Gateway بعد إعادة التشغيل جارية." + "يؤدي استبدال رمز الإعداد إلى مسح بيانات اعتماد الإعداد ورموز الجهاز المحفوظة على هذا الهاتف قبل إعادة الاتصال. قد يحتاج هذا الهاتف إلى الموافقة على إمكانات العقدة مرة أخرى؛ لا تتابع إلا إذا كنت تقصد الاقتران باستخدام رمز إعداد جديد لـ Gateway." + "افتح عملية أتمتة لفحص إعداداتها وسجل تشغيلها. يمكن أيضًا للاتصالات ذات نطاق المسؤول تشغيلها أو تعديلها أو تفعيلها أو تعطيلها أو حذفها." + "السياق --" + "تم عزل المقترح." + "تم إيقاف التشغيل الآلي مؤقتًا." + "OpenClaw للجوال" + "A2UI reset" + "Gateway غير متاح" + "Read" + "تحتاج هذه المهارة إلى عنصر إعداد واحد. يعرض Android ما هو مثبّت؛ وتظل تغييرات الإعداد/التكوين على سطح المكتب أو CLI." + "آخر تشغيل" + "يلزم الوصول إلى الكاميرا لمسح QR الخاص بالإعداد ضوئيًا." + "تعذّر تحديث النموذج." + "إطلاق الفقاعات" + "thread reply" + "حذف…" + "اتصل بـ Gateway لفحص عمليات الأتمتة." + "السجلات الأخيرة" + "جارٍ تحميل عمليات التشغيل الأخيرة…" + "لا يمكن معاينة هذا الملف. قد يكون ملفًا ثنائيًا أو كبيرًا جدًا." + "تحقق" + "قراءة موقع هذا الهاتف" + "مفتاح المهارة" + "تم تثبيت %1$s." + "بوابات Gateway" + "actions: %1$s" + "وضع إعادة التوجيه" + "%1$sألف" + "تبديل تخطيط المحادثات" + "لا توجد نقطة نهاية TLS" + "بوابة OpenClaw" + "الإعداد يدويًا" + "جارٍ التفكير…" + "يتطلب الوصول إلى Gateway المراجعة" + "1 معلّق" + "%1$sث" + "تطبيق المقترح؟" + "ليس الآن" + "غير موافق عليه" + "البحث في التطبيقات" + "نموذج واحد مُعدّ" + "تجاهل إشعار الموافقة" + "·" + "غير متصل" + "مزود الكلام" + "موافقة Gateway قيد التنفيذ. سيعيد OpenClaw المحاولة تلقائيًا." + "الحد الأقصى" + "تتطلب تغييرات cron صلاحية operator.admin." + "التفكير" + "screen snapshot" + "العُقد المرصودة: %1$s" + "لم يتم العثور على إجراءات" + "حفظ والاتصال" + "list" + "سجّل Gateway الموافقة وحفظ الاختيار." + "أدخل نقطة نهاية يدوية صالحة للاتصال." + "المساعد" + "جارٍ الإرسال إلى الدردشة..." + "حفظ الملف الشخصي" + "مقفل" + "تعديل عملية الأتمتة" + "استخدم الشبكة نفسها، أو عنوان URL آمنًا لـ Gateway عن بُعد." + "المرساة" + "اللغة" + "هذا التطبيق أقدم من Gateway. حدّث OpenClaw على هذا الجهاز، ثم أعد المحاولة." + "الكل" + "جلسة Gateway قيد التنفيذ" + "في انتظار المراجعة" + "لا توجد Skills مثبتة." + "جارٍ التحقق من Gateway" + "التدرّج %1$s" + "نتيجة %1$s غير معروفة. أعِد الاتصال وحدّث Skills، ثم أعِد المحاولة؛ ينضم Gateway بأمان إلى عملية تثبيت مطابقة لا تزال قيد التشغيل." + "نسيان" + "لا توجد بوابات Gateway مقترنة." + "%1$s · %2$s" + "<سر محجوب>" + "%1$s مشكلات" + "OpenClaw" + "جارٍ الاستماع · %1$s في قائمة الانتظار" + "تم كتم صوت المساعد" + "تعمل إجراءات العُقد فقط عندما يكون التطبيق المستهدف في المقدمة (يتم التحقق عبر المسار البعيد). تعمل الإجراءات العامة وإجراءات التطبيق نفسه هنا." + "لم يتم العثور على أي بوابات بعد. استخدم الإعداد اليدوي إذا كان الاكتشاف محظورًا." + "فتح المحادثة" + "جارٍ العمل" + "ابدأ التحدث..." + "عقدة الهاتف" + "فائق الارتفاع" + "شغّل على مضيف Gateway:" + "تتطلب تغييرات المهارات operator.admin. أعِد الاتصال باستخدام رمز Gateway مميز يملك صلاحيات المسؤول." + "اتصل بـ Gateway لفحص مهارات ClawHub." + "تبقى قائمة التطبيقات على هذا الهاتف." + "خامل" + "يظهر في إعدادات إمكانية الوصول في Android." + "التسليم الذكي" + "رفض" + "أعاد Gateway الحالة \'%1$s\' بعد %2$s." + "رمز Gateway غير مكوّن" + "Not available to this agent" + "الملفات" + "الأذونات" + "تعذّر تشغيل الكاميرا. اختر صورة QR من المعرض أو أدخل رمز الإعداد يدويًا." + "اضغط للنسخ" + "في الانتظار %1$sد" + "%1$s." + "اتصل بـ Gateway لتثبيت مهارات ClawHub." + "البحث الصوتي" + " · الميكروفون: يستمع" + "أعد الاتصال بصلاحية operator.admin لمراجعة إعدادات Gateway وتغييرها." + "تحميل المزيد" + "الرصد خلال 3 ثوانٍ" + "run" + "جارٍ إنشاء الصوت…" + "← رجوع" + "قطع الاتصال" + "شغّل أمر الموافقة على كمبيوتر Gateway، ثم تحقّق مرة أخرى." + "عمليات الأتمتة" + "%1$sد" + "الوثوق" + "رمز QR هذا ليس رمز إعداد QR خاصًا بـ OpenClaw. أنشئ رمزًا جديدًا باستخدام openclaw qr، ثم حاول مرة أخرى." + "الميكروفون المفضل غير متاح؛ جارٍ استخدام التوجيه التلقائي." + "مرفوض" + "تضمين حزم Android وحزم الخلفية." + "Gateway جاهز." + "تم التشغيل" + "Structured Output" + "يستغرق هذا وقتًا أطول من المتوقع.\nتحقق من أن Gateway قيد التشغيل ويمكن الوصول إليه." + "لا توجد مهام في الخلفية لهذا الوكيل." + "جارٍ إعادة الاتصال" + "يتحقق OpenClaw من الوصول إلى Gateway والعقدة." + "Code Execution" + "لا يوجد استخدام لمزوّدين" + "مراجعة" + "يلزم منح إذن استخدام الميكروفون." + "%1$sي" + "يتوفر %1$s" + "يقوم OpenClaw بإعادة المزامنة" + "انقطع تدفق الأحداث؛ حاول التحديث." + "تعذر تحميل العُقد والأجهزة." + "وصّل Gateway لتحميل Skills." + "غير معروف" + "المخرجات" + "فشل التحدث: أُغلق موفّر الوقت الفعلي بشكل غير متوقع." + "OpenClaw حساس للوقت" + "ban" + "رمز Gateway مطلوب" + "جهاز مقترن" + "يحتاج إلى موافقة مجددًا" + "غير مجدول" + "جهات الاتصال" + "يظل هاتفك هادئًا حتى تدعو الحاجة إليه" + "يستمع · جارٍ إرسال الصوت في قائمة الانتظار" + "تعذّر تحميل تفاصيل المهمة" + "رسالة الوكيل" + "يتطلب Gateway هوية هذا الجهاز. أعد المصادقة أو أعد ضبط اتصال هذا Gateway." + "الجلسة التالية" + "أمان الاتصال" + "تخطي الآن" + "الموقع الإلكتروني" + "وصّل Gateway لتحميل طلبات الموافقة في التطبيق." + "تم نسخ %1$s" + "لم يتم تحديد أي تطبيقات. لن تتم إعادة توجيه أي شيء حتى تضيف تطبيقات." + "%1$s %2$s" + "يتطلب الإعداد" + "غير مقترن" + "تلقّى Gateway هذا الهاتف" + "لا توجد نماذج مكوّنة" + "تعطيل" + "لغة التطبيق" + "جارٍ إقران Gateway" + "المصادقة المحفوظة غير صالحة" + "%1$s نطاقات" + "وصّل Gateway لتحميل السجلات الأخيرة." + "حفظ كلمات التنبيه" + "أدِر المهارات المثبّتة وأضف الإصدارات الموثوقة من ClawHub." + "جارٍ الإرسال…" + "لم يتم تحميل أي وكلاء بعد." + "البحث في ClawHub" + "تتحقق الدردشة من سلامة Gateway." + "الاقتران مطلوب" + "عمليات التشغيل النشطة" + "فشل — %1$s" + "الاتصال بين هذا الهاتف وOpenClaw." + "summarize" + "تم حفظ صورة الأداة في التنزيلات" + "جارٍ البدء…" + "%1$s رموز مميزة" + "خطأ في العميل" + "تحقق من الجهاز الذي يطلب الوصول قبل منحه الإذن." + "ميكروفون Bluetooth LE" + "%1$s %2$s" + "تم تفعيل التشغيل الآلي." + "%1$sم" + "Memory Get" + "%1$s · %2$s" + "مؤرشف" + "إعادة التحميل" + "البحث في عمليات الأتمتة" + "ستظهر الهواتف المرتبطة ومضيفو العُقد هنا بعد الاقتران." + "%1$s: %2$s" + "تم تغيير هذه المهمة الآلية على Gateway. راجع أحدث إصدار قبل الحفظ مرة أخرى." + "إيقاف الإملاء" + "سهل القراءة" + "مراسلة OpenClaw" + "كلمة مرور Gateway غير صالحة. أعد إدخالها أو أعد ضبط اتصال هذا Gateway." + "إعادة الاتصال" + "وقت ISO، مثل 2026-07-09T09:30:00Z" + "%1$s أدوات" + "رفض رد سابق هذه الموافقة بالفعل." + "مرتبط" + "فتح %1$s" + "%1$s/%2$s" + "اكتمل تشغيل المهمة الآلية بحالة غير معروفة." + "قائمة الانتظار دون اتصال ممتلئة (%1$s من الرسائل)؛ احذف العناصر قيد الانتظار أولًا." + "تتطلب البوابات العامة wss:// أو Tailscale Serve. يُسمح باستخدام ws:// مع localhost ومضيفي .local ومحاكي Android وعناوين IP الخاصة بشبكة LAN." + "وصّل Gateway لتحميل تفاصيل Skill." + "الوصول الكامل مطلوب" + "مستمع التنبيه" + "توسيع معاينة الرابط" + "مسح البحث في المحادثات" + "NULL (فشل)" + "تحديث" + "المشرف" + "يتطلب الانتباه" + "اقرن هذا الجهاز بـ Gateway لتنشيطه فقط عند وجود عمل فعلي، والاحتفاظ بنظرة عامة مباشرة على الوكلاء في متناولك، وتجنب حلقات الخلفية التي تستنزف البطارية." + "الأدوار" + "رد" + "دليل المزوّدين" + "فعّل الإذن في الإعدادات" + "A2UI push" + "تحقق من الوصول" + "إذا كان Gateway متاحًا، فمن المفترض أن تكتمل إعادة الاتصال دون تدخل." + "دور الوكيل" + "عزل" + "تنبيه" + "جارٍ البحث…" + "من أين أحصل على رمز الإعداد؟" + "تعذّر تمكين المهارة." + "pdf" + "إزالة" + "%1$s%% متصلة" + "لا توجد قنوات" + "صوت في الوقت الفعلي" + "إجراءات الرفض والحجر الصحي في Skill Workshop" + "العُقد والأجهزة" + "مركز الأوامر المحلي" + "emoji upload" + "جارٍ تحميل المعاينة…" + "مرتفع" + "focus" + "describe" + "سياق %1$s" + "جارٍ الاستماع إلى الرد..." + "voice" + "متصل بـ %1$s" + "role add" + "تحتاج المحادثة إلى انتباه" + "تفعيل الميكروفون" + "يجمع OpenClaw ويرسل أسماء التطبيقات ومعرّفات الحزم وحالتها الظاهرة على هذا الهاتف عندما يطلبها Gateway المقترن بـ OpenClaw. يتيح ذلك لمساعدك الإجابة عن الأسئلة واتخاذ الإجراءات باستخدام التطبيقات المثبَّتة." + "Gateway غير متصل" + "السياسة" + "انتهت مهلة تأكيد الرسالة المُرسلة؛ حدّث الصفحة للتحقق من التسليم." + "ملفات الدعم" + "التعبير" + "المهام في الخلفية" + "حلم" + "لا توجد تطبيقات محظورة. يمكن للتطبيقات إعادة التوجيه ما لم تضف عمليات حظر." + "أداة التعرّف على الكلام غير متاحة" + "المنصة" + "لم يُرجع Gateway إعداد %1$s" + "هل تريد نسيان Gateway؟" + "وصف اختياري" + "فتح %1$s" + "لوحة الصفحة الرئيسية" + "يحلم" + "%1$s إلى %2$s" + "مشاركة الملف" + "في الوقت الفعلي" + "API" + "OpenClaw يعمل…" + "تحدّث أو أملِ باستخدام OpenClaw" + "مشاركة معلومات التطبيقات المثبَّتة؟" + "جارٍ تحميل الأتمتة…" + "حذف الأتمتة" + "المساعد الافتراضي" + "اختر موفّر %1$s مدعومًا على Gateway" + "غير متاح" + "مجلد فارغ" + "فتح الإعدادات" + "إيقاف" + "أسلوب الكتابة" + "إيقاف" + "لا توجد محادثات مطابقة حتى الآن." + "نجح إقران Gateway.\nوافق على إمكانات عقدة هذا الهاتف من واجهة مستخدم للمشغّل." + "هذه المهارة مثبّتة، لكنها غير مؤهلة للتشغيل حاليًا. استخدم سطح المكتب أو CLI لإجراء تغييرات على الإعدادات." + "أداة التعرّف مشغولة" + "Gateway المنزلي" + "شغّل أمر الموافقة على Gateway" + "الخدمة معطّلة" + "تعذر تحميل مقترحات ورشة عمل Skills." + "أطلعني على آخر مستجدات سلاسل محادثاتي الأخيرة في OpenClaw واقترح الخطوات التالية." + "ليس الآن" + "رمز QR لـ OpenClaw" + "start" + "عقدة OpenClaw · المحادثة" + "قراءة الأحداث وتحديثها" + "فشل التحدث: أُغلق موفّر الوقت الفعلي: %1$s" + "وصّل Gateway لتصفح ملفات مساحة العمل." + "%1$s عبر ترحيل Gateway" + "تعذّر تحميل كتالوج محادثات Gateway" + "جارٍ الرصد · مهمة مجدولة واحدة" + "كل %1$s ساعة" + "سطح الشاشة" + "ترجمات OpenClaw · %1$s" + "طلب أمر" + "محدّث" + "القناة" + "إلغاء كتم الصوت" + "مجموعة جديدة…" + "جارٍ تحضير الصوت…" + "تكيفي" + "قريبًا" + "%1$s عامل إضافي" + "Web Search" + "جرّب الدردشة أو الصوت أو المحادثات أو المزوّدين أو الإعدادات." + "OpenClaw نشط" + "navigate" + "طُلب %1$s" + "اتصل بـ Gateway لفحص سجل تشغيل عمليات الأتمتة." + "الوصول إلى الجهاز؛ لا تزال موافقة Gateway مطلوبة" + "تم الإلغاء" + "أدخل رمز إعداد صالحًا أو عنوان Gateway." + "النماذج" + "OpenClaw سلبي" + "كلمة مرور Gateway غير صالحة" + "تعذر التحقق من تغيير اقتران الجهاز. حدّث الصفحة وحاول مرة أخرى." + "عرض التفاصيل" + "Bash" + "الرمز المميز" + "يمكن لوكيل OpenClaw المتصل استخدام إمكانات الجهاز التي تفعّلها. تابع فقط إذا كنت تثق بـ Gateway والوكيل الذي تتصل به." + "جمع البرنقيل" + "تم منح الوصول إلى الصور المحددة أو الوصول الكامل إلى الصور." + "منفّذ إمكانية الوصول" + "%1$s من العناصر المفقودة" + "طي قائمة التحقق الخاصة بالخطة" + "موافقة العقدة مطلوبة" + "توصيل Gateway" + "... +%1$s أخرى" + "توسيع قائمة التحقق الخاصة بالخطة" + "المتصفح" + "screen record" + "التشغيل معلّق" + "يتيح التمكين لـ OpenClaw مراقبة شاشات التطبيقات الأخرى والتحكم بها عند التفعيل. يلزم منح إذن إمكانية الوصول في Android." + "المصدر" + "ذكاء اصطناعي شخصي على أجهزتك" + "Attach" + "تلقائي" + "نظرة عامة" + "فشل طلب الاستعادة. اضغط لإعادة المحاولة." + "فيديو" + "%1$s\n\n" + "غير مشفّر" + "التقويم" + "حالة Gateway ليست جيدة؛ لا يمكن الإرسال" + "📎 %1$s" + "الحالة الأخيرة" + "انتظر حتى انتهاء الرد الحالي قبل بدء محادثة جديدة." + "الملف الشخصي" + "ستظهر حدود المزوّد هنا عندما يبلّغ عنها Gateway." + "مشكلة واحدة" + "يتم الاحتفاظ بالمحادثات في \"%1$s\" ونقلها مجددًا إلى غير مجمّعة." + "موصى به" + "تاريخ الإنشاء" + "%1$s/%2$s رموز مميزة نشطة" + "لا توجد نتيجة إجراء" + "الالتقاط" + "%1$s…" + "فتح تفاصيل المهارة" + "فشل النطق: %1$s" + "بدء المحادثة" + "تعذر تحميل هذا المجلد." + "لم يحتوِ رمز QR على رمز إعداد صالح." + "راجع صلاحيات الوصول إلى العقدة" + "إضافة عبارة تنبيه" + "تعذّر الوصول إلى Gateway" + "الأتمتة" + "يحتاج إلى اتصال" + "تعذّر البت في الموافقة. حدّث وحاول مرة أخرى." + "import" + "كيف يظهر هذا الهاتف في OpenClaw." + "التركيز على البحث في المحادثات" + "توصيل Gateway" + "قراءة التقويم" + "يتم تحديث النظرة العامة عند إعادة الاتصال وعند فتح هذه الشاشة." + "تعذّر تعطيل المهارة." + "لا يزال الاتصال جاريًا" + "خلال %1$s د" + "قراءة SMS" + "وصّل Gateway لتحميل الاستخدام." + "ما الذي يمكنك مساعدتي في فعله من هذا الهاتف الآن؟" + "تحتاج إلى موافقة" + "دردشة جديدة" + "اتصل بـ Gateway لتحديث مقترحات ورشة عمل Skills." + "فشل طلب OpenClaw." + "الإذن مطلوب" + "راجع جاهزية الموفرين\nوالنماذج المكوّنة." + "جارٍ التحميل" + "تنبيه الفشل" + "السمة ونص Android المترجم." + "الميكروفون متوقف · جارٍ الإرسال…" + "لا شيء" + "عرض" + "الاسم" + "الإصدار" + "Cron" + "اربط هذا الهاتف بـ Gateway قبل فتح OpenClaw." + "إزالة عبارة التنبيه" + "لم يتم قبول رمز الإعداد. أنشئ رمزًا جديدًا باستخدام openclaw qr." + "14 رسالة · Android" + "فشل النسخ: %1$s" + "دائمًا" + "تعذر تحميل الأحلام." + "تمت إضافة تشغيل المهمة الآلية إلى قائمة الانتظار." + "Conversation Turn" + "بدأ التشغيل الآلي." + "مجموعة جديدة" + "خطأ في الخادم" + "Video Generation" + "موافقة Gateway معلّقة. شغّل openclaw devices list على مضيف Gateway، ووافق على هذا الهاتف، ثم أعد المحاولة." + "تظهر الإدخالات بعد أن تكتب دورة الأحلام ملخصًا سرديًا." + "%1$s مللي ثانية" + "مخزن الذاكرة" + "المساعد يعمل" + "يمكن لـ OpenClaw عرض قائمة التطبيقات الظاهرة في المشغّل." + "فشل التحدث: %1$s" + "البحث في Skills المثبتة" + "فحص" + "Process" + "سلاسل المحادثات الأخيرة" + "الطرفية" + "الحالية" + "حساب واحد" + "متوقف مؤقتًا" + "السماح بالكاميرا" + "ستظهر طلبات الموافقة على Exec هنا أثناء اتصال هذا الهاتف." + " · الميكروفون: قيد الانتظار" + "نسخ" + "تم نسخ التفاصيل" + "حذف" + "اطلب من OpenClaw استخدام إمكانات Android." + "member" + "جارٍ التحقق مما إذا كان هذا Gateway يدعم مساعد إعدادات OpenClaw." + "استخدم خيارات الاسترداد أدناه لإعادة الاتصال." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "تعذر تحميل القنوات." + "خلال %1$s يوم" + "الأخطاء المتتالية" + "تعذّرت قراءة رمز QR من هذه الصورة. اختر صورة أوضح أو أدخل رمز الإعداد يدويًا." + "إصدار Gateway أقدم من هذا التطبيق. حدّث OpenClaw على مضيف Gateway، ثم أعد المحاولة." + "اتصل قبل استخدام الدردشة والصوت والحالة المباشرة." + "إعادة الاتصال بـ Gateway" + "جهة خارجية" + "مراجعة الجاهزية" + "محدود" + "شعار OpenClaw" + "إلغاء تثبيت النموذج" + "أسطح المراسلة المتصلة بهذا Gateway." + "جارٍ الإرسال" + "ستظهر المحادثات المؤرشفة هنا." + "تم نسخ الأمر" + "لا تتوفر معاينة" + "وافق على هذا الهاتف في Gateway.\nثم أعد محاولة الاتصال." + "مسح رمز QR ضوئيًا" + "دليل عمل الأمر · لا يمكن مسحه" + "نشاط المحادثة" + "متاح" + "حذف عملية الأتمتة؟" + "%1$s اليوم · %2$s إجمالًا" + "كلمة المرور" + "عزل المقترح؟" + "لا توجد إشعارات تراخيص مضمّنة في هذا الإصدار." + "تعذّر حفظ صورة الأداة" + "قيد الانتظار %1$s" + "يتحدث…" + "المزوّدون والنماذج" + "العقدة" + "%1$s " + "الموجّه غير متاح" + "السجلات" + "اتصل بـ Gateway لفحص مقترحات ورشة عمل Skills." + "الأدوات" + "مفتاح تبديل Gateway" + "إرسال SMS" + "OpenClaw جاهز للمتابعة في دردشتك العادية." + "لم يتم العثور على أوامر" + "لا يوجد تحديث للوحة الرسم بعد. اضغط لإعادة المحاولة." + "تحتاج الطرفية إلى Gateway متصل" + "Exec" + "عامل تصفية التطبيقات" + "الرئيسي" + "%1$sk" + "Gateway مطلوب" + "الوصول" + "الحِزم: snapshot=%1$s foreground=%2$s" + "إعادة محاولة الاتصال" + "برنامج جدولة Cron متوقف." + "فتح" + "تم نسخ الرسالة" + "تعذر علينا الوصول إلى Gateway.\nلنعالج هذه المشكلة." + "الآن" + "حذف بعد التشغيل" + "محدّد على هذا الهاتف" + "unpin" + "Session History" + "إلغاء التثبيت" + "استخدم هذا الهاتف" + "تعذر تحميل تفاصيل ClawHub لـ %1$s." + "الأدوات قيد التشغيل" + "مشاركة الموقع الدقيق أثناء تفعيل الموقع." + "Mobile UI" + "السمة" + "لا يزال Gateway يعرض هذه الموافقة على أنها معلّقة. راجعها قبل المحاولة مرة أخرى." + "إنهاء الملاحظة الصوتية" + "الإملاء: %1$s" + "غير مسموح" + "اختر صورة أخرى" + "معاينة الصورة" + "لا يستمع OpenClaw إلا عندما تبدأ المحادثة أو الإملاء." + "مشاركة الخطوات والنشاط" + "يتطلب الإعداد" + "حدّث هذا Gateway لاستخدام مساعد إعدادات OpenClaw." + "يتطلب اتصال Gateway هذا صلاحية operator.admin لتثبيت مهارات ClawHub." + "تم تطبيق المقترح." + "%1$s قيد الانتظار" + "قبل %1$s س" + "قراءة سجل المكالمات" + "%1$s في قائمة الانتظار · بانتظار Gateway" + "نقل إلى مجموعة" + "امسح رمز QR للإقران" + "تم رفض الموافقة." + "تعذر فحص مقترح ورشة عمل Skills." + "مثبّت" + "الملف الشخصي والجهاز" + "إغلاق محدد مستوى التفكير" + "تعذّرت إضافة الرسالة إلى قائمة الانتظار لإرسالها لاحقًا." + "حجر صحي" + "الجدولة · %1$s" + "تعذّر تحديث مستوى التفكير." + "فتح محدد مستوى التفكير" + "انتهت مهلة الرد الصوتي؛ جارٍ إعادة محاولة الدور في قائمة الانتظار" + "التخطيط: مفصّل" + "تعذر فك ترميز هذه الصورة." + "Gateway والصوت والإشعارات والخصوصية" + "ملفات مساحة عمل الوكيل" + "سيفقد هذا الجهاز وصوله الموثوق إلى Gateway." + "استخدم requestId من الأمر المعلّق في أمر الموافقة." + "الجدول الزمني" + "حد المعدل" + "لم يتم التسليم" + "الحمولة · %1$s" + "قيد التشغيل" + "استخدام المخالب" + "إنهاء" + "استخدام ثقة النظام" + "لا يوجد مزوّدون جاهزون" + "يعطي الأولوية لميكروفونات Bluetooth المتصلة." + "تم حظر %1$s تطبيق من إعادة التوجيه." + "إجراءات الرسالة" + "النوع" + "إلغاء الأرشفة" + "Transcripts" + "كلمات التنبيه" + "قم بتكوين %1$s على Gateway" + "امسح رمز QR ضوئيًا أو استخدم رمز الإعداد من OpenClaw Gateway لديك." + "نموذج أولي لنظام التصميم" + "الغربلة" + " · التحدث: مفعّل" + "لا توجد بيانات استخدام بعد." + "فشلت المحادثة قبل بدء التشغيل؛ حاول مرة أخرى." + "إرسال" + "تم تجاهل بعض الصور المشتركة أو تعذّرت إضافتها." + "كتابة التقويم" + "timeout" + "منخفض" + "قائمة الحظر" + "act" + "Dismiss Task" + "فشلت المحادثة" + "OpenClaw · مباشر" + "المثبتة" + "انتهت مهلة انتظار الرد؛ حاول مجددًا أو حدّث الصفحة." + "العثور على المحادثات السابقة" + "تصفّح المحادثات" + "جارٍ التحديث" + "جمع اللؤلؤ" + "افتح الكاميرا وضع الرمز من openclaw qr داخل الإطار." + "لا توجد أجهزة" + "إعادة توجيه الإشعارات" + "سأبقي هذه المحادثة منفصلة عن الدردشة العادية مع الوكيل." + "تعود جلسة Gateway إلى الاتصال بالإنترنت. يُفترض أن تستقر اختصارات الوكلاء تلقائيًا بعد لحظات." + "جرّب بحثًا مختلفًا أو امسح الاستعلام الحالي." + "السماح بالموقع في الخلفية؟" + "الصعود إلى السطح" + "التمهيد" + "%1$s · %2$s · %3$s" + "إلغاء الملاحظة الصوتية" + "التمرير للخلف" + "openclaw gateway" + "تم إقران Gateway" + "الانسلاخ" + "نستمع إلى دورك التالي." + "OpenClaw يعمل" + "إدخال سجل" + "فشل: تعذر الوصول إلى نقطة نهاية البوابة الآمنة لهذا المضيف." + "Gateway غير متصل. أصلح الاتصال أدناه أو انسخ معلومات التشخيص." + "وضع الاستعداد" + "اختبار اختبار 1 2 3" + "تعذر البحث عن مهارات ClawHub." + "لا توجد مطالبة" + "الكاميرا الأمامية" + "فتح إدخال السجل" + "انتهت مهلة الشبكة" + "الآن" + "إعادة تسمية المجموعة…" + "المزيد من الوكلاء" + "openclaw nodes approve REQUEST_ID" + "تثبيت" + "thread list" + "فتح %1$s" + "upload" + "كلمة مرور Gateway غير مكوّنة" + "إعدادات الإملاء" + "تم تحميل نماذج موفّر الخدمة، لكن حالة الجاهزية غير متاحة." + "حذف المحادثة؟" + "يحوّل OpenClaw هذا الهاتف إلى واجهة أوامر محمولة ومنظّمة لسلاسل المحادثات والصوت ومزوّدي الخدمة وGateway." + "الأحدث أولاً" + "الدورة التالية" + diff --git a/app/src/main/res/values-de/assistant.xml b/app/src/main/res/values-de/assistant.xml new file mode 100644 index 0000000..7c5a7f9 --- /dev/null +++ b/app/src/main/res/values-de/assistant.xml @@ -0,0 +1,7 @@ + + + "OpenClaw %1$s fragen" + "OpenClaw anweisen, %1$s" + "OpenClaw öffnen und %1$s fragen" + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..484a160 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Diesem Gateway vertrauen? + Vertrauen und fortfahren + Abbrechen + Neuer Chat im Worktree + Überprüfen Sie den Zertifikatfingerabdruck, bevor Sie diesem Gateway vertrauen.\n\n%1$s + Das Gateway-Zertifikat wurde geändert. Fahren Sie nur fort, wenn Sie diese Änderung erwartet haben.\n\nAlter SHA-256-Wert:\n%1$s\n\nNeuer SHA-256-Wert:\n%2$s + Unbekannt + VERSION + COMMIT + ERSTELLT + Version %1$s + Git-Commit %1$s + Erstellt am %1$s UTC, Zeitstempel %2$s + Build-Datum %1$s + Vollständigen Git-Commit-Hash kopieren + Vollständigen Build-Zeitstempel kopieren + Git-Commit von OpenClaw + Build-Zeitstempel von OpenClaw + Git-Commit kopiert + Build-Zeitstempel kopiert + + "Ein Anhang konnte nicht für den Versand vorbereitet werden." + "Mikrofon aus" + "OpenClaw-Warnmeldungen anzeigen" + "Thread-Aktivität" + "Vollständig" + "Genehmigung erteilt und gespeichert." + "Letzte Anrufe anzeigen" + "1 ausstehend" + "Nicht unterstützter Anhang" + "0 = exakt" + "Verbinde das Gateway, um Threads zu durchsuchen." + "%1$s Konten" + "Cron-Änderungen erfordern operator.admin. Einrichtungscodes gewähren diese Berechtigung absichtlich nicht. Stellen Sie die Verbindung mit dem gemeinsamen Token oder Passwort des Gateways wieder her, um Administratorzugriff anzufordern. Wenn dieses Gerät weiterhin nicht darüber verfügt, genehmigen Sie die ausstehende Bereichserweiterung über einen vorhandenen Administrator-Client." + "Apply Patch" + "Kneifen" + "Lautsprecher einschalten" + "Aufeinanderfolgende Überspringungen" + "Dieser Ordner enthält noch keine Dateien." + "Nicht verbunden" + "Status installierter Skills prüfen und verwalten." + "Fehlgeschlagen" + "Standard-Agent" + "Kamera" + "Aus Gruppe entfernen" + "Suche läuft" + "Für Sprachwiedergabe pausiert" + "Das Gateway überprüft vor dem Download genau diese Version mit ClawHub. Wenn für die Version eine ausdrückliche Risikobestätigung erforderlich ist, zeigt Android vor dem erneuten Versuch die Gateway-Warnung an." + "Der Einrichtungscode verwendet eine IPv6-Zonen-ID. Verwende eine IPv6-Adresse ohne Bereichsangabe oder einen LAN-Hostnamen." + "Anhang" + "Aktivierungswörter, Spracheingabe und Wiedergabe konfigurieren." + "Hört zu (PTT)" + "Vorschlag abgelehnt." + "Seitenleiste anzeigen" + "Benutzer" + "%1$s · %2$s" + "Minimal" + "Ablehnen" + "AKTIVER AGENT" + "1 geplant" + "Keine Antwort" + "%1$s ausgewählt" + "Befehl-argv-JSON-Array" + "Dieses Bild konnte nicht gelesen werden. Wähle einen deutlichen Screenshot oder ein deutliches Bild des QR-Codes aus openclaw qr." + "Der Skill-Workshop-Vorschlag konnte nicht mit der Aktion %1$s bearbeitet werden." + "Anderswo beantwortet" + "Gateway hat die Genehmigung einmalig erfasst." + "status" + "OpenClaw überprüft den Standort nur, wenn Ihr gekoppeltes Gateway ihn anfordert. Wählen Sie auf dem nächsten Android-Bildschirm %1$s aus, um Überprüfungen zuzulassen, während die App im Hintergrund ausgeführt wird." + "ablehnen" + "Kontrast" + "Gateway-Einrichtung ersetzen?" + "Automationen konnten nicht geladen werden." + "Du" + "Integriertes Mikrofon" + "Oberfläche" + "Keine Vorschläge" + "Haupt-Thread" + "Chat öffnen" + "Aktionen zur Gerätekopplung sind in dieser Gateway-Sitzung nicht verfügbar. Führen Sie openclaw devices list auf dem Gateway-Host aus und verwalten Sie die Anfrage dort. Die Genehmigung von Node-Funktionen erfolgt separat und weiterhin mit nodes approve <request id>." + "Aktionsanfrage" + "list pins" + "Verbinde dich mit einem Gateway, um Skill-Workshop-Vorschläge zu laden." + "Einrichtungscode wurde nicht akzeptiert" + "Abmelden" + "Der Anbieter für Echtzeittranskription ist nicht konfiguriert." + "System-Apps anzeigen" + "Aktualisieren Sie Ihr Gateway, um die Modellkonfiguration des Anbieters anzuzeigen." + "Diktat wird gesendet" + "Prüfen Sie diesen Vorschlag, um das zugehörige Markdown zu laden." + "OpenClaw öffnen und %1$s fragen" + "Schlussfolgern" + "Client" + "Angewendet" + "Video" + "Hervorgehoben" + "Online" + "Berechtigungsbereiche" + "Der Echtzeit-Sprachanbieter ist nicht konfiguriert." + "%1$s · %2$s" + "kick" + "Das Gateway hat eine ungültige Automation zurückgegeben." + "Instanz-ID" + "Gateway-Token ist erforderlich. Geben Sie ihn erneut ein oder bearbeiten Sie diese Verbindung." + "Quelle" + "Aktualisieren" + "%1$s in der Warteschlange" + "Chat starten" + "Aufrufe von Chat-Tools, die im aktiven Thread warten, bleiben hier sichtbar." + "Zertifikatsprüfung erforderlich" + "Öffne die aktuelle Canvas-Oberfläche, um sie zu untersuchen oder mit ihr zu interagieren." + "Die Automation wurde aktualisiert." + "Keine letzten Sitzungen" + "Skript" + "Gateway-Status, Bereitschaft des Telefonknotens und aktueller Log-Stream." + "Automatisierungsdetails öffnen" + "Laufzeit" + "1 weiterer Worker" + "Agent %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Bitte aktivieren Sie %1$s in den Android-Einstellungen, um fortzufahren." + "Reparieren" + "reactions" + "Fertig" + "Version und Update" + "OpenClaw zeigt hier Genehmigungen, fehlgeschlagene Aufträge und Kanalprobleme an." + "USB-Mikrofon" + "Zu viele Freigaben warten darauf, hinzugefügt zu werden." + "Übersprungen" + "Verwenden Sie die LAN-Adresse des Gateway-Computers oder einen sicheren Remote-Hostnamen." + "Ein" + "Threads werden durchsucht" + "Echtzeitgespräch" + "· %1$s" + "OpenClaw bereitet eine Antwort vor." + "Genehmigung einmalig erteilt." + "Anbieter einrichten" + "keine" + "Skript-Payloads bleiben unverändert erhalten. Verwenden Sie die CLI, um dieses Skript zu bearbeiten." + "Android-Einrichtungsanleitung" + "%1$s Apps sind für die Weiterleitung blockiert." + "Gekoppelte Geräte" + ":%1$s" + "%1$s ausstehend" + "Gerätename" + "Absenden" + "Standort" + "z. B. America/New_York" + "Sitzungsziel" + "ClawHub-Skill prüfen" + "snapshot" + "Die Kopplungsanfrage dieses Geräts ablehnen?" + "Bevorzugtes Mikrofon" + "Node-Host" + "Stufe" + "App-Auswahl schließen" + "Fügen Sie einen geteilten Gateway-Token oder einen vom Operator ausgestellten Token ein." + "Alle Systeme funktionieren ordnungsgemäß" + "Gateway-Diagnose kopiert" + "Audiofehler" + "Einrichtung ersetzen" + "Schnellaktionen" + "Senden fehlgeschlagen: Der Chat ist vor Beginn der Ausführung fehlgeschlagen; versuchen Sie es erneut." + "Mikrofon" + "Der Chat prüft noch den Status des Gateways." + "Genauer Standort" + "Einmal erlauben" + "+%1$s weitere" + "thread create" + "Blockiert" + "Aktivierungswort oder -phrase" + "Gateway benötigt eine Gerätefreigabe" + "Externes Mikrofon" + "%1$s/%2$s bereit" + "Verbunden (Operator offline)" + "Funktion nicht genehmigt" + "Dadurch werden die Automatisierung und ihr Zeitplan dauerhaft vom Gateway entfernt." + "Bild wird geladen…" + "Verbinden" + "Node-Zugriff genehmigen" + "Gateway hinzufügen" + "Transkription nicht verfügbar: %1$s" + "Bild" + "Fluten" + "Bildvorschau schließen" + "eval" + "Letzter Befehl: %1$s" + "Halten Sie ein Terminal auf dem Gerät geöffnet, auf dem OpenClaw ausgeführt wird." + "Keine fehlenden Elemente" + "Die Canvas-Ausgabe erfordert eine aktive Gateway-Verbindung." + "%1$s · %2$s" + "Isoliert" + "© 2026 OpenClaw Foundation — MIT-Lizenz." + "PDF" + "Conversations" + "Speicherkonsolidierung und Traumtagebuch." + "Create Goal" + "Diese Automatisierung wurde während der Bearbeitung geändert. Setze sie vor dem Speichern auf die neueste Gateway-Version zurück." + "Wenn eine Verbindung besteht, kann das Gateway das Smartphone mit einer stillen Push-Benachrichtigung aktivieren, anstatt eine ständig aktive Sitzung aufrechtzuerhalten." + "Aufwachmodus" + "Gekoppeltes Gerät entfernen?" + "Systemereignistext" + "Widget-Bild konnte nicht kopiert werden" + "Nein" + "Optionaler Pfad" + "Sendet Sprachnachricht in Warteschlange" + "Integriert" + "hide" + "runs" + "Gateway-Passwort ist erforderlich. Geben Sie es erneut ein oder bearbeiten Sie diese Verbindung." + "Ereignistext" + "Live-Transkript" + "Die Modellkonfiguration des Anbieters konnte nicht geladen werden." + "%1$s App darf weiterleiten." + "Spracheinrichtung" + "Video anhängen" + "Weitere Bilder ausgeblendet: %1$s" + "Kopplungsanfrage ablehnen?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Herausgeber" + "Von hier abzweigen" + " · Gespräch: Spricht" + "Optionale Überschreibung" + "Standard" + "Befehlsgenehmigung" + "Die App und das Gateway verwenden inkompatible Protokollversionen. Aktualisieren Sie OpenClaw auf beiden und versuchen Sie es erneut." + "Bildschirm aktualisieren" + "Auf aktuelle Fotos und Medien zugreifen" + "Zuhören" + "Verbunden" + "Abgeschlossen" + "Dein Smartphone ist mit %1$s gekoppelt. Fahre fort, um den Node-Zugriff abzuschließen." + "Aktueller Thread" + "Denkmodus %1$s" + "Stummgeschaltet" + "OpenClaw dankt seinen Partnern in der Open-Source-Community." + "offen" + "Verbindung zum Gateway wird hergestellt" + "Das Gateway kann diesen Pfad ändern, aber einen vorhandenen Pfad nicht löschen." + "TTS" + "Speichern…" + "Anker %1$s" + "search" + "Aktivieren" + "Geplante Gateway-Aufgaben prüfen und verwalten." + "Eine frühere Antwort hat diesen Befehl bereits einmalig erlaubt." + "generate" + "Nur in einem vertrauenswürdigen privaten Netzwerk verwenden." + "Einstellungen suchen" + "Talk ist aktiv" + "Gateway-Authentifizierung ist nicht konfiguriert. Bearbeite diese Verbindung und versuche es erneut." + "Fehlgeschlagen: Sicherer Endpunkt erreicht, aber die TLS-Fingerabdruckprüfung ist abgelaufen. Überprüfen Sie Tailscale Serve oder Gateway-TLS und versuchen Sie es erneut." + "Schritt 1" + "Diktat" + "App-Auswahl öffnen" + "Keine ausstehenden Genehmigungen" + "edit" + "Mit deiner Gateway verbinden" + "Gib den Einrichtungscode aus openclaw qr ein." + "Diagnose" + "Andere Apps bleiben unberührt." + "Knacken" + "Dadurch werden der Thread und sein Transkript dauerhaft gelöscht." + "Gerät genehmigt." + "Automatischer erneuter Versuch" + "Widget-Bild kopiert" + "%1$s Rollen" + "1 fehlendes Element" + "%1$s geplant" + "react" + "Agenten" + "Verbinde das Gateway, um Automatisierungen zu laden." + "Verbindung wird wiederhergestellt…" + "Zurück zur Einrichtung" + "send" + "Verbindung konnte nicht getestet werden" + "Überprüfen und installieren" + "Verwandeln Sie dieses Gerät in einen sicheren OpenClaw-Knoten für Chat, Sprache, Kamera und Gerätetools." + "Manuelle Einrichtung" + "Öffne den Chat, um den aktuellen Thread zu starten oder fortzusetzen." + "Tipp: Beenden Sie das Zuhören, um den erfassten Beitrag zu senden." + "Überspringen" + "Sprachanfrage fehlgeschlagen" + "Dadurch wird \"%1$s\" abgelehnt und der Status von Skill Workshop über das Gateway aktualisiert." + "Panzern" + "update" + "Teilen" + "Kamera aktiviert" + "Telegram, WhatsApp, E-Mail und andere Kanäle werden nach der Einrichtung hier angezeigt." + "Netzwerkfehler" + "Gezeitentümpel erkunden" + "Canvas jetzt für session=%1$s source=%2$s wiederherstellen. Falls bereits ein A2UI-Status vorhanden ist, diesen sofort wiedergeben. Andernfalls ein kompaktes, für Mobilgeräte optimiertes Dashboard in Canvas erstellen und rendern." + "Start fehlgeschlagen: %1$s" + "Nicht angefordert" + "Konfigurieren Sie einen Anbieter für %1$s auf dem Gateway" + "kill" + "Genehmigungen" + "Dateien nicht verfügbar" + "Als ungelesen markieren" + "Personen und Kontaktdaten finden" + "Geräteidentität erforderlich" + "OpenClaw-Thread" + "Zugriff auf die Fotomediathek erlauben." + "Eine frühere Antwort hat diese Genehmigung bereits bearbeitet." + "Keine aktuellen Threads" + "Zeitüberschreitung %1$ss" + "Keine Übereinstimmungen" + "Benachrichtigungen ausgewählter Apps lesen" + "Verfügbarkeit unbekannt" + "Talk einrichten" + "Zusätzlich" + "Gateway gekoppelt. Warten auf Operatorzugriff." + "Bild anhängen" + "Wählen Sie aus, was OpenClaw erreicht." + "Erneute Genehmigung der Funktion ausstehend" + "Hervorgehobene Elemente überprüfen" + "Hört zu..." + "Auf den neuesten Stand bringen" + "Nachricht" + "Kontakte lesen" + "Der Offline-Anhangspeicher ist voll; lösche zuerst Elemente in der Warteschlange." + "Einmalig" + "Umbenennen" + "Keine Kanäle gefunden." + "Alle anzeigen" + "Neues Gerät" + "Session Status" + "Bildvorschau öffnen" + "Sitzungszweig geändert; überprüfen und diese Nachricht erneut versuchen." + "close" + "Das sieht nach einem Einrichtungscode aus. Gehe zurück, wähle „Gateway einrichten“ und dann „Einrichtungscode verwenden“." + "✦" + "Agenten & Automatisierung" + "Anwenden" + "Die Ausführung der Automation wurde übersprungen." + "Fortfahren" + "Überwachung · %1$s geplante Aufträge" + "Durchsuchen" + "tabs" + "Ausstehend" + "Sprechen: %1$s" + "read" + "Text auswählen" + "Bewegungsaktivität" + "description: %1$s" + "Audio abspielen" + "Zeit" + "Nicht verifiziert" + "Yield" + "Genehmigungsbefehl kopieren" + "Aktuelle Bildschirmausgabe und interaktive App-Oberfläche." + "Dienst verbunden" + "Anzeige" + "Bereit, wenn du es bist" + "Anbieterkatalog konnte nicht geladen werden." + "Spricht · wartet auf Antwort" + "Nicht erteilt" + "Änderungen speichern" + "Das Gateway hat die Ausführung der Automation abgelehnt." + "Session Send" + "Auf ClawHub suchen" + "Erlaubt angeforderte Standortprüfungen immer, während OpenClaw im Hintergrund ausgeführt wird; Android zeigt dies in der dauerhaften Knotenbenachrichtigung an." + "Systemereignis" + "Gateway verbinden, um Anbieter anzuzeigen" + "Nächster Heartbeat" + "Gateway gekoppelt. Warten auf Genehmigung der Node-Funktion." + "Pökeln" + "Canvas schließen" + "Kontakte schreiben" + "Keine installierten Skills entsprechen dieser Suche." + "Einrichtung des Sprachanbieters" + "Music Generation" + "Gesprächseinstellungen" + "Überwachung · 1 Thread" + "Payload-Text" + "Text festlegen" + "Genehmigung %1$s" + "Gateway hat die Bereitschaft von %1$s nicht zurückgegeben" + "%1$s Modelle konfiguriert. Aktualisieren Sie, um die Verfügbarkeit erneut zu prüfen." + "Conversation Send" + "Canvas" + "1 Anbieter" + "Das Gateway-Zertifikat konnte nicht automatisch gelesen werden. Fügen Sie den auf dem Gateway-Host ermittelten SHA-256-Fingerabdruck ein." + "Senden fehlgeschlagen: %1$s" + "Bridge" + "Zustellungsfehler" + "OpenClaw von deinem Smartphone verwenden" + "Darstellung" + "Skill-Workshop" + "Token erforderlich" + "Vorschau · %1$s" + "Mikrofonberechtigung erforderlich" + "Verbinden Sie das Gateway, um Vorschläge aus Skill Workshop zu laden." + "Alle Systeme betriebsbereit" + "Gateway nicht erreichbar" + "OC" + "Aktualisiert" + "Verbunden (Knoten offline)" + "Startseite" + "Diktierfunktion hört zu" + "Keine archivierten Threads" + "Wählen Sie die auf dieser Gateway verfügbaren Assistenten aus und prüfen Sie sie." + "Sprechmodus aktiv" + "In Bearbeitung · 1 aktiver Lauf" + "Zustimmen und aktivieren" + "Gateway-Update erforderlich" + "Bild kopieren" + "Gateway-URL" + "main, isolated, current oder session:<id>" + "Medien nicht verfügbar" + "Verbinden Sie sich mit Ihrer Gateway, um eine Shell im Agent-Arbeitsbereich zu öffnen." + "%1$s://%2$s:%3$s" + "Genehmigungsdetails konnten nicht geladen werden. Aktualisieren Sie die Ansicht und versuchen Sie es erneut." + "Ich kann den Gateway-Status prüfen, die Konfiguration reparieren, Modelle wechseln oder Kanäle verbinden." + "Tool Call" + "Threads" + "Write" + "Beginne mit einer Eingabe oder nutze die Spracheingabe." + "T" + "Einstellungen öffnen" + "Wird beobachtet…" + "Gespräch beenden" + "Letzter Fehler" + "Überprüfen Sie Aktionen, die Ihre Aufmerksamkeit erfordern." + "Für alle Agenten deaktiviert." + "Sprachmodus starten" + "Zurück zu den Hintergrundaufgaben" + "Eine andere Cron-Aktion wird noch abgeschlossen." + "Abklingzeit %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Die Spracherkennung auf dem Gerät ist nicht verfügbar." + "Skript · schreibgeschützt" + "Die Anhänge sind zu groß, um sie für eine einzelne Nachricht in die Warteschlange zu stellen. Entfernen Sie einige und versuchen Sie es erneut." + "1 Modell konfiguriert. Aktualisieren Sie, um die Verfügbarkeit erneut zu prüfen." + "Widget nicht verfügbar" + "Für diesen Host ist eine sichere Verbindung erforderlich." + "Zuletzt verwendet" + "Keine passenden Automatisierungen." + "Smartphone kann die Gateway erreichen" + "Gateway" + "Abgelaufen" + "Geplante OpenClaw-Arbeit von Ihrer Gateway." + "Sub-agent" + "Warten auf Gerätefreigabe" + "Thread wird geladen" + "Dieses Gateway verwendet jetzt ein Zertifikat, dem dieses Gerät vertraut." + "Staffelung in ms" + "event create" + "Dokument" + "Gateway einrichten" + "Video abspielen" + "Gespeicherte Authentifizierung ist ungültig. Authentifizieren Sie sich erneut oder setzen Sie diese Gateway-Verbindung zurück." + "Beim Verwenden" + "screenshot" + "Hierher zurückspulen" + "Cron-Ausdruck, z. B. 0 9 * * *" + "Zurück zur Sprache" + "Sprechen" + "Details" + "%1$s/%2$s online" + "%1$s Apps dürfen weiterleiten." + "Chat" + "Mikrofonzugriff ist erforderlich." + "Krabbeln" + "Bearbeiten" + "Ruhezeiten" + "Diagnosedaten kopieren" + "Geplant" + "Erstellen" + "Läuft in %1$s ab" + "Schließen" + "Vorschlag ablehnen?" + "Sprachfehler (%1$s)" + "Problem" + "Durchsuchen Sie die Registry-Metadaten. Das Gateway überprüft die Vertrauenswürdigkeit vor jedem Download erneut." + "Verwenden Sie für die lokale Einrichtung eine private LAN-IP oder aktivieren Sie Tailscale Serve bzw. stellen Sie für den Remotezugriff eine wss://-Gateway-URL bereit." + "Wird gesendet…" + "Konto %1$s" + "Suggest Task" + "Suchen" + "Hört zu" + "Automatisierung nicht geladen." + "Ein Gateway-Update ist verfügbar. Führen Sie das Update über die Web-UI oder CLI aus, wenn Sie bereit sind." + "bald" + "Keine Gateway-Genehmigungen." + "Host" + "Fügen Sie pro Feld ein Aktivierungswort oder eine Aktivierungsphrase hinzu. Sagen Sie anschließend eines davon vor Ihrem Befehl." + "Transkribieren und dann senden" + "Ausführen um" + "Audio pausieren" + "Zugriff auf das Gateway-Gerät" + "Keine Vorschau" + "Geräte" + "OpenClaw für Android." + "Genehmigung der Funktion ausstehend" + "Speichere deine Änderungen oder setze sie zurück, bevor du diese Automatisierung ausführst, aktivierst, deaktivierst, löschst oder aktualisierst." + "Noch keine Automatisierungen." + "Für diesen Skill müssen %1$s Elemente eingerichtet werden. Android zeigt an, was installiert ist; Änderungen an Einrichtung und Konfiguration sind nur über den Desktop oder die CLI möglich." + "%1$s kürzlich" + "Kanäle" + "Unphased" + "Auf diesem Smartphone aktiv" + "Knotenzugriff wird geprüft" + "Zeitzone" + "Skill-Workshop-Aktionen zum Prüfen und Anwenden" + "Immer erlauben" + "present" + "Auf der Gateway installierte Skills werden hier angezeigt." + "Der Code ist möglicherweise abgelaufen oder wurde für ein anderes Gateway generiert." + "Berechtigung erforderlich" + "Die Automation hat eine ungültige Konfiguration." + "Zulassungsliste" + "Einrichtung, Status und Reparatur" + "groups" + "Öffentlicher Schlüssel" + "Info" + "In diesem Bild wurde kein Einrichtungs-QR-Code gefunden. Wähle den von openclaw qr generierten QR-Code oder gib den Einrichtungscode manuell ein." + "permissions" + "Verbinden Sie die Gateway, um Knoten und gekoppelte Geräte zu laden." + "Zweig wechseln" + "Keine Skills" + "Antworten werden laut wiedergegeben" + "Als gelesen markieren" + "Knotengenehmigung ausstehend" + "wake" + "%1$s Vorschläge" + "Die Gateway-Authentifizierung erfordert Aufmerksamkeit." + "Verbindungsdetails" + "Millisekunden" + "Spracherkennung" + "Beschreibung" + "Aktuelle Unterhaltungen" + "Dein Telefon sendet diese Informationen an dein Gateway, nicht an einen von OpenClaw betriebenen Server. Dein Gateway kann sie in Anfragen an den von dir gewählten KI-Anbieter einbeziehen." + "Zustellung" + "Lautsprecher stummschalten" + "%1$s läuft · %2$s fertig · %3$s fehlgeschlagen" + "Gateway-Verbindung wird geöffnet" + "Überwachung · %1$s Threads" + "Die Automation wurde ausgeführt." + "Keine passenden Apps." + "An Chat senden" + "Die Automation wurde gelöscht." + "Aktivieren" + "Letzte Ausführungen" + "Richten Sie den QR-Code innerhalb des Quadrats aus." + "Genehmigungen konnten nicht geladen werden." + "Ich habe genehmigt" + "Verbinden Sie Ihre Gateway, um die Anbieterbereitschaft zu laden." + "Nicht gekoppelt" + "Diese Genehmigung ist abgelaufen, bevor sie bearbeitet werden konnte." + "Beobachtung in %1$ss — zur Ziel-App wechseln" + "Agenten-Prompt" + "emoji list" + "Wiederkehrend" + "OpenClaw suchen" + "%1$s ausstehend" + "Spracherkennung auf dem Gerät nicht verfügbar" + "Keine App kann diese Nachricht teilen" + "Suche schließen" + "Zu überwachender Befehl" + "Zustand" + "Benachrichtigungszugriff" + "Lautsprecher stummgeschaltet" + "Threads durchsuchen" + "OK" + "Einrichtungsanleitung konnte nicht geöffnet werden." + "OpenClaw %1$s fragen" + "Wait for Agents" + "Adresse" + "Auf dem Gateway erstellte geplante Aufgaben werden hier angezeigt." + "Der neueste Protokollausschnitt wird angezeigt." + "Einrichtungscode verwenden" + "sticker" + "Verwende ein sicheres wss:// oder Tailscale Serve Gateway, erzeuge einen Setup-Code mit vollem Zugriff in der Control UI oder mit openclaw qr, scanne oder füge ihn dann unten ein und stelle die Verbindung erneut her, um Einstellungen und Upgrades zu aktivieren." + "steer" + "Ausgewählt" + "Android kann einen vorhandenen Einrichtungscode scannen oder einfügen, aber dieses Gateway stellt der App die Erstellung von Einrichtungscodes noch nicht bereit. Erstellen Sie den QR-Code/Code auf dem Gateway-Host mit openclaw qr und scannen Sie ihn dann hier oder fügen Sie den Einrichtungscode unten ein." + "Canvas-Status" + "Verbindung beheben" + "Bild speichern" + "Knoten %1$s" + "Gateway-Passwort erforderlich" + "Update Plan" + "Anhang entfernen" + "Die Ausführung der Automation ist fehlgeschlagen." + "Anbieterlimits und Kontingentstatus." + "Gateway-Talk-Katalog nicht geladen" + "dieses Gateway" + "Noch keine letzten Ausführungen." + "Sprachmodell auf dem Gerät nicht verfügbar" + "Dashboard benötigt ein verbundenes Gateway" + "Passende Vorschläge erscheinen hier, nachdem Agenten wiederverwendbare Skill-Entwürfe erstellt haben." + "Session Search" + "OpenClaw spricht" + "QR scannen" + "Ausgewählte Apps" + "Änderungen verwerfen" + "Genehmigungsbefehl kopiert" + "Zustellungsstatus" + "QR-Code nicht akzeptiert" + "Ihre Zentrale für Sprachbefehle." + "Verbindung testen" + "OPENCLAW" + "Web Fetch" + "Prompt" + "Gerät genehmigen?" + "Verbinde dich mit deinem Gateway, um dieses Sitzungs-Dashboard zu öffnen." + "%1$s und die gespeicherten Anmeldedaten von diesem Smartphone entfernen?" + "Der QR-Code verweist auf eine unsichere Remote-Gateway. %1$s %2$s" + "Bildschirmoberfläche bereit" + "Gateway koppeln" + "Verbinde die Gateway, um Kanäle zu laden." + "Wird bei anderen Sprachaktivitäten pausiert." + "Modell" + "Fotos" + "Einrichtungscode einfügen" + "OpenClaw spricht" + "Verbindung wird hergestellt..." + " · Standort: Immer" + "Nachrichten: %1$s" + "Riffen" + "Vom Gateway laden" + "text: %1$s" + "Erforderlich" + "rename group" + "Bereit" + "Das Tagebuch wartet auf seinen ersten Eintrag." + "Genehmigen" + "Live-Seite" + "Die Automation wird bereits ausgeführt." + "Diese Automatisierung nach einer erfolgreichen einmaligen Ausführung entfernen." + "Bereit für Chat und Sprache" + "Verbunden (Operator: %1$s)" + "Gateway-Kopplung ist abgeschlossen. Genehmige dieses Telefon als Node, damit OpenClaw die von dir aktivierten Gerätefunktionen verwenden kann." + "Antwort abgebrochen" + "Bild" + "%1$s zurückgehalten" + "Keine passenden Threads" + "delete" + "Layout: Kompakt" + "channels" + "Erteilt" + "Alle %1$s Minuten" + "1 Token" + "%1$s %2$s" + "Installierte Apps" + "ausstehend" + "Sprachnachricht wird vorbereitet…" + "Nie" + "Subsystem" + "Bei Befehlsende" + "Verbindung" + "Der Ausführungsverlauf der Automatisierung konnte nicht geladen werden." + "Name der Automatisierung" + "Schritt 2" + "Diagnostizieren" + "Einige Kanalstatusprüfungen wurden nicht abgeschlossen." + "pin" + "%1$s kopieren" + "Gekoppelt" + "Aktivierungswörter konnten nicht gespeichert werden" + "Dadurch wird \"%1$s\" unter Quarantäne gestellt und der Status von Skill Workshop über das Gateway aktualisiert." + "Sprachnachricht aufnehmen" + "In Warteschlange" + "Beantwortet" + "Kameratools auf Anfrage zulassen." + "Probleme" + "Sprachaktivierung" + "Kopplungsanfrage abgelehnt." + "vor %1$s Tagen" + "roles" + "Skills" + "Archivieren" + "Knoten offline. Stellen Sie die Verbindung wieder her und versuchen Sie es erneut." + "System" + "Remote-IP" + "Nicht gruppiert" + "Zeitplandetails" + "Telefonfunktionen" + "Nicht verfügbar" + "Dashboard" + "Token einfügen" + "Keine Anbieter" + "SHA-256-Fingerabdruck" + "Noch keine Threads" + "Bluetooth-Mikrofon" + "Zuletzt verwendet" + "Thread umbenennen" + "Ergebnis der Auflösung unbekannt. Aktionen bleiben deaktiviert, bis der Gateway-Eintrag verifiziert wurde." + "dialog" + "Auf Aktivierungswörter warten" + "camera snap" + "Wiedergabe wird vorbereitet…" + "Gateway hat unbekannten Anbieter %1$s ausgewählt" + "delete group" + "Android folgen · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Verbinden Sie die Gateway, um Agents zu laden." + "Zurück" + "Nachricht teilen" + "Generiere einen QR-Code." + "Neu starten" + "Lautsprecher ein" + "Gruppe löschen?" + "Fehlt" + "Vorschläge suchen" + "stop" + "Sicher (TLS)" + "Keine Knoten oder gekoppelten Geräte." + "%1$s %% übrig %2$s" + "Einrichtungscode abgelaufen" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "TAGEBUCH" + "notify" + "Dieses Smartphone bleibt im Ruhezustand, bis das Gateway es benötigt. Dann wird es aktiviert, synchronisiert und kehrt in den Ruhezustand zurück." + "%1$s konfigurierte Modelle" + "Lizenzen" + "Verbinde das Gateway, um ClawHub-Skills zu suchen." + "Skill" + "Die Gateway-Verbindung hat sich geändert. Starten Sie OpenClaw neu, um die Verbindung wiederherzustellen." + "Geräte-ID" + "Gateway hat den aktiven Anbieter für %1$s nicht identifiziert" + "Warten" + "Aktivierungswörter gespeichert" + "Älteste zuerst" + "Bildschirm" + "Läuft seit" + "IPv6-Zonen-IDs werden nicht unterstützt. Verwende eine IPv6-Adresse ohne Bereichsangabe oder einen LAN-Hostnamen." + "Gesendet — Zustellung wird bestätigt…" + "Audio" + "This gateway connection needs operator.admin to update skills." + "Einrichtungscode" + "Gateway-Warnung bestätigen und installieren" + "Chat aktualisieren" + "Intervall" + "Aktionen für Skill-Workshop-Vorschläge erfordern den Bereich operator.admin." + "Sitzungen" + "Umbenennen…" + "Verbinden Sie die Gateway, um Dreaming zu laden." + "Einrichtung" + "Talk öffnen" + "poll" + "Verbinden, um Ihre Agenten zu laden" + "role remove" + " · Gespräch: Hört zu" + "ClawHub hat keine installierbare Version für %1$s zurückgegeben." + "Befehl" + "Diese Genehmigung wurde abgebrochen, bevor sie bearbeitet werden konnte." + "Mikrofon an · wartet auf Gateway" + "Text" + "%1$s von %2$s werden angezeigt. Suche verfeinern, um mehr zu sehen." + "v%1$s verfügbar" + "%1$s://%2$s" + "%1$s... (OK)" + "Anbieter und konfigurierte Modelle" + "Verbindung wird hergestellt…" + "Mit einem Gateway verbinden, um Aktivierungswörter zu speichern" + "Profil öffnen" + "Starte deine Gateway." + "Hilf mir, dieses Ziel in eine praktische Checkliste zu verwandeln: " + "Sitzungssuche löschen" + "Port" + "Einrichtungscode eingeben" + "Gateway-Protokolle konnten nicht geladen werden." + "%1$s Anbieter bereit" + "Deine Agenten sind bereit" + "Auf dem Gateway ist kein Anbieter für %1$s konfiguriert" + "Hört für eine Eingabe zu" + "Beobachten" + "Epoch-Millisekunden (optional)" + "Keine Modelle konfiguriert. Aktualisieren Sie, um die Verfügbarkeit erneut zu prüfen." + "Einstellungen" + "Rückkamera" + "approve" + "Bevor du beginnst" + "Skills konnten nicht geladen werden." + "Deaktiviert" + "Warte noch auf Genehmigung" + "Hintergrundaufgaben konnten nicht geladen werden" + "Prüfen Sie, ob OpenClaw auf diesem Telefon klar sprechen kann." + "In Bearbeitung · %1$s aktive Läufe" + "Arbeitsverzeichnis des Befehls" + "Gruppenname" + "Aus Galerie auswählen" + "Version %1$s" + "Zurück" + "Connect the gateway to update skills." + "Nach Ausführung löschen" + "Der Einrichtungscode verweist auf eine unsichere Remote-Gateway. %1$s %2$s" + "Computer" + "Gateway getrennt." + "Session Settings" + "Gateway verbinden, um zu starten" + "Sicherheitshinweis" + "Andere Antwort" + "Warnung zu geteiltem Bild schließen" + "Das Gateway hat eine andere ClawHub-Version geprüft. Prüfen Sie den Skill vor der Installation erneut." + "Systemzugriff öffnen" + "Abgeschlossen" + "Bild nicht verfügbar" + "Benachrichtigungen" + "Anwenden, Ablehnen und Quarantäne erfordern den Bereich operator.admin. Stellen Sie die Verbindung mit gemeinsamer Gateway-Authentifizierung wieder her oder genehmigen Sie ein Upgrade des Geräte-Bereichs operator.admin, um Lifecycle-Aktionen zu aktivieren." + "sticker upload" + "Hummer fangen" + "Messages to recover" + "openclaw devices approve %1$s" + "Lesbare Gateway-Logdetails." + "Überprüfe generierte Skill-Vorschläge, bevor sie zu aktiven Skills werden." + "Gebündelt" + "%1$s verfügbar" + "Knotengenehmigung ausstehend" + "Gateway ausstehend" + "Authentifizierung erforderlich" + "Knoten" + "Aktiv lassen" + "OpenClaw antwortet" + "Dokumentation" + "%1$s bereit" + "Noch keine Ausgabe" + "Gerätesprache wird nicht unterstützt" + "In Warteschlange — wird nach erneuter Verbindung gesendet" + "vor %1$s Min." + "Aktueller Zweig" + "Kopplungszugriff wird geprüft" + "Eingeschränkter Gateway-Zugriff" + "Tools werden ausgeführt..." + "Genehmigung wird geprüft…" + "Fotos und Clips mit diesem Telefon aufnehmen" + "Verbunden und bereit" + "Schließen" + "Ein Ziel in eine umsetzbare Checkliste verwandeln." + "Der Einrichtungscode enthält eine ungültige Gateway-URL." + "Aktiviere nur Zugriffe, mit denen du einverstanden bist, dass OpenClaw sie verwendet, während dieses Telefon verbunden ist. Du kannst sie später in den Android-Einstellungen ändern." + "Konto" + "remove" + "Passwort optional" + "Gateway-Authentifizierung muss überprüft werden. Prüfen Sie die Gateway-Einstellungen und versuchen Sie es erneut." + "Der QR-Code verwendet eine IPv6-Zonen-ID. Verwende eine IPv6-Adresse ohne Bereichsangabe oder einen LAN-Hostnamen." + "add" + "Krillen" + "Fehlerfrei" + "Fertig in %1$s" + "Argumente" + "Installationsoptionen" + "In %1$s Std." + "Die Gateway-Genehmigung steht noch aus. Führen Sie auf dem Gateway-Host Folgendes aus:" + "Administratorzugriff erforderlich" + "set groups" + "Modell anheften" + "Suche löschen" + "Für berechtigte Agenten aktiviert." + "Kein aktueller Thread" + "bounds: %1$s" + "Nach %1$s" + "Dem Scheduler erlauben, diese Automatisierung auszuführen." + "%1$s angewendet" + "Noch kein Traumtagebuch." + "Hintergrundaufgaben aktualisieren" + "Fasse die letzten Threads und die nächsten Schritte zusammen." + "Wird auf dem Gerät ausgeführt, solange OpenClaw sichtbar ist." + "%1$s arbeitet" + "%1$s %2$s" + "Rohdaten" + "Ausführungen" + "Jetzt ausführen" + "Unbenannter Zweig" + "Konfiguriert" + "camera list" + "1 angewendet" + "camera clip" + "Ja" + "Audiotest" + "Zurückgehalten" + "events" + "Arbeitsverzeichnis" + "Zum neuesten springen" + "Immer erlauben" + "QR- oder Einrichtungscode scannen" + "Installing" + "Live-Knoten, gekoppelte Telefone und ausstehende Geräteanfragen." + "Snapshot: %1$s" + "Eine frühere Antwort hat diesen Befehl bereits erlaubt und die Auswahl gespeichert." + "Ausstehende Anfragen" + "Genehmigt" + "Arbeitsbereich" + "Sprache" + "Bereit zum Sprechen" + "Subagents" + "Fehlgeschlagen: Kein sicherer Gateway-Endpunkt erkannt. Aktiviere Gateway-TLS oder Tailscale Serve, oder verwende eine vertrauenswürdige private LAN-Adresse mit ausgewählter Option „Unverschlüsselt“." + "Signale" + "Sitzungsziel" + "Gateway hat eine Ablehnung erfasst." + "Akzeptieren" + "Frag OpenClaw alles" + "Zum Fortfahren erneut verbinden" + "%1$s gekoppelt" + "Dadurch wird \"%1$s\" angewendet und der Status von Skill Workshop über das Gateway aktualisiert." + "Gateway offline" + "openclaw devices list" + "Verbindungsstatus von OpenClaw Node" + "Hinweise bleiben auf diesem Telefon." + "OpenClaw kann ausgewählte Hinweise empfangen." + "Bildschirm öffnen" + "Chat-Aktionen" + "Steuerung anderer Apps erlauben?" + "Wird geprüft" + "Scannen Sie einen Einrichtungscode oder fügen Sie ihn ein, um ein weiteres Gateway hinzuzufügen." + "Swarm" + "TLS-Zeitüberschreitung" + "Letzte Sitzungen" + "Gekoppeltes Gerät entfernt." + "Gateway gekoppelt. Die Genehmigung der Knotenfunktion wird überprüft." + "Bewegung" + "Cron-Aktion fehlgeschlagen." + "Führe auf dem Gateway-Computer Folgendes aus:" + "Sitzungen suchen" + "Protokolle aktualisieren" + "Bild nicht verfügbar · Zum Wiederholen tippen" + "openclaw nodes approve %1$s" + "Sprachnachricht · %1$s" + "Nutzung" + "Nautilieren" + "Kontext %1$s %%" + "Sprachaufforderungen transkribieren" + "Stummschalten" + "Starten Sie eine neue Unterhaltung; sie wird dann hier angezeigt." + "Verbindungsproblem" + "Mittel" + "Abzweigen" + "Lautsprecher aktivieren" + "Systemereignistext" + "Sortierung: %1$s" + "%1$s warten" + "Image Generation" + "Sprachnachricht" + "Nichts erfordert deine Aufmerksamkeit" + "OpenClaw benötigt %1$s-Berechtigungen, um fortzufahren." + "Mikrofon des kabelgebundenen Headsets" + "Seiten" + "Zugestellt" + "Fällig" + "Skill-Details sind im aktuellen Skills-Status nicht verfügbar." + "Wählen Sie aus, was dieses Telefon teilen kann." + "Für diese Automation befindet sich bereits eine Ausführung in der Warteschlange." + "Verbinde das Gateway, um Automatisierungen zu verwalten." + "Die Automation ist noch nicht fällig." + "Keine Details" + "Die Genehmigung läuft.\nOpenClaw stellt die Verbindung automatisch wieder her." + "Verbinden Sie Ihre Gateway, um die Anbieterbereitschaft anzuzeigen." + "Warten auf Kopplung" + "Eine Unterhaltung beginnen oder fortsetzen" + "Keine geplanten Aufgaben" + "Auf OpenClaw antworten…" + "Status" + "OpenClaw Node · Verbunden" + "Aktiv" + "Debug-Status der Bildschirmfreigabe anzeigen." + "Keine Limits gemeldet" + "Scanner schließen" + "Alle %1$s Tage" + "Aktiviert" + "Aktivieren und Einstellungen öffnen" + "Online und bereit" + "Ask User" + "Chatfehler" + "Vorwärts scrollen" + "%1$s von %2$s" + "Arbeit planen" + "console" + "Erneut versuchen" + "Starten Sie einen Chat, und Ihre aktiven OpenClaw-Unterhaltungen werden hier angezeigt." + "Die Automation konnte nicht geladen werden." + "Shell im Agenten-Arbeitsbereich" + "%1$s aktiv" + "Geräteberechtigungen auswählen" + "Letzte Dauer" + "Standard-Agent" + "%1$s Std." + "Unterhaltung ist aktiv" + "%1$s konnte nicht von ClawHub installiert werden." + "Willkommen bei OpenClaw" + "Andere Apps steuern" + "Signalindex" + "Geheimnis eingeben…" + "%1$s:%2$s" + "OpenClaw anweisen, %1$s" + "Entdeckt" + "Seitenleiste ausblenden" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Audiowiedergabe ist nicht verfügbar" + "anwenden" + "Nutzungsdaten konnten nicht geladen werden." + "Den Knoten während aktiver Aufgaben verfügbar halten." + "Nächstes Aufwachen" + "%1$s/%2$s" + "Keine aktuellen Protokolleinträge." + "Manuelle Gateway" + "Gruppe umbenennen" + "Update Goal" + "Anbieterverfügbarkeit unbekannt" + "Anbieter" + "Gruppe löschen…" + "Nutzdaten" + "Anrufliste" + "Memory Search" + "%1$s Anbieter" + "Telefonkontext & Datenschutz" + "%1$s/%2$s verbunden" + "%1$s %2$s" + "Die Wiederherstellung nach dem Gateway-Neustart läuft noch." + "Beim Ersetzen des Einrichtungscodes werden die auf diesem Smartphone gespeicherten Einrichtungsdaten und Geräte-Token vor der erneuten Verbindung gelöscht. Die Knotenfunktionen dieses Smartphones müssen möglicherweise erneut genehmigt werden. Fahren Sie nur fort, wenn Sie es mit einem neuen Gateway-Einrichtungscode koppeln möchten." + "Öffne eine Automatisierung, um ihre Konfiguration und ihren Ausführungsverlauf anzuzeigen. Verbindungen mit Administratorbereich können sie außerdem ausführen, bearbeiten, aktivieren, deaktivieren oder löschen." + "Kontext --" + "Vorschlag unter Quarantäne gestellt." + "Die Automation wurde pausiert." + "OpenClaw Mobile" + "A2UI reset" + "Gateway nicht verfügbar" + "Read" + "Für diesen Skill muss 1 Element eingerichtet werden. Android zeigt an, was installiert ist; Änderungen an Einrichtung und Konfiguration sind nur über den Desktop oder die CLI möglich." + "Letzte Ausführung" + "Kamerazugriff ist erforderlich, um den Einrichtungs-QR-Code zu scannen." + "Das Modell konnte nicht aktualisiert werden." + "Blubbern" + "thread reply" + "Löschen…" + "Mit dem Gateway verbinden, um Automatisierungen zu prüfen." + "AKTUELLE PROTOKOLLE" + "Letzte Ausführungen werden geladen…" + "Für diese Datei ist keine Vorschau verfügbar. Sie ist möglicherweise binär oder zu groß." + "Prüfen" + "Standort dieses Telefons abrufen" + "Skill-Schlüssel" + "%1$s wurde installiert." + "Gateways" + "actions: %1$s" + "Weiterleitungsmodus" + "%1$sk" + "Thread-Layout umschalten" + "Kein TLS-Endpunkt" + "OpenClaw-Gateway" + "Manuell einrichten" + "Denkt nach…" + "Gateway-Zugriff muss überprüft werden" + "1 zurückgehalten" + "%1$ss" + "Vorschlag anwenden?" + "Nicht jetzt" + "Nicht genehmigt" + "Apps suchen" + "1 konfiguriertes Modell" + "Genehmigungshinweis schließen" + "·" + "Offline" + "Sprachanbieter" + "Die Gateway-Genehmigung wird durchgeführt. OpenClaw versucht es automatisch erneut." + "Max." + "Cron-Änderungen erfordern operator.admin-Zugriff." + "Denkt nach" + "screen snapshot" + "Beobachtete Knoten: %1$s" + "Keine Aktionen gefunden" + "Speichern & verbinden" + "list" + "Gateway hat die Genehmigung erfasst und die Auswahl gespeichert." + "Geben Sie einen gültigen manuellen Endpunkt ein, um eine Verbindung herzustellen." + "Assistent" + "Wird an den Chat gesendet..." + "Profil speichern" + "Gesperrt" + "Automatisierung bearbeiten" + "Verwenden Sie dasselbe Netzwerk oder eine sichere Remote-Gateway-URL." + "Anker" + "Sprache" + "Diese App ist älter als das Gateway. Aktualisieren Sie OpenClaw auf diesem Gerät und versuchen Sie es erneut." + "Alle" + "Gateway-Sitzung wird hergestellt" + "Warten auf Überprüfung" + "Keine Skills installiert." + "Gateway wird geprüft" + "Versatz %1$s" + "Das Ergebnis für %1$s ist unbekannt. Stellen Sie die Verbindung wieder her, aktualisieren Sie Skills und versuchen Sie es erneut; das Gateway führt eine passende, noch laufende Installation sicher zusammen." + "Vergessen" + "Keine gekoppelten Gateways." + "%1$s · %2$s" + "<redacted secret>" + "%1$s Probleme" + "OpenClaw" + "Hört zu · %1$s in der Warteschlange" + "Sprachausgabe des Assistenten stummgeschaltet" + "Knotenaktionen werden nur ausgeführt, wenn die Ziel-App im Vordergrund ist (validiert über den Remote-Pfad). Globale Aktionen und Aktionen innerhalb derselben App funktionieren hier." + "Noch keine Gateways gefunden. Verwenden Sie die manuelle Einrichtung, wenn die Erkennung blockiert ist." + "Thread öffnen" + "In Bearbeitung" + "Beginnen Sie zu sprechen..." + "Telefonknoten" + "Xhigh" + "Auf dem Gateway-Host ausführen:" + "Änderungen an Skills erfordern operator.admin. Stellen Sie die Verbindung mit einem adminfähigen Gateway-Token wieder her." + "Verbinde das Gateway, um ClawHub-Skills zu prüfen." + "App-Liste bleibt auf diesem Telefon." + "Inaktiv" + "Wird in den Android-Bedienungshilfe-Einstellungen angezeigt." + "Intelligente Zustellung" + "Ablehnen" + "Gateway hat nach %2$s den Status „%1$s“ zurückgegeben." + "Gateway-Token nicht konfiguriert" + "Not available to this agent" + "Dateien" + "Berechtigungen" + "Die Kamera konnte nicht gestartet werden. Wähle ein QR-Bild aus der Galerie oder gib den Einrichtungscode manuell ein." + "Zum Kopieren tippen" + "Warten %1$s Min." + "%1$s." + "Verbinde das Gateway, um ClawHub-Skills zu installieren." + "Sprache suchen" + " · Mikrofon: Hört zu" + "Stelle die Verbindung mit operator.admin-Zugriff wieder her, um Gateway-Einstellungen zu überprüfen und zu ändern." + "Mehr laden" + "In 3s beobachten" + "run" + "Stimme wird generiert…" + "← Zurück" + "Trennen" + "Führe den approve-Befehl auf dem Gateway-Computer aus und prüfe dann erneut." + "Automatisierungen" + "%1$s Min." + "Vertrauen" + "Dieser QR-Code ist kein OpenClaw-Einrichtungs-QR-Code. Generiere mit openclaw qr einen neuen Code und versuche es erneut." + "Das bevorzugte Mikrofon ist nicht verfügbar; die automatische Auswahl wird verwendet." + "Abgelehnt" + "Android- und Hintergrundpakete einschließen." + "Ihr Gateway ist bereit." + "Ausgelöst" + "Structured Output" + "Dies dauert länger als erwartet.\nPrüfen Sie, ob das Gateway ausgeführt wird und erreichbar ist." + "Keine Hintergrundaufgaben für diesen Agenten." + "Verbindung wird wiederhergestellt" + "OpenClaw prüft den Zugriff auf Gateway und Node." + "Code Execution" + "Keine Anbieternutzung" + "Überprüfen" + "Die Mikrofonberechtigung ist erforderlich." + "%1$s T." + "%1$s verfügbar" + "OpenClaw wird erneut synchronisiert" + "Ereignisstream unterbrochen; versuche, die Ansicht zu aktualisieren." + "Knoten und Geräte konnten nicht geladen werden." + "Verbinden Sie die Gateway, um Skills zu laden." + "unbekannt" + "Ausgabe" + "Talk fehlgeschlagen: Realtime-Anbieter wurde unerwartet geschlossen." + "OpenClaw Zeitkritisch" + "ban" + "Gateway-Token erforderlich" + "Gekoppeltes Gerät" + "Erneute Genehmigung erforderlich" + "Nicht geplant" + "Kontakte" + "Ihr Smartphone bleibt ruhig, bis es benötigt wird" + "Hört zu · sendet Sprachnachricht in Warteschlange" + "Aufgabendetails konnten nicht geladen werden" + "Agentennachricht" + "Gateway erfordert diese Geräteidentität. Authentifizieren Sie sich erneut oder setzen Sie diese Gateway-Verbindung zurück." + "Nächste Sitzung" + "Verbindungssicherheit" + "Vorerst überspringen" + "Website" + "Verbinden Sie die Gateway, um Genehmigungsanfragen in der App zu laden." + "%1$s kopiert" + "Keine Apps ausgewählt. Es wird nichts weitergeleitet, bis Sie Apps hinzufügen." + "%1$s %2$s" + "Einrichtung erforderlich" + "Nicht gekoppelt" + "Gateway hat dieses Smartphone erkannt" + "Keine konfigurierten Modelle" + "Deaktivieren" + "App-Sprache" + "Gateway wird gekoppelt" + "Gespeicherte Authentifizierung ungültig" + "%1$s Bereiche" + "Verbinden Sie die Gateway, um aktuelle Protokolle zu laden." + "Aktivierungswörter speichern" + "Verwalten Sie installierte Skills und fügen Sie vertrauenswürdige Versionen aus ClawHub hinzu." + "Wird gesendet…" + "Noch keine Agents geladen." + "ClawHub durchsuchen" + "Der Chat überprüft den Status des Gateway." + "Kopplung erforderlich" + "Aktive Ausführungen" + "Fehlgeschlagen — %1$s" + "Verbindung zwischen diesem Telefon und OpenClaw." + "summarize" + "Widget-Bild unter Downloads gespeichert" + "Wird gestartet…" + "%1$s Token" + "Clientfehler" + "Verifizieren Sie dieses anfragende Gerät, bevor Sie Zugriff gewähren." + "Bluetooth-LE-Mikrofon" + "%1$s %2$s" + "Die Automation wurde aktiviert." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Archiviert" + "Neu laden" + "Automatisierungen durchsuchen" + "Verknüpfte Telefone und Knotenhosts werden nach der Kopplung hier angezeigt." + "%1$s: %2$s" + "Diese Automation wurde auf dem Gateway geändert. Überprüfen Sie die neueste Version, bevor Sie erneut speichern." + "Diktat beenden" + "Gut lesbar" + "Nachricht an OpenClaw" + "Gateway-Passwort ist ungültig. Geben Sie es erneut ein oder setzen Sie diese Gateway-Verbindung zurück." + "Erneut verbinden" + "ISO-Zeit, z. B. 2026-07-09T09:30:00Z" + "%1$s Tools" + "Eine frühere Antwort hat diese Genehmigung bereits verweigert." + "Verknüpft" + "%1$s öffnen" + "%1$s/%2$s" + "Die Ausführung der Automation wurde mit einem unbekannten Status beendet." + "Die Offline-Warteschlange ist voll (%1$s Nachrichten). Löschen Sie zuerst Elemente aus der Warteschlange." + "Öffentliche Gateways erfordern wss:// oder Tailscale Serve. ws:// ist für localhost, .local-Hosts, den Android-Emulator und private LAN-IPs zulässig." + "Verbinden Sie die Gateway, um Skill-Details zu laden." + "Vollzugriff erforderlich" + "Aktivierungswort-Erkennung" + "Linkvorschau erweitern" + "Thread-Suche löschen" + "NULL (FEHLGESCHLAGEN)" + "Aktualisieren" + "Admin" + "Aufmerksamkeit erforderlich" + "Koppeln Sie dieses Gerät mit Ihrem Gateway, damit es nur für echte Aufgaben aktiviert wird, Sie stets einen aktuellen Überblick über Ihre Agenten haben und akkuraubende Hintergrundschleifen vermieden werden." + "Rollen" + "Antworten" + "Anbieterkatalog" + "Berechtigung in den Einstellungen aktivieren" + "A2UI push" + "Zugriff prüfen" + "Wenn das Gateway erreichbar ist, sollte die erneute Verbindung ohne Eingriff abgeschlossen werden." + "Agent-Durchlauf" + "unter Quarantäne stellen" + "Achtung" + "Suche läuft…" + "Wo erhalte ich einen Einrichtungscode?" + "Skill konnte nicht aktiviert werden." + "pdf" + "Entfernen" + "%1$s %% online" + "Keine Kanäle" + "Echtzeit-Sprache" + "Skill-Workshop-Aktionen zum Ablehnen und für Quarantäne" + "Knoten & Geräte" + "Lokale Kommandozentrale" + "emoji upload" + "Vorschau wird geladen…" + "Hoch" + "focus" + "describe" + "%1$s-Kontext" + "Antwort wird abgehört..." + "voice" + "Mit %1$s verbunden" + "role add" + "Chat erfordert Aufmerksamkeit" + "Mikrofon aktivieren" + "OpenClaw erfasst und sendet die Namen, Paket-IDs und den Status der auf diesem Telefon sichtbaren Apps, wenn dein gekoppeltes OpenClaw Gateway danach fragt. So kann dein Assistent Fragen beantworten und Aktionen mit installierten Apps ausführen." + "Gateway nicht verbunden" + "Richtlinie" + "Zeitüberschreitung bei der Bestätigung der gesendeten Nachricht; aktualisiere die Ansicht, um die Zustellung zu prüfen." + "Unterstützungsdateien" + "Ausdruck" + "Hintergrundaufgaben" + "Traum" + "Keine Apps blockiert. Apps können weiterleiten, sofern Sie keine Blockierungen hinzufügen." + "Spracherkennung nicht verfügbar" + "Plattform" + "Gateway hat die Einrichtung von %1$s nicht zurückgegeben" + "Gateway vergessen?" + "Optionale Beschreibung" + "%1$s öffnen" + "Start-Canvas" + "Träumen" + "%1$s bis %2$s" + "Datei teilen" + "Echtzeit" + "API" + "OpenClaw arbeitet…" + "Mit OpenClaw sprechen oder diktieren" + "Informationen zu installierten Apps teilen?" + "Automatisierung wird geladen…" + "Automatisierung löschen" + "Standardassistent" + "Wählen Sie auf dem Gateway einen unterstützten Anbieter für %1$s aus" + "Nicht verfügbar" + "Leerer Ordner" + "Einstellungen öffnen" + "Aus" + "Typografie" + "Stopp" + "Noch keine passenden Threads." + "Die Gateway-Kopplung war erfolgreich.\nGenehmigen Sie die Knotenfunktionen dieses Telefons über eine Bedienoberfläche." + "Dieser Skill ist installiert, kann derzeit aber nicht ausgeführt werden. Verwenden Sie für Konfigurationsänderungen den Desktop oder die CLI." + "Spracherkennung ausgelastet" + "Home-Gateway" + "Führen Sie den Freigabebefehl auf dem Gateway aus" + "Dienst deaktiviert" + "Vorschläge aus Skill Workshop konnten nicht geladen werden." + "Bring mich bei meinen letzten OpenClaw-Threads auf den neuesten Stand und schlage nächste Schritte vor." + "Nicht jetzt" + "openclaw qr" + "start" + "OpenClaw Node · Gespräch" + "Ereignisse lesen und aktualisieren" + "Talk fehlgeschlagen: Realtime-Anbieter wurde geschlossen: %1$s" + "Verbinden Sie das Gateway, um die Dateien im Arbeitsbereich zu durchsuchen." + "%1$s über Gateway-Relay" + "Gateway-Talk-Katalog konnte nicht geladen werden" + "Überwachung · 1 geplanter Auftrag" + "Alle %1$s Stunden" + "Bildschirmoberfläche" + "OpenClaw-Übersetzungen · %1$s" + "Befehlsanfrage" + "Auf dem neuesten Stand" + "Kanal" + "Stummschaltung aufheben" + "Neue Gruppe…" + "Audio wird vorbereitet…" + "Adaptiv" + "Bald" + "%1$s weitere Worker" + "Web Search" + "Probiere Chat, Sprache, Threads, Anbieter oder Einstellungen aus." + "OpenClaw Aktiv" + "navigate" + "angefordert %1$s" + "Verbinde das Gateway, um den Ausführungsverlauf der Automatisierung anzuzeigen." + "Gerätezugriff; Zustimmung im Gateway weiterhin erforderlich" + "Abgebrochen" + "Geben Sie einen gültigen Einrichtungscode oder eine Gateway-Adresse ein." + "Modelle" + "OpenClaw Passiv" + "Gateway-Passwort ungültig" + "Die Änderung der Gerätekopplung konnte nicht verifiziert werden. Aktualisieren Sie die Ansicht und versuchen Sie es erneut." + "Details anzeigen" + "Bash" + "Token" + "Der verbundene OpenClaw-Agent kann die von dir aktivierten Gerätefunktionen verwenden. Fahre nur fort, wenn du der Gateway und dem Agenten vertraust, mit denen du dich verbindest." + "Seepocken sammeln" + "Ausgewählter oder vollständiger Fotozugriff gewährt." + "Bedienungshilfen-Executor" + "%1$s fehlende Elemente" + "Plan-Checkliste einklappen" + "Node-Genehmigung erforderlich" + "Gateway verbinden" + "... +%1$s weitere" + "Plan-Checkliste ausklappen" + "Browser" + "screen record" + "Ausführung ausstehend" + "Durch Aktivieren kann OpenClaw im aktivierten Zustand die Bildschirme anderer Apps beobachten und steuern. Der Zugriff auf die Android-Bedienungshilfen ist erforderlich." + "Ursprung" + "Persönliche KI auf deinen Geräten" + "Attach" + "Automatisch" + "Übersicht" + "Wiederherstellung konnte nicht angefordert werden. Zum erneuten Versuchen tippen." + "Video" + "%1$s\n\n" + "Unverschlüsselt" + "Kalender" + "Gateway-Status nicht OK; Senden nicht möglich" + "📎 %1$s" + "Letzter Status" + "Warten Sie, bis die aktuelle Antwort abgeschlossen ist, bevor Sie einen neuen Chat starten." + "Profil" + "Anbieterlimits werden hier angezeigt, wenn Ihr Gateway sie meldet." + "1 Problem" + "Threads in \"%1$s\" bleiben erhalten und werden zurück nach „Nicht gruppiert“ verschoben." + "Empfohlen" + "Erstellt" + "%1$s/%2$s aktive Token" + "Kein Aktionsergebnis" + "Schnappen" + "%1$s…" + "Skill-Details öffnen" + "Sprachausgabe fehlgeschlagen: %1$s" + "Talk starten" + "Dieser Ordner konnte nicht geladen werden." + "Der QR-Code enthielt keinen gültigen Einrichtungscode." + "Knotenzugriff überprüfen" + "Aktivierungsphrase hinzufügen" + "Gateway nicht erreichbar" + "Automatisierung" + "Verbindung erforderlich" + "Genehmigung konnte nicht bearbeitet werden. Aktualisieren Sie die Ansicht und versuchen Sie es erneut." + "import" + "So wird dieses Telefon in OpenClaw angezeigt." + "Thread-Suche fokussieren" + "Gateway verbinden" + "Kalender lesen" + "Die Übersicht wird beim erneuten Verbinden und beim Öffnen dieses Bildschirms aktualisiert." + "Skill konnte nicht deaktiviert werden." + "Verbindung wird weiterhin hergestellt" + "In %1$s Min." + "SMS lesen" + "Verbinden Sie die Gateway, um die Nutzung zu laden." + "Wobei kannst du mir gerade über dieses Smartphone helfen?" + "Genehmigung erforderlich" + "Neuer Chat" + "Verbinde das Gateway, um Skill-Workshop-Vorschläge zu aktualisieren." + "OpenClaw-Anfrage fehlgeschlagen." + "Berechtigung erforderlich" + "Anbieterbereitschaft\nund konfigurierte Modelle prüfen." + "Wird geladen" + "Fehlerwarnung" + "Design und übersetzter Android-Text." + "Mikrofon aus · sendet…" + "Keine" + "Ansehen" + "Name" + "Version" + "Cron" + "Verbinde dieses Telefon mit einem Gateway, bevor du OpenClaw öffnest." + "Aktivierungsphrase entfernen" + "Der Einrichtungscode wurde nicht akzeptiert. Generiere mit openclaw qr einen neuen Code." + "14 Nachrichten · Android" + "Transkription fehlgeschlagen: %1$s" + "Immer" + "Dreaming konnte nicht geladen werden." + "Die Ausführung der Automation wurde der Warteschlange hinzugefügt." + "Conversation Turn" + "Die Automation wurde gestartet." + "Neue Gruppe" + "Serverfehler" + "Video Generation" + "Die Gateway-Genehmigung steht noch aus. Führen Sie openclaw devices list auf dem Gateway-Host aus, genehmigen Sie dieses Telefon und versuchen Sie es erneut." + "Einträge erscheinen, nachdem ein Dreaming-Zyklus eine narrative Zusammenfassung geschrieben hat." + "%1$s ms" + "Speicherdatenbank" + "Assistent arbeitet" + "OpenClaw kann im Launcher sichtbare Apps auflisten." + "Sprechen fehlgeschlagen: %1$s" + "Installierte Skills durchsuchen" + "Prüfen" + "Process" + "Letzte Threads" + "Terminal" + "Aktuell" + "1 Konto" + "Pausiert" + "Kamera zulassen" + "Exec-Genehmigungsanfragen werden hier angezeigt, während dieses Telefon verbunden ist." + " · Mikrofon: Ausstehend" + "Kopieren" + "Details kopiert" + "Löschen" + "OpenClaw bitten, Android-Funktionen zu verwenden." + "member" + "Es wird geprüft, ob dieses Gateway den OpenClaw-Einstellungsassistenten unterstützt." + "Nutze die Wiederherstellungsoptionen unten, um die Verbindung wiederherzustellen." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Kanäle konnten nicht geladen werden." + "In %1$s Tagen" + "Aufeinanderfolgende Fehler" + "Aus diesem Bild konnte kein QR-Code gelesen werden. Wähle ein deutlicheres Bild oder gib den Einrichtungscode manuell ein." + "Das Gateway ist älter als diese App. Aktualisieren Sie OpenClaw auf dem Gateway-Host und versuchen Sie es erneut." + "Vor Chat, Sprache und Live-Status verbinden." + "Gateway erneut verbinden" + "Drittanbieter" + "Bereitschaft prüfen" + "Eingeschränkt" + "OpenClaw-Logo" + "Modell lösen" + "Mit dieser Gateway verbundene Messaging-Oberflächen." + "Wird gesendet" + "Archivierte Threads werden hier angezeigt." + "Befehl kopiert" + "Keine Vorschau verfügbar" + "Genehmigen Sie dieses Telefon auf dem Gateway.\nVersuchen Sie dann erneut, eine Verbindung herzustellen." + "QR-Code scannen" + "Arbeitsverzeichnis des Befehls · kann nicht geleert werden" + "Thread-Aktivität" + "Verfügbar" + "Automatisierung löschen?" + "%1$s heute · %2$s insgesamt" + "Passwort" + "Vorschlag unter Quarantäne stellen?" + "In diesem Build sind keine Lizenzhinweise enthalten." + "Widget-Bild konnte nicht gespeichert werden" + "Wartet seit %1$s" + "Spricht…" + "Anbieter & Modelle" + "Knoten" + "%1$s " + "Prompt nicht verfügbar" + "Protokolle" + "Verbinden Sie das Gateway, um Vorschläge aus Skill Workshop zu prüfen." + "Werkzeuge" + "Gateway-Schalter" + "SMS senden" + "OpenClaw ist bereit, in Ihrem gewöhnlichen Chat fortzufahren." + "Keine Befehle gefunden" + "Noch kein Canvas-Update. Tippen Sie, um es erneut zu versuchen." + "Terminal benötigt eine verbundene Gateway" + "Exec" + "App-Filter" + "Hauptagent" + "%1$sk" + "Gateway erforderlich" + "Zugriff" + "Pakete: snapshot=%1$s foreground=%2$s" + "Verbindung erneut versuchen" + "Der Cron-Scheduler wurde angehalten." + "Öffnen" + "Nachricht kopiert" + "Ihr Gateway war nicht erreichbar.\nLassen Sie uns das Problem beheben." + "jetzt" + "Nach Ausführung löschen" + "Auf diesem Smartphone ausgewählt" + "unpin" + "Session History" + "Loslösen" + "Dieses Smartphone verwenden" + "ClawHub-Details für %1$s konnten nicht geladen werden." + "Tools werden ausgeführt" + "Genauen Standort teilen, solange die Standortfreigabe aktiviert ist." + "Mobile UI" + "Design" + "Das Gateway zeigt diese Genehmigung weiterhin als ausstehend an. Prüfen Sie sie, bevor Sie es erneut versuchen." + "Sprachnachricht abschließen" + "Diktat: %1$s" + "Nicht erlaubt" + "Anderes Bild auswählen" + "Bildvorschau" + "OpenClaw hört nur zu, wenn Sie Gespräch oder Diktat starten." + "Schritte und Aktivität teilen" + "Einrichtung erforderlich" + "Aktualisiere dieses Gateway, um den OpenClaw-Einstellungsassistenten zu verwenden." + "Diese Gateway-Verbindung benötigt operator.admin, um ClawHub-Skills zu installieren." + "Vorschlag angewendet." + "%1$s ausstehend" + "vor %1$s Std." + "Anrufliste lesen" + "%1$s in der Warteschlange · Warten auf Gateway" + "In Gruppe verschieben" + "QR-Code zum Koppeln scannen" + "Genehmigung verweigert." + "Der Vorschlag aus Skill Workshop konnte nicht geprüft werden." + "Angeheftet" + "Profil & Gerät" + "Auswahl der Denkstufe schließen" + "Die Nachricht konnte nicht für die spätere Zustellung in die Warteschlange gestellt werden." + "Quarantäne" + "Zeitplan · %1$s" + "Die Denkstufe konnte nicht aktualisiert werden." + "Auswahl der Denkstufe öffnen" + "Zeitüberschreitung bei der Sprachantwort; der Durchlauf in der Warteschlange wird erneut versucht" + "Layout: Detailliert" + "Dieses Bild konnte nicht decodiert werden." + "Gateway, Sprache, Benachrichtigungen, Datenschutz" + "Dateien im Agenten-Arbeitsbereich" + "Dieses Gerät verliert seinen vertrauenswürdigen Gateway-Zugriff." + "Verwende die requestId aus dem ausstehenden Befehl im approve-Befehl." + "Zeitplan" + "Ratenbegrenzung" + "Nicht zugestellt" + "Nutzdaten · %1$s" + "Wird ausgeführt" + "Krallen" + "Beenden" + "Systemvertrauen verwenden" + "Keine Anbieter bereit" + "Priorisiert verbundene Bluetooth-Mikrofone." + "%1$s App ist für die Weiterleitung blockiert." + "Nachrichtenaktionen" + "Art" + "Aus Archiv entfernen" + "Transcripts" + "Aktivierungswörter" + "Konfigurieren Sie %1$s auf dem Gateway" + "Scannen Sie einen QR-Code oder verwenden Sie den Einrichtungscode von Ihrem OpenClaw Gateway." + "Designsystem-Prototyp" + "Sieben" + " · Gespräch: Ein" + "Noch keine Nutzungsdaten." + "Der Chat ist vor Beginn der Ausführung fehlgeschlagen. Versuchen Sie es erneut." + "Senden" + "Einige geteilte Bilder wurden ausgelassen oder konnten nicht hinzugefügt werden." + "Kalender schreiben" + "timeout" + "Niedrig" + "Blockliste" + "act" + "Dismiss Task" + "Chat fehlgeschlagen" + "OpenClaw · Live" + "Installiert" + "Zeitüberschreitung beim Warten auf eine Antwort; versuche es erneut oder aktualisiere die Ansicht." + "Frühere Unterhaltungen finden" + "Threads durchsuchen" + "Wird aktualisiert" + "Perlen fischen" + "Öffnen Sie die Kamera und richten Sie den Code von openclaw qr aus." + "Keine Geräte" + "Benachrichtigungen weiterleiten" + "Ich halte diese Unterhaltung getrennt vom normalen Agenten-Chat." + "Die Gateway-Sitzung wird wieder online geschaltet. Die Agenten-Kurzbefehle sollten in Kürze automatisch wieder verfügbar sein." + "Versuchen Sie eine andere Suche oder löschen Sie die aktuelle Suchanfrage." + "Hintergrundstandort zulassen?" + "Auftauchen" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Sprachnachricht abbrechen" + "Zurück scrollen" + "openclaw gateway" + "Gateway gekoppelt" + "Häuten" + "Warte auf Ihren nächsten Beitrag." + "OpenClaw arbeitet" + "Protokolleintrag" + "Fehlgeschlagen: Der sichere Gateway-Endpunkt für diesen Host konnte nicht erreicht werden." + "Das Gateway ist offline. Behebe unten das Verbindungsproblem oder kopiere die Diagnosedaten." + "Bereitschaft" + "Test, Test, 1, 2, 3" + "ClawHub-Skills konnten nicht gesucht werden." + "Kein Prompt" + "Frontkamera" + "Protokolleintrag öffnen" + "Netzwerk-Zeitüberschreitung" + "Jetzt" + "Gruppe umbenennen…" + "Weitere Agenten" + "openclaw nodes approve REQUEST_ID" + "Anheften" + "thread list" + "%1$s öffnen" + "upload" + "Gateway-Passwort nicht konfiguriert" + "Diktateinstellungen" + "Anbietermodelle wurden geladen, aber die Bereitschaft ist nicht verfügbar." + "Thread löschen?" + "OpenClaw verwandelt dieses Smartphone in eine übersichtliche mobile Bedienoberfläche für Threads, Sprache, Anbieter und Gateway." + "Neueste zuerst" + "Nächster Zyklus" + diff --git a/app/src/main/res/values-es/assistant.xml b/app/src/main/res/values-es/assistant.xml new file mode 100644 index 0000000..bf7c1be --- /dev/null +++ b/app/src/main/res/values-es/assistant.xml @@ -0,0 +1,7 @@ + + + "preguntar a OpenClaw %1$s" + "decirle a OpenClaw que %1$s" + "abrir OpenClaw y preguntar %1$s" + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..a2b33b7 --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + ¿Confiar en este gateway? + Confiar y continuar + Cancelar + Nuevo chat en worktree + Verifica la huella digital del certificado antes de confiar en este gateway.\n\n%1$s + El certificado del gateway cambió. Continúa solo si esperabas este cambio.\n\nSHA-256 anterior:\n%1$s\n\nSHA-256 nuevo:\n%2$s + Desconocido + VERSIÓN + COMMIT + COMPILADO + Versión %1$s + Commit de Git %1$s + Compilado el %1$s UTC, marca de tiempo %2$s + Fecha de compilación %1$s + Copiar el hash completo del commit de Git + Copiar la marca de tiempo completa de la compilación + Commit de Git de OpenClaw + Marca de tiempo de compilación de OpenClaw + Commit de Git copiado + Marca de tiempo de compilación copiada + + "No se pudo preparar un archivo adjunto para enviarlo." + "Micrófono apagado" + "Mostrar alertas de OpenClaw" + "Actividad del hilo" + "Completo" + "Aprobación permitida y guardada." + "Mostrar el historial de llamadas recientes" + "1 pendiente" + "Adjunto no compatible" + "0 = exacto" + "Conecta el Gateway para buscar hilos." + "%1$s cuentas" + "Los cambios de Cron requieren operator.admin. Los códigos de configuración no lo conceden de forma intencionada. Vuelve a conectarte con el token compartido o la contraseña del Gateway para solicitar acceso de administrador. Si este dispositivo aún no lo tiene, aprueba la actualización de permisos pendiente desde un cliente administrador existente." + "Apply Patch" + "Pellizcando" + "Activar altavoz" + "Omisiones consecutivas" + "Esta carpeta aún no tiene archivos." + "Sin conexión" + "Consulta y gestiona el estado de las skills instaladas." + "Fallida" + "Agente predeterminado" + "Cámara" + "Quitar del grupo" + "Buscando" + "En pausa para la reproducción de voz" + "Gateway verificará esta versión exacta con ClawHub antes de descargarla. Si la versión requiere una aceptación explícita del riesgo, Android mostrará la advertencia de Gateway antes de volver a intentarlo." + "El código de configuración usa un ID de zona IPv6. Usa una dirección IPv6 sin ámbito o un nombre de host de LAN." + "Archivo adjunto" + "Configura las palabras de activación, la conversación y la reproducción." + "Escuchando (PTT)" + "Propuesta rechazada." + "Mostrar barra lateral" + "usuario" + "%1$s · %2$s" + "Mínimo" + "Denegar" + "AGENTE ACTIVO" + "1 programada" + "Sin respuesta" + "%1$s seleccionado" + "Array JSON de argv del comando" + "No se pudo leer esa imagen. Selecciona una captura de pantalla clara o una imagen del código QR de openclaw qr." + "No se pudo %1$s la propuesta de Skill Workshop." + "Respondida en otro lugar" + "Gateway registró la aprobación una vez." + "status" + "OpenClaw solo comprueba la ubicación cuando el Gateway vinculado la solicita. En la siguiente pantalla de Android, elige %1$s para permitir las comprobaciones mientras la aplicación está en segundo plano." + "rechazar" + "Contraste" + "¿Reemplazar la configuración de Gateway?" + "No se pudieron cargar las automatizaciones." + "Tú" + "Micrófono integrado" + "Superficie" + "No hay propuestas" + "Hilo principal" + "Abrir chat" + "Las acciones de vinculación de dispositivos no están disponibles en esta sesión de Gateway. Ejecuta openclaw devices list en el host de Gateway y gestiona la solicitud allí. La aprobación de capacidades del nodo se realiza por separado y sigue usando nodes approve <request id>." + "Solicitud de acción" + "list pins" + "Conéctate a un Gateway para cargar las propuestas del Taller de Skills." + "No se aceptó el código de configuración" + "Cerrar sesión" + "El proveedor de transcripción en tiempo real no está configurado." + "Mostrar apps del sistema" + "Actualiza tu Gateway para ver la configuración del modelo del proveedor." + "Enviando dictado" + "Inspecciona esta propuesta para cargar su contenido Markdown." + "abrir OpenClaw y preguntar %1$s" + "razonamiento" + "Cliente" + "Aplicado" + "vídeo" + "Promocionado" + "En línea" + "Ámbitos" + "El proveedor de voz en tiempo real no está configurado." + "%1$s · %2$s" + "kick" + "Gateway devolvió una automatización no válida." + "ID de instancia" + "Se requiere el token de Gateway. Introdúcelo de nuevo o edita esta conexión." + "Fuente" + "Actualizar" + "%1$s en cola" + "Iniciar chat" + "Las llamadas a herramientas del chat que esperan en el hilo activo siguen visibles aquí." + "Se requiere revisar el certificado" + "Abre la superficie actual de Canvas para inspeccionarla o interactuar con ella." + "Automatización actualizada." + "No hay sesiones recientes" + "Script" + "Estado del Gateway, preparación del nodo del teléfono y flujo de registros recientes." + "Abrir detalles de la automatización" + "Entorno de ejecución" + "1 trabajador más" + "Agente %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Activa %1$s en Ajustes de Android para continuar." + "Reparar" + "reactions" + "Listo" + "Versión y actualización" + "OpenClaw mostrará aquí las aprobaciones, los trabajos fallidos y los problemas de canales." + "Micrófono USB" + "Hay demasiados recursos compartidos esperando para agregarse." + "Omitido" + "Usa la dirección LAN del ordenador del Gateway o un nombre de host remoto seguro." + "Activado" + "Buscando hilos" + "Conversación en tiempo real" + "· %1$s" + "OpenClaw está preparando una respuesta." + "Aprobación permitida una vez." + "Configuración del proveedor" + "ninguna" + "Las cargas útiles del script se conservan sin cambios. Usa la CLI para editar este script." + "Guía de configuración de Android" + "%1$s aplicaciones bloqueadas para el reenvío." + "Dispositivos emparejados" + ":%1$s" + "%1$s pendientes" + "Nombre del dispositivo" + "Enviar" + "Ubicación" + "p. ej. America/New_York" + "Destino de la sesión" + "Revisar la Skill de ClawHub" + "snapshot" + "¿Rechazar la solicitud de emparejamiento de este dispositivo?" + "Micrófono preferido" + "Host del nodo" + "Nivel" + "Cerrar selector de apps" + "Pega un token compartido del Gateway o un token emitido por el operador." + "Todos los sistemas funcionan con normalidad" + "Diagnósticos de Gateway copiados" + "Error de audio" + "Reemplazar configuración" + "Acciones rápidas" + "Error al enviar: el chat falló antes de que comenzara la ejecución; inténtalo de nuevo." + "Micrófono" + "El chat sigue comprobando el estado del Gateway." + "Ubicación precisa" + "Permitir una vez" + "+%1$s más" + "thread create" + "Bloqueada" + "Palabra o frase de activación" + "Gateway necesita la aprobación del dispositivo" + "Micrófono externo" + "%1$s/%2$s listas" + "Conectado (operador sin conexión)" + "Capacidad no aprobada" + "Esto elimina permanentemente la automatización y su programación del Gateway." + "Cargando imagen…" + "Conectar" + "Aprobar acceso del nodo" + "Añadir Gateway" + "Transcripción no disponible: %1$s" + "Imagen" + "Fluyendo" + "Cerrar la vista previa de la imagen" + "eval" + "Último comando: %1$s" + "Ten una terminal abierta en el dispositivo que ejecuta OpenClaw." + "No falta ningún elemento" + "La salida del lienzo necesita una conexión activa al Gateway." + "%1$s · %2$s" + "Aislado" + "© 2026 OpenClaw Foundation — Licencia MIT." + "PDF" + "Conversations" + "Consolidación de memoria y diario de sueños." + "Create Goal" + "Esta automatización cambió mientras la editabas. Revierte a la versión más reciente del Gateway antes de guardar." + "Cuando está conectado, el Gateway puede activar el teléfono mediante una notificación push silenciosa en lugar de mantener una sesión siempre activa." + "Modo de activación" + "¿Eliminar dispositivo emparejado?" + "Texto del evento del sistema" + "No se pudo copiar la imagen del widget" + "No" + "Ruta opcional" + "Enviando voz en cola" + "Integrado" + "hide" + "runs" + "Se requiere la contraseña de Gateway. Introdúcela de nuevo o edita esta conexión." + "Texto del evento" + "Transcripción en vivo" + "No se pudo cargar la configuración del modelo del proveedor." + "%1$s aplicación autorizada para reenviar." + "Configuración de voz" + "Adjuntar video" + "Imágenes adicionales ocultas: %1$s" + "¿Rechazar solicitud de emparejamiento?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Editor" + "Bifurcar desde aquí" + " · Conversación: Hablando" + "Anulación opcional" + "Predeterminado" + "Aprobación de comandos" + "La aplicación y el Gateway usan versiones de protocolo incompatibles. Actualiza OpenClaw en ambos y vuelve a intentarlo." + "Actualizar pantalla" + "Leer fotos y archivos multimedia recientes" + "Escuchar" + "Conectado" + "Completada" + "Tu teléfono está emparejado con %1$s. Continúa para completar el acceso al nodo." + "Hilo actual" + "Razonamiento %1$s" + "Silenciado" + "OpenClaw agradece a sus socios de la comunidad de código abierto." + "abierto" + "Conectando con el Gateway" + "El gateway puede cambiar esta ruta pero no puede borrar una ruta existente." + "TTS" + "Guardando…" + "Anclaje %1$s" + "search" + "Activar" + "Inspecciona y gestiona el trabajo programado del Gateway." + "Una respuesta anterior ya permitió este comando una vez." + "generate" + "Úsalo únicamente en una red privada de confianza." + "Buscar en configuración" + "La conversación está activa" + "La autenticación de Gateway no está configurada. Edita esta conexión e inténtalo de nuevo." + "Error: se alcanzó el endpoint seguro, pero se agotó el tiempo de espera de la verificación de la huella TLS. Comprueba Tailscale Serve o el TLS del gateway e inténtalo de nuevo." + "Paso 1" + "Dictado" + "Abrir selector de apps" + "No hay aprobaciones pendientes" + "edit" + "Conéctate a tu Gateway" + "Introduce el código de configuración de openclaw qr." + "Diagnóstico" + "Las demás apps permanecen sin cambios." + "Crujiendo" + "Esto elimina permanentemente el hilo y su transcripción." + "Dispositivo aprobado." + "Reintentando automáticamente" + "Imagen del widget copiada" + "%1$s roles" + "Falta 1 elemento" + "%1$s programadas" + "react" + "Agentes" + "Conecta el Gateway para cargar las automatizaciones." + "Reconectando…" + "Volver a la configuración" + "send" + "No se pudo probar la conexión" + "Verificar e instalar" + "Convierte este dispositivo en un nodo seguro de OpenClaw para chat, voz, cámara y herramientas del dispositivo." + "Configuración manual" + "Abre el chat para iniciar o reanudar el hilo actual." + "Consejo: deja de escuchar para enviar el turno capturado." + "Omitir" + "Error en la solicitud de voz" + "Esto rechazará \"%1$s\" y actualizará el estado de Skill Workshop desde el Gateway." + "Descascarando" + "update" + "Compartir" + "Cámara activada" + "Telegram, WhatsApp, email y otros canales aparecerán aquí después de la configuración." + "Error de red" + "Explorando pozas de marea" + "Restaura ahora el lienzo para session=%1$s source=%2$s. Si existe un estado de A2UI, reprodúcelo inmediatamente. De lo contrario, crea y renderiza un panel compacto y adaptado a dispositivos móviles en Canvas." + "Error al iniciar: %1$s" + "No solicitado" + "Configura un proveedor de %1$s en el Gateway" + "kill" + "Aprobaciones" + "Archivos no disponibles" + "Marcar como no leído" + "Buscar personas y datos de contacto" + "Se requiere la identidad del dispositivo" + "Hilo de OpenClaw" + "Permitir acceso a la fototeca." + "Una respuesta anterior ya resolvió esta aprobación." + "No hay hilos recientes" + "Tiempo de espera: %1$ss" + "No hay coincidencias" + "Leer notificaciones de aplicaciones seleccionadas" + "Disponibilidad desconocida" + "Configurar conversación" + "Extra" + "Gateway emparejado. Esperando el acceso del operador." + "Adjuntar imagen" + "Elige qué llega a OpenClaw." + "Reaprobación de capacidad pendiente" + "Revisa los elementos resaltados" + "Escuchando..." + "Ponme al día" + "Mensaje" + "Leer contactos" + "El almacenamiento de archivos adjuntos sin conexión está lleno; elimina primero los elementos en cola." + "Una vez" + "Cambiar nombre" + "No se encontraron canales." + "Ver todo" + "Nuevo dispositivo" + "Session Status" + "Abrir vista previa de la imagen" + "La rama de la sesión cambió; revisa y reintenta este mensaje." + "close" + "Parece un código de configuración. Vuelve atrás, selecciona Configurar Gateway y luego Usar código de configuración." + "✦" + "Agentes y automatización" + "Aplicar" + "Se omitió la ejecución de la automatización." + "Continuar" + "Supervisando · %1$s tareas programadas" + "Explorar" + "tabs" + "Pendiente" + "Conversación: %1$s" + "read" + "Seleccionar texto" + "Actividad de movimiento" + "descripción: %1$s" + "Reproducir audio" + "Hora" + "Sin verificar" + "Yield" + "Copiar comando de aprobación" + "Salida de la pantalla actual y superficie interactiva de la app." + "Servicio conectado" + "Visualización" + "Listo cuando quieras" + "No se pudo cargar el catálogo de proveedores." + "Hablando · esperando respuesta" + "No concedido" + "Guardar cambios" + "Gateway rechazó la ejecución de la automatización." + "Session Send" + "Buscar en ClawHub" + "Permite siempre las comprobaciones de ubicación solicitadas mientras OpenClaw está en segundo plano; Android muestra esto en la notificación persistente del nodo." + "Evento del sistema" + "Conecta Gateway para ver los proveedores" + "Próximo latido" + "Gateway emparejado. Esperando la aprobación de capacidades del nodo." + "Poniendo en salmuera" + "Cerrar Canvas" + "Escribir contactos" + "Ninguna skill instalada coincide con esta búsqueda." + "Configuración del proveedor de conversación" + "Music Generation" + "Configuración de Talk" + "Supervisión · 1 hilo" + "Texto de carga útil" + "Establecer texto" + "Aprobación %1$s" + "Gateway no devolvió la disponibilidad de %1$s" + "%1$s modelos configurados. Actualiza para volver a comprobar la disponibilidad." + "Conversation Send" + "Lienzo" + "1 proveedor" + "No se pudo leer automáticamente el certificado del gateway. Pega la huella digital SHA-256 obtenida en el host del gateway." + "Error al enviar: %1$s" + "Puente" + "Error de entrega" + "Usa OpenClaw desde tu teléfono" + "Apariencia" + "Taller de Skills" + "Necesita un token" + "Vista previa · %1$s" + "Se requiere permiso para usar el micrófono" + "Conecta el Gateway para cargar las propuestas de Skill Workshop." + "Todos los sistemas operativos" + "No se puede acceder al Gateway" + "OC" + "Actualizado" + "Conectado (nodo sin conexión)" + "Inicio" + "El dictado está escuchando" + "No hay hilos archivados" + "Elige e inspecciona los asistentes disponibles en este gateway." + "Modo de conversación activo" + "Trabajando · 1 ejecución activa" + "Aceptar y habilitar" + "Se requiere actualizar el Gateway" + "Copiar imagen" + "URL del Gateway" + "main, isolated, current o session:<id>" + "Contenido multimedia no disponible" + "Conéctate a tu Gateway para abrir un shell en el espacio de trabajo del agente." + "%1$s://%2$s:%3$s" + "No se pudieron cargar los detalles de la aprobación. Actualice y vuelva a intentarlo." + "Puedo comprobar el estado del Gateway, reparar la configuración, cambiar modelos o conectar canales." + "Tool Call" + "Hilos" + "Write" + "Empieza con una indicación o usa la voz." + "D" + "Abrir Ajustes" + "Observando…" + "Finalizar conversación" + "Último error" + "Revisa las acciones que requieren tu atención." + "Desactivada para todos los agentes." + "Iniciar voz" + "Volver a las tareas en segundo plano" + "Aún está finalizando otra acción de cron." + "Tiempo de espera %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "El reconocimiento de voz en el dispositivo no está disponible." + "Script · solo lectura" + "Los archivos adjuntos son demasiado grandes para ponerlos en cola en un solo mensaje; elimina algunos e inténtalo de nuevo." + "1 modelo configurado. Actualiza para volver a comprobar la disponibilidad." + "Widget no disponible" + "Se requiere una conexión segura para este host." + "Recientes" + "No hay automatizaciones coincidentes." + "El teléfono puede acceder al Gateway" + "Gateway" + "Caducada" + "Trabajo de OpenClaw programado desde tu gateway." + "Sub-agent" + "Esperando la aprobación del dispositivo" + "Cargando hilo" + "Este gateway ahora presenta un certificado en el que confía este dispositivo." + "Escalonar ms" + "event create" + "documento" + "Configurar Gateway" + "Reproducir video" + "La autenticación guardada no es válida. Vuelve a autenticarte o restablece esta conexión de Gateway." + "Mientras se usa" + "screenshot" + "Rebobinar hasta aquí" + "Expresión cron, p. ej. 0 9 * * *" + "Volver a voz" + "Hablar" + "Detalles" + "%1$s/%2$s en línea" + "%1$s aplicaciones autorizadas para reenviar." + "Chat" + "Se necesita acceso al micrófono." + "Desplazándose" + "Editar" + "Horas de silencio" + "Copiar diagnóstico" + "Programado" + "Crear" + "Caduca en %1$s" + "Descartar" + "¿Rechazar propuesta?" + "Error de voz (%1$s)" + "Problema" + "Busca en los metadatos del registro. El Gateway vuelve a verificar la confianza antes de cualquier descarga." + "Use una dirección IP de LAN privada para la configuración local, o habilite Tailscale Serve / exponga una URL de gateway wss:// para el acceso remoto." + "Enviando…" + "Cuenta %1$s" + "Suggest Task" + "Buscar" + "Escuchando" + "No se ha cargado la automatización." + "Hay una actualización de Gateway disponible. Ejecuta la actualización desde la Web UI o la CLI cuando quieras." + "pronto" + "No hay aprobaciones del Gateway." + "Host" + "Añade una palabra o frase de activación por campo. Después, di una antes de tu comando." + "Transcribir y luego enviar" + "Ejecutar a las" + "Pausar audio" + "Acceso al dispositivo Gateway" + "Sin vista previa" + "Dispositivos" + "OpenClaw para Android." + "Aprobación de capacidad pendiente" + "Guarda o revierte tus cambios antes de ejecutar, activar, desactivar, eliminar o actualizar esta automatización." + "Aún no hay automatizaciones." + "Esta skill necesita configurar %1$s elementos. Android muestra lo que está instalado; los cambios de configuración se realizan desde la aplicación de escritorio o la CLI." + "%1$s recientes" + "Canales" + "Sin fase" + "Activo en este teléfono" + "Comprobando el acceso al nodo" + "Zona horaria" + "Acciones de inspección y aplicación de Skill Workshop" + "Permitir siempre" + "present" + "Las Skills instaladas en el Gateway aparecerán aquí." + "Es posible que el código haya caducado o que se haya generado para otro Gateway." + "Permiso necesario" + "La automatización tiene una configuración no válida." + "Lista de permitidos" + "Configuración, estado y reparación" + "groups" + "Clave pública" + "Acerca de" + "No se encontró ningún código QR de configuración en esa imagen. Selecciona el código QR generado por openclaw qr o introduce manualmente el código de configuración." + "permissions" + "Conecta el gateway para cargar nodos y dispositivos emparejados." + "Cambiar rama" + "No hay Skills" + "Las respuestas se reproducen en voz alta" + "Marcar como leído" + "Aprobación de nodo pendiente" + "wake" + "%1$s propuestas" + "La autenticación del Gateway requiere atención." + "Detalles de la conexión" + "Milisegundos" + "Reconocimiento de voz" + "Descripción" + "Conversaciones recientes" + "Tu teléfono envía esta información a tu Gateway, no a un servidor gestionado por OpenClaw. Tu Gateway puede incluirla en las solicitudes al proveedor de IA que elijas." + "Entrega" + "Silenciar altavoz" + "%1$s en ejecución · %2$s completados · %3$s fallidos" + "Abriendo la conexión con el Gateway" + "Supervisión · %1$s hilos" + "La ejecución de la automatización ha finalizado." + "No hay apps coincidentes." + "Enviar al chat" + "Automatización eliminada." + "Activar" + "Ejecuciones recientes" + "Alinea el código QR dentro del cuadrado." + "No se pudieron cargar las aprobaciones." + "Ya lo aprobé" + "Conecta tu Gateway para cargar la disponibilidad de los proveedores." + "No emparejado" + "Esta aprobación caducó antes de poder resolverse." + "Observando en %1$ss — cambia a la app de destino" + "Prompt del agente" + "emoji list" + "Recurrente" + "Buscar en OpenClaw" + "%1$s pendientes" + "El reconocimiento de voz en el dispositivo no está disponible" + "Ninguna app puede compartir este mensaje" + "Cerrar búsqueda" + "Comando a observar" + "Estado" + "Acceso a notificaciones" + "Altavoz silenciado" + "Buscar hilos" + "Aceptar" + "No se pudo abrir la guía de configuración." + "preguntar a OpenClaw %1$s" + "Wait for Agents" + "Dirección" + "El trabajo programado creado en el gateway aparecerá aquí." + "Mostrando el fragmento de registro más reciente." + "Usar código de configuración" + "sticker" + "Usa un Gateway seguro wss:// o Tailscale Serve, genera un código de configuración de acceso completo en la Control UI o con openclaw qr, luego escanéalo o pégalo abajo y vuelve a conectarte para habilitar los ajustes y las actualizaciones." + "steer" + "Seleccionado" + "Android puede escanear o pegar un código de configuración existente, pero este gateway aún no expone la generación de códigos de configuración a la app. Genera el QR/código en el host del gateway con openclaw qr y luego escanéalo aquí o pega el código de configuración a continuación." + "Estado del lienzo" + "Corregir conexión" + "Guardar imagen" + "Nodo %1$s" + "Se necesita la contraseña de Gateway" + "Update Plan" + "Eliminar adjunto" + "La ejecución de la automatización ha fallado." + "Límites del proveedor y estado de la cuota." + "Catálogo de conversación de Gateway no cargado" + "este gateway" + "Aún no hay ejecuciones recientes." + "El modelo de lenguaje en el dispositivo no está disponible" + "El panel necesita un Gateway conectado" + "Las propuestas coincidentes aparecerán aquí después de que los agentes creen borradores de skills reutilizables." + "Session Search" + "OpenClaw está hablando" + "Escanear QR" + "Aplicaciones seleccionadas" + "Revertir cambios" + "Comando de aprobación copiado" + "Estado de la entrega" + "Código QR no aceptado" + "Tu centro de comandos de voz." + "Probar conexión" + "OPENCLAW" + "Web Fetch" + "Indicación" + "¿Aprobar dispositivo?" + "Conéctate a tu Gateway para abrir el panel de esta sesión." + "¿Eliminar %1$s y sus credenciales guardadas de este teléfono?" + "El código QR apunta a un Gateway remoto no seguro. %1$s %2$s" + "Superficie de pantalla lista" + "Emparejar Gateway" + "Conecta el Gateway para cargar los canales." + "Se pausa durante otra actividad de voz." + "Modelo" + "Fotos" + "Pegar código de configuración" + "OpenClaw está hablando" + "Conectando..." + " · Ubicación: Siempre" + "Mensajes: %1$s" + "Navegando entre arrecifes" + "Cargar desde el gateway" + "texto: %1$s" + "Necesita" + "rename group" + "Listo" + "El diario está esperando su primera entrada." + "Aprobar" + "Página en vivo" + "La automatización ya está en ejecución." + "Elimina esta automatización después de una ejecución única satisfactoria." + "Listo para chat y voz" + "Conectado (operador: %1$s)" + "El emparejamiento con Gateway se ha completado. Aprueba este teléfono como nodo para que OpenClaw pueda usar las capacidades del dispositivo que habilites." + "Respuesta cancelada" + "imagen" + "%1$s retenidos" + "No hay hilos coincidentes" + "delete" + "Diseño: Compacto" + "channels" + "Concedido" + "Cada %1$s min" + "1 token" + "%1$s %2$s" + "Aplicaciones instaladas" + "pendiente" + "Preparando nota de voz…" + "Nunca" + "Subsistema" + "Al finalizar el comando" + "Conexión" + "No se pudo cargar el historial de ejecuciones de automatizaciones." + "Nombre de la automatización" + "Paso 2" + "Diagnosticar" + "Algunas comprobaciones de estado de los canales no se completaron." + "pin" + "Copiar %1$s" + "Emparejado" + "No se pudieron guardar las palabras de activación" + "Esto pondrá \"%1$s\" en cuarentena y actualizará el estado de Skill Workshop desde el Gateway." + "Grabar nota de voz" + "En cola" + "Respondida" + "Permitir las herramientas de cámara cuando se soliciten." + "Problemas" + "Activación por voz" + "Solicitud de emparejamiento rechazada." + "hace %1$s d" + "roles" + "Skills" + "Archivar" + "El nodo está sin conexión. Vuelve a conectarlo e inténtalo de nuevo." + "Sistema" + "IP remota" + "Sin agrupar" + "Detalles de la programación" + "Capacidades del teléfono" + "No disponible" + "Panel" + "Pegar token" + "No hay proveedores" + "Huella digital SHA-256" + "Aún no hay hilos" + "Micrófono Bluetooth" + "Reciente" + "Cambiar el nombre del hilo" + "Resultado de la resolución desconocido. Las acciones permanecen deshabilitadas hasta verificar el registro del Gateway." + "dialog" + "Escuchar palabras de activación" + "camera snap" + "Preparando reproducción…" + "Gateway seleccionó un proveedor desconocido: %1$s" + "delete group" + "Seguir Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Conecta el Gateway para cargar los agentes." + "Volver" + "Compartir mensaje" + "Genera un código QR." + "Reiniciar" + "Altavoz activado" + "¿Eliminar el grupo?" + "Falta" + "Buscar propuestas" + "stop" + "Seguro (TLS)" + "No hay nodos ni dispositivos emparejados." + "%1$s%% restante %2$s" + "El código de configuración caducó" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DIARIO" + "notify" + "Este teléfono permanece inactivo hasta que el Gateway lo necesita; entonces se activa, se sincroniza y vuelve a entrar en reposo." + "%1$s modelos configurados" + "Licencias" + "Conecta el Gateway para buscar Skills de ClawHub." + "Skill" + "La conexión con el Gateway cambió. Reinicia OpenClaw para volver a conectarlo." + "ID del dispositivo" + "Gateway no identificó el proveedor activo de %1$s" + "En espera" + "Palabras de activación guardadas" + "Más antiguos primero" + "Pantalla" + "En ejecución desde" + "No se admiten los ID de zona IPv6. Usa una dirección IPv6 sin ámbito o un nombre de host de LAN." + "Enviado — confirmando la entrega…" + "audio" + "This gateway connection needs operator.admin to update skills." + "Código de configuración" + "Aceptar la advertencia de Gateway e instalar" + "Actualizar chat" + "Intervalo" + "Las acciones de propuestas de Skill Workshop requieren el ámbito operator.admin." + "Sesiones" + "Cambiar nombre…" + "Conecta el Gateway para cargar el dreaming." + "Configuración" + "Abrir Talk" + "poll" + "Conéctate para cargar tus agentes" + "role remove" + " · Conversación: Escuchando" + "ClawHub no devolvió una versión instalable para %1$s." + "Comando" + "Esta aprobación se canceló antes de poder resolverse." + "Micrófono activado · esperando al gateway" + "Texto" + "Mostrando %1$s de %2$s. Refina la búsqueda para ver más." + "v%1$s disponible" + "%1$s://%2$s" + "%1$s... (CORRECTO)" + "Proveedores y modelos configurados" + "Conectando…" + "Conéctate a un Gateway para guardar las palabras de activación" + "Abrir perfil" + "Inicia tu Gateway." + "Ayúdame a convertir este objetivo en una lista de verificación práctica: " + "Borrar búsqueda de sesiones" + "Puerto" + "Introduce el código de configuración" + "No se pudieron cargar los registros del Gateway." + "%1$s proveedores listos" + "Tus agentes están listos" + "No hay ningún proveedor de %1$s configurado en el Gateway" + "Escuchando durante un turno" + "Observar" + "Milisegundos de época (opcional)" + "No hay modelos configurados. Actualiza para volver a comprobar la disponibilidad." + "Configuración" + "Cámara trasera" + "approve" + "Antes de empezar" + "No se pudieron cargar las Skills." + "Deshabilitada" + "Aún esperando aprobación" + "No se pudieron cargar las tareas en segundo plano" + "Comprueba que OpenClaw pueda hablar con claridad en este teléfono." + "Trabajando · %1$s ejecuciones activas" + "Directorio de trabajo del comando" + "Nombre del grupo" + "Elegir de la galería" + "Version %1$s" + "Atrás" + "Connect the gateway to update skills." + "Eliminar después de ejecutar" + "El código de configuración apunta a un Gateway remoto no seguro. %1$s %2$s" + "Computer" + "Gateway desconectado." + "Session Settings" + "Conecta el Gateway para comenzar" + "Aviso de seguridad" + "Otra respuesta" + "Descartar advertencia de imagen compartida" + "El Gateway evaluó una versión diferente de ClawHub. Revisa de nuevo la Skill antes de instalarla." + "Abrir acceso del sistema" + "Finalizada" + "Imagen no disponible" + "Notificaciones" + "Aplicar, rechazar y poner en cuarentena requieren el ámbito operator.admin. Vuelve a conectar con la autenticación compartida del gateway o aprueba una actualización del ámbito operator.admin del dispositivo para habilitar las acciones de ciclo de vida." + "sticker upload" + "Pescando langostas" + "Messages to recover" + "openclaw devices approve %1$s" + "Detalle legible del registro del Gateway." + "Revisa las propuestas de skills generadas antes de que se conviertan en skills activas." + "Incluido" + "%1$s disponibles" + "Aprobación del nodo pendiente" + "Gateway pendiente" + "Se requiere autenticación" + "Nodos" + "Mantener activo" + "OpenClaw está respondiendo" + "Documentación" + "%1$s listos" + "Aún no hay resultados" + "El idioma del dispositivo no es compatible" + "En cola — se enviará al volver a conectar" + "hace %1$s min" + "Rama actual" + "Comprobando el acceso de emparejamiento" + "Acceso limitado al Gateway" + "Ejecutando herramientas..." + "Comprobando aprobación…" + "Captura fotos y clips desde este teléfono" + "Conectado y listo" + "Cerrar" + "Convierte un objetivo en una lista de verificación práctica." + "El código de configuración tiene una URL de Gateway no válida." + "Habilita solo el acceso que te sientas cómodo permitiendo que OpenClaw use mientras este teléfono esté conectado. Puedes cambiarlo más tarde en la configuración de Android." + "Cuenta" + "remove" + "Contraseña opcional" + "La autenticación de Gateway necesita revisión. Comprueba la configuración de Gateway y vuelve a intentarlo." + "El código QR usa un ID de zona IPv6. Usa una dirección IPv6 sin ámbito o un nombre de host de LAN." + "add" + "Buscando kril" + "En buen estado" + "Completado en %1$s" + "Argumentos" + "Opciones de instalación" + "En %1$s h" + "La aprobación del Gateway está pendiente. Ejecuta esto en el host del Gateway:" + "Se requiere acceso de administrador" + "set groups" + "Fijar modelo" + "Borrar búsqueda" + "Activada para los agentes elegibles." + "No hay ningún hilo actual" + "límites: %1$s" + "Después de %1$s" + "Permite que el programador ejecute esta automatización." + "%1$s aplicados" + "Aún no hay diario de sueños." + "Actualizar tareas en segundo plano" + "Resume los hilos recientes y los próximos pasos." + "Se ejecuta en el dispositivo mientras OpenClaw está visible." + "%1$s está trabajando" + "%1$s %2$s" + "Sin procesar" + "Ejecuciones" + "Ejecutar ahora" + "Rama sin título" + "Configurado" + "camera list" + "1 aplicado" + "camera clip" + "Sí" + "Prueba de audio" + "Retenido" + "events" + "Directorio de trabajo" + "Ir a lo más reciente" + "Permitir siempre" + "Escanear QR o código de configuración" + "Installing" + "Nodos activos, teléfonos emparejados y solicitudes de dispositivos pendientes." + "Captura: %1$s" + "Una respuesta anterior ya permitió este comando y guardó la elección." + "Solicitudes pendientes" + "Aprobado" + "Espacio de trabajo" + "Voz" + "Listo para hablar" + "Subagents" + "Error: no se detectó ningún endpoint seguro del Gateway. Habilita TLS del Gateway o Tailscale Serve, o usa una dirección LAN privada de confianza con Sin cifrar seleccionado." + "Señales" + "Destino de la sesión" + "Gateway registró una denegación." + "Aceptar" + "Pregúntale a OpenClaw lo que quieras" + "Vuelve a conectarte para continuar" + "%1$s emparejados" + "Esto aplicará \"%1$s\" y actualizará el estado de Skill Workshop desde el Gateway." + "Gateway sin conexión" + "openclaw devices list" + "Estado de conexión del nodo de OpenClaw" + "Las alertas permanecen en este teléfono." + "OpenClaw puede recibir alertas seleccionadas." + "Abrir pantalla" + "Acciones del chat" + "¿Permitir el control de otras apps?" + "Inspeccionando" + "Escanea o pega un código de configuración para añadir otro gateway." + "Enjambre" + "Se agotó el tiempo de espera de TLS" + "Sesiones recientes" + "Dispositivo emparejado eliminado." + "Gateway emparejado. Comprobando la aprobación de capacidades del nodo." + "Movimiento" + "La acción de cron falló." + "En la computadora de Gateway, ejecuta:" + "Buscar sesiones" + "Actualizar registros" + "Imagen no disponible · Toca para volver a intentarlo" + "openclaw nodes approve %1$s" + "Nota de voz · %1$s" + "Uso" + "Nautileando" + "Contexto %1$s%%" + "Transcribir indicaciones de voz" + "Silenciar" + "Inicia una nueva conversación y aparecerá aquí." + "Problema de conexión" + "Media" + "Bifurcar" + "Activar altavoz" + "Texto de evento del sistema" + "Orden: %1$s" + "%1$s en espera" + "Image Generation" + "Nota de voz" + "Nada requiere tu atención" + "OpenClaw necesita permisos de %1$s para continuar." + "Micrófono de auriculares con cable" + "Páginas" + "Entregado" + "Pendiente" + "Los detalles de la Skill no están disponibles en el estado actual de Skills." + "Elige qué puede compartir este teléfono." + "Esta automatización ya tiene una ejecución en cola." + "Conecta el Gateway para gestionar las automatizaciones." + "La automatización aún no está programada." + "Sin detalles" + "La aprobación está en curso.\nOpenClaw volverá a conectarse automáticamente." + "Conecta tu Gateway para ver la preparación de los proveedores." + "Esperando emparejamiento" + "Inicie o continúe una conversación" + "No hay tareas programadas" + "Responder a OpenClaw…" + "Estado" + "Nodo de OpenClaw · Conectado" + "Activo" + "Mostrar el estado de depuración del uso compartido de pantalla." + "No se informaron límites" + "Cerrar escáner" + "Cada %1$s d" + "Activado" + "Activar y abrir ajustes" + "En línea y listo" + "Ask User" + "Error de chat" + "Desplazar hacia adelante" + "%1$s de %2$s" + "Planifica el trabajo" + "console" + "Reintentar" + "Inicia un chat y tus conversaciones activas de OpenClaw aparecerán aquí." + "No se pudo cargar la automatización." + "Shell en el espacio de trabajo del agente" + "%1$s activos" + "Elige los permisos del dispositivo" + "Duración de la última ejecución" + "Agente predeterminado" + "%1$s h" + "La conversación está activa" + "No se pudo instalar %1$s desde ClawHub." + "Te damos la bienvenida a OpenClaw" + "Controlar otras apps" + "Índice de señales" + "Introduce el secreto…" + "%1$s:%2$s" + "decirle a OpenClaw que %1$s" + "Descubierto" + "Ocultar barra lateral" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "La reproducción de audio no está disponible" + "aplicar" + "No se pudo cargar el uso." + "Mantener el nodo disponible durante el trabajo activo." + "Próxima activación" + "%1$s/%2$s" + "No hay entradas de registro recientes." + "Gateway manual" + "Renombrar grupo" + "Update Goal" + "Disponibilidad del proveedor desconocida" + "Proveedores" + "Eliminar grupo…" + "Carga útil" + "Registro de llamadas" + "Memory Search" + "%1$s proveedores" + "Contexto del teléfono y privacidad" + "%1$s/%2$s conectados" + "%1$s %2$s" + "La recuperación tras el reinicio del Gateway sigue en curso." + "Al reemplazar el código de configuración, se borran las credenciales de configuración y los tokens de dispositivo guardados en este teléfono antes de volver a conectarse. Es posible que este teléfono necesite de nuevo la aprobación de capacidades del nodo; continúa solo si deseas vincularlo con un nuevo código de configuración del gateway." + "Abre una automatización para consultar su configuración y su historial de ejecuciones. Las conexiones con permisos de administrador también pueden ejecutarla, editarla, activarla, desactivarla o eliminarla." + "Contexto --" + "Propuesta puesta en cuarentena." + "Automatización en pausa." + "OpenClaw móvil" + "A2UI reset" + "Gateway no disponible" + "Read" + "Esta skill necesita configurar 1 elemento. Android muestra lo que está instalado; los cambios de configuración se realizan desde la aplicación de escritorio o la CLI." + "Última ejecución" + "Se necesita acceso a la cámara para escanear el QR de configuración." + "No se pudo actualizar el modelo." + "Burbujeando" + "thread reply" + "Eliminar…" + "Conecta el Gateway para consultar las automatizaciones." + "REGISTROS RECIENTES" + "Cargando ejecuciones recientes…" + "No se puede obtener una vista previa de este archivo. Puede ser un archivo binario o demasiado grande." + "Comprobar" + "Leer la ubicación de este teléfono" + "Clave de Skill" + "Se instaló %1$s." + "Gateways" + "acciones: %1$s" + "Modo de reenvío" + "%1$sk" + "Cambiar el diseño de los hilos" + "No hay ningún endpoint TLS" + "Gateway de OpenClaw" + "Configurar manualmente" + "Pensando…" + "El acceso a Gateway necesita revisión" + "1 retenido" + "%1$ss" + "¿Aplicar la propuesta?" + "Ahora no" + "No aprobado" + "Buscar apps" + "1 modelo configurado" + "Descartar aviso de aprobación" + "·" + "Sin conexión" + "Proveedor de voz" + "La aprobación del Gateway está en curso. OpenClaw volverá a intentarlo automáticamente." + "Máx." + "Los cambios de cron requieren acceso operator.admin." + "Pensando" + "screen snapshot" + "Nodos observados: %1$s" + "No se encontraron acciones" + "Guardar y conectar" + "list" + "Gateway registró la aprobación y guardó la elección." + "Introduce un endpoint manual válido para conectarte." + "asistente" + "Enviando al chat..." + "Guardar perfil" + "Bloqueado" + "Editar automatización" + "Usa la misma red o una URL de Gateway remota segura." + "Ancla" + "Idioma" + "Esta aplicación es más antigua que el Gateway. Actualiza OpenClaw en este dispositivo y vuelve a intentarlo." + "Todo" + "Sesión del Gateway en curso" + "Esperando revisión" + "No hay Skills instaladas." + "Comprobando el Gateway" + "Desfase %1$s" + "Se desconoce el resultado para %1$s. Vuelve a conectarte, actualiza Skills y vuelve a intentarlo; el Gateway se une de forma segura a una instalación coincidente que aún esté en curso." + "Olvidar" + "No hay gateways emparejados." + "%1$s · %2$s" + "<secreto redactado>" + "%1$s problemas" + "OpenClaw" + "Escuchando · %1$s en cola" + "Voz del asistente silenciada" + "Las acciones de nodo solo se ejecutan cuando la app de destino está en primer plano (validado a través de la ruta remota). Las acciones globales y las acciones en la misma app funcionan aquí." + "Aún no se encontraron gateways. Usa la configuración manual si la detección está bloqueada." + "Abrir hilo" + "Trabajando" + "Empieza a hablar..." + "Nodo del teléfono" + "Muy alto" + "Ejecutar en el host de Gateway:" + "Los cambios en las skills requieren operator.admin. Vuelve a conectarte con un token de Gateway con permisos de administrador." + "Conecta el Gateway para consultar Skills de ClawHub." + "La lista de apps permanece en este teléfono." + "Inactivo" + "Se muestra en los ajustes de Accesibilidad de Android." + "Entrega inteligente" + "Rechazar" + "Gateway devolvió el estado \'%1$s\' después de %2$s." + "El token de Gateway no está configurado" + "Not available to this agent" + "Archivos" + "Permisos" + "No se pudo iniciar la cámara. Selecciona una imagen QR de la galería o introduce manualmente el código de configuración." + "Toca para copiar" + "Esperando %1$s min" + "%1$s." + "Conecta el Gateway para instalar Skills de ClawHub." + "Buscar voz" + " · Micrófono: escuchando" + "Vuelve a conectar con acceso operator.admin para revisar y cambiar la configuración del Gateway." + "Cargar más" + "Observar en 3s" + "run" + "Generando voz…" + "← Atrás" + "Desconectar" + "Ejecuta el comando de aprobación en la computadora de Gateway y luego vuelve a comprobarlo." + "Automatizaciones" + "%1$s min" + "Confiar" + "Ese código QR no es un código QR de configuración de OpenClaw. Genera uno nuevo con openclaw qr y vuelve a intentarlo." + "El micrófono preferido no está disponible; se usará el enrutamiento automático." + "Rechazado" + "Incluye Android y paquetes en segundo plano." + "Tu Gateway está listo." + "Activado" + "Structured Output" + "Esto está tardando más de lo esperado.\nComprueba que el Gateway esté en ejecución y accesible." + "No hay tareas en segundo plano para este agente." + "Reconectando" + "OpenClaw está comprobando el acceso al Gateway y al nodo." + "Code Execution" + "Sin uso de proveedores" + "Revisar" + "Se requiere permiso para usar el micrófono." + "%1$s d" + "%1$s disponibles" + "OpenClaw se está sincronizando de nuevo" + "El flujo de eventos se interrumpió; intenta actualizar." + "No se pudieron cargar los nodos y dispositivos." + "Conecta el Gateway para cargar las Skills." + "desconocido" + "Resultado" + "Error de conversación: el proveedor en tiempo real se cerró inesperadamente." + "OpenClaw urgente" + "ban" + "Se necesita el token de Gateway" + "Dispositivo emparejado" + "Necesita nueva aprobación" + "No programado" + "Contactos" + "Tu teléfono permanece inactivo hasta que se necesita" + "Escuchando · enviando voz en cola" + "No se pudieron cargar los detalles de la tarea" + "Mensaje del agente" + "Gateway requiere esta identidad de dispositivo. Vuelve a autenticarte o restablece esta conexión de Gateway." + "Próxima sesión" + "Seguridad de la conexión" + "Omitir por ahora" + "Sitio web" + "Conecta el Gateway para cargar las solicitudes de aprobación en la app." + "%1$s copiado" + "No hay aplicaciones seleccionadas. No se reenviará nada hasta que añadas aplicaciones." + "%1$s %2$s" + "Requiere configuración" + "No emparejado" + "Gateway recibió este teléfono" + "No hay modelos configurados" + "Desactivar" + "Idioma de la aplicación" + "Emparejando Gateway" + "La autenticación guardada no es válida" + "%1$s ámbitos" + "Conecta el gateway para cargar los registros recientes." + "Guardar palabras de activación" + "Gestiona las skills instaladas y añade versiones de confianza desde ClawHub." + "Enviando…" + "Aún no se han cargado agentes." + "Buscar en ClawHub" + "El chat está comprobando el estado del Gateway." + "Se requiere emparejamiento" + "Ejecuciones activas" + "Error — %1$s" + "Conexión entre este teléfono y OpenClaw." + "summarize" + "Imagen del widget guardada en Descargas" + "Iniciando…" + "%1$s tokens" + "Error del cliente" + "Verifica el dispositivo solicitante antes de concederle acceso." + "Micrófono Bluetooth LE" + "%1$s %2$s" + "Automatización habilitada." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Archivada" + "Recargar" + "Buscar automatizaciones" + "Los teléfonos vinculados y los hosts de nodos aparecerán aquí después del emparejamiento." + "%1$s: %2$s" + "Esta automatización ha cambiado en el Gateway. Revisa la versión más reciente antes de volver a guardarla." + "Detener dictado" + "Legible" + "Enviar mensaje a OpenClaw" + "La contraseña de Gateway no es válida. Vuelve a introducirla o restablece esta conexión de Gateway." + "Reconectar" + "Hora ISO, p. ej. 2026-07-09T09:30:00Z" + "%1$s herramientas" + "Una respuesta anterior ya denegó esta aprobación." + "Vinculado" + "Abrir %1$s" + "%1$s/%2$s" + "La ejecución de la automatización ha finalizado con un estado desconocido." + "La cola sin conexión está llena (%1$s mensajes); elimina primero los elementos en cola." + "Los gateways públicos requieren wss:// o Tailscale Serve. ws:// está permitido para localhost, hosts .local, el emulador de Android y direcciones IP de LAN privadas." + "Conecta el Gateway para cargar los detalles de la Skill." + "Se requiere acceso completo" + "Detector de activación" + "Expandir vista previa del enlace" + "Borrar la búsqueda de hilos" + "NULL (FALLIDO)" + "Actualizar" + "Administrador" + "Requiere atención" + "Vincula este dispositivo con tu Gateway para activarlo solo cuando haya trabajo real, tener a mano una vista general en vivo de los agentes y evitar bucles en segundo plano que agotan la batería." + "Roles" + "Responder" + "Catálogo de proveedores" + "Activar permiso en Ajustes" + "A2UI push" + "Comprobar acceso" + "Si el Gateway está accesible, la reconexión debería completarse sin intervención." + "Turno del agente" + "poner en cuarentena" + "Atención" + "Buscando…" + "¿Dónde obtengo un código de configuración?" + "No se pudo activar la skill." + "pdf" + "Eliminar" + "%1$s%% en línea" + "Sin canales" + "Voz en tiempo real" + "Acciones de rechazo y cuarentena de Skill Workshop" + "Nodos y dispositivos" + "Centro de comandos local" + "emoji upload" + "Cargando vista previa…" + "Alto" + "focus" + "describe" + "contexto de %1$s" + "Escuchando la respuesta..." + "voice" + "Conectado a %1$s" + "role add" + "El chat requiere atención" + "Activar micrófono" + "OpenClaw recopila y envía los nombres, los ID de paquete y el estado de las aplicaciones visibles en este teléfono cuando tu Gateway de OpenClaw vinculado lo solicita. Esto permite que tu asistente responda preguntas y realice acciones usando las aplicaciones instaladas." + "Gateway no conectado" + "Política" + "Se agotó el tiempo de espera para confirmar el mensaje enviado; actualiza para comprobar la entrega." + "Archivos de soporte" + "Expresión" + "Tareas en segundo plano" + "Sueño" + "No hay aplicaciones bloqueadas. Las aplicaciones pueden reenviar contenido a menos que añadas bloqueos." + "El reconocimiento de voz no está disponible" + "Plataforma" + "Gateway no devolvió la configuración de %1$s" + "¿Olvidar gateway?" + "Descripción opcional" + "Abrir %1$s" + "Lienzo de inicio" + "Soñando" + "De %1$s a %2$s" + "Compartir archivo" + "Tiempo real" + "API" + "OpenClaw está trabajando…" + "Hable o dicte con OpenClaw" + "¿Compartir información de las aplicaciones instaladas?" + "Cargando automatización…" + "Eliminar automatización" + "Asistente predeterminado" + "Elige un proveedor de %1$s compatible en el Gateway" + "No disponible" + "Carpeta vacía" + "Abrir ajustes" + "Desactivado" + "Tipografía" + "Detener" + "Aún no hay hilos coincidentes." + "El emparejamiento con el Gateway funcionó.\nAprueba las capacidades de nodo de este teléfono desde una interfaz de operador." + "Esta skill está instalada, pero actualmente no cumple los requisitos para ejecutarse. Usa la aplicación de escritorio o la CLI para realizar cambios de configuración." + "Reconocedor ocupado" + "Gateway doméstico" + "Ejecuta el comando de aprobación en el Gateway" + "Servicio deshabilitado" + "No se pudieron cargar las propuestas de Skill Workshop." + "Ponme al día sobre mis hilos recientes de OpenClaw y sugiere los próximos pasos." + "Ahora no" + "openclaw qr" + "start" + "Nodo de OpenClaw · Conversación" + "Leer y actualizar eventos" + "Error de conversación: el proveedor en tiempo real se cerró: %1$s" + "Conecta el Gateway para explorar los archivos del espacio de trabajo." + "%1$s mediante relé de Gateway" + "No se pudo cargar el catálogo de conversación de Gateway" + "Supervisando · 1 tarea programada" + "Cada %1$s h" + "Superficie de pantalla" + "Traducciones de OpenClaw · %1$s" + "Solicitud de comando" + "Actualizado" + "Canal" + "Activar sonido" + "Nuevo grupo…" + "Preparando audio…" + "Adaptativo" + "Pronto" + "%1$s trabajadores más" + "Web Search" + "Prueba Chat, Voz, Hilos, Proveedores o Configuración." + "OpenClaw activo" + "navigate" + "solicitado %1$s" + "Conecta el Gateway para consultar el historial de ejecuciones de automatizaciones." + "Acceso al dispositivo; aún se requiere la activación en Gateway" + "Cancelado" + "Introduce un código de configuración o una dirección de gateway válidos." + "Modelos" + "OpenClaw pasivo" + "La contraseña de Gateway no es válida" + "No se pudo verificar el cambio de emparejamiento del dispositivo. Actualiza e inténtalo de nuevo." + "Ver detalles" + "Bash" + "Token" + "El agente de OpenClaw conectado puede usar las capacidades del dispositivo que habilites. Continúa solo si confías en el Gateway y en el agente al que te conectas." + "Buscando percebes" + "Acceso seleccionado o completo a fotos concedido." + "Ejecutor de accesibilidad" + "Faltan %1$s elementos" + "Contraer la lista de verificación del plan" + "Se requiere la aprobación del nodo" + "Conectar Gateway" + "... +%1$s más" + "Expandir la lista de verificación del plan" + "Navegador" + "screen record" + "Ejecución pendiente" + "Al activarlo, OpenClaw podrá observar y controlar las pantallas de otras apps cuando esté armado. Se requiere el acceso de accesibilidad de Android." + "Origen" + "IA personal en tus dispositivos" + "Attach" + "Automático" + "Resumen" + "No se pudo solicitar la restauración. Toca para volver a intentarlo." + "Video" + "%1$s\n\n" + "Sin cifrar" + "Calendario" + "El estado del Gateway no es correcto; no se puede enviar" + "📎 %1$s" + "Último estado" + "Espera a que termine la respuesta actual antes de iniciar un chat nuevo." + "Perfil" + "Los límites del proveedor aparecerán aquí cuando tu Gateway los informe." + "1 problema" + "Los hilos de \"%1$s\" se conservan y vuelven a Sin agrupar." + "Recomendado" + "Creado" + "%1$s/%2$s tokens activos" + "Sin resultado de acción" + "Chasqueando" + "%1$s…" + "Abrir detalles de la Skill" + "Error al reproducir la voz: %1$s" + "Iniciar conversación" + "No se pudo cargar esta carpeta." + "El código QR no contenía un código de configuración válido." + "Revisa el acceso al nodo" + "Añadir frase de activación" + "No se puede acceder al gateway" + "Automatización" + "Necesita conexión" + "No se pudo resolver la aprobación. Actualice y vuelva a intentarlo." + "import" + "Cómo aparece este teléfono en OpenClaw." + "Enfocar la búsqueda de hilos" + "Conectar el Gateway" + "Leer calendario" + "La vista general se actualiza al volver a conectarse y cuando se abre esta pantalla." + "No se pudo desactivar la skill." + "Conectando todavía" + "En %1$s min" + "Leer SMS" + "Conecta el Gateway para cargar el uso." + "¿Qué puedes ayudarme a hacer desde este teléfono ahora mismo?" + "Necesita aprobación" + "Nuevo chat" + "Conecta el Gateway para actualizar las propuestas de Skill Workshop." + "La solicitud de OpenClaw falló." + "Permiso requerido" + "Revisa la preparación de los proveedores\ny los modelos configurados." + "Cargando" + "Alerta de fallo" + "Tema y texto de Android traducido." + "Micrófono apagado · enviando…" + "Ninguno" + "Ver" + "Nombre" + "Versión" + "Cron" + "Conecta este teléfono a un Gateway antes de abrir OpenClaw." + "Eliminar frase de activación" + "No se aceptó el código de configuración. Genera uno nuevo con openclaw qr." + "14 mensajes · Android" + "Error de transcripción: %1$s" + "Siempre" + "No se pudo cargar la actividad onírica." + "Ejecución de la automatización añadida a la cola." + "Conversation Turn" + "La automatización se ha iniciado." + "Nuevo grupo" + "Error del servidor" + "Video Generation" + "La aprobación del Gateway está pendiente. Ejecuta openclaw devices list en el host del Gateway, aprueba este teléfono y vuelve a intentarlo." + "Las entradas aparecen después de que un ciclo de dreaming escriba un resumen narrativo." + "%1$s ms" + "Almacén de memoria" + "Asistente trabajando" + "OpenClaw puede listar las apps visibles en el lanzador." + "Error al hablar: %1$s" + "Buscar Skills instaladas" + "Inspeccionar" + "Process" + "Hilos recientes" + "Terminal" + "Actual" + "1 cuenta" + "En pausa" + "Permitir cámara" + "Las solicitudes de aprobación de ejecución aparecerán aquí mientras este teléfono esté conectado." + " · Micrófono: pendiente" + "Copiar" + "Detalles copiados" + "Eliminar" + "Pide a OpenClaw que use las funciones de Android." + "member" + "Comprobando si este Gateway admite el asistente de configuración de OpenClaw." + "Usa las opciones de recuperación que aparecen a continuación para volver a conectarte." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "No se pudieron cargar los canales." + "En %1$s d" + "Errores consecutivos" + "No se pudo leer ningún código QR de esa imagen. Selecciona una imagen más clara o introduce manualmente el código de configuración." + "El Gateway es más antiguo que esta aplicación. Actualiza OpenClaw en el host del Gateway y vuelve a intentarlo." + "Conéctate antes de usar el chat, la voz y el estado en vivo." + "Reconectar Gateway" + "De terceros" + "Revisar preparación" + "Limitado" + "Logotipo de OpenClaw" + "Dejar de fijar modelo" + "Superficies de mensajería conectadas a este Gateway." + "Enviando" + "Los hilos archivados aparecerán aquí." + "Comando copiado" + "No hay vista previa disponible" + "Aprueba este teléfono en el Gateway.\nLuego, vuelve a intentar la conexión." + "Escanear código QR" + "Directorio de trabajo del comando · no se puede borrar" + "Actividad del hilo" + "Disponible" + "¿Eliminar la automatización?" + "%1$s hoy · %2$s en total" + "Contraseña" + "¿Poner propuesta en cuarentena?" + "No se incluyen avisos de licencia en esta compilación." + "No se pudo guardar la imagen del widget" + "Esperando %1$s" + "Hablando…" + "Proveedores y modelos" + "Nodo" + "%1$s " + "Indicación no disponible" + "Registros" + "Conecta el Gateway para consultar las propuestas de Skill Workshop." + "Herramientas" + "Interruptor del Gateway" + "Enviar SMS" + "OpenClaw está listo para continuar en tu chat habitual." + "No se encontraron comandos" + "Aún no hay ninguna actualización del lienzo. Toca para volver a intentarlo." + "Terminal necesita un Gateway conectado" + "Exec" + "Filtro de apps" + "Principal" + "%1$sk" + "Se requiere Gateway" + "Acceso" + "Paquetes: snapshot=%1$s foreground=%2$s" + "Reintentar la conexión" + "El programador cron está detenido." + "Abrir" + "Mensaje copiado" + "No pudimos acceder a tu Gateway.\nVamos a solucionarlo." + "ahora" + "Eliminar después de ejecutar" + "Seleccionado en este teléfono" + "unpin" + "Session History" + "Desfijar" + "Usar este teléfono" + "No se pudieron cargar los detalles de ClawHub para %1$s." + "Herramientas en ejecución" + "Compartir la ubicación precisa mientras la ubicación esté habilitada." + "Mobile UI" + "Tema" + "El Gateway aún muestra esta aprobación como pendiente. Revísala antes de volver a intentarlo." + "Finalizar nota de voz" + "Dictado: %1$s" + "No permitido" + "Elegir otra imagen" + "Vista previa de la imagen" + "OpenClaw solo escucha cuando inicias Conversación o Dictado." + "Compartir pasos y actividad" + "Requiere configuración" + "Actualiza este Gateway para usar el asistente de configuración de OpenClaw." + "Esta conexión con el Gateway necesita operator.admin para instalar Skills de ClawHub." + "Propuesta aplicada." + "%1$s pendientes" + "hace %1$s h" + "Leer registro de llamadas" + "%1$s en cola · esperando al Gateway" + "Mover al grupo" + "Escanear QR para enlazar" + "Aprobación denegada." + "No se pudo consultar la propuesta de Skill Workshop." + "Fijado" + "Perfil y dispositivo" + "Cerrar selector de nivel de pensamiento" + "No se pudo poner el mensaje en cola para entregarlo más tarde." + "Cuarentena" + "Programación · %1$s" + "No se pudo actualizar el nivel de razonamiento." + "Abrir selector de nivel de pensamiento" + "Se agotó el tiempo de espera de la respuesta de voz; reintentando el turno en cola" + "Diseño: Detallado" + "No se pudo decodificar esta imagen." + "Gateway, voz, notificaciones y privacidad" + "Archivos del espacio de trabajo del agente" + "Este dispositivo perderá su acceso de confianza al Gateway." + "Usa el requestId del comando pendiente en el comando de aprobación." + "Programación" + "Límite de frecuencia" + "No entregado" + "Carga útil · %1$s" + "En ejecución" + "Arañando" + "Finalizar" + "Usar la confianza del sistema" + "No hay proveedores listos" + "Prioriza los micrófonos Bluetooth conectados." + "%1$s aplicación bloqueada para el reenvío." + "Acciones del mensaje" + "Tipo" + "Desarchivar" + "Transcripts" + "Palabras de activación" + "Configura %1$s en el Gateway" + "Escanea un código QR o usa el código de configuración de tu OpenClaw Gateway." + "Prototipo de sistema de diseño" + "Tamizando" + " · Conversación: Activada" + "Aún no hay datos de uso." + "El chat falló antes de que se iniciara la ejecución; inténtalo de nuevo." + "Enviar" + "Algunas imágenes compartidas se omitieron o no se pudieron añadir." + "Escribir calendario" + "timeout" + "Bajo" + "Lista de bloqueados" + "act" + "Dismiss Task" + "El chat falló" + "OpenClaw · En vivo" + "Instaladas" + "Se agotó el tiempo de espera de una respuesta; inténtalo de nuevo o actualiza." + "Buscar conversaciones anteriores" + "Explorar hilos" + "Actualizando" + "Buscando perlas" + "Abre la cámara y encuadra el código de openclaw qr." + "No hay dispositivos" + "Reenviar notificaciones" + "Mantendré esta conversación separada del chat habitual del agente." + "La sesión del Gateway está volviendo a conectarse. Los accesos directos de los agentes deberían estabilizarse automáticamente en un momento." + "Prueba con otra búsqueda o borra la consulta actual." + "¿Permitir ubicación en segundo plano?" + "Saliendo a la superficie" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Cancelar nota de voz" + "Desplazar hacia atrás" + "openclaw gateway" + "Gateway emparejado" + "Mudando" + "Escuchando tu próximo turno." + "OpenClaw está trabajando" + "Entrada de registro" + "Error: no se pudo alcanzar el endpoint seguro del gateway para este host." + "El Gateway está sin conexión. Corrige la conexión a continuación o copia los diagnósticos." + "En espera" + "Probando probando 1 2 3" + "No se pudieron buscar Skills de ClawHub." + "Sin instrucción" + "Cámara frontal" + "Abrir entrada del registro" + "Tiempo de espera de red agotado" + "Ahora" + "Cambiar nombre del grupo…" + "Más agentes" + "openclaw nodes approve REQUEST_ID" + "Fijar" + "thread list" + "Abrir %1$s" + "upload" + "La contraseña de Gateway no está configurada" + "Configuración de dictado" + "Se cargaron los modelos del proveedor, pero la disponibilidad no está disponible." + "¿Eliminar el hilo?" + "OpenClaw convierte este teléfono en una interfaz móvil sencilla para hilos, voz, proveedores y Gateway." + "Más recientes primero" + "Próximo ciclo" + diff --git a/app/src/main/res/values-fa/assistant.xml b/app/src/main/res/values-fa/assistant.xml new file mode 100644 index 0000000..4e5f7dd --- /dev/null +++ b/app/src/main/res/values-fa/assistant.xml @@ -0,0 +1,7 @@ + + + "از OpenClaw بپرس %1$s" + "به OpenClaw بگو %1$s" + "OpenClaw را باز کن و بپرس %1$s" + + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..93a408a --- /dev/null +++ b/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + به این دروازه اعتماد دارید؟ + اعتماد و ادامه + لغو + چت جدید در worktree + پیش از اعتماد به این دروازه، اثر انگشت گواهی را تأیید کنید.\n\n%1$s + گواهی دروازه تغییر کرده است. فقط در صورتی ادامه دهید که انتظار این تغییر را داشتید.\n\nSHA-256 قدیمی:\n%1$s\n\nSHA-256 جدید:\n%2$s + نامشخص + نسخه + کامیت + ساخته‌شده + نسخهٔ %1$s + کامیت Git ‏%1$s + ساخته‌شده در %1$s به وقت UTC، برچسب زمانی %2$s + تاریخ ساخت %1$s + کپی هش کامل کامیت Git + کپی برچسب زمانی کامل ساخت + کامیت Git مربوط به OpenClaw + برچسب زمانی ساخت OpenClaw + کامیت Git کپی شد + برچسب زمانی ساخت کپی شد + + "پیوست برای ارسال آماده نشد." + "میکروفون خاموش" + "نمایش هشدارهای OpenClaw" + "فعالیت رشته" + "کامل" + "تأیید اجازه داده شد و ذخیره شد." + "نمایش سابقه تماس‌های اخیر" + "۱ در انتظار" + "پیوست پشتیبانی‌نشده" + "0 = دقیق" + "برای جستجوی گفتگوها، به Gateway متصل شوید." + "%1$s حساب" + "تغییرات Cron به operator.admin نیاز دارند. کدهای راه‌اندازی عمداً این دسترسی را اعطا نمی‌کنند. برای درخواست دسترسی مدیر، با توکن مشترک یا گذرواژه Gateway دوباره متصل شوید. اگر این دستگاه همچنان فاقد این دسترسی است، ارتقای محدوده در انتظار را از یک کلاینت مدیر موجود تأیید کنید." + "Apply Patch" + "نیشگون‌گرفتن" + "لغو بی‌صدا کردن بلندگو" + "ردشدن‌های متوالی" + "این پوشه هنوز هیچ فایلی ندارد." + "متصل نیست" + "وضعیت مهارت‌های نصب‌شده را بررسی و مدیریت کنید." + "ناموفق" + "عامل پیش‌فرض" + "دوربین" + "حذف از گروه" + "در حال جست‌وجو" + "برای پخش صدا متوقف شد" + "Gateway پیش از دانلود، این انتشار دقیق را با ClawHub تأیید می‌کند. اگر انتشار به تأیید صریح ریسک نیاز داشته باشد، Android پیش از تلاش مجدد هشدار Gateway را نمایش می‌دهد." + "کد راه‌اندازی از شناسهٔ محدودهٔ IPv6 استفاده می‌کند. از یک نشانی IPv6 بدون محدوده یا نام میزبان LAN استفاده کنید." + "پیوست" + "واژه‌های بیدارباش، گفت‌وگو و پخش را پیکربندی کنید." + "در حال شنیدن (PTT)" + "پیشنهاد رد شد." + "نمایش نوار کناری" + "کاربر" + "%1$s · %2$s" + "حداقلی" + "رد کردن" + "عامل فعال" + "۱ مورد زمان‌بندی‌شده" + "پاسخی دریافت نشد" + "%1$s انتخاب شد" + "آرایه JSON دستور argv" + "خواندن این تصویر ممکن نبود. یک اسکرین‌شات یا تصویر واضح از کد QR ایجادشده با openclaw qr انتخاب کنید." + "پیشنهاد Skill Workshop را نمی‌توان %1$s کرد." + "در جای دیگری پاسخ داده شده" + "Gateway تأیید را یک‌بار ثبت کرد." + "status" + "OpenClaw فقط زمانی موقعیت مکانی را بررسی می‌کند که Gateway جفت‌شده شما آن را درخواست کند. در صفحه بعدی Android، %1$s را انتخاب کنید تا بررسی‌ها هنگام اجرای برنامه در پس‌زمینه مجاز باشند." + "رد" + "کنتراست" + "تنظیم Gateway جایگزین شود؟" + "بارگیری اجراهای خودکار ممکن نشد." + "شما" + "میکروفون داخلی" + "رابط" + "هیچ پیشنهادی نیست" + "رشته اصلی" + "باز کردن چت" + "اقدامات جفت‌سازی دستگاه در این نشست Gateway در دسترس نیست. دستور openclaw devices list را روی میزبان Gateway اجرا کنید و درخواست را در آنجا مدیریت کنید. تأیید قابلیت گره جداگانه است و همچنان از nodes approve <request id> استفاده می‌کند." + "درخواست اقدام" + "list pins" + "برای بارگذاری پیشنهادهای کارگاه Skill به یک Gateway متصل شوید." + "کد راه‌اندازی پذیرفته نشد" + "خروج از حساب" + "ارائه‌دهنده رونویسی بلادرنگ پیکربندی نشده است." + "نمایش برنامه‌های سیستم" + "برای مشاهده پیکربندی مدل ارائه‌دهنده، Gateway خود را به‌روزرسانی کنید." + "در حال ارسال دیکته" + "برای بارگذاری Markdown این پیشنهاد، آن را بررسی کنید." + "OpenClaw را باز کن و بپرس %1$s" + "استدلال" + "کلاینت" + "اعمال‌شده" + "ویدئو" + "ارتقایافته" + "آنلاین" + "دامنه‌ها" + "ارائه‌دهنده صدای بلادرنگ پیکربندی نشده است." + "%1$s · %2$s" + "kick" + "Gateway یک اجرای خودکار نامعتبر برگرداند." + "شناسه نمونه" + "توکن Gateway لازم است. دوباره آن را وارد کنید یا این اتصال را ویرایش کنید." + "منبع" + "تازه‌سازی" + "%1$s در صف" + "شروع گفتگو" + "فراخوانی‌های ابزار Chat که در رشته فعال منتظر هستند، اینجا قابل مشاهده می‌مانند." + "بازبینی گواهی لازم است" + "سطح Canvas فعلی را برای بررسی یا تعامل با آن باز کنید." + "اجرای خودکار به‌روزرسانی شد." + "نشست اخیری وجود ندارد" + "اسکریپت" + "وضعیت Gateway، آمادگی گره تلفن و جریان گزارش‌های اخیر." + "باز کردن جزئیات خودکارسازی" + "زمان اجرا" + "۱ کارگر دیگر" + "عامل %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "برای ادامه، لطفاً %1$s را در تنظیمات Android فعال کنید." + "تعمیر" + "reactions" + "انجام شد" + "نسخه و به‌روزرسانی" + "OpenClaw تأییدها، کارهای ناموفق و مشکلات کانال را اینجا نمایش خواهد داد." + "میکروفون USB" + "تعداد بسیار زیادی اشتراک در انتظار افزوده شدن است." + "رد شد" + "از نشانی LAN رایانه Gateway یا نام میزبان راه دور امن استفاده کنید." + "روشن" + "در حال جستجوی گفتگوها" + "گفت‌وگوی بلادرنگ" + "· %1$s" + "OpenClaw در حال آماده‌سازی پاسخ است." + "تأیید یک‌بار اجازه داده شد." + "راه‌اندازی ارائه‌دهنده" + "هیچ‌کدام" + "محتوای اسکریپت بدون تغییر حفظ می‌شود. برای ویرایش این اسکریپت از CLI استفاده کنید." + "راهنمای راه‌اندازی Android" + "%1$s برنامه از بازارسال مسدود شده‌اند." + "دستگاه‌های جفت‌شده" + ":%1$s" + "%1$s در انتظار" + "نام دستگاه" + "ارسال" + "موقعیت مکانی" + "مثلاً America/New_York" + "مقصد نشست" + "بررسی Skill در ClawHub" + "snapshot" + "درخواست جفت‌سازی این دستگاه رد شود؟" + "میکروفون ترجیحی" + "میزبان گره" + "سطح" + "بستن انتخاب‌گر برنامه" + "یک توکن مشترک Gateway یا توکن صادرشده توسط اپراتور را بچسبانید." + "همه سامانه‌ها در وضعیت عادی هستند" + "عیب‌یابی Gateway کپی شد" + "خطای صوتی" + "جایگزینی تنظیمات" + "اقدام‌های سریع" + "ارسال ناموفق بود: گفت‌وگو پیش از شروع اجرا ناموفق شد؛ دوباره تلاش کنید." + "میکروفون" + "گفت‌وگو همچنان در حال بررسی سلامت Gateway است." + "موقعیت مکانی دقیق" + "یک‌بار اجازه بده" + "+%1$s مورد دیگر" + "thread create" + "مسدود" + "واژه یا عبارت بیدارباش" + "Gateway به تأیید دستگاه نیاز دارد" + "میکروفون خارجی" + "%1$s/%2$s آماده" + "متصل (اپراتور آفلاین است)" + "قابلیت تأییدنشده" + "با این کار، خودکارسازی و زمان‌بندی آن برای همیشه از Gateway حذف می‌شوند." + "در حال بارگذاری تصویر…" + "اتصال" + "تأیید دسترسی node" + "افزودن Gateway" + "رونویسی در دسترس نیست: %1$s" + "تصویر" + "جزرومد" + "بستن پیش‌نمایش تصویر" + "eval" + "آخرین فرمان: %1$s" + "روی دستگاهی که OpenClaw را اجرا می‌کند، یک ترمینال باز داشته باشید." + "هیچ موردی کم نیست" + "خروجی بوم به اتصال فعال Gateway نیاز دارد." + "%1$s · %2$s" + "ایزوله" + "© 2026 OpenClaw Foundation — مجوز MIT." + "PDF" + "Conversations" + "تثبیت حافظه و دفترچهٔ رؤیاها." + "Create Goal" + "این خودکارسازی هنگام ویرایش شما تغییر کرده است. پیش از ذخیره، آن را به آخرین نسخه Gateway بازگردانید." + "پس از اتصال، Gateway می‌تواند به‌جای حفظ یک نشست همیشه‌فعال، گوشی را با یک اعلان بی‌صدا بیدار کند." + "حالت بیدارباش" + "دستگاه جفت‌شده حذف شود؟" + "متن رویداد سیستم" + "تصویر ویجت کپی نشد" + "خیر" + "مسیر اختیاری" + "در حال ارسال صدای در صف" + "داخلی" + "hide" + "runs" + "رمز عبور Gateway لازم است. دوباره آن را وارد کنید یا این اتصال را ویرایش کنید." + "متن رویداد" + "رونوشت زنده" + "پیکربندی مدل ارائه‌دهنده بارگیری نشد." + "%1$s برنامه مجاز به بازارسال است." + "راه‌اندازی صدا" + "پیوست ویدیو" + "تصاویر اضافی پنهان شده‌اند: %1$s" + "درخواست جفت‌سازی رد شود؟" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "ناشر" + "انشعاب از اینجا" + " · گفت‌وگو: در حال صحبت" + "بازنویسی اختیاری" + "پیش‌فرض" + "تأیید فرمان" + "برنامه و Gateway از نسخه‌های پروتکل ناسازگار استفاده می‌کنند. OpenClaw را در هر دو به‌روزرسانی کنید و سپس دوباره تلاش کنید." + "تازه‌سازی صفحه" + "خواندن عکس‌ها و رسانه‌های اخیر" + "شنیدن" + "متصل" + "تکمیل‌شده" + "تلفن شما با %1$s جفت شده است. برای تکمیل دسترسی گره ادامه دهید." + "گفتگوی فعلی" + "تفکر %1$s" + "بی‌صدا" + "OpenClaw از شرکای خود در جامعهٔ متن‌باز قدردانی می‌کند." + "باز" + "در حال اتصال به Gateway" + "gateway می‌تواند این مسیر را تغییر دهد اما نمی‌تواند مسیر موجود را پاک کند." + "TTS" + "در حال ذخیره…" + "لنگر %1$s" + "search" + "فعال‌سازی" + "کارهای زمان‌بندی‌شده Gateway را بررسی و مدیریت کنید." + "پاسخ قبلی این فرمان را یک‌بار اجازه داده است." + "generate" + "فقط در یک شبکه خصوصی مورد اعتماد استفاده کنید." + "جستجوی تنظیمات" + "گفت‌وگو فعال است" + "احراز هویت Gateway پیکربندی نشده است. این اتصال را ویرایش کنید و دوباره تلاش کنید." + "ناموفق: نقطه پایانی امن در دسترس قرار گرفت، اما تأیید اثرانگشت TLS به پایان زمان مجاز رسید. Tailscale Serve یا TLS Gateway را بررسی کنید و دوباره تلاش کنید." + "مرحله ۱" + "دیکته" + "باز کردن انتخاب‌گر برنامه" + "هیچ تأیید در انتظاری وجود ندارد" + "edit" + "اتصال به Gateway" + "کد راه‌اندازی را از openclaw qr وارد کنید." + "عیب‌یابی" + "برنامه‌های دیگر دست‌نخورده باقی می‌مانند." + "ترک‌خوردن" + "این کار رشته و رونوشت آن را برای همیشه حذف می‌کند." + "دستگاه تأیید شد." + "تلاش مجدد به‌صورت خودکار" + "تصویر ویجت کپی شد" + "%1$s نقش" + "۱ مورد کم است" + "%1$s مورد زمان‌بندی‌شده" + "react" + "عامل‌ها" + "برای بارگیری خودکارسازی‌ها، به Gateway متصل شوید." + "در حال اتصال مجدد…" + "بازگشت به راه‌اندازی" + "send" + "امکان آزمایش اتصال وجود نداشت" + "تأیید و نصب" + "این دستگاه را به یک گره امن OpenClaw برای چت، صدا، دوربین و ابزارهای دستگاه تبدیل کنید." + "راه‌اندازی دستی" + "برای شروع یا ازسرگیری رشته فعلی، Chat را باز کنید." + "نکته: برای ارسال نوبت ضبط‌شده، گوش دادن را متوقف کنید." + "رد کردن" + "درخواست صوتی ناموفق بود" + "این کار \"%1$s\" را رد می‌کند و وضعیت Skill Workshop را از Gateway تازه‌سازی می‌کند." + "صدف‌سازی" + "update" + "اشتراک‌گذاری" + "دوربین فعال شد" + "Telegram، WhatsApp، ایمیل و کانال‌های دیگر پس از راه‌اندازی اینجا نمایش داده می‌شوند." + "خطای شبکه" + "گشت‌وگذار در آبگیرهای جزرومدی" + "اکنون Canvas را برای session=%1$s و source=%2$s بازیابی کنید. اگر وضعیت A2UI موجود است، فوراً آن را بازپخش کنید. در غیر این صورت، یک داشبورد جمع‌وجور و مناسب موبایل در Canvas ایجاد و رندر کنید." + "شروع ناموفق بود: %1$s" + "درخواست نشده" + "یک ارائه‌دهنده %1$s را روی Gateway پیکربندی کنید" + "kill" + "تأییدها" + "فایل‌ها در دسترس نیستند" + "علامت‌گذاری به‌عنوان خوانده‌نشده" + "یافتن افراد و اطلاعات تماس" + "هویت دستگاه لازم است" + "گفتگوی OpenClaw" + "اجازه دسترسی به کتابخانه عکس را بدهید." + "پاسخ قبلی این تأیید را حل کرده است." + "رشته اخیری وجود ندارد" + "مهلت زمانی %1$s ثانیه" + "موردی یافت نشد" + "خواندن اعلان‌های برنامه‌های انتخاب‌شده" + "وضعیت دسترسی نامشخص است" + "راه‌اندازی مکالمه" + "اضافی" + "Gateway جفت شد. در انتظار دسترسی اپراتور." + "پیوست کردن تصویر" + "انتخاب کنید چه چیزهایی به OpenClaw برسد." + "تأیید مجدد قابلیت در انتظار است" + "موارد برجسته‌شده را بررسی کنید" + "در حال گوش‌دادن..." + "من را در جریان بگذار" + "پیام" + "خواندن مخاطبین" + "فضای ذخیره‌سازی آفلاین پیوست‌ها پر است؛ ابتدا موارد در صف را حذف کنید." + "یک‌بار" + "تغییر نام" + "هیچ کانالی پیدا نشد." + "مشاهده همه" + "دستگاه جدید" + "Session Status" + "باز کردن پیش‌نمایش تصویر" + "شاخه نشست تغییر کرد؛ این پیام را بررسی کرده و دوباره امتحان کنید." + "close" + "به نظر می‌رسد این یک کد راه‌اندازی است. به عقب برگردید و راه‌اندازی Gateway را انتخاب کنید، سپس استفاده از کد راه‌اندازی را انتخاب کنید." + "✦" + "عامل‌ها و خودکارسازی" + "اعمال" + "اجرای خودکار نادیده گرفته شد." + "ادامه" + "در حال پایش · %1$s کار زمان‌بندی‌شده" + "مرور" + "tabs" + "در انتظار" + "گفت‌وگو: %1$s" + "read" + "انتخاب متن" + "فعالیت حرکتی" + "description: %1$s" + "پخش صدا" + "زمان" + "تأییدنشده" + "Yield" + "کپی فرمان تأیید" + "خروجی صفحهٔ فعلی و سطح تعاملی برنامه." + "سرویس متصل شد" + "نمایش" + "هر وقت آماده باشید، آماده‌ام" + "کاتالوگ ارائه‌دهندگان بارگیری نشد." + "در حال صحبت · در انتظار پاسخ" + "اعطا نشده" + "ذخیره تغییرات" + "Gateway اجرای خودکار را رد کرد." + "Session Send" + "یافتن در ClawHub" + "همیشه بررسی‌های موقعیت مکانی درخواست‌شده را زمانی که OpenClaw در پس‌زمینه است مجاز می‌کند؛ Android این را در اعلان پایدار گره نشان می‌دهد." + "رویداد سیستم" + "برای مشاهده ارائه‌دهندگان، به Gateway متصل شوید" + "ضربان بعدی" + "Gateway جفت شد. در انتظار تأیید قابلیت نود." + "آب‌نمک‌سودکردن" + "بستن Canvas" + "نوشتن مخاطبین" + "هیچ مهارت نصب‌شده‌ای با این جستجو مطابقت ندارد." + "راه‌اندازی ارائه‌دهنده گفتار" + "Music Generation" + "تنظیمات Talk" + "در حال نظارت · ۱ رشته" + "متن Payload" + "تنظیم متن" + "تأیید %1$s" + "Gateway وضعیت آمادگی %1$s را برنگرداند" + "%1$s مدل پیکربندی شده است. برای بررسی دوباره موجود بودن، تازه‌سازی کنید." + "Conversation Send" + "بوم" + "۱ ارائه‌دهنده" + "گواهی Gateway به‌طور خودکار قابل خواندن نبود. اثر انگشت SHA-256 دریافت‌شده در میزبان Gateway را جای‌گذاری کنید." + "ارسال ناموفق بود: %1$s" + "پل ارتباطی" + "خطای تحویل" + "از OpenClaw در تلفن خود استفاده کنید" + "ظاهر" + "کارگاه Skill" + "به توکن نیاز دارد" + "پیش‌نمایش · %1$s" + "مجوز میکروفون لازم است" + "برای بارگیری پیشنهادهای Skill Workshop، به Gateway متصل شوید." + "همهٔ سامانه‌ها عملیاتی هستند" + "Gateway در دسترس نیست" + "OC" + "به‌روزرسانی‌شده" + "متصل (گره آفلاین است)" + "خانه" + "دیکته در حال شنیدن است" + "رشته بایگانی‌شده‌ای وجود ندارد" + "دستیارهای موجود در این Gateway را انتخاب و بررسی کنید." + "حالت گفت‌وگو فعال است" + "در حال کار · ۱ اجرای فعال" + "موافقت و فعال‌سازی" + "به‌روزرسانی Gateway لازم است" + "کپی تصویر" + "نشانی URL Gateway" + "main، isolated، current یا session:<id>" + "رسانه در دسترس نیست" + "برای باز کردن یک shell در فضای کاری عامل، به Gateway خود متصل شوید." + "%1$s://%2$s:%3$s" + "بارگیری جزئیات تأیید ممکن نشد. تازه‌سازی کنید و دوباره تلاش کنید." + "می‌توانم وضعیت Gateway را بررسی کنم، پیکربندی را تعمیر کنم، مدل‌ها را تغییر دهم یا کانال‌ها را متصل کنم." + "Tool Call" + "رشته‌ها" + "Write" + "با یک درخواست شروع کنید یا از صدا استفاده کنید." + "D" + "باز کردن تنظیمات" + "در حال مشاهده…" + "پایان مکالمه" + "آخرین خطا" + "اقداماتی را که نیاز به توجه شما دارند مرور کنید." + "برای همه عامل‌ها غیرفعال است." + "شروع مکالمه صوتی" + "بازگشت به وظایف پس‌زمینه" + "یک عملیات cron دیگر هنوز در حال تکمیل است." + "زمان انتظار %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "پیامک" + "تشخیص گفتار روی دستگاه در دسترس نیست." + "اسکریپت · فقط خواندنی" + "پیوست‌ها برای قرار گرفتن در صف یک پیام بیش از حد بزرگ هستند؛ تعدادی را حذف و دوباره تلاش کنید." + "۱ مدل پیکربندی شده است. برای بررسی دوباره موجود بودن، تازه‌سازی کنید." + "ویجت در دسترس نیست" + "برای این میزبان، اتصال امن الزامی است." + "اخیر" + "خودکارسازی منطبقی یافت نشد." + "تلفن می‌تواند به Gateway دسترسی داشته باشد" + "Gateway" + "منقضی شده" + "کارهای زمان‌بندی‌شده OpenClaw از Gateway شما." + "Sub-agent" + "در انتظار تأیید دستگاه" + "در حال بارگذاری رشته" + "این Gateway اکنون گواهی‌ای ارائه می‌کند که مورد اعتماد این دستگاه است." + "میلی‌ثانیه پراکندگی" + "event create" + "سند" + "راه‌اندازی Gateway" + "پخش ویدیو" + "احراز هویت ذخیره‌شده نامعتبر است. دوباره احراز هویت کنید یا این اتصال Gateway را بازنشانی کنید." + "هنگام استفاده" + "screenshot" + "بازگشت به اینجا" + "عبارت cron، مثلاً 0 9 * * *" + "بازگشت به صدا" + "صحبت" + "جزئیات" + "%1$s/%2$s آنلاین" + "%1$s برنامه مجاز به بازارسال." + "گفتگو" + "دسترسی به میکروفون لازم است." + "جست‌وخیز" + "ویرایش" + "ساعات سکوت" + "کپی کردن اطلاعات عیب‌یابی" + "زمان‌بندی‌شده" + "ایجاد" + "انقضا در %1$s" + "رد کردن" + "پیشنهاد رد شود؟" + "خطای گفتار (%1$s)" + "مشکل" + "فراداده رجیستری را جستجو کنید. Gateway پیش از هرگونه دانلود، اعتماد را دوباره تأیید می‌کند." + "برای راه‌اندازی محلی از یک IP خصوصی LAN استفاده کنید، یا برای دسترسی از راه دور Tailscale Serve را فعال کنید / یک نشانی URL از نوع wss:// برای Gateway در دسترس قرار دهید." + "در حال ارسال…" + "حساب %1$s" + "Suggest Task" + "جستجو" + "در حال گوش دادن" + "خودکارسازی بارگیری نشده است." + "یک به‌روزرسانی برای Gateway در دسترس است. هر زمان آماده بودید، به‌روزرسانی را از Web UI یا CLI اجرا کنید." + "به‌زودی" + "هیچ تأییدیه‌ای برای Gateway وجود ندارد." + "میزبان" + "در هر فیلد یک واژه یا عبارت بیدارباش اضافه کنید. سپس پیش از فرمان خود یکی از آن‌ها را بگویید." + "رونویسی و سپس ارسال" + "اجرا در" + "توقف صدا" + "دسترسی به دستگاه Gateway" + "بدون پیش‌نمایش" + "دستگاه‌ها" + "OpenClaw برای Android." + "تأیید قابلیت در انتظار است" + "پیش از اجرا، فعال‌سازی، غیرفعال‌سازی، حذف یا تازه‌سازی این خودکارسازی، ویرایش‌های خود را ذخیره یا بازگردانی کنید." + "هنوز خودکارسازی‌ای وجود ندارد." + "این Skills به %1$s مورد راه‌اندازی نیاز دارد. Android موارد نصب‌شده را نمایش می‌دهد؛ تغییرات راه‌اندازی/پیکربندی فقط از طریق دسکتاپ یا CLI انجام می‌شوند." + "%1$s اخیر" + "کانال‌ها" + "بدون فاز" + "فعال در این گوشی" + "در حال بررسی دسترسی گره" + "منطقه زمانی" + "اقدامات بازرسی و اعمال Skill Workshop" + "همیشه اجازه بده" + "present" + "Skills نصب‌شده روی Gateway اینجا نمایش داده می‌شوند." + "ممکن است کد منقضی شده باشد یا برای Gateway دیگری تولید شده باشد." + "مجوز لازم است" + "پیکربندی اجرای خودکار نامعتبر است." + "فهرست مجاز" + "راه‌اندازی، وضعیت و تعمیر" + "groups" + "کلید عمومی" + "درباره" + "هیچ کد QR راه‌اندازی در این تصویر پیدا نشد. کد QR ایجادشده با openclaw qr را انتخاب کنید، یا کد راه‌اندازی را به‌صورت دستی وارد کنید." + "permissions" + "برای بارگیری گره‌ها و دستگاه‌های جفت‌شده، Gateway را متصل کنید." + "تغییر شاخه" + "هیچ مهارتی وجود ندارد" + "پاسخ‌ها با صدای بلند پخش می‌شوند" + "علامت‌گذاری به‌عنوان خوانده‌شده" + "تأیید گره در انتظار است" + "wake" + "%1$s پیشنهاد" + "احراز هویت Gateway نیاز به رسیدگی دارد." + "جزئیات اتصال" + "میلی‌ثانیه" + "تشخیص گفتار" + "توضیحات" + "گفتگوهای اخیر" + "تلفن شما این اطلاعات را به Gateway شما ارسال می‌کند، نه به سروری که توسط OpenClaw اجرا می‌شود. ممکن است Gateway شما آن را در درخواست‌ها به ارائه‌دهندهٔ هوش مصنوعی که انتخاب کرده‌اید بگنجاند." + "تحویل" + "بی‌صدا کردن بلندگو" + "%1$s در حال اجرا · %2$s انجام‌شده · %3$s ناموفق" + "در حال باز کردن اتصال Gateway" + "در حال نظارت · %1$s رشته" + "اجرای خودکار به پایان رسید." + "برنامه مطابقی یافت نشد." + "ارسال به چت" + "اجرای خودکار حذف شد." + "فعال کردن" + "اجراهای اخیر" + "کد QR را داخل مربع تراز کنید." + "بارگیری تأییدها ممکن نشد." + "تأیید کرده‌ام" + "Gateway خود را وصل کنید تا آمادگی ارائه‌دهنده بارگیری شود." + "جفت‌نشده" + "این تأیید پیش از حل شدن منقضی شد." + "مشاهده در %1$s ثانیه — به برنامه مقصد بروید" + "اعلان عامل" + "emoji list" + "تکرارشونده" + "جستجوی OpenClaw" + "%1$s در انتظار" + "تشخیص گفتار روی دستگاه در دسترس نیست" + "هیچ برنامه‌ای نمی‌تواند این پیام را به اشتراک بگذارد" + "بستن جستجو" + "دستور برای پایش" + "سلامت" + "شنود اعلان‌ها" + "بلندگو بی‌صدا شد" + "جستجوی رشته‌ها" + "تأیید" + "راهنمای راه‌اندازی باز نشد." + "از OpenClaw بپرس %1$s" + "Wait for Agents" + "نشانی" + "کارهای زمان‌بندی‌شده ایجادشده در Gateway اینجا نمایش داده می‌شوند." + "در حال نمایش آخرین بخش گزارش." + "استفاده از کد راه‌اندازی" + "sticker" + "از یک Gateway امن wss:// یا Tailscale Serve استفاده کنید، یک کد راه‌اندازی با دسترسی کامل در Control UI یا با openclaw qr ایجاد کنید، سپس آن را در زیر اسکن یا جای‌گذاری کرده و دوباره متصل شوید تا تنظیمات و به‌روزرسانی‌ها فعال شوند." + "steer" + "انتخاب‌شده" + "Android می‌تواند یک کد راه‌اندازی موجود را اسکن یا جای‌گذاری کند، اما این gateway هنوز تولید کد راه‌اندازی را در اختیار برنامه قرار نمی‌دهد. QR/کد را روی میزبان gateway با openclaw qr تولید کنید، سپس آن را اینجا اسکن کنید یا کد راه‌اندازی را در پایین جای‌گذاری کنید." + "وضعیت بوم" + "رفع مشکل اتصال" + "ذخیره تصویر" + "گره %1$s" + "رمز عبور Gateway لازم است" + "Update Plan" + "حذف پیوست" + "اجرای خودکار ناموفق بود." + "محدودیت‌های ارائه‌دهنده و وضعیت سهمیه." + "کاتالوگ گفت‌وگوی Gateway بارگذاری نشده است" + "این Gateway" + "هنوز اجرای اخیری وجود ندارد." + "مدل زبانی روی دستگاه در دسترس نیست" + "داشبورد به یک Gateway متصل نیاز دارد" + "پیشنهادهای منطبق پس از ساختن پیش‌نویس‌های Skill قابل‌استفاده‌ی مجدد توسط عامل‌ها اینجا نمایش داده می‌شوند." + "Session Search" + "OpenClaw در حال صحبت است" + "اسکن QR" + "برنامه‌های انتخاب‌شده" + "بازگرداندن تغییرات" + "دستور تأیید کپی شد" + "وضعیت تحویل" + "کد QR پذیرفته نشد" + "مرکز فرمان صوتی شما." + "آزمایش اتصال" + "OPENCLAW" + "Web Fetch" + "درخواست" + "دستگاه تأیید شود؟" + "برای باز کردن داشبورد این نشست، به Gateway خود متصل شوید." + "%1$s و اطلاعات ورود ذخیره‌شده آن از این تلفن حذف شوند؟" + "کد QR به یک gateway راه‌دور ناامن اشاره دارد. %1$s %2$s" + "سطح صفحه آماده است" + "جفت‌سازی Gateway" + "برای بارگیری کانال‌ها، Gateway را متصل کنید." + "هنگام سایر فعالیت‌های صوتی متوقف می‌شود." + "مدل" + "عکس‌ها" + "چسباندن کد راه‌اندازی" + "OpenClaw در حال صحبت است" + "در حال اتصال..." + " · مکان: همیشه" + "پیام‌ها: %1$s" + "صخره‌سازی" + "بارگیری از Gateway" + "text: %1$s" + "نیاز دارد" + "rename group" + "آماده" + "دفترچه منتظر نخستین ورودی خود است." + "تأیید کردن" + "صفحه زنده" + "اجرای خودکار در حال حاضر فعال است." + "این خودکارسازی پس از یک اجرای یک‌باره موفق حذف شود." + "آماده برای گفتگو و صدا" + "متصل (اپراتور: %1$s)" + "جفت‌سازی Gateway کامل شد. این تلفن را به‌عنوان یک node تأیید کنید تا OpenClaw بتواند از قابلیت‌های دستگاه که فعال می‌کنید استفاده کند." + "پاسخ لغو شد" + "تصویر" + "%1$s نگه‌داشته‌شده" + "هیچ گفتگوی منطبقی وجود ندارد" + "delete" + "چیدمان: فشرده" + "channels" + "اعطا شده" + "هر %1$s دقیقه" + "۱ توکن" + "%1$s %2$s" + "برنامه‌های نصب‌شده" + "در انتظار" + "در حال آماده‌سازی یادداشت صوتی…" + "هرگز" + "زیرسامانه" + "در زمان خروج فرمان" + "اتصال" + "تاریخچه اجرای خودکارسازی بارگیری نشد." + "نام اتوماسیون" + "مرحله ۲" + "عیب‌یابی" + "برخی بررسی‌های وضعیت کانال تکمیل نشدند." + "pin" + "کپی %1$s" + "جفت‌شده" + "ذخیره واژه‌های بیدارباش ممکن نشد" + "این کار \"%1$s\" را قرنطینه می‌کند و وضعیت Skill Workshop را از Gateway تازه‌سازی می‌کند." + "ضبط یادداشت صوتی" + "در صف" + "پاسخ داده شده" + "در صورت درخواست، ابزارهای دوربین را مجاز کنید." + "مشکلات" + "بیدارباش صوتی" + "درخواست جفت‌سازی رد شد." + "%1$s روز پیش" + "roles" + "Skills" + "بایگانی" + "گره آفلاین است. دوباره متصل شوید و مجدداً تلاش کنید." + "سیستم" + "IP راه دور" + "گروه‌بندی‌نشده" + "جزئیات زمان‌بندی" + "قابلیت‌های تلفن" + "در دسترس نیست" + "داشبورد" + "چسباندن توکن" + "ارائه‌دهنده‌ای وجود ندارد" + "اثر انگشت SHA-256" + "هنوز رشته‌ای وجود ندارد" + "میکروفون Bluetooth" + "اخیر" + "تغییر نام گفتگو" + "نتیجه تعیین تکلیف نامشخص است. تا زمانی که رکورد Gateway تأیید نشود، اقدامات غیرفعال می‌مانند." + "dialog" + "گوش‌دادن به واژه‌های بیدارباش" + "camera snap" + "در حال آماده‌سازی پخش…" + "Gateway ارائه‌دهنده ناشناخته %1$s را انتخاب کرد" + "delete group" + "دنبال کردن Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "برای بارگذاری عامل‌ها، به Gateway متصل شوید." + "بازگشت" + "اشتراک‌گذاری پیام" + "یک کد QR ایجاد کنید." + "راه‌اندازی مجدد" + "بلندگو روشن است" + "گروه حذف شود؟" + "موجود نیست" + "جستجوی پیشنهادها" + "stop" + "امن (TLS)" + "هیچ گره یا دستگاه جفت‌شده‌ای وجود ندارد." + "%1$s٪ باقی‌مانده %2$s" + "کد راه‌اندازی منقضی شده است" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "دفترچهٔ رؤیا" + "notify" + "این تلفن تا زمانی که Gateway به آن نیاز داشته باشد در حالت غیرفعال می‌ماند، سپس بیدار و همگام می‌شود و دوباره به حالت خواب می‌رود." + "%1$s مدل پیکربندی‌شده" + "مجوزها" + "برای جست‌وجوی Skills در ClawHub، Gateway را متصل کنید." + "مهارت" + "اتصال Gateway تغییر کرد. برای اتصال مجدد OpenClaw را دوباره راه‌اندازی کنید." + "شناسه دستگاه" + "Gateway ارائه‌دهنده فعال %1$s را شناسایی نکرد" + "در انتظار" + "واژه‌های بیدارباش ذخیره شدند" + "ابتدا قدیمی‌ترین" + "صفحه" + "در حال اجرا از" + "شناسه‌های محدودهٔ IPv6 پشتیبانی نمی‌شوند. از یک نشانی IPv6 بدون محدوده یا نام میزبان LAN استفاده کنید." + "ارسال شد — در حال تأیید تحویل…" + "صدا" + "This gateway connection needs operator.admin to update skills." + "کد راه‌اندازی" + "تأیید هشدار Gateway و نصب" + "به‌روزرسانی گفتگو" + "بازه زمانی" + "اقدامات پیشنهادهای کارگاه Skill به سطح دسترسی operator.admin نیاز دارند." + "جلسه‌ها" + "تغییر نام…" + "Gateway را متصل کنید تا dreaming بارگیری شود." + "راه‌اندازی" + "باز کردن Talk" + "poll" + "برای بارگیری عامل‌هایتان متصل شوید" + "role remove" + " · گفت‌وگو: در حال شنیدن" + "ClawHub نسخه‌ای قابل نصب برای %1$s برنگرداند." + "فرمان" + "این تأیید پیش از حل شدن لغو شد." + "میکروفون روشن · در انتظار Gateway" + "متن" + "نمایش %1$s از %2$s. برای موارد بیشتر، جستجو را محدودتر کنید." + "نسخه %1$s در دسترس است" + "%1$s://%2$s" + "%1$s... (تأیید)" + "ارائه‌دهندگان و مدل‌های پیکربندی‌شده" + "در حال اتصال…" + "برای ذخیره واژه‌های بیدارباش به یک Gateway متصل شوید" + "باز کردن پروفایل" + "Gateway خود را شروع کنید." + "کمکم کن این هدف را به یک چک‌لیست کاربردی تبدیل کنم: " + "پاک کردن جستجوی نشست" + "درگاه" + "وارد کردن کد راه‌اندازی" + "بارگیری گزارش‌های Gateway ممکن نشد." + "%1$s ارائه‌دهنده آماده است" + "عامل‌های شما آماده‌اند" + "هیچ ارائه‌دهنده %1$s روی Gateway پیکربندی نشده است" + "در حال گوش‌دادن برای یک نوبت" + "مشاهده" + "میلی‌ثانیه اپاک (اختیاری)" + "هیچ مدلی پیکربندی نشده است. برای بررسی دوباره موجود بودن، تازه‌سازی کنید." + "تنظیمات" + "دوربین پشت" + "approve" + "قبل از شروع" + "Skills بارگیری نشدند." + "غیرفعال" + "هنوز در انتظار تأیید" + "بارگیری وظایف پس‌زمینه ممکن نشد" + "بررسی کنید که OpenClaw بتواند روی این تلفن به‌وضوح صحبت کند." + "در حال کار · %1$s اجرای فعال" + "پوشه کاری فرمان" + "نام گروه" + "انتخاب از گالری" + "Version %1$s" + "بازگشت" + "Connect the gateway to update skills." + "حذف پس از اجرا" + "کد راه‌اندازی به یک gateway راه‌دور ناامن اشاره دارد. %1$s %2$s" + "Computer" + "Gateway قطع شده است." + "Session Settings" + "برای شروع، Gateway را متصل کنید" + "اعلان امنیتی" + "پاسخ دیگر" + "رد کردن هشدار تصویر اشتراکی" + "Gateway نسخه دیگری از ClawHub را ارزیابی کرده است. پیش از نصب، Skill را دوباره بررسی کنید." + "باز کردن دسترسی سیستم" + "پایان‌یافته" + "تصویر در دسترس نیست" + "اعلان‌ها" + "اعمال، رد و قرنطینه به دامنه operator.admin نیاز دارند. با احراز هویت مشترک gateway دوباره متصل شوید یا ارتقای دامنه operator.admin دستگاه را تأیید کنید تا اقدامات چرخه عمر فعال شوند." + "sticker upload" + "خرچنگ‌گیری" + "Messages to recover" + "openclaw devices approve %1$s" + "جزئیات خوانای گزارش Gateway." + "پیشنهادهای Skill تولیدشده را پیش از تبدیل‌شدن به Skill فعال بررسی کنید." + "همراه" + "%1$s مورد در دسترس" + "تأیید گره در انتظار است" + "Gateway در انتظار" + "احراز هویت لازم است" + "گره‌ها" + "بیدار نگه داشتن" + "OpenClaw در حال پاسخ‌دادن است" + "مستندات" + "%1$s آماده" + "هنوز خروجی‌ای وجود ندارد" + "زبان دستگاه پشتیبانی نمی‌شود" + "در صف — پس از اتصال مجدد ارسال می‌شود" + "%1$s دقیقه پیش" + "شاخه فعلی" + "در حال بررسی دسترسی جفت‌سازی" + "دسترسی محدود به Gateway" + "در حال اجرای ابزارها..." + "در حال بررسی تأیید…" + "گرفتن عکس و کلیپ با این تلفن" + "متصل و آماده" + "بستن" + "یک هدف را به چک‌لیستی عملی تبدیل کن." + "کد راه‌اندازی URL نامعتبر gateway دارد." + "فقط دسترسی‌هایی را فعال کنید که با اجازه‌دادن به OpenClaw برای استفاده از آن‌ها هنگام اتصال این تلفن راحت هستید. می‌توانید بعداً این موارد را در تنظیمات Android تغییر دهید." + "حساب" + "remove" + "Password اختیاری" + "احراز هویت Gateway نیاز به بازبینی دارد. تنظیمات Gateway را بررسی کنید، سپس دوباره تلاش کنید." + "کد QR از شناسهٔ محدودهٔ IPv6 استفاده می‌کند. از یک نشانی IPv6 بدون محدوده یا نام میزبان LAN استفاده کنید." + "add" + "کریل‌گیری" + "سالم" + "در مدت %1$s انجام شد" + "آرگومان‌ها" + "گزینه‌های نصب" + "%1$s ساعت دیگر" + "تأیید Gateway در انتظار است. این دستور را روی میزبان Gateway اجرا کنید:" + "دسترسی مدیر لازم است" + "set groups" + "سنجاق کردن مدل" + "پاک‌کردن جست‌وجو" + "برای عامل‌های واجد شرایط فعال است." + "رشته فعالی وجود ندارد" + "bounds: %1$s" + "پس از %1$s" + "به زمان‌بند اجازه دهید این خودکارسازی را اجرا کند." + "%1$s اعمال‌شده" + "هنوز دفترچهٔ رؤیایی وجود ندارد." + "تازه‌سازی وظایف پس‌زمینه" + "رشته‌های اخیر و گام‌های بعدی را خلاصه کن." + "تا زمانی که OpenClaw قابل مشاهده است، روی دستگاه اجرا می‌شود." + "%1$s در حال کار است" + "%1$s %2$s" + "خام" + "اجراها" + "اکنون اجرا شود" + "شاخه بدون عنوان" + "پیکربندی‌شده" + "camera list" + "۱ اعمال‌شده" + "camera clip" + "بله" + "آزمایش صدا" + "نگه‌داشته‌شده" + "events" + "دایرکتوری کاری" + "پرش به آخرین مورد" + "همیشه مجاز باشد" + "اسکن QR یا کد راه‌اندازی" + "Installing" + "گره‌های زنده، تلفن‌های جفت‌شده و درخواست‌های در انتظار دستگاه." + "عکس فوری: %1$s" + "پاسخ قبلی این فرمان را اجازه داده و انتخاب را ذخیره کرده است." + "درخواست‌های در انتظار" + "تأییدشده" + "فضای کاری" + "صدا" + "آماده گفت‌وگو" + "Subagents" + "ناموفق: هیچ نقطه پایانی امنی برای gateway شناسایی نشد. TLS gateway یا Tailscale Serve را فعال کنید، یا از یک آدرس LAN خصوصی مورد اعتماد با انتخاب Unencrypted استفاده کنید." + "سیگنال‌ها" + "هدف نشست" + "Gateway یک رد را ثبت کرد." + "پذیرفتن" + "هر چیزی از OpenClaw بپرسید" + "برای ادامه دوباره متصل شوید" + "%1$s جفت‌شده" + "این کار \"%1$s\" را اعمال می‌کند و وضعیت Skill Workshop را از Gateway تازه‌سازی می‌کند." + "Gateway آفلاین است" + "openclaw devices list" + "وضعیت اتصال گره OpenClaw" + "هشدارها روی این تلفن می‌مانند." + "OpenClaw می‌تواند هشدارهای انتخاب‌شده را دریافت کند." + "باز کردن صفحه" + "اقدامات گفت‌وگو" + "اجازه کنترل برنامه‌های دیگر؟" + "در حال بازرسی" + "برای افزودن Gateway دیگر، کد راه‌اندازی را اسکن یا جای‌گذاری کنید." + "Swarm" + "زمان TLS به پایان رسید" + "جلسه‌های اخیر" + "دستگاه جفت‌شده حذف شد." + "Gateway جفت شد. در حال بررسی تأیید قابلیت گره." + "حرکت" + "عملیات cron ناموفق بود." + "روی رایانه Gateway، اجرا کنید:" + "جستجوی نشست‌ها" + "بازخوانی گزارش‌ها" + "تصویر در دسترس نیست · برای تلاش مجدد ضربه بزنید" + "openclaw nodes approve %1$s" + "پیام صوتی · %1$s" + "مصرف" + "ناتیلوس‌وار شدن" + "زمینه %1$s%%" + "رونویسی درخواست‌های صوتی" + "بی‌صدا کردن" + "یک گفتگوی جدید را شروع کنید تا اینجا نمایش داده شود." + "مشکل اتصال" + "متوسط" + "انشعاب" + "فعال کردن بلندگو" + "متن رویداد سیستم" + "مرتب‌سازی: %1$s" + "%1$s در انتظار" + "Image Generation" + "یادداشت صوتی" + "هیچ موردی نیاز به توجه شما ندارد" + "OpenClaw برای ادامه به مجوزهای %1$s نیاز دارد." + "میکروفون هدست سیمی" + "صفحه‌ها" + "تحویل داده شد" + "سررسید" + "جزئیات Skill در وضعیت فعلی Skills در دسترس نیست." + "انتخاب کنید این تلفن چه چیزهایی را می‌تواند به اشتراک بگذارد." + "این اجرای خودکار از قبل یک اجرا در صف دارد." + "برای مدیریت خودکارسازی‌ها، به Gateway متصل شوید." + "هنوز زمان اجرای خودکار فرا نرسیده است." + "بدون جزئیات" + "تأیید در حال انجام است.\nOpenClaw به‌طور خودکار دوباره متصل می‌شود." + "برای مشاهده آمادگی ارائه‌دهنده، Gateway خود را متصل کنید." + "در انتظار جفت‌سازی" + "شروع یا ادامه یک گفت‌وگو" + "هیچ کار زمان‌بندی‌شده‌ای وجود ندارد" + "پاسخ به OpenClaw…" + "وضعیت" + "گره OpenClaw · متصل" + "فعال" + "وضعیت اشکال‌زدایی اشتراک‌گذاری صفحه را نمایش دهید." + "هیچ محدودیتی گزارش نشده است" + "بستن اسکنر" + "هر %1$s روز" + "فعال" + "فعال‌سازی و باز کردن تنظیمات" + "آنلاین و آماده" + "Ask User" + "خطای چت" + "پیمایش به جلو" + "%1$s از %2$s" + "برنامه‌ریزی کار" + "console" + "تلاش مجدد" + "یک گفتگو را شروع کنید تا مکالمه‌های فعال OpenClaw شما اینجا ظاهر شوند." + "بارگیری اجرای خودکار ممکن نشد." + "پوسته در فضای کاری عامل" + "%1$s فعال" + "مجوزهای دستگاه را انتخاب کنید" + "مدت آخرین اجرا" + "عامل پیش‌فرض" + "%1$s ساعت" + "گفت‌وگو فعال است" + "نصب %1$s از ClawHub ممکن نشد." + "به OpenClaw خوش آمدید" + "کنترل برنامه‌های دیگر" + "نمایهٔ سیگنال" + "راز را وارد کنید…" + "%1$s:%2$s" + "به OpenClaw بگو %1$s" + "کشف‌شده" + "پنهان کردن نوار کناری" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "پخش صدا در دسترس نیست" + "اعمال" + "اطلاعات استفاده بارگیری نشد." + "در حین کار فعال، گره را در دسترس نگه دارید." + "بیدارباش بعدی" + "%1$s/%2$s" + "هیچ ورودی گزارش اخیری وجود ندارد." + "Gateway دستی" + "تغییر نام گروه" + "Update Goal" + "در دسترس بودن ارائه‌دهنده نامشخص است" + "ارائه‌دهندگان" + "حذف گروه…" + "بار داده" + "گزارش تماس‌ها" + "Memory Search" + "%1$s ارائه‌دهنده" + "زمینه تلفن و حریم خصوصی" + "%1$s/%2$s متصل" + "%1$s %2$s" + "بازیابی پس از راه‌اندازی مجدد Gateway همچنان در حال انجام است." + "جایگزینی کد راه‌اندازی، اطلاعات احراز هویت راه‌اندازی و توکن‌های دستگاه ذخیره‌شده در این تلفن را پیش از اتصال مجدد پاک می‌کند. ممکن است قابلیت‌های گره این تلفن دوباره نیازمند تأیید باشند؛ فقط زمانی ادامه دهید که قصد دارید آن را با یک کد راه‌اندازی جدید Gateway جفت کنید." + "برای بررسی پیکربندی و تاریخچه اجرای یک خودکارسازی، آن را باز کنید. اتصال‌های دارای سطح دسترسی مدیر می‌توانند آن را اجرا، ویرایش، فعال، غیرفعال یا حذف نیز بکنند." + "زمینه --" + "پیشنهاد قرنطینه شد." + "اجرای خودکار متوقف شد." + "OpenClaw موبایل" + "A2UI reset" + "Gateway در دسترس نیست" + "Read" + "این Skills به ۱ مورد راه‌اندازی نیاز دارد. Android موارد نصب‌شده را نمایش می‌دهد؛ تغییرات راه‌اندازی/پیکربندی فقط از طریق دسکتاپ یا CLI انجام می‌شوند." + "آخرین اجرا" + "برای اسکن QR راه‌اندازی، دسترسی به دوربین لازم است." + "مدل به‌روزرسانی نشد." + "حباب‌سازی" + "thread reply" + "حذف…" + "برای بررسی اجراهای خودکار، Gateway را متصل کنید." + "گزارش‌های اخیر" + "در حال بارگذاری اجراهای اخیر…" + "پیش‌نمایش این فایل امکان‌پذیر نیست. ممکن است فایل دودویی یا بیش از حد بزرگ باشد." + "بررسی" + "خواندن موقعیت مکانی این تلفن" + "کلید مهارت" + "%1$s نصب شد." + "Gatewayها" + "actions: %1$s" + "حالت بازارسال" + "%1$sk" + "تغییر چیدمان گفتگوها" + "بدون نقطه پایانی TLS" + "Gateway OpenClaw" + "راه‌اندازی دستی" + "در حال فکر کردن…" + "دسترسی Gateway نیاز به بازبینی دارد" + "۱ نگه‌داشته‌شده" + "%1$s ثانیه" + "پیشنهاد اعمال شود؟" + "فعلاً نه" + "تأییدنشده" + "جستجوی برنامه‌ها" + "۱ مدل پیکربندی‌شده" + "رد کردن اعلان تأیید" + "·" + "آفلاین" + "ارائه‌دهنده گفتار" + "تأیید Gateway در حال انجام است. OpenClaw به‌طور خودکار دوباره تلاش خواهد کرد." + "حداکثر" + "تغییرات cron به دسترسی operator.admin نیاز دارند." + "در حال فکر کردن" + "screen snapshot" + "گره‌های مشاهده‌شده: %1$s" + "اقدامی پیدا نشد" + "ذخیره و اتصال" + "list" + "Gateway تأیید را ثبت و انتخاب را ذخیره کرد." + "یک endpoint دستی معتبر برای اتصال وارد کنید." + "دستیار" + "در حال ارسال به چت..." + "ذخیره پروفایل" + "قفل‌شده" + "ویرایش خودکارسازی" + "از همان شبکه یا یک URL امن Gateway راه دور استفاده کنید." + "لنگر" + "زبان" + "این برنامه قدیمی‌تر از Gateway است. OpenClaw را در این دستگاه به‌روزرسانی کنید و سپس دوباره تلاش کنید." + "همه" + "نشست Gateway در حال انجام است" + "در انتظار بازبینی" + "هیچ Skills نصب نشده است." + "در حال بررسی Gateway" + "تأخیر تصادفی %1$s" + "نتیجه برای %1$s نامشخص است. دوباره متصل شوید، Skills را تازه‌سازی کنید و سپس دوباره تلاش کنید؛ Gateway با رعایت ایمنی به نصب منطبقی که هنوز در حال اجراست می‌پیوندد." + "فراموش کردن" + "هیچ Gateway جفت‌شده‌ای وجود ندارد." + "%1$s · %2$s" + "<رمز محرمانه حذف‌شده>" + "%1$s مشکل" + "OpenClaw" + "در حال شنیدن · %1$s در صف" + "گفتار دستیار بی‌صدا است" + "اقدام‌های گره فقط زمانی اجرا می‌شوند که برنامه مقصد در پیش‌زمینه باشد (اعتبارسنجی‌شده از طریق مسیر راه دور). اقدام‌های سراسری و اقدام‌های همان برنامه در اینجا کار می‌کنند." + "هنوز هیچ Gateway یافت نشده است. اگر شناسایی مسدود شده، از راه‌اندازی دستی استفاده کنید." + "باز کردن رشته" + "در حال کار" + "شروع به صحبت کنید..." + "گره تلفن" + "Xhigh" + "روی میزبان Gateway اجرا کنید:" + "تغییر مهارت‌ها به operator.admin نیاز دارد. با یک توکن Gateway دارای دسترسی مدیر دوباره متصل شوید." + "برای بررسی Skills در ClawHub، Gateway را متصل کنید." + "فهرست برنامه‌ها روی این تلفن باقی می‌ماند." + "بیکار" + "در تنظیمات دسترس‌پذیری اندروید نمایش داده می‌شود." + "تحویل هوشمند" + "رد" + "Gateway پس از %2$s وضعیت \'%1$s\' را برگرداند." + "توکن Gateway پیکربندی نشده است" + "Not available to this agent" + "فایل‌ها" + "مجوزها" + "راه‌اندازی دوربین ممکن نبود. یک تصویر QR از گالری انتخاب کنید یا کد راه‌اندازی را به‌صورت دستی وارد کنید." + "برای کپی ضربه بزنید" + "در انتظار %1$sm" + "%1$s." + "برای نصب Skills از ClawHub، Gateway را متصل کنید." + "جستجوی صدا" + " · میکروفون: در حال گوش دادن" + "برای بازبینی و تغییر تنظیمات Gateway با دسترسی operator.admin دوباره متصل شوید." + "بارگیری بیشتر" + "مشاهده در ۳ ثانیه" + "run" + "در حال تولید صدا…" + "← بازگشت" + "قطع اتصال" + "فرمان approve را روی رایانه Gateway اجرا کنید، سپس دوباره بررسی کنید." + "خودکارسازی‌ها" + "%1$s دقیقه" + "اعتماد" + "این کد QR، کد QR راه‌اندازی OpenClaw نیست. با openclaw qr یک کد جدید ایجاد کنید، سپس دوباره امتحان کنید." + "میکروفون ترجیحی در دسترس نیست؛ از مسیریابی خودکار استفاده می‌شود." + "ردشده" + "بسته‌های Android و پس‌زمینه را شامل کنید." + "Gateway شما آماده است." + "فعال شد" + "Structured Output" + "این فرایند بیش از حد انتظار طول کشیده است.\nبررسی کنید که Gateway در حال اجرا و در دسترس باشد." + "هیچ وظیفه پس‌زمینه‌ای برای این عامل وجود ندارد." + "در حال اتصال مجدد" + "OpenClaw در حال بررسی دسترسی به Gateway و گره است." + "Code Execution" + "بدون استفاده از ارائه‌دهنده" + "بازبینی" + "مجوز میکروفون لازم است." + "%1$s روز" + "%1$s مورد در دسترس" + "OpenClaw در حال همگام‌سازی مجدد است" + "جریان رویداد قطع شد؛ صفحه را تازه‌سازی کنید." + "بارگیری گره‌ها و دستگاه‌ها ممکن نشد." + "برای بارگذاری Skills، Gateway را متصل کنید." + "ناشناخته" + "خروجی" + "گفت‌وگو ناموفق بود: ارائه‌دهنده بلادرنگ به‌طور غیرمنتظره بسته شد." + "OpenClaw حساس به زمان" + "ban" + "توکن Gateway لازم است" + "دستگاه جفت‌شده" + "نیاز به تأیید دوباره دارد" + "زمان‌بندی نشده" + "مخاطبان" + "گوشی شما تا زمانی که لازم نباشد، بی‌صدا می‌ماند" + "در حال گوش دادن · در حال ارسال صدای در صف" + "بارگیری جزئیات وظیفه ممکن نشد" + "پیام عامل" + "Gateway به این هویت دستگاه نیاز دارد. دوباره احراز هویت کنید یا این اتصال Gateway را بازنشانی کنید." + "جلسه بعدی" + "امنیت اتصال" + "فعلاً رد شود" + "وب‌سایت" + "برای بارگذاری درخواست‌های تأیید در برنامه، به Gateway متصل شوید." + "%1$s کپی شد" + "هیچ برنامه‌ای انتخاب نشده است. تا زمانی که برنامه‌ای اضافه نکنید، چیزی ارسال نمی‌شود." + "%1$s %2$s" + "نیاز به راه‌اندازی دارد" + "جفت‌نشده" + "Gateway این تلفن را دریافت کرد" + "هیچ مدلی پیکربندی نشده است" + "غیرفعال کردن" + "زبان برنامه" + "در حال جفت‌سازی Gateway" + "احراز هویت ذخیره‌شده نامعتبر است" + "%1$s محدوده" + "برای بارگیری گزارش‌های اخیر، Gateway را متصل کنید." + "ذخیره واژه‌های بیدارباش" + "مهارت‌های نصب‌شده را مدیریت کنید و نسخه‌های مورد اعتماد را از ClawHub اضافه کنید." + "در حال ارسال…" + "هنوز عاملی بارگذاری نشده است." + "جستجو در ClawHub" + "گفت‌وگو در حال بررسی سلامت Gateway است." + "نیاز به جفت‌سازی" + "اجراهای فعال" + "ناموفق — %1$s" + "اتصال بین این تلفن و OpenClaw." + "summarize" + "تصویر ویجت در Downloads ذخیره شد" + "در حال شروع…" + "%1$s توکن" + "خطای کلاینت" + "پیش از اعطای دسترسی، این دستگاه درخواست‌کننده را بررسی کنید." + "میکروفون Bluetooth LE" + "%1$s %2$s" + "اجرای خودکار فعال شد." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "بایگانی‌شده" + "بارگذاری مجدد" + "جستجوی خودکارسازی‌ها" + "تلفن‌های پیوندشده و میزبان‌های گره پس از جفت‌سازی اینجا نمایش داده می‌شوند." + "%1$s: %2$s" + "این اجرای خودکار در Gateway تغییر کرده است. پیش از ذخیره مجدد، آخرین نسخه را بررسی کنید." + "توقف دیکته" + "خوانا" + "پیام به OpenClaw" + "رمز عبور Gateway نامعتبر است. دوباره آن را وارد کنید یا این اتصال Gateway را بازنشانی کنید." + "اتصال مجدد" + "زمان ISO، مثلاً 2026-07-09T09:30:00Z" + "%1$s ابزار" + "پاسخ قبلی این تأیید را رد کرده است." + "پیوند داده‌شده" + "باز کردن %1$s" + "%1$s/%2$s" + "اجرای خودکار با وضعیتی نامشخص به پایان رسید." + "صف آفلاین پر است (%1$s پیام)؛ ابتدا موارد در صف را حذف کنید." + "Gatewayهای عمومی به wss:// یا Tailscale Serve نیاز دارند. استفاده از ws:// برای localhost، میزبان‌های .local، شبیه‌ساز Android و IPهای خصوصی LAN مجاز است." + "برای بارگذاری جزئیات Skill، Gateway را متصل کنید." + "دسترسی کامل لازم است" + "شنونده بیدارباش" + "گسترش پیش‌نمایش پیوند" + "پاک کردن جستجوی گفتگو" + "NULL (ناموفق)" + "به‌روزرسانی" + "مدیر" + "نیازمند توجه" + "این دستگاه را با Gateway خود جفت کنید تا فقط برای کار واقعی بیدار شود، نمای کلی زنده‌ای از عامل‌ها در دسترس داشته باشید و از چرخه‌های پس‌زمینه‌ای پرمصرف جلوگیری کنید." + "نقش‌ها" + "پاسخ" + "فهرست ارائه‌دهندگان" + "فعال کردن مجوز در تنظیمات" + "A2UI push" + "بررسی دسترسی" + "اگر Gateway در دسترس باشد، اتصال مجدد باید بدون مداخله تکمیل شود." + "نوبت عامل" + "قرنطینه" + "نیازمند توجه" + "در حال جست‌وجو…" + "کد راه‌اندازی را از کجا دریافت کنم؟" + "فعال‌کردن مهارت ممکن نبود." + "pdf" + "حذف" + "%1$s٪ آنلاین" + "هیچ کانالی وجود ندارد" + "صدای هم‌زمان" + "اقدامات رد و قرنطینه Skill Workshop" + "گره‌ها و دستگاه‌ها" + "مرکز فرمان محلی" + "emoji upload" + "در حال بارگیری پیش‌نمایش…" + "زیاد" + "focus" + "describe" + "زمینه %1$s" + "در حال گوش‌دادن به پاسخ..." + "voice" + "به %1$s متصل است" + "role add" + "گفتگو نیاز به توجه دارد" + "فعال کردن میکروفون" + "OpenClaw نام‌ها، شناسه‌های بسته و وضعیت برنامه‌های قابل‌مشاهده روی این تلفن را هنگامی که Gateway جفت‌شدهٔ OpenClaw شما آن‌ها را درخواست کند، جمع‌آوری و ارسال می‌کند. این کار به دستیار شما امکان می‌دهد با استفاده از برنامه‌های نصب‌شده به پرسش‌ها پاسخ دهد و اقداماتی انجام دهد." + "Gateway متصل نیست" + "خط‌مشی" + "زمان انتظار برای تأیید پیام ارسال‌شده به پایان رسید؛ برای بررسی تحویل، صفحه را تازه‌سازی کنید." + "فایل‌های پشتیبان" + "عبارت" + "وظایف پس‌زمینه" + "رؤیا" + "هیچ برنامه‌ای مسدود نشده است. برنامه‌ها می‌توانند ارسال کنند، مگر اینکه مواردی را مسدود کنید." + "تشخیص‌دهنده گفتار در دسترس نیست" + "پلتفرم" + "Gateway تنظیمات %1$s را برنگرداند" + "Gateway فراموش شود؟" + "توضیح اختیاری" + "باز کردن %1$s" + "بوم خانه" + "در حال رؤیاپردازی" + "%1$s تا %2$s" + "اشتراک‌گذاری فایل" + "بلادرنگ" + "API" + "OpenClaw در حال کار است…" + "با OpenClaw صحبت یا دیکته کنید" + "اطلاعات برنامه‌های نصب‌شده به اشتراک گذاشته شود؟" + "در حال بارگیری خودکارسازی…" + "حذف خودکارسازی" + "دستیار پیش‌فرض" + "یک ارائه‌دهنده پشتیبانی‌شده %1$s را روی Gateway انتخاب کنید" + "در دسترس نیست" + "پوشه خالی" + "باز کردن تنظیمات" + "خاموش" + "تایپوگرافی" + "توقف" + "هنوز گفتگوی منطبقی وجود ندارد." + "جفت‌سازی Gateway موفق بود.\nقابلیت‌های گره این تلفن را از طریق رابط کاربری اپراتور تأیید کنید." + "این Skills نصب شده است، اما در حال حاضر شرایط اجرا را ندارد. برای تغییرات پیکربندی از دسکتاپ یا CLI استفاده کنید." + "تشخیص‌دهنده مشغول است" + "Gateway خانگی" + "فرمان تأیید را در Gateway اجرا کنید" + "سرویس غیرفعال شد" + "پیشنهادهای Skill Workshop بارگیری نشدند." + "من را در جریان رشته‌های اخیر OpenClaw بگذار و گام‌های بعدی را پیشنهاد کن." + "فعلاً نه" + "openclaw qr" + "start" + "گره OpenClaw · گفت‌وگو" + "خواندن و به‌روزرسانی رویدادها" + "گفت‌وگو ناموفق بود: ارائه‌دهنده بلادرنگ بسته شد: %1$s" + "برای مرور فایل‌های فضای کاری، به Gateway متصل شوید." + "%1$s از طریق رله Gateway" + "بارگذاری کاتالوگ گفت‌وگوی Gateway ممکن نبود" + "در حال پایش · ۱ کار زمان‌بندی‌شده" + "هر %1$s ساعت" + "سطح نمایشگر" + "ترجمه‌های OpenClaw · %1$s" + "درخواست فرمان" + "به‌روز" + "کانال" + "خارج کردن از حالت بی‌صدا" + "گروه جدید…" + "در حال آماده‌سازی صدا…" + "تطبیقی" + "به‌زودی" + "%1$s کارگر دیگر" + "Web Search" + "گفتگو، صدا، گفتگوها، ارائه‌دهندگان یا تنظیمات را امتحان کنید." + "OpenClaw فعال" + "navigate" + "درخواست‌شده %1$s" + "برای بررسی تاریخچه اجرای خودکارسازی، به Gateway متصل شوید." + "دسترسی به دستگاه؛ موافقت در Gateway همچنان الزامی است" + "لغو شد" + "یک کد راه‌اندازی یا آدرس gateway معتبر وارد کنید." + "مدل‌ها" + "OpenClaw غیرفعال" + "رمز عبور Gateway نامعتبر است" + "تأیید تغییر جفت‌سازی دستگاه ممکن نشد. صفحه را تازه‌سازی کنید و دوباره تلاش کنید." + "مشاهده جزئیات" + "Bash" + "توکن" + "عامل OpenClaw متصل می‌تواند از قابلیت‌های دستگاهی که فعال می‌کنید استفاده کند. فقط در صورتی ادامه دهید که به Gateway و عاملی که به آن وصل می‌شوید اعتماد دارید." + "جمع‌آوری بارناکل" + "دسترسی انتخابی یا کامل به عکس‌ها اعطا شده است." + "اجراکننده دسترس‌پذیری" + "%1$s مورد کم است" + "جمع‌کردن فهرست بررسی برنامه" + "تأیید گره لازم است" + "اتصال Gateway" + "... +%1$s مورد دیگر" + "بازکردن فهرست بررسی برنامه" + "مرورگر" + "screen record" + "اجرای در انتظار" + "فعال‌سازی به OpenClaw اجازه می‌دهد هنگام مسلح بودن، صفحه‌های برنامه‌های دیگر را مشاهده و کنترل کند. دسترسی دسترس‌پذیری اندروید لازم است." + "مبدأ" + "هوش مصنوعی شخصی روی دستگاه‌های شما" + "Attach" + "خودکار" + "نمای کلی" + "درخواست بازیابی ناموفق بود. برای تلاش مجدد ضربه بزنید." + "ویدیو" + "%1$s\n\n" + "رمزنگاری‌نشده" + "تقویم" + "وضعیت سلامت Gateway مطلوب نیست؛ ارسال ممکن نیست" + "📎 %1$s" + "آخرین وضعیت" + "پیش از شروع گفت‌وگویی جدید، منتظر بمانید تا پاسخ فعلی تمام شود." + "پروفایل" + "وقتی Gateway شما آن‌ها را گزارش کند، محدودیت‌های ارائه‌دهنده اینجا نمایش داده می‌شوند." + "۱ مشکل" + "گفتگوهای موجود در «%1$s» حفظ می‌شوند و به بخش بدون گروه بازمی‌گردند." + "پیشنهادشده" + "ایجادشده" + "%1$s/%2$s توکن فعال" + "نتیجه‌ای برای اقدام نیست" + "تق‌تق‌کردن" + "%1$s…" + "باز کردن جزئیات مهارت" + "پخش گفتار ناموفق بود: %1$s" + "شروع مکالمه" + "این پوشه بارگیری نشد." + "کد QR شامل کد راه‌اندازی معتبر نبود." + "دسترسی گره را بررسی کنید" + "افزودن عبارت بیدارباش" + "دسترسی به gateway ممکن نیست" + "خودکارسازی" + "نیازمند اتصال" + "تأیید تعیین تکلیف نشد. صفحه را تازه‌سازی و دوباره تلاش کنید." + "import" + "این تلفن چگونه برای OpenClaw نمایش داده می‌شود." + "تمرکز روی جستجوی گفتگو" + "اتصال Gateway" + "خواندن تقویم" + "نمای کلی هنگام اتصال مجدد و باز شدن این صفحه تازه‌سازی می‌شود." + "غیرفعال‌کردن مهارت ممکن نبود." + "همچنان در حال اتصال" + "%1$s دقیقه دیگر" + "خواندن SMS" + "برای بارگذاری میزان استفاده، به Gateway متصل شوید." + "در حال حاضر از طریق این تلفن در انجام چه کارهایی می‌توانید به من کمک کنید؟" + "نیازمند تأیید" + "گفت‌وگوی جدید" + "برای به‌روزرسانی پیشنهادهای کارگاه Skill، به Gateway متصل شوید." + "درخواست OpenClaw ناموفق بود." + "مجوز لازم است" + "آمادگی ارائه‌دهنده\nو مدل‌های پیکربندی‌شده را بررسی کنید." + "در حال بارگذاری" + "هشدار خرابی" + "قالب و متن ترجمه‌شده اندروید." + "میکروفون خاموش · در حال ارسال…" + "هیچ‌کدام" + "مشاهده" + "نام" + "نسخه" + "Cron" + "پیش از باز کردن OpenClaw، این تلفن را به یک Gateway متصل کنید." + "حذف عبارت بیدارباش" + "کد راه‌اندازی پذیرفته نشد. با openclaw qr یک کد جدید ایجاد کنید." + "۱۴ پیام · Android" + "رونویسی ناموفق بود: %1$s" + "همیشه" + "بارگیری رؤیاپردازی ممکن نشد." + "اجرای خودکار در صف قرار گرفت." + "Conversation Turn" + "اجرای خودکار آغاز شد." + "گروه جدید" + "خطای سرور" + "Video Generation" + "تأیید Gateway در انتظار است. دستور openclaw devices list را روی میزبان Gateway اجرا کنید، این تلفن را تأیید کنید و سپس دوباره تلاش کنید." + "ورودی‌ها پس از آن ظاهر می‌شوند که یک چرخهٔ dreaming خلاصه‌ای روایی بنویسد." + "%1$s میلی‌ثانیه" + "ذخیره‌گاه حافظه" + "دستیار در حال کار است" + "OpenClaw می‌تواند برنامه‌های قابل‌نمایش در لانچر را فهرست کند." + "مکالمه ناموفق بود: %1$s" + "جست‌وجوی Skills نصب‌شده" + "بررسی" + "Process" + "رشته‌های اخیر" + "ترمینال" + "فعلی" + "۱ حساب" + "متوقف‌شده" + "اجازه به دوربین" + "درخواست‌های تأیید اجرا تا زمانی که این تلفن متصل است اینجا نمایش داده می‌شوند." + " · میکروفون: در انتظار" + "کپی" + "جزئیات کپی شد" + "حذف" + "از OpenClaw بخواهید از قابلیت‌های Android استفاده کند." + "member" + "در حال بررسی اینکه آیا این Gateway از دستیار تنظیمات OpenClaw پشتیبانی می‌کند." + "برای اتصال مجدد، از گزینه‌های بازیابی زیر استفاده کنید." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "بارگیری کانال‌ها ممکن نشد." + "%1$s روز دیگر" + "خطاهای متوالی" + "خواندن کد QR از این تصویر ممکن نبود. تصویر واضح‌تری انتخاب کنید یا کد راه‌اندازی را به‌صورت دستی وارد کنید." + "Gateway قدیمی‌تر از این برنامه است. OpenClaw را روی میزبان Gateway به‌روزرسانی کنید و سپس دوباره تلاش کنید." + "پیش از چت، تماس صوتی و وضعیت زنده متصل شوید." + "اتصال دوباره به Gateway" + "شخص ثالث" + "بررسی آمادگی" + "محدود" + "لوگوی OpenClaw" + "برداشتن سنجاق مدل" + "سطوح پیام‌رسانی متصل به این Gateway." + "در حال ارسال" + "رشته‌های بایگانی‌شده اینجا نمایش داده می‌شوند." + "فرمان کپی شد" + "پیش‌نمایشی موجود نیست" + "این تلفن را در Gateway تأیید کنید.\nسپس دوباره برای اتصال تلاش کنید." + "اسکن کد QR" + "پوشه کاری فرمان · قابل پاک‌کردن نیست" + "فعالیت رشته" + "در دسترس" + "خودکارسازی حذف شود؟" + "%1$s امروز · در مجموع %2$s" + "رمز عبور" + "پیشنهاد قرنطینه شود؟" + "هیچ اعلان مجوزی در این بیلد گنجانده نشده است." + "تصویر ویجت ذخیره نشد" + "در انتظار %1$s" + "در حال صحبت…" + "ارائه‌دهندگان و مدل‌ها" + "گره" + "%1$s " + "درخواست در دسترس نیست" + "گزارش‌ها" + "برای بررسی پیشنهادهای Skill Workshop، به Gateway متصل شوید." + "ابزارها" + "کلید Gateway" + "ارسال SMS" + "OpenClaw آماده است تا در چت معمولی شما ادامه دهد." + "هیچ دستوری یافت نشد" + "هنوز به‌روزرسانی‌ای برای بوم وجود ندارد. برای تلاش مجدد ضربه بزنید." + "ترمینال به یک Gateway متصل نیاز دارد" + "Exec" + "فیلتر برنامه" + "اصلی" + "%1$sk" + "Gateway لازم است" + "دسترسی" + "بسته‌ها: snapshot=%1$s foreground=%2$s" + "تلاش دوباره برای اتصال" + "زمان‌بند Cron متوقف شده است." + "باز کردن" + "پیام کپی شد" + "نتوانستیم به Gateway شما دسترسی پیدا کنیم.\nبیایید این مشکل را برطرف کنیم." + "اکنون" + "حذف پس از اجرا" + "در این تلفن انتخاب شده است" + "unpin" + "Session History" + "برداشتن سنجاق" + "استفاده از این تلفن" + "جزئیات ClawHub برای %1$s بارگیری نشد." + "ابزارها در حال اجرا هستند" + "هنگامی که مکان فعال است، موقعیت مکانی دقیق را به اشتراک بگذارید." + "Mobile UI" + "پوسته" + "Gateway همچنان این تأیید را در انتظار نشان می‌دهد. پیش از تلاش مجدد، آن را بررسی کنید." + "پایان یادداشت صوتی" + "دیکته: %1$s" + "مجاز نیست" + "انتخاب تصویر دیگر" + "پیش‌نمایش تصویر" + "OpenClaw فقط وقتی گوش می‌دهد که Talk یا Dictation را شروع کنید." + "اشتراک‌گذاری گام‌ها و فعالیت" + "نیازمند راه‌اندازی" + "برای استفاده از دستیار تنظیمات OpenClaw این Gateway را به‌روزرسانی کنید." + "این اتصال Gateway برای نصب Skills از ClawHub به operator.admin نیاز دارد." + "پیشنهاد اعمال شد." + "%1$s در انتظار" + "%1$s ساعت پیش" + "خواندن گزارش تماس" + "%1$s در صف · در انتظار gateway" + "انتقال به گروه" + "برای جفت‌سازی، کد QR را اسکن کنید" + "تأیید رد شد." + "پیشنهاد Skill Workshop بررسی نشد." + "سنجاق‌شده" + "نمایه و دستگاه" + "بستن انتخاب‌گر سطح تفکر" + "پیام برای تحویل در آینده در صف قرار نگرفت." + "قرنطینه" + "زمان‌بندی · %1$s" + "سطح تفکر به‌روزرسانی نشد." + "باز کردن انتخاب‌گر سطح تفکر" + "مهلت پاسخ صوتی به پایان رسید؛ تلاش مجدد برای نوبت در صف" + "چیدمان: با جزئیات" + "این تصویر قابل رمزگشایی نبود." + "Gateway، صدا، اعلان‌ها، حریم خصوصی" + "فایل‌های فضای کاری عامل" + "این دستگاه دسترسی مورد اعتماد خود به Gateway را از دست خواهد داد." + "از requestId مربوط به فرمان در انتظار، در فرمان approve استفاده کنید." + "زمان‌بندی" + "محدودیت نرخ" + "تحویل داده نشد" + "بار داده · %1$s" + "در حال اجرا" + "چنگ‌زدن" + "پایان" + "استفاده از اعتماد سیستم" + "هیچ ارائه‌دهنده آماده‌ای وجود ندارد" + "میکروفون‌های Bluetooth متصل را در اولویت قرار می‌دهد." + "%1$s برنامه از بازارسال مسدود شده است." + "اقدام‌های پیام" + "نوع" + "خارج کردن از بایگانی" + "Transcripts" + "واژه‌های بیدارباش" + "%1$s را روی Gateway پیکربندی کنید" + "یک کد QR را اسکن کنید یا از کد راه‌اندازی OpenClaw Gateway خود استفاده کنید." + "نمونهٔ اولیهٔ سیستم طراحی" + "الک‌کردن" + " · گفت‌وگو: روشن" + "هنوز داده‌ای دربارهٔ میزان استفاده وجود ندارد." + "گفت‌وگو پیش از شروع اجرا ناموفق بود؛ دوباره تلاش کنید." + "ارسال" + "برخی از تصاویر اشتراک‌گذاری‌شده حذف شدند یا امکان افزودن آن‌ها وجود نداشت." + "نوشتن تقویم" + "timeout" + "کم" + "فهرست مسدود" + "act" + "Dismiss Task" + "گفت‌وگو ناموفق بود" + "OpenClaw · زنده" + "نصب‌شده" + "زمان انتظار برای پاسخ به پایان رسید؛ دوباره تلاش کنید یا صفحه را تازه‌سازی کنید." + "یافتن گفت‌وگوهای قبلی" + "مرور گفتگوها" + "در حال تازه‌سازی" + "صید مروارید" + "دوربین را باز کنید و کد openclaw qr را در کادر قرار دهید." + "هیچ دستگاهی وجود ندارد" + "ارسال اعلان‌ها" + "این گفتگو را جدا از چت معمولی عامل نگه می‌دارم." + "نشست Gateway در حال بازگشت به حالت آنلاین است. میان‌برهای عامل باید تا لحظاتی دیگر به‌طور خودکار پایدار شوند." + "جست‌وجوی دیگری را امتحان کنید یا عبارت جست‌وجوی فعلی را پاک کنید." + "اجازه دادن به موقعیت مکانی در پس‌زمینه؟" + "آمدن به سطح" + "راه‌اندازی اولیه" + "%1$s · %2$s · %3$s" + "لغو یادداشت صوتی" + "پیمایش به عقب" + "openclaw gateway" + "Gateway جفت شد" + "پوست‌اندازی" + "در انتظار نوبت بعدی شما." + "OpenClaw در حال کار است" + "ورودی گزارش" + "ناموفق: امکان دسترسی به نقطه پایانی امن Gateway برای این میزبان وجود نداشت." + "Gateway آفلاین است. اتصال را در زیر اصلاح کنید یا اطلاعات عیب‌یابی را کپی کنید." + "آماده‌به‌کار" + "تست تست ۱ ۲ ۳" + "جست‌وجوی Skills در ClawHub ممکن نشد." + "بدون پرامپت" + "دوربین جلو" + "باز کردن ورودی گزارش" + "مهلت اتصال شبکه به پایان رسید" + "اکنون" + "تغییر نام گروه…" + "عامل‌های بیشتر" + "openclaw nodes approve REQUEST_ID" + "سنجاق کردن" + "thread list" + "باز کردن %1$s" + "upload" + "رمز عبور Gateway پیکربندی نشده است" + "تنظیمات دیکته" + "مدل‌های ارائه‌دهنده بارگیری شدند، اما وضعیت آمادگی در دسترس نیست." + "گفتگو حذف شود؟" + "OpenClaw این تلفن را به یک رابط فرمان موبایل ساده برای رشته‌ها، صدا، ارائه‌دهندگان و Gateway تبدیل می‌کند." + "ابتدا جدیدترین" + "چرخه بعدی" + diff --git a/app/src/main/res/values-fr/assistant.xml b/app/src/main/res/values-fr/assistant.xml new file mode 100644 index 0000000..0fa4b98 --- /dev/null +++ b/app/src/main/res/values-fr/assistant.xml @@ -0,0 +1,7 @@ + + + "demander à OpenClaw %1$s" + "dire à OpenClaw de %1$s" + "ouvrir OpenClaw et demander %1$s" + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..dcb029d --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Faire confiance à cette passerelle ? + Faire confiance et continuer + Annuler + Nouveau chat dans le worktree + Vérifiez l’empreinte du certificat avant d’accorder votre confiance à cette passerelle.\n\n%1$s + Le certificat de la passerelle a changé. Continuez uniquement si vous vous attendiez à ce changement.\n\nAncien SHA-256 :\n%1$s\n\nNouveau SHA-256 :\n%2$s + Inconnu + VERSION + COMMIT + COMPILÉ + Version %1$s + Commit Git %1$s + Compilé le %1$s UTC, horodatage %2$s + Date de compilation %1$s + Copier le hash complet du commit Git + Copier l’horodatage complet de compilation + Commit Git d’OpenClaw + Horodatage de compilation d’OpenClaw + Commit Git copié + Horodatage de compilation copié + + "Impossible de préparer une pièce jointe pour l’envoi." + "Micro désactivé" + "Afficher les alertes OpenClaw" + "Activité du fil" + "Complet" + "Approbation autorisée et enregistrée." + "Afficher l’historique récent des appels" + "1 en attente" + "Pièce jointe non prise en charge" + "0 = exact" + "Connectez le Gateway pour rechercher des fils." + "%1$s comptes" + "Les modifications de Cron nécessitent operator.admin. Les codes de configuration ne l’accordent pas intentionnellement. Reconnectez-vous avec le jeton partagé ou le mot de passe du Gateway pour demander un accès administrateur. Si cet appareil ne dispose toujours pas de cet accès, approuvez l’extension d’autorisation en attente depuis un client administrateur existant." + "Apply Patch" + "Pincement" + "Réactiver le haut-parleur" + "Ignorés consécutifs" + "Ce dossier ne contient pas encore de fichiers." + "Non connecté" + "Consultez et gérez l’état des compétences installées." + "Échec" + "Agent par défaut" + "Appareil photo" + "Retirer du groupe" + "Recherche en cours" + "En pause pour la lecture vocale" + "Le Gateway vérifiera cette version exacte auprès de ClawHub avant le téléchargement. Si la version nécessite une acceptation explicite des risques, Android affichera l’avertissement du Gateway avant de réessayer." + "Le code de configuration utilise un ID de zone IPv6. Utilisez une adresse IPv6 sans portée ou un nom d’hôte LAN." + "Pièce jointe" + "Configurez les mots d’activation, la conversation et la lecture." + "Écoute (PTT)" + "Proposition rejetée." + "Afficher la barre latérale" + "utilisateur" + "%1$s · %2$s" + "Minimal" + "Refuser" + "AGENT ACTIF" + "1 planifiée" + "Aucune réponse" + "%1$s sélectionné" + "Tableau JSON argv de la commande" + "Impossible de lire cette image. Choisissez une capture d’écran ou une image nette du code QR fourni par openclaw qr." + "Impossible d’effectuer l’action %1$s sur la proposition Skill Workshop." + "Répondu ailleurs" + "Le Gateway a enregistré l\'approbation une fois." + "status" + "OpenClaw vérifie uniquement la localisation lorsque votre Gateway associé la demande. Sur l’écran Android suivant, choisissez %1$s pour autoriser les vérifications lorsque l’application est en arrière-plan." + "rejeter" + "Contraste" + "Remplacer la configuration de la Gateway ?" + "Impossible de charger les automatisations." + "Vous" + "Microphone intégré" + "Surface" + "Aucune proposition" + "Fil principal" + "Ouvrir le chat" + "Les actions d’association d’appareils ne sont pas disponibles dans cette session Gateway. Exécutez openclaw devices list sur l’hôte Gateway et gérez-y la demande. L’approbation des capacités du nœud est distincte et utilise toujours nodes approve <request id>." + "Demande d’action" + "list pins" + "Connectez-vous à un Gateway pour charger les propositions de l\'Atelier de Skills." + "Le code de configuration n’a pas été accepté" + "Se déconnecter" + "Le fournisseur de transcription en temps réel n’est pas configuré." + "Afficher les applications système" + "Mettez à jour votre Gateway pour afficher la configuration des modèles du fournisseur." + "Envoi de la dictée" + "Inspectez cette proposition pour charger son contenu Markdown." + "ouvrir OpenClaw et demander %1$s" + "raisonnement" + "Client" + "Appliqué" + "vidéo" + "Promu" + "En ligne" + "Portées" + "Le fournisseur vocal en temps réel n’est pas configuré." + "%1$s · %2$s" + "kick" + "Gateway a renvoyé une automatisation non valide." + "ID de l’instance" + "Le jeton du Gateway est requis. Saisissez-le à nouveau ou modifiez cette connexion." + "Source" + "Actualiser" + "%1$s en attente" + "Démarrer le chat" + "Les appels d’outils du chat en attente dans le fil actif restent visibles ici." + "Vérification du certificat requise" + "Ouvrez la surface Canvas actuelle pour l\'inspecter ou interagir avec elle." + "Automatisation mise à jour." + "Aucune session récente" + "Script" + "État du Gateway, disponibilité du nœud téléphonique et flux récent du journal." + "Ouvrir les détails de l’automatisation" + "Environnement d’exécution" + "1 worker de plus" + "Agent %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Veuillez activer %1$s dans les paramètres Android pour continuer." + "Réparer" + "reactions" + "Terminé" + "Version et mise à jour" + "OpenClaw affichera ici les approbations, les tâches échouées et les problèmes de canaux." + "Microphone USB" + "Trop de partages attendent d\'être ajoutés." + "Ignoré" + "Utilisez l’adresse LAN de l’ordinateur Gateway ou un nom d’hôte distant sécurisé." + "Activé" + "Recherche de fils" + "Conversation en temps réel" + "· %1$s" + "OpenClaw prépare une réponse." + "Approbation autorisée une fois." + "Configuration du fournisseur" + "aucune" + "Les contenus des scripts sont conservés sans modification. Utilisez la CLI pour modifier ce script." + "Guide de configuration Android" + "%1$s applications empêchées de transférer." + "Appareils associés" + ":%1$s" + "%1$s en attente" + "Nom de l’appareil" + "Envoyer" + "Localisation" + "ex. America/New_York" + "Cible de session" + "Examiner la skill ClawHub" + "snapshot" + "Rejeter la demande d’appairage de cet appareil ?" + "Microphone préféré" + "Hôte du nœud" + "Niveau" + "Fermer le sélecteur d’applications" + "Collez un jeton Gateway partagé ou un jeton émis par un opérateur." + "Tous les systèmes fonctionnent normalement" + "Diagnostics du gateway copiés" + "Erreur audio" + "Remplacer la configuration" + "Actions rapides" + "Échec de l’envoi : la discussion a échoué avant le démarrage de l’exécution ; réessayez." + "Microphone" + "Le chat vérifie toujours l’état du Gateway." + "Localisation précise" + "Autoriser une fois" + "+%1$s de plus" + "thread create" + "Bloqué" + "Mot ou phrase d’activation" + "Gateway nécessite l’approbation de l’appareil" + "Microphone externe" + "%1$s/%2$s prêtes" + "Connecté (opérateur hors ligne)" + "Capacité non approuvée" + "Cette action supprime définitivement l’automatisation et sa planification du Gateway." + "Chargement de l’image…" + "Connecter" + "Approuver l’accès au nœud" + "Ajouter un Gateway" + "Transcription indisponible : %1$s" + "Image" + "Marée" + "Fermer l\'aperçu de l\'image" + "eval" + "Dernière commande : %1$s" + "Ayez un terminal ouvert sur l’appareil exécutant OpenClaw." + "Aucun élément manquant" + "La sortie du Canvas nécessite une connexion Gateway active." + "%1$s · %2$s" + "Isolé" + "© 2026 OpenClaw Foundation — Licence MIT." + "PDF" + "Conversations" + "Consolidation de la mémoire et journal des rêves." + "Create Goal" + "Cette automatisation a été modifiée pendant que vous la modifiiez. Rétablissez la dernière version du Gateway avant d’enregistrer." + "Une fois connecté, le Gateway peut réveiller le téléphone à l’aide d’une notification push silencieuse au lieu de maintenir une session toujours active." + "Mode de réveil" + "Supprimer l’appareil appairé ?" + "Texte de l\'événement système" + "Impossible de copier l’image du widget" + "Non" + "Chemin facultatif" + "Envoi de la voix en file d’attente" + "Intégré" + "hide" + "runs" + "Le mot de passe du Gateway est requis. Saisissez-le à nouveau ou modifiez cette connexion." + "Texte de l\'événement" + "Transcription en direct" + "Impossible de charger la configuration des modèles du fournisseur." + "%1$s application autorisée à transférer." + "Configuration vocale" + "Joindre une vidéo" + "Images supplémentaires masquées : %1$s" + "Rejeter la demande d’appairage ?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Éditeur" + "Bifurquer à partir d\'ici" + " · Conversation : parole" + "Remplacement facultatif" + "Par défaut" + "Approbation de commande" + "L’application et le Gateway utilisent des versions de protocole incompatibles. Mettez à jour OpenClaw sur les deux, puis réessayez." + "Actualiser l’écran" + "Lire les photos et médias récents" + "Écouter" + "Connecté" + "Terminée" + "Votre téléphone est associé à %1$s. Continuez pour finaliser l’accès au nœud." + "Fil actuel" + "Réflexion %1$s" + "Coupé" + "OpenClaw remercie ses partenaires de la communauté open source." + "ouvert" + "Connexion à la Gateway" + "Le gateway peut modifier ce chemin mais ne peut pas effacer un chemin existant." + "TTS" + "Enregistrement…" + "Ancrage %1$s" + "search" + "Activer" + "Consultez et gérez les tâches planifiées du Gateway." + "Une réponse précédente a déjà autorisé cette commande une fois." + "generate" + "À utiliser uniquement sur un réseau privé de confiance." + "Rechercher dans les paramètres" + "La conversation est en direct" + "L’authentification du Gateway n’est pas configurée. Modifiez cette connexion, puis réessayez." + "Échec : point de terminaison sécurisé atteint, mais la vérification de l’empreinte TLS a expiré. Vérifiez Tailscale Serve ou le TLS de la gateway, puis réessayez." + "Étape 1" + "Dictée" + "Ouvrir le sélecteur d’applications" + "Aucune approbation en attente" + "edit" + "Connectez-vous à votre Gateway" + "Saisissez le code de configuration fourni par openclaw qr." + "Diagnostics" + "Les autres applications restent intactes." + "Craquement" + "Cette action supprime définitivement le fil et sa transcription." + "Appareil approuvé." + "Nouvelle tentative automatique" + "Image du widget copiée" + "%1$s rôles" + "1 élément manquant" + "%1$s planifiées" + "react" + "Agents" + "Connectez le Gateway pour charger les automatisations." + "Reconnexion…" + "Revenir à la configuration" + "send" + "Impossible de tester la connexion" + "Vérifier et installer" + "Transformez cet appareil en nœud OpenClaw sécurisé pour le chat, la voix, la caméra et les outils de l’appareil." + "Configuration manuelle" + "Ouvrez le chat pour démarrer ou reprendre le fil actuel." + "Astuce : arrêtez l’écoute pour envoyer l’intervention capturée." + "Ignorer" + "Échec de la requête vocale" + "Cela rejettera \"%1$s\" et actualisera l’état de Skill Workshop depuis le Gateway." + "Décorticage" + "update" + "Partager" + "Caméra activée" + "Telegram, WhatsApp, e-mail et les autres canaux apparaissent ici après la configuration." + "Erreur réseau" + "Exploration des mares" + "Restaurez maintenant le canevas pour session=%1$s source=%2$s. Si un état A2UI existe déjà, rejouez-le immédiatement. Sinon, créez et affichez dans Canvas un tableau de bord compact adapté aux appareils mobiles." + "Échec du démarrage : %1$s" + "Non demandé" + "Configurez un fournisseur %1$s sur le Gateway" + "kill" + "Approbations" + "Fichiers indisponibles" + "Marquer comme non lu" + "Rechercher des personnes et leurs coordonnées" + "Identité de l’appareil requise" + "Fil OpenClaw" + "Autoriser l’accès à la photothèque." + "Une réponse précédente a déjà résolu cette approbation." + "Aucun fil de discussion récent" + "Délai d’expiration %1$s s" + "Aucun résultat" + "Lire les notifications des applications sélectionnées" + "Disponibilité inconnue" + "Configurer la conversation" + "Supplément" + "Gateway associé. En attente de l’accès opérateur." + "Joindre une image" + "Choisissez ce qui parvient à OpenClaw." + "Réapprobation de la capacité en attente" + "Vérifiez les éléments en surbrillance" + "Écoute en cours..." + "Mets-moi au courant" + "Message" + "Lire les contacts" + "Le stockage hors ligne des pièces jointes est plein ; supprimez d’abord les éléments en attente." + "Une seule fois" + "Renommer" + "Aucun canal trouvé." + "Tout afficher" + "Nouvel appareil" + "Session Status" + "Ouvrir l\'aperçu de l\'image" + "La branche de session a changé ; vérifiez et réessayez ce message." + "close" + "Cela ressemble à un code de configuration. Revenez en arrière, sélectionnez Configurer Gateway, puis Utiliser le code de configuration." + "✦" + "Agents et automatisation" + "Appliquer" + "L’exécution de l’automatisation a été ignorée." + "Continuer" + "Surveillance · %1$s tâches planifiées" + "Parcourir" + "tabs" + "En attente" + "Conversation : %1$s" + "read" + "Sélectionner le texte" + "Activité physique" + "description : %1$s" + "Lire l\'audio" + "Heure" + "Non vérifié" + "Yield" + "Copier la commande d’approbation" + "Sortie de l’écran actuel et surface d’application interactive." + "Service connecté" + "Affichage" + "Prêt quand vous l’êtes" + "Impossible de charger le catalogue des fournisseurs." + "Parle · en attente de réponse" + "Non accordé" + "Enregistrer les modifications" + "Gateway a rejeté l’exécution de l’automatisation." + "Session Send" + "Rechercher sur ClawHub" + "Autorise toujours les vérifications de localisation demandées lorsque OpenClaw est en arrière-plan ; Android l’affiche dans la notification de nœud persistante." + "Événement système" + "Connectez Gateway pour afficher les fournisseurs" + "Prochain signal de présence" + "Gateway associé. En attente de l’approbation des capacités du nœud." + "Saumurage" + "Fermer Canvas" + "Modifier les contacts" + "Aucune compétence installée ne correspond à cette recherche." + "Configuration du fournisseur de conversation" + "Music Generation" + "Paramètres de Talk" + "Surveillance · 1 fil de discussion" + "Texte de la charge utile" + "Définir le texte" + "Approbation %1$s" + "Gateway n’a pas renvoyé l’état de préparation de %1$s" + "%1$s modèles configurés. Actualisez pour vérifier à nouveau la disponibilité." + "Conversation Send" + "Canevas" + "1 fournisseur" + "Le certificat du Gateway n’a pas pu être lu automatiquement. Collez l’empreinte SHA-256 obtenue sur l’hôte du Gateway." + "Échec de l’envoi : %1$s" + "Pont" + "Erreur de livraison" + "Utilisez OpenClaw depuis votre téléphone" + "Apparence" + "Atelier de Skills" + "Jeton requis" + "Aperçu · %1$s" + "Autorisation d’accès au microphone requise" + "Connectez le Gateway pour charger les propositions de Skill Workshop." + "Tous les systèmes sont opérationnels" + "Gateway inaccessible" + "OC" + "Mis à jour" + "Connecté (nœud hors ligne)" + "Accueil" + "La dictée est à l’écoute" + "Aucun fil archivé" + "Choisir et inspecter les assistants disponibles sur cette Gateway." + "Mode conversation actif" + "En cours · 1 exécution active" + "Accepter et activer" + "Mise à jour du Gateway requise" + "Copier l’image" + "URL du Gateway" + "main, isolated, current ou session:<id>" + "Média indisponible" + "Connectez-vous à votre Gateway pour ouvrir un shell dans l’espace de travail de l’agent." + "%1$s://%2$s:%3$s" + "Impossible de charger les détails de l\'approbation. Actualisez et réessayez." + "Je peux vérifier l’état du Gateway, réparer la configuration, changer de modèle ou connecter des canaux." + "Tool Call" + "Fils de discussion" + "Write" + "Commencez par une invite ou utilisez la voix." + "J" + "Ouvrir les paramètres" + "Observation…" + "Terminer la conversation" + "Dernière erreur" + "Vérifiez les actions qui nécessitent votre attention." + "Désactivée pour tous les agents." + "Démarrer le mode vocal" + "Retour aux tâches en arrière-plan" + "Une autre action cron est toujours en cours de finalisation." + "Délai de récupération %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "La reconnaissance vocale sur l’appareil n’est pas disponible." + "Script · lecture seule" + "Les pièces jointes sont trop volumineuses pour être mises en file d’attente dans un seul message ; supprimez-en certaines et réessayez." + "1 modèle configuré. Actualisez pour vérifier à nouveau la disponibilité." + "Widget indisponible" + "Une connexion sécurisée est requise pour cet hôte." + "Récents" + "Aucune automatisation correspondante." + "Le téléphone peut joindre la Gateway" + "Gateway" + "Expiré" + "Travail OpenClaw planifié depuis votre Gateway." + "Sub-agent" + "En attente de l’approbation de l’appareil" + "Chargement du fil de discussion" + "Ce Gateway présente désormais un certificat approuvé par cet appareil." + "Décalage ms" + "event create" + "document" + "Configurer Gateway" + "Lire la vidéo" + "L’authentification enregistrée n’est pas valide. Authentifiez-vous à nouveau ou réinitialisez cette connexion au Gateway." + "Pendant l’utilisation" + "screenshot" + "Revenir ici" + "Expression cron, par ex. 0 9 * * *" + "Retour à la voix" + "Parler" + "Détails" + "%1$s/%2$s en ligne" + "%1$s applications autorisées à transférer." + "Discussion" + "L’accès au microphone est nécessaire." + "Déplacement" + "Modifier" + "Heures de tranquillité" + "Copier les diagnostics" + "Planifié" + "Créer" + "Expire dans %1$s" + "Ignorer" + "Rejeter la proposition ?" + "Erreur de reconnaissance vocale (%1$s)" + "Problème" + "Recherchez dans les métadonnées du registre. Le Gateway vérifie à nouveau la fiabilité avant tout téléchargement." + "Utilisez une adresse IP de réseau local privé pour la configuration locale, ou activez Tailscale Serve / exposez une URL de gateway en wss:// pour l’accès à distance." + "Envoi…" + "Compte %1$s" + "Suggest Task" + "Rechercher" + "Écoute" + "Automatisation non chargée." + "Une mise à jour de Gateway est disponible. Lancez la mise à jour depuis l’interface Web ou la CLI lorsque vous êtes prêt." + "bientôt" + "Aucune approbation Gateway." + "Hôte" + "Ajoutez un mot ou une phrase d’activation par champ. Prononcez-en ensuite un avant votre commande." + "Transcrire puis envoyer" + "Exécuter à" + "Mettre l\'audio en pause" + "Accès à l’appareil Gateway" + "Aucun aperçu" + "Appareils" + "OpenClaw pour Android." + "Approbation de la capacité en attente" + "Enregistrez ou annulez vos modifications avant d’exécuter, d’activer, de désactiver, de supprimer ou d’actualiser cette automatisation." + "Aucune automatisation pour le moment." + "Cette compétence nécessite %1$s éléments de configuration. Android indique ce qui est installé ; les modifications de configuration s’effectuent depuis l’application de bureau ou la CLI." + "%1$s récentes" + "Canaux" + "Non phasé" + "Actif sur ce téléphone" + "Vérification de l’accès au nœud" + "Fuseau horaire" + "Actions d\'inspection et d\'application de Skill Workshop" + "Toujours autoriser" + "present" + "Les Skills installés sur la Gateway apparaîtront ici." + "Le code a peut-être expiré ou a été généré pour un autre Gateway." + "Autorisation requise" + "La configuration de l’automatisation n’est pas valide." + "Liste d’autorisation" + "Configuration, état et réparation" + "groups" + "Clé publique" + "À propos" + "Aucun code QR de configuration n’a été trouvé dans cette image. Choisissez le code QR généré par openclaw qr ou saisissez manuellement le code de configuration." + "permissions" + "Connectez le Gateway pour charger les nœuds et les appareils associés." + "Changer de branche" + "Aucune compétence" + "Les réponses sont lues à voix haute" + "Marquer comme lu" + "Approbation du nœud en attente" + "wake" + "%1$s propositions" + "L’authentification du Gateway nécessite votre attention." + "Détails de la connexion" + "Millisecondes" + "Reconnaissance vocale" + "Description" + "Conversations récentes" + "Votre téléphone envoie ces informations à votre Gateway, et non à un serveur exploité par OpenClaw. Votre Gateway peut les inclure dans les requêtes adressées au fournisseur d\'IA que vous avez choisi." + "Livraison" + "Couper le haut-parleur" + "%1$s en cours · %2$s terminés · %3$s échoués" + "Ouverture de la connexion à la Gateway" + "Surveillance · %1$s fils de discussion" + "L’exécution de l’automatisation est terminée." + "Aucune application correspondante." + "Envoyer au chat" + "Automatisation supprimée." + "Activer" + "Exécutions récentes" + "Alignez le code QR à l’intérieur du carré." + "Impossible de charger les approbations." + "J’ai approuvé" + "Connectez votre Gateway pour charger la disponibilité des fournisseurs." + "Non associé" + "Cette approbation a expiré avant de pouvoir être résolue." + "Observation dans %1$s s — basculez vers l\'application cible" + "Invite de l’agent" + "emoji list" + "Récurrent" + "Rechercher dans OpenClaw" + "%1$s en attente" + "Reconnaissance vocale sur l’appareil indisponible" + "Aucune app ne peut partager ce message" + "Fermer la recherche" + "Commande à surveiller" + "État" + "Accès aux notifications" + "Haut-parleur coupé" + "Rechercher des fils" + "OK" + "Impossible d’ouvrir le guide de configuration." + "demander à OpenClaw %1$s" + "Wait for Agents" + "Adresse" + "Les tâches planifiées créées sur le Gateway apparaîtront ici." + "Affichage du dernier bloc de journal." + "Utiliser le code de configuration" + "sticker" + "Utilisez un Gateway sécurisé wss:// ou Tailscale Serve, générez un code de configuration à accès complet dans la Control UI ou avec openclaw qr, puis scannez-le ou collez-le ci-dessous et reconnectez-vous pour activer les paramètres et les mises à niveau." + "steer" + "Sélectionné" + "Android peut scanner ou coller un code de configuration existant, mais ce gateway n’expose pas encore la génération de code de configuration à l’application. Générez le QR/code sur l’hôte du gateway avec openclaw qr, puis scannez-le ici ou collez le code de configuration ci-dessous." + "État du canevas" + "Corriger la connexion" + "Enregistrer l’image" + "Nœud %1$s" + "Mot de passe Gateway requis" + "Update Plan" + "Supprimer la pièce jointe" + "L’exécution de l’automatisation a échoué." + "Limites des fournisseurs et état des quotas." + "Catalogue de discussion Gateway non chargé" + "ce Gateway" + "Aucune exécution récente pour le moment." + "Modèle linguistique sur l’appareil indisponible" + "Le tableau de bord nécessite un Gateway connecté" + "Les propositions correspondantes apparaîtront ici après que les agents auront créé des brouillons de skills réutilisables." + "Session Search" + "OpenClaw parle" + "Scanner le QR code" + "Applications sélectionnées" + "Annuler les modifications" + "Commande d’approbation copiée" + "Statut de livraison" + "QR code non accepté" + "Votre centre de commandes vocales." + "Tester la connexion" + "OPENCLAW" + "Web Fetch" + "Invite" + "Approuver l’appareil ?" + "Connectez-vous à votre Gateway pour ouvrir le tableau de bord de cette session." + "Supprimer %1$s et ses identifiants enregistrés de ce téléphone ?" + "Le code QR pointe vers une Gateway distante non sécurisée. %1$s %2$s" + "Surface d’écran prête" + "Appairer la Gateway" + "Connectez le Gateway pour charger les canaux." + "Se met en pause pendant les autres activités vocales." + "Modèle" + "Photos" + "Coller le code de configuration" + "OpenClaw parle" + "Connexion..." + " · Localisation : Toujours" + "Messages : %1$s" + "Récif" + "Charger depuis le Gateway" + "texte : %1$s" + "Requis" + "rename group" + "Prêt" + "Le journal attend sa première entrée." + "Approuver" + "Page en direct" + "L’automatisation est déjà en cours d’exécution." + "Supprimer cette automatisation après une exécution ponctuelle réussie." + "Prêt pour le chat et la voix" + "Connecté (opérateur : %1$s)" + "L’association au Gateway est terminée. Approuvez ce téléphone comme nœud afin qu’OpenClaw puisse utiliser les fonctionnalités de l’appareil que vous activez." + "Réponse interrompue" + "image" + "%1$s suspendus" + "Aucun fil correspondant" + "delete" + "Disposition : compacte" + "channels" + "Accordé" + "Toutes les %1$s min" + "1 jeton" + "%1$s %2$s" + "Applications installées" + "en attente" + "Préparation de la note vocale…" + "Jamais" + "Sous-système" + "À la fin de la commande" + "Connexion" + "Impossible de charger l’historique d’exécution des automatisations." + "Nom de l’automatisation" + "Étape 2" + "Diagnostiquer" + "Certaines vérifications de l’état des canaux ne se sont pas terminées." + "pin" + "Copier %1$s" + "Associé" + "Impossible d’enregistrer les mots d’activation" + "Cela mettra \"%1$s\" en quarantaine et actualisera l’état de Skill Workshop depuis le Gateway." + "Enregistrer une note vocale" + "En attente" + "Répondu" + "Autoriser les outils de caméra sur demande." + "Problèmes" + "Réveil vocal" + "Demande d’appairage rejetée." + "il y a %1$s j" + "roles" + "Skills" + "Archiver" + "Nœud hors ligne. Reconnectez-vous et réessayez." + "Système" + "Adresse IP distante" + "Non regroupé" + "Détails de la planification" + "Fonctionnalités du téléphone" + "Non disponible" + "Tableau de bord" + "Coller le jeton" + "Aucun fournisseur" + "Empreinte SHA-256" + "Aucun fil pour le moment" + "Microphone Bluetooth" + "Récent" + "Renommer le fil" + "Résultat de la résolution inconnu. Les actions restent désactivées jusqu’à ce que l’enregistrement du Gateway soit vérifié." + "dialog" + "Écouter les mots d’activation" + "camera snap" + "Préparation de la lecture…" + "Gateway a sélectionné un fournisseur inconnu %1$s" + "delete group" + "Suivre Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Connectez le Gateway pour charger les agents." + "Retour" + "Partager le message" + "Générez un QR code." + "Redémarrer" + "Haut-parleur activé" + "Supprimer le groupe ?" + "Manquant" + "Rechercher des propositions" + "stop" + "Sécurisé (TLS)" + "Aucun nœud ni appareil associé." + "%1$s %% restants %2$s" + "Le code de configuration a expiré" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "JOURNAL" + "notify" + "Ce téléphone reste en veille jusqu’à ce que le Gateway en ait besoin, puis se réveille, se synchronise et se remet en veille." + "%1$s modèles configurés" + "Licences" + "Connectez le Gateway pour rechercher des Skills ClawHub." + "Skill" + "La connexion au Gateway a changé. Redémarrez OpenClaw pour vous reconnecter." + "ID de l’appareil" + "Gateway n’a pas identifié le fournisseur %1$s actif" + "En attente" + "Mots d’activation enregistrés" + "Plus anciens d\'abord" + "Écran" + "En cours depuis" + "Les ID de zone IPv6 ne sont pas pris en charge. Utilisez une adresse IPv6 sans portée ou un nom d’hôte LAN." + "Envoyé — confirmation de la livraison…" + "audio" + "This gateway connection needs operator.admin to update skills." + "Code de configuration" + "Accepter l’avertissement du Gateway et installer" + "Actualiser le chat" + "Intervalle" + "Les actions sur les propositions de Skill Workshop nécessitent le périmètre operator.admin." + "Sessions" + "Renommer…" + "Connectez la Gateway pour charger le dreaming." + "Configuration" + "Ouvrir Talk" + "poll" + "Connectez-vous pour charger vos agents" + "role remove" + " · Conversation : écoute" + "ClawHub n’a renvoyé aucune version installable pour %1$s." + "Commande" + "Cette approbation a été annulée avant de pouvoir être résolue." + "Micro activé · en attente du Gateway" + "Texte" + "%1$s sur %2$s affichées. Affinez la recherche pour en voir davantage." + "v%1$s disponible" + "%1$s://%2$s" + "%1$s... (OK)" + "Fournisseurs et modèles configurés" + "Connexion…" + "Connectez-vous à un Gateway pour enregistrer les mots d’activation" + "Ouvrir le profil" + "Démarrez votre Gateway." + "Aidez-moi à transformer cet objectif en une liste de tâches concrètes : " + "Effacer la recherche de sessions" + "Port" + "Saisir le code de configuration" + "Impossible de charger les journaux du Gateway." + "%1$s fournisseurs prêts" + "Vos agents sont prêts" + "Aucun fournisseur %1$s n’est configuré sur le Gateway" + "Écoute pour un seul échange" + "Observer" + "Millisecondes epoch (facultatif)" + "Aucun modèle configuré. Actualisez pour vérifier à nouveau la disponibilité." + "Réglages" + "Caméra arrière" + "approve" + "Avant de commencer" + "Impossible de charger les Skills." + "Désactivé" + "En attente d’approbation" + "Impossible de charger les tâches en arrière-plan" + "Vérifiez qu’OpenClaw peut parler clairement sur ce téléphone." + "En cours · %1$s exécutions actives" + "Répertoire de travail de la commande" + "Nom du groupe" + "Choisir depuis la galerie" + "Version %1$s" + "Retour" + "Connect the gateway to update skills." + "Supprimer après l’exécution" + "Le code de configuration pointe vers une Gateway distante non sécurisée. %1$s %2$s" + "Computer" + "Gateway déconnecté." + "Session Settings" + "Connectez le Gateway pour commencer" + "Avis de sécurité" + "Autre réponse" + "Ignorer l\'avertissement d\'image partagée" + "Le Gateway a évalué une autre version de ClawHub. Examinez à nouveau la compétence avant de l’installer." + "Ouvrir l’accès système" + "Terminée" + "Image indisponible" + "Notifications" + "Appliquer, rejeter et mettre en quarantaine nécessitent la portée operator.admin. Reconnectez-vous avec l\'authentification partagée du gateway ou approuvez une mise à niveau de la portée operator.admin de l\'appareil pour activer les actions de cycle de vie." + "sticker upload" + "Pêche au homard" + "Messages to recover" + "openclaw devices approve %1$s" + "Détail lisible du journal du Gateway." + "Examinez les propositions de skills générées avant qu\'elles ne deviennent des Skills actives." + "Inclus" + "%1$s disponibles" + "Approbation du nœud en attente" + "Gateway en attente" + "Authentification requise" + "Nœuds" + "Garder actif" + "OpenClaw répond" + "Documentation" + "%1$s prêts" + "Aucune sortie pour le moment" + "Langue de l’appareil non prise en charge" + "En attente — envoi lors de la reconnexion" + "il y a %1$s min" + "Branche actuelle" + "Vérification de l’accès d’appairage" + "Accès limité au Gateway" + "Exécution des outils..." + "Vérification de l’approbation…" + "Prendre des photos et enregistrer des clips avec ce téléphone" + "Connecté et prêt" + "Fermer" + "Transformez un objectif en liste de tâches concrètes." + "Le code de configuration contient une URL de Gateway non valide." + "N’activez que les accès que vous acceptez de laisser OpenClaw utiliser lorsque ce téléphone est connecté. Vous pourrez les modifier plus tard dans les paramètres Android." + "Compte" + "remove" + "Mot de passe facultatif" + "L’authentification du Gateway doit être vérifiée. Vérifiez les paramètres du Gateway, puis réessayez." + "Le code QR utilise un ID de zone IPv6. Utilisez une adresse IPv6 sans portée ou un nom d’hôte LAN." + "add" + "Krillage" + "Sain" + "Terminé en %1$s" + "Arguments" + "Options d’installation" + "Dans %1$s h" + "L’approbation du Gateway est en attente. Exécutez ceci sur l’hôte du Gateway :" + "Accès administrateur requis" + "set groups" + "Épingler le modèle" + "Effacer la recherche" + "Activée pour les agents éligibles." + "Aucun fil actuel" + "limites : %1$s" + "Après %1$s" + "Autoriser le planificateur à exécuter cette automatisation." + "%1$s appliqués" + "Aucun journal de rêves pour le moment." + "Actualiser les tâches en arrière-plan" + "Résumer les fils de discussion récents et les prochaines étapes." + "S’exécute sur l’appareil tant qu’OpenClaw est visible." + "%1$s travaille" + "%1$s %2$s" + "Brut" + "Exécutions" + "Exécuter maintenant" + "Branche sans titre" + "Configuré" + "camera list" + "1 appliqué" + "camera clip" + "Oui" + "Test audio" + "Retenu" + "events" + "Répertoire de travail" + "Aller au plus récent" + "Toujours autoriser" + "Scanner un QR code ou un code de configuration" + "Installing" + "Nœuds en direct, téléphones associés et demandes d’appareil en attente." + "Instantané : %1$s" + "Une réponse précédente a déjà autorisé cette commande et enregistré le choix." + "Demandes en attente" + "Approuvé" + "Espace de travail" + "Voix" + "Prêt à parler" + "Subagents" + "Échec : aucun point de terminaison sécurisé de Gateway n\'a été détecté. Activez le TLS de Gateway ou Tailscale Serve, ou utilisez une adresse LAN privée de confiance avec l\'option Non chiffré sélectionnée." + "Signaux" + "Session cible" + "Le Gateway a enregistré un refus." + "Accepter" + "Demandez n’importe quoi à OpenClaw" + "Reconnectez-vous pour continuer" + "%1$s appairés" + "Cela appliquera \"%1$s\" et actualisera l’état de Skill Workshop depuis le Gateway." + "Gateway hors ligne" + "openclaw devices list" + "État de la connexion au nœud OpenClaw" + "Les alertes restent sur ce téléphone." + "OpenClaw peut recevoir les alertes sélectionnées." + "Ouvrir l\'écran" + "Actions de chat" + "Autoriser le contrôle d\'autres applications ?" + "Inspection" + "Scannez ou collez un code de configuration pour ajouter un autre Gateway." + "Essaim" + "Délai TLS dépassé" + "Sessions récentes" + "Appareil appairé supprimé." + "Gateway appairé. Vérification de l’approbation des fonctionnalités du nœud." + "Mouvement" + "Échec de l’action cron." + "Sur l’ordinateur Gateway, exécutez :" + "Rechercher des sessions" + "Actualiser les journaux" + "Image indisponible · Appuyez pour réessayer" + "openclaw nodes approve %1$s" + "Note vocale · %1$s" + "Utilisation" + "Nautilage" + "Contexte %1$s%%" + "Transcrire les invites vocales" + "Couper le micro" + "Démarrez une nouvelle conversation et elle s’affichera ici." + "Problème de connexion" + "Moyen" + "Dupliquer" + "Activer le haut-parleur" + "Texte de l’événement système" + "Tri : %1$s" + "%1$s en attente" + "Image Generation" + "Note vocale" + "Rien ne nécessite votre attention" + "OpenClaw a besoin des autorisations %1$s pour continuer." + "Microphone du casque filaire" + "Pages" + "Distribué" + "Échéance" + "Les détails du Skill ne sont pas disponibles dans l’état actuel des Skills." + "Choisissez ce que ce téléphone peut partager." + "Une exécution de cette automatisation est déjà en file d’attente." + "Connectez le Gateway pour gérer les automatisations." + "L’automatisation n’est pas encore prévue." + "Aucun détail" + "L’approbation est en cours.\nOpenClaw se reconnectera automatiquement." + "Connectez votre Gateway pour voir la disponibilité des fournisseurs." + "En attente d’association" + "Démarrer ou poursuivre une conversation" + "Aucune tâche planifiée" + "Répondre à OpenClaw…" + "État" + "Nœud OpenClaw · Connecté" + "Actif" + "Afficher l’état de débogage du partage d’écran." + "Aucune limite signalée" + "Fermer le scanner" + "Tous les %1$s j" + "Activé" + "Activer et ouvrir les paramètres" + "En ligne et prêt" + "Ask User" + "Erreur de chat" + "Défiler vers l\'avant" + "%1$s sur %2$s" + "Planifier le travail" + "console" + "Réessayer" + "Démarrez une discussion et vos conversations OpenClaw actives apparaîtront ici." + "Impossible de charger l’automatisation." + "Shell dans l’espace de travail de l’agent" + "%1$s actif(s)" + "Choisir les autorisations de l’appareil" + "Dernière durée" + "Agent par défaut" + "%1$s h" + "La conversation est en direct" + "Impossible d’installer %1$s depuis ClawHub." + "Bienvenue dans OpenClaw" + "Contrôler d\'autres applications" + "Index des signaux" + "Saisir le secret…" + "%1$s:%2$s" + "dire à OpenClaw de %1$s" + "Découvert" + "Masquer la barre latérale" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "La lecture audio est indisponible" + "appliquer" + "Impossible de charger l’utilisation." + "Maintenir le nœud disponible pendant les tâches actives." + "Prochain réveil" + "%1$s/%2$s" + "Aucune entrée de journal récente." + "Gateway manuel" + "Renommer le groupe" + "Update Goal" + "Disponibilité du fournisseur inconnue" + "Fournisseurs" + "Supprimer le groupe…" + "Charge utile" + "Journal d’appels" + "Memory Search" + "%1$s fournisseurs" + "Contexte du téléphone et confidentialité" + "%1$s/%2$s connectés" + "%1$s %2$s" + "La récupération après le redémarrage du Gateway est toujours en cours." + "Le remplacement du code de configuration efface les identifiants de configuration et les jetons d’appareil enregistrés sur ce téléphone avant la reconnexion. Il peut être nécessaire d’approuver à nouveau les capacités du nœud pour ce téléphone. Ne continuez que si vous souhaitez l’associer à un nouveau code de configuration Gateway." + "Ouvrez une automatisation pour consulter sa configuration et son historique d’exécution. Les connexions avec des droits d’administrateur peuvent également l’exécuter, la modifier, l’activer, la désactiver ou la supprimer." + "Contexte --" + "Proposition mise en quarantaine." + "Automatisation mise en pause." + "OpenClaw mobile" + "A2UI reset" + "Gateway indisponible" + "Read" + "Cette compétence nécessite 1 élément de configuration. Android indique ce qui est installé ; les modifications de configuration s’effectuent depuis l’application de bureau ou la CLI." + "Dernière exécution" + "L’accès à l’appareil photo est nécessaire pour scanner le QR de configuration." + "Impossible de mettre à jour le modèle." + "Bouillonnement" + "thread reply" + "Supprimer…" + "Connectez Gateway pour consulter les automatisations." + "JOURNAUX RÉCENTS" + "Chargement des exécutions récentes…" + "Impossible de prévisualiser ce fichier. Il est peut-être binaire ou trop volumineux." + "Vérifier" + "Lire la position de ce téléphone" + "Clé du Skill" + "%1$s a été installé." + "Gateways" + "actions : %1$s" + "Mode de transfert" + "%1$sk" + "Changer la disposition des fils" + "Aucun point de terminaison TLS" + "Gateway OpenClaw" + "Configurer manuellement" + "Réflexion…" + "L’accès à Gateway doit être vérifié" + "1 suspendu" + "%1$ss" + "Appliquer la proposition ?" + "Pas maintenant" + "Non approuvé" + "Rechercher des applications" + "1 modèle configuré" + "Ignorer l\'avis d\'approbation" + "·" + "Hors ligne" + "Fournisseur de reconnaissance vocale" + "L’approbation du Gateway est en cours. OpenClaw réessaiera automatiquement." + "Max" + "Les modifications cron nécessitent l’accès operator.admin." + "Réflexion" + "screen snapshot" + "Nœuds observés : %1$s" + "Aucune action trouvée" + "Enregistrer et se connecter" + "list" + "Le Gateway a enregistré l\'approbation et le choix." + "Saisissez un endpoint manuel valide pour vous connecter." + "assistant" + "Envoi au chat..." + "Enregistrer le profil" + "Verrouillé" + "Modifier l’automatisation" + "Utilisez le même réseau, ou une URL Gateway distante sécurisée." + "Ancrage" + "Langue" + "Cette application est plus ancienne que le Gateway. Mettez à jour OpenClaw sur cet appareil, puis réessayez." + "Tout" + "Session du Gateway en cours" + "En attente de vérification" + "Aucun Skill installé." + "Vérification du Gateway" + "Décalage %1$s" + "Le résultat pour %1$s est inconnu. Reconnectez-vous, actualisez Skills, puis réessayez ; le Gateway rejoint en toute sécurité une installation correspondante toujours en cours." + "Oublier" + "Aucun Gateway associé." + "%1$s · %2$s" + "<secret masqué>" + "%1$s problèmes" + "OpenClaw" + "Écoute · %1$s en attente" + "Voix de l’assistant coupée" + "Les actions sur les nœuds ne s\'exécutent que lorsque l\'application cible est au premier plan (validé via le chemin distant). Les actions globales et les actions dans la même application fonctionnent ici." + "Aucun Gateway trouvé pour le moment. Utilisez la configuration manuelle si la détection est bloquée." + "Ouvrir le fil" + "En cours" + "Commencez à parler..." + "Nœud de téléphone" + "Xhigh" + "Exécutez sur l’hôte Gateway :" + "Les modifications de compétences nécessitent operator.admin. Reconnectez-vous avec un jeton de Gateway disposant des droits d’administration." + "Connectez le Gateway pour examiner les Skills ClawHub." + "La liste des applications reste sur ce téléphone." + "Inactif" + "Affiché dans les paramètres d\'accessibilité Android." + "Livraison intelligente" + "Rejeter" + "Gateway a renvoyé l’état \'%1$s\' après %2$s." + "Jeton Gateway non configuré" + "Not available to this agent" + "Fichiers" + "Autorisations" + "Impossible de démarrer l’appareil photo. Choisissez une image du code QR dans la galerie ou saisissez manuellement le code de configuration." + "Appuyez pour copier" + "En attente %1$sm" + "%1$s." + "Connectez le Gateway pour installer des Skills ClawHub." + "Rechercher une voix" + " · Micro : écoute" + "Reconnectez-vous avec l’accès operator.admin pour consulter et modifier les paramètres du Gateway." + "Charger plus" + "Observer dans 3 s" + "run" + "Génération de la voix…" + "← Retour" + "Déconnecter" + "Exécutez la commande d’approbation sur l’ordinateur Gateway, puis vérifiez à nouveau." + "Automatisations" + "%1$s min" + "Faire confiance" + "Ce code QR n’est pas un code QR de configuration OpenClaw. Générez un nouveau code avec openclaw qr, puis réessayez." + "Le microphone préféré n’est pas disponible ; utilisation du routage automatique." + "Rejeté" + "Inclure Android et les packages en arrière-plan." + "Votre Gateway est prête." + "Déclenché" + "Structured Output" + "Cela prend plus de temps que prévu.\nVérifiez que la Gateway est en cours d’exécution et accessible." + "Aucune tâche en arrière-plan pour cet agent." + "Reconnexion" + "OpenClaw vérifie l’accès au gateway et au nœud." + "Code Execution" + "Aucune utilisation de fournisseur" + "Examiner" + "L’autorisation d’utiliser le microphone est requise." + "%1$s j" + "%1$s disponibles" + "OpenClaw se resynchronise" + "Le flux d’événements a été interrompu ; essayez d’actualiser." + "Impossible de charger les nœuds et les appareils." + "Connectez la Gateway pour charger les Skills." + "inconnu" + "Résultat" + "Échec de la conversation : le fournisseur en temps réel s’est fermé de manière inattendue." + "OpenClaw urgent" + "ban" + "Jeton Gateway requis" + "Appareil associé" + "Nécessite une nouvelle approbation" + "Non planifié" + "Contacts" + "Votre téléphone reste silencieux jusqu’à ce qu’il soit nécessaire" + "Écoute · envoi de la voix en file d’attente" + "Impossible de charger les détails de la tâche" + "Message de l\'agent" + "Le Gateway requiert l’identité de cet appareil. Authentifiez-vous à nouveau ou réinitialisez cette connexion au Gateway." + "Prochaine session" + "Sécurité de la connexion" + "Ignorer pour l\'instant" + "Site web" + "Connectez le Gateway pour charger les demandes d’approbation dans l’application." + "%1$s copié" + "Aucune application sélectionnée. Rien n’est transféré tant que vous n’ajoutez pas d’applications." + "%1$s %2$s" + "Configuration requise" + "Non associé" + "Gateway a reçu ce téléphone" + "Aucun modèle configuré" + "Désactiver" + "Langue de l\'application" + "Appairage de la Gateway" + "Authentification enregistrée invalide" + "%1$s portées" + "Connectez le Gateway pour charger les journaux récents." + "Enregistrer les mots d’activation" + "Gérez les compétences installées et ajoutez des versions fiables depuis ClawHub." + "Envoi…" + "Aucun agent chargé pour le moment." + "Rechercher dans ClawHub" + "La discussion vérifie l’état du Gateway." + "Appairage requis" + "Exécutions actives" + "Échec — %1$s" + "Connexion entre ce téléphone et OpenClaw." + "summarize" + "Image du widget enregistrée dans Téléchargements" + "Démarrage…" + "%1$s jetons" + "Erreur du client" + "Vérifiez l’appareil à l’origine de cette demande avant d’accorder l’accès." + "Microphone Bluetooth LE" + "%1$s %2$s" + "Automatisation activée." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Archivé" + "Recharger" + "Rechercher des automatisations" + "Les téléphones liés et les hôtes de nœud apparaîtront ici après l’association." + "%1$s : %2$s" + "Cette automatisation a été modifiée sur Gateway. Vérifiez la dernière version avant de l’enregistrer à nouveau." + "Arrêter la dictée" + "Lisible" + "Envoyer un message à OpenClaw" + "Le mot de passe du Gateway n’est pas valide. Saisissez-le à nouveau ou réinitialisez cette connexion au Gateway." + "Reconnecter" + "Heure ISO, par ex. 2026-07-09T09:30:00Z" + "%1$s outils" + "Une réponse précédente a déjà refusé cette approbation." + "Associé" + "Ouvrir %1$s" + "%1$s/%2$s" + "L’exécution de l’automatisation s’est terminée avec un état inconnu." + "La file d’attente hors ligne est pleine (%1$s messages) ; supprimez d’abord des éléments en attente." + "Les gateways publics nécessitent wss:// ou Tailscale Serve. ws:// est autorisé pour localhost, les hôtes .local, l’émulateur Android et les adresses IP de réseaux locaux privés." + "Connectez la Gateway pour charger les détails du Skill." + "Accès complet requis" + "Détection des mots d’activation" + "Développer l’aperçu du lien" + "Effacer la recherche de fils" + "NULL (ÉCHEC)" + "Mettre à jour" + "Administration" + "Nécessite une attention" + "Associez cet appareil à votre Gateway pour ne le réveiller que pour des tâches réelles, garder à portée de main une vue d’ensemble actualisée des agents et éviter les boucles en arrière-plan qui épuisent la batterie." + "Rôles" + "Répondre" + "Catalogue des fournisseurs" + "Activer l’autorisation dans les paramètres" + "A2UI push" + "Vérifier l’accès" + "Si le Gateway est accessible, la reconnexion devrait s’effectuer sans intervention." + "Tour de l’agent" + "mettre en quarantaine" + "Attention" + "Recherche…" + "Où puis-je obtenir un code de configuration ?" + "Impossible d’activer la compétence." + "pdf" + "Supprimer" + "%1$s%% en ligne" + "Aucun canal" + "Voix en temps réel" + "Actions de rejet et de quarantaine de Skill Workshop" + "Nœuds et appareils" + "Centre de commande local" + "emoji upload" + "Chargement de l’aperçu…" + "Élevé" + "focus" + "describe" + "contexte %1$s" + "Écoute de la réponse..." + "voice" + "Connecté à %1$s" + "role add" + "Le chat nécessite votre attention" + "Activer le microphone" + "OpenClaw collecte et envoie les noms, les identifiants de package et l\'état des applications visibles sur ce téléphone lorsque votre Gateway OpenClaw associé les demande. Cela permet à votre assistant de répondre aux questions et d\'effectuer des actions à l\'aide des applications installées." + "Gateway non connecté" + "Politique" + "Délai d’attente dépassé lors de la confirmation du message envoyé ; actualisez pour vérifier sa livraison." + "Fichiers d’assistance" + "Expression" + "Tâches en arrière-plan" + "Rêver" + "Aucune application bloquée. Les applications peuvent transférer des données tant que vous n’ajoutez pas de blocages." + "Reconnaissance vocale indisponible" + "Plateforme" + "Gateway n’a pas renvoyé la configuration de %1$s" + "Oublier le Gateway ?" + "Description facultative" + "Ouvrir %1$s" + "Canvas d’accueil" + "Rêve" + "De %1$s à %2$s" + "Partager le fichier" + "Temps réel" + "API" + "OpenClaw travaille…" + "Parler ou dicter avec OpenClaw" + "Partager les informations sur les applications installées ?" + "Chargement de l’automatisation…" + "Supprimer l’automatisation" + "Assistant par défaut" + "Choisissez un fournisseur %1$s pris en charge sur le Gateway" + "Indisponible" + "Dossier vide" + "Ouvrir les paramètres" + "Désactivé" + "Typographie" + "Arrêter" + "Aucun fil correspondant pour le moment." + "L’appairage de la Gateway a réussi.\nApprouvez les capacités de nœud de ce téléphone depuis une interface opérateur." + "Cette compétence est installée, mais ne peut pas être exécutée actuellement. Utilisez l’application de bureau ou la CLI pour modifier sa configuration." + "Reconnaissance occupée" + "Gateway domestique" + "Exécutez la commande d’approbation sur Gateway" + "Service désactivé" + "Impossible de charger les propositions de Skill Workshop." + "Fais-moi un récapitulatif de mes fils de discussion OpenClaw récents et suggère les prochaines étapes." + "Pas maintenant" + "openclaw qr" + "start" + "Nœud OpenClaw · Conversation" + "Lire et mettre à jour les événements" + "Échec de la conversation : le fournisseur en temps réel s’est fermé : %1$s" + "Connectez le Gateway pour parcourir les fichiers de l’espace de travail." + "%1$s via le relais Gateway" + "Impossible de charger le catalogue de discussion Gateway" + "Surveillance · 1 tâche planifiée" + "Toutes les %1$s h" + "Surface d’écran" + "Traductions OpenClaw · %1$s" + "Demande de commande" + "À jour" + "Canal" + "Réactiver le micro" + "Nouveau groupe…" + "Préparation de l\'audio…" + "Adaptatif" + "Bientôt" + "%1$s workers de plus" + "Web Search" + "Essayez Chat, Voix, Fils, Fournisseurs ou Paramètres." + "OpenClaw actif" + "navigate" + "demandé %1$s" + "Connectez le Gateway pour consulter l’historique d’exécution des automatisations." + "Accès à l’appareil ; l’activation dans Gateway reste requise" + "Annulé" + "Saisissez un code de configuration ou une adresse de Gateway valide." + "Modèles" + "OpenClaw passif" + "Mot de passe Gateway invalide" + "Impossible de vérifier la modification de l’appairage de l’appareil. Actualisez et réessayez." + "Afficher les détails" + "Bash" + "Jeton" + "L’agent OpenClaw connecté peut utiliser les fonctionnalités de l’appareil que vous activez. Continuez uniquement si vous faites confiance à la Gateway et à l’agent auxquels vous vous connectez." + "Collecte de bernacles" + "Accès sélectionné ou complet aux photos accordé." + "Exécuteur d\'accessibilité" + "%1$s éléments manquants" + "Réduire la liste de contrôle du plan" + "Approbation du nœud requise" + "Connecter Gateway" + "... +%1$s de plus" + "Développer la liste de contrôle du plan" + "Navigateur" + "screen record" + "Exécution en attente" + "L\'activation permet à OpenClaw d\'observer et de contrôler les écrans d\'autres applications lorsqu\'il est armé. L\'accès à l\'accessibilité Android est requis." + "Origine" + "IA personnelle sur vos appareils" + "Attach" + "Automatique" + "Vue d’ensemble" + "Échec de la demande de restauration. Appuyez pour réessayer." + "Vidéo" + "%1$s\n\n" + "Non chiffré" + "Calendrier" + "L’état du Gateway n’est pas OK ; envoi impossible" + "📎 %1$s" + "Dernier statut" + "Attendez la fin de la réponse en cours avant de démarrer une nouvelle discussion." + "Profil" + "Les limites du fournisseur s’afficheront ici lorsque votre Gateway les signalera." + "1 problème" + "Les fils de \"%1$s\" sont conservés et replacés dans Non groupés." + "Recommandé" + "Créé" + "%1$s/%2$s jetons actifs" + "Aucun résultat d\'action" + "Claquement" + "%1$s…" + "Ouvrir les détails du Skill" + "Échec de la synthèse vocale : %1$s" + "Démarrer la conversation" + "Impossible de charger ce dossier." + "Le code QR ne contenait pas de code de configuration valide." + "Vérifiez l’accès au nœud" + "Ajouter une phrase d’activation" + "Impossible de joindre le gateway" + "Automatisation" + "Connexion requise" + "Impossible de résoudre l\'approbation. Actualisez et réessayez." + "import" + "Comment ce téléphone apparaît dans OpenClaw." + "Activer la recherche de fils" + "Connecter le Gateway" + "Lire le calendrier" + "La vue d’ensemble s’actualise lors de la reconnexion et à l’ouverture de cet écran." + "Impossible de désactiver la compétence." + "Connexion toujours en cours" + "Dans %1$s min" + "Lire les SMS" + "Connectez le Gateway pour charger l’utilisation." + "Que pouvez-vous m’aider à faire depuis ce téléphone dès maintenant ?" + "Nécessite une approbation" + "Nouvelle discussion" + "Connectez le Gateway pour mettre à jour les propositions de Skill Workshop." + "La requête OpenClaw a échoué." + "Autorisation requise" + "Vérifiez la disponibilité des fournisseurs\net les modèles configurés." + "Chargement" + "Alerte d’échec" + "Thème et texte Android traduit." + "Micro désactivé · envoi…" + "Aucun" + "Voir" + "Nom" + "Version" + "Cron" + "Connectez ce téléphone à un Gateway avant d’ouvrir OpenClaw." + "Supprimer la phrase d’activation" + "Le code de configuration n’a pas été accepté. Générez un nouveau code avec openclaw qr." + "14 messages · Android" + "Échec de la transcription : %1$s" + "Toujours" + "Impossible de charger le mode rêve." + "L’exécution de l’automatisation a été mise en file d’attente." + "Conversation Turn" + "L’automatisation a démarré." + "Nouveau groupe" + "Erreur du serveur" + "Video Generation" + "L’approbation du Gateway est en attente. Exécutez openclaw devices list sur l’hôte du Gateway, approuvez ce téléphone, puis réessayez." + "Les entrées apparaissent après qu’un cycle de dreaming écrit un résumé narratif." + "%1$s ms" + "Magasin de mémoire" + "Assistant en cours de travail" + "OpenClaw peut répertorier les applications visibles dans le lanceur." + "Échec de la conversation : %1$s" + "Rechercher dans les Skills installés" + "Inspecter" + "Process" + "Fils de discussion récents" + "Terminal" + "Actuel" + "1 compte" + "En pause" + "Autoriser l’appareil photo" + "Les demandes d’approbation d’exécution apparaîtront ici tant que ce téléphone sera connecté." + " · Micro : en attente" + "Copier" + "Détails copiés" + "Supprimer" + "Demandez à OpenClaw d’utiliser les fonctionnalités d’Android." + "member" + "Vérification de la prise en charge de l’assistant de paramètres OpenClaw par ce Gateway." + "Utilisez les options de récupération ci-dessous pour vous reconnecter." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Impossible de charger les canaux." + "Dans %1$s j" + "Erreurs consécutives" + "Impossible de lire un code QR dans cette image. Choisissez une image plus nette ou saisissez manuellement le code de configuration." + "Le Gateway est plus ancien que cette application. Mettez à jour OpenClaw sur l’hôte du Gateway, puis réessayez." + "Connectez-vous avant d’utiliser le chat, la voix et le statut en direct." + "Reconnecter le Gateway" + "Tiers" + "Vérifier l’état de préparation" + "Limité" + "Logo OpenClaw" + "Désépingler le modèle" + "Surfaces de messagerie connectées à ce Gateway." + "Envoi" + "Les fils archivés apparaîtront ici." + "Commande copiée" + "Aucun aperçu disponible" + "Approuvez ce téléphone sur la Gateway.\nRéessayez ensuite de vous connecter." + "Scanner le code QR" + "Répertoire de travail de la commande · impossible à effacer" + "Activité du fil" + "Disponible" + "Supprimer l’automatisation ?" + "%1$s aujourd’hui · %2$s au total" + "Mot de passe" + "Mettre la proposition en quarantaine ?" + "Aucun avis de licence n’est inclus dans cette version." + "Impossible d’enregistrer l’image du widget" + "En attente depuis %1$s" + "En train de parler…" + "Fournisseurs et modèles" + "Nœud" + "%1$s " + "Invite indisponible" + "Journaux" + "Connectez le Gateway pour examiner les propositions de Skill Workshop." + "Outils" + "Commutateur du Gateway" + "Envoyer des SMS" + "OpenClaw est prêt à continuer dans votre chat ordinaire." + "Aucune commande trouvée" + "Aucune mise à jour du canevas pour le moment. Appuyez pour réessayer." + "Le terminal nécessite un Gateway connecté" + "Exec" + "Filtre d’applications" + "Principal" + "%1$sk" + "Gateway requis" + "Accès" + "Paquets : snapshot=%1$s foreground=%2$s" + "Réessayer la connexion" + "Le planificateur Cron est arrêté." + "Ouvrir" + "Message copié" + "Impossible de joindre votre Gateway.\nRésolvons ce problème." + "maintenant" + "Supprimer après exécution" + "Sélectionné sur ce téléphone" + "unpin" + "Session History" + "Désépingler" + "Utiliser ce téléphone" + "Impossible de charger les détails ClawHub pour %1$s." + "Outils en cours d’exécution" + "Partager la position précise lorsque la localisation est activée." + "Mobile UI" + "Thème" + "Le Gateway affiche encore cette approbation comme en attente. Vérifiez-la avant de réessayer." + "Terminer la note vocale" + "Dictée : %1$s" + "Non autorisé" + "Choisir une autre image" + "Aperçu de l’image" + "OpenClaw écoute uniquement lorsque vous démarrez Talk ou Dictation." + "Partager les pas et l’activité" + "Configuration requise" + "Mettez à jour ce Gateway pour utiliser l’assistant de paramètres OpenClaw." + "Cette connexion au Gateway nécessite operator.admin pour installer des Skills ClawHub." + "Proposition appliquée." + "%1$s en attente" + "il y a %1$s h" + "Lire le journal d’appels" + "%1$s en attente · en attente du Gateway" + "Déplacer vers un groupe" + "Scanner le code QR pour jumeler" + "Approbation refusée." + "Impossible d’examiner la proposition de Skill Workshop." + "Épinglé" + "Profil et appareil" + "Fermer le sélecteur de niveau de réflexion" + "Impossible de mettre le message en file d’attente pour un envoi ultérieur." + "Quarantaine" + "Planification · %1$s" + "Impossible de mettre à jour le niveau de réflexion." + "Ouvrir le sélecteur de niveau de réflexion" + "La réponse vocale a expiré ; nouvelle tentative pour le tour mis en file d’attente" + "Disposition : détaillée" + "Cette image n’a pas pu être décodée." + "Gateway, voix, notifications, confidentialité" + "Fichiers de l’espace de travail de l’agent" + "Cet appareil perdra son accès approuvé au Gateway." + "Utilisez le requestId de la commande en attente dans la commande d’approbation." + "Planification" + "Limite de débit" + "Non distribué" + "Charge utile · %1$s" + "En cours" + "Agrippement" + "Terminer" + "Utiliser la confiance du système" + "Aucun fournisseur prêt" + "Donne la priorité aux microphones Bluetooth connectés." + "%1$s application empêchée de transférer." + "Actions du message" + "Type" + "Désarchiver" + "Transcripts" + "Mots d’activation" + "Configurez %1$s sur le Gateway" + "Scannez un code QR ou utilisez le code de configuration de votre OpenClaw Gateway." + "Prototype de système de design" + "Tamisage" + " · Conversation : activée" + "Aucune donnée d’utilisation pour le moment." + "La discussion a échoué avant le début de l’exécution ; réessayez." + "Envoyer" + "Certaines images partagées ont été omises ou n’ont pas pu être ajoutées." + "Modifier le calendrier" + "timeout" + "Faible" + "Liste de blocage" + "act" + "Dismiss Task" + "Échec du chat" + "OpenClaw · En direct" + "Installé" + "Délai d’attente de la réponse dépassé ; réessayez ou actualisez." + "Retrouver les conversations précédentes" + "Parcourir les fils" + "Actualisation" + "Pêche aux perles" + "Ouvrez l’appareil photo et cadrez le code depuis openclaw qr." + "Aucun appareil" + "Transférer les notifications" + "Je garderai cette conversation séparée du chat ordinaire de l’agent." + "La session du Gateway se reconnecte. Les raccourcis des agents devraient se rétablir automatiquement dans un instant." + "Essayez une autre recherche ou effacez la requête actuelle." + "Autoriser la localisation en arrière-plan ?" + "Remontée à la surface" + "Amorçage" + "%1$s · %2$s · %3$s" + "Annuler la note vocale" + "Défiler vers l\'arrière" + "openclaw gateway" + "Gateway appairée" + "Mue" + "À l’écoute de votre prochaine intervention." + "OpenClaw travaille" + "Entrée de journal" + "Échec : impossible d’atteindre le point de terminaison sécurisé de la gateway pour cet hôte." + "Le Gateway est hors ligne. Corrigez la connexion ci-dessous ou copiez les diagnostics." + "En veille" + "Test test 1 2 3" + "Impossible de rechercher les Skills ClawHub." + "Aucune invite" + "Caméra avant" + "Ouvrir l’entrée de journal" + "Délai d’attente du réseau dépassé" + "Maintenant" + "Renommer le groupe…" + "Plus d\'agents" + "openclaw nodes approve REQUEST_ID" + "Épingler" + "thread list" + "Ouvrir %1$s" + "upload" + "Mot de passe Gateway non configuré" + "Paramètres de dictée" + "Les modèles du fournisseur sont chargés, mais leur disponibilité est inconnue." + "Supprimer le fil ?" + "OpenClaw transforme ce téléphone en une interface de commande mobile épurée pour les fils de discussion, la voix, les fournisseurs et Gateway." + "Plus récents d\'abord" + "Prochain cycle" + diff --git a/app/src/main/res/values-hi/assistant.xml b/app/src/main/res/values-hi/assistant.xml new file mode 100644 index 0000000..d855645 --- /dev/null +++ b/app/src/main/res/values-hi/assistant.xml @@ -0,0 +1,7 @@ + + + "OpenClaw से %1$s पूछें" + "OpenClaw को %1$s बताने के लिए कहें" + "OpenClaw खोलें और %1$s पूछें" + + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..8e6f67f --- /dev/null +++ b/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + इस गेटवे पर भरोसा करें? + भरोसा करें और जारी रखें + रद्द करें + worktree में नया चैट + इस गेटवे पर भरोसा करने से पहले प्रमाणपत्र फ़िंगरप्रिंट सत्यापित करें।\n\n%1$s + गेटवे प्रमाणपत्र बदल गया है। केवल तभी जारी रखें जब आपको इस बदलाव की अपेक्षा थी।\n\nपुराना SHA-256:\n%1$s\n\nनया SHA-256:\n%2$s + अज्ञात + संस्करण + कमिट + बिल्ड + संस्करण %1$s + Git कमिट %1$s + %1$s UTC को बिल्ड किया गया, टाइमस्टैम्प %2$s + बिल्ड की तारीख %1$s + पूरा Git कमिट हैश कॉपी करें + पूरा बिल्ड टाइमस्टैम्प कॉपी करें + OpenClaw Git कमिट + OpenClaw बिल्ड टाइमस्टैम्प + Git कमिट कॉपी किया गया + बिल्ड टाइमस्टैम्प कॉपी किया गया + + "भेजने के लिए अटैचमेंट तैयार नहीं किया जा सका।" + "माइक बंद" + "OpenClaw अलर्ट दिखाएँ" + "थ्रेड गतिविधि" + "पूर्ण" + "स्वीकृति की अनुमति दी गई और सहेजी गई।" + "हाल का कॉल इतिहास दिखाएँ" + "1 लंबित" + "असमर्थित अटैचमेंट" + "0 = सटीक" + "थ्रेड खोजने के लिए Gateway कनेक्ट करें।" + "%1$s खाते" + "Cron परिवर्तनों के लिए operator.admin आवश्यक है। सेटअप कोड जानबूझकर इसकी अनुमति नहीं देते। एडमिन एक्सेस का अनुरोध करने के लिए Gateway के साझा टोकन या पासवर्ड से दोबारा कनेक्ट करें। अगर इस डिवाइस के पास अब भी यह एक्सेस नहीं है, तो किसी मौजूदा एडमिन क्लाइंट से लंबित स्कोप अपग्रेड को स्वीकृति दें।" + "Apply Patch" + "चुटकी काटना" + "स्पीकर अनम्यूट करें" + "लगातार स्किप" + "इस फ़ोल्डर में अभी कोई फ़ाइल नहीं है." + "कनेक्ट नहीं है" + "इंस्टॉल किए गए Skill की स्थिति देखें और प्रबंधित करें।" + "विफल" + "डिफ़ॉल्ट एजेंट" + "कैमरा" + "समूह से हटाएँ" + "खोज जारी है" + "वॉइस प्लेबैक के लिए रोका गया" + "डाउनलोड से पहले Gateway इस सटीक रिलीज़ को ClawHub से सत्यापित करेगा। यदि रिलीज़ के लिए जोखिम की स्पष्ट स्वीकृति आवश्यक है, तो दोबारा प्रयास करने से पहले Android Gateway चेतावनी दिखाएगा।" + "सेटअप कोड IPv6 ज़ोन ID का उपयोग करता है। बिना स्कोप वाला IPv6 पता या LAN होस्टनाम उपयोग करें।" + "अटैचमेंट" + "वेक वर्ड, बातचीत और प्लेबैक कॉन्फ़िगर करें।" + "सुन रहा है (PTT)" + "प्रस्ताव अस्वीकार किया गया." + "साइडबार दिखाएँ" + "उपयोगकर्ता" + "%1$s · %2$s" + "न्यूनतम" + "अस्वीकार करें" + "सक्रिय एजेंट" + "1 निर्धारित" + "कोई जवाब नहीं" + "%1$s चुना गया" + "कमांड argv JSON ऐरे" + "उस इमेज को पढ़ा नहीं जा सका। openclaw qr से मिले QR का स्पष्ट स्क्रीनशॉट या इमेज चुनें।" + "Skill Workshop प्रस्ताव को %1$s नहीं किया जा सका।" + "कहीं और उत्तर दिया गया" + "Gateway ने स्वीकृति एक बार दर्ज की।" + "status" + "OpenClaw केवल तभी स्थान की जाँच करता है, जब आपका युग्मित Gateway इसका अनुरोध करता है। ऐप के बैकग्राउंड में होने पर जाँच की अनुमति देने के लिए अगली Android स्क्रीन पर %1$s चुनें।" + "अस्वीकार करें" + "कंट्रास्ट" + "Gateway सेटअप बदलें?" + "ऑटोमेशन लोड नहीं किए जा सके।" + "आप" + "अंतर्निहित माइक्रोफ़ोन" + "सतह" + "कोई प्रस्ताव नहीं" + "मुख्य थ्रेड" + "चैट खोलें" + "इस Gateway सत्र में डिवाइस पेयरिंग की कार्रवाइयाँ उपलब्ध नहीं हैं। Gateway होस्ट पर openclaw devices list चलाएँ और अनुरोध को वहीं प्रबंधित करें। नोड क्षमता की स्वीकृति अलग है और अब भी nodes approve <request id> का उपयोग करती है।" + "कार्रवाई का अनुरोध" + "list pins" + "Skill Workshop प्रस्ताव लोड करने के लिए किसी Gateway से कनेक्ट करें." + "सेटअप कोड स्वीकार नहीं किया गया" + "साइन आउट करें" + "रीयल-टाइम ट्रांसक्रिप्शन प्रदाता कॉन्फ़िगर नहीं किया गया है।" + "सिस्टम ऐप्स दिखाएँ" + "प्रदाता मॉडल कॉन्फ़िगरेशन देखने के लिए अपना Gateway अपडेट करें।" + "डिक्टेशन भेजा जा रहा है" + "इस प्रस्ताव का markdown लोड करने के लिए इसका निरीक्षण करें।" + "OpenClaw खोलें और %1$s पूछें" + "तर्क" + "क्लाइंट" + "लागू किया गया" + "वीडियो" + "प्रमोट किया गया" + "ऑनलाइन" + "दायरे" + "रीयल-टाइम वॉइस प्रदाता कॉन्फ़िगर नहीं किया गया है।" + "%1$s · %2$s" + "kick" + "Gateway ने एक अमान्य ऑटोमेशन लौटाया।" + "इंस्टेंस ID" + "Gateway टोकन आवश्यक है। इसे फिर से दर्ज करें या इस कनेक्शन को संपादित करें।" + "स्रोत" + "रीफ़्रेश करें" + "%1$s कतार में हैं" + "चैट शुरू करें" + "सक्रिय थ्रेड में प्रतीक्षारत चैट टूल कॉल यहाँ दिखाई देते रहेंगे।" + "प्रमाणपत्र की समीक्षा आवश्यक है" + "इसे जांचने या इससे इंटरैक्ट करने के लिए वर्तमान Canvas surface खोलें।" + "ऑटोमेशन अपडेट किया गया।" + "कोई हालिया सत्र नहीं" + "स्क्रिप्ट" + "Gateway स्थिति, फ़ोन नोड की तैयारी, और हाल की लॉग स्ट्रीम।" + "ऑटोमेशन का विवरण खोलें" + "रनटाइम" + "1 और worker" + "एजेंट %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "जारी रखने के लिए कृपया Android Settings में %1$s सक्षम करें।" + "मरम्मत करें" + "reactions" + "पूर्ण" + "वर्शन और अपडेट" + "OpenClaw अनुमोदन, विफल जॉब्स और चैनल समस्याएँ यहाँ दिखाएगा।" + "USB माइक्रोफ़ोन" + "बहुत से शेयर जोड़े जाने की प्रतीक्षा कर रहे हैं।" + "छोड़ा गया" + "Gateway कंप्यूटर का LAN पता या सुरक्षित रिमोट होस्टनेम इस्तेमाल करें।" + "चालू" + "थ्रेड खोजे जा रहे हैं" + "रीयलटाइम टॉक" + "· %1$s" + "OpenClaw जवाब तैयार कर रहा है।" + "स्वीकृति एक बार अनुमति दी गई।" + "प्रदाता सेटअप" + "कोई नहीं" + "स्क्रिप्ट पेलोड बिना बदलाव के संरक्षित रहते हैं। इस स्क्रिप्ट को संपादित करने के लिए CLI का उपयोग करें।" + "Android सेटअप गाइड" + "%1$s ऐप्स को फ़ॉरवर्ड करने से ब्लॉक किया गया है।" + "पेयर किए गए डिवाइस" + ":%1$s" + "%1$s लंबित" + "डिवाइस का नाम" + "सबमिट करें" + "स्थान" + "जैसे America/New_York" + "सेशन लक्ष्य" + "ClawHub Skill की समीक्षा करें" + "snapshot" + "इस डिवाइस का पेयरिंग अनुरोध अस्वीकार करें?" + "पसंदीदा माइक्रोफ़ोन" + "नोड होस्ट" + "स्तर" + "ऐप पिकर बंद करें" + "साझा Gateway टोकन या ऑपरेटर द्वारा जारी टोकन पेस्ट करें।" + "सभी सिस्टम सामान्य हैं" + "Gateway डायग्नोस्टिक्स कॉपी किए गए" + "ऑडियो त्रुटि" + "सेटअप बदलें" + "त्वरित कार्रवाइयाँ" + "भेजना विफल रहा: रन शुरू होने से पहले चैट विफल हो गई; फिर से कोशिश करें।" + "माइक्रोफ़ोन" + "चैट अभी भी Gateway की स्थिति जाँच रही है।" + "सटीक स्थान" + "एक बार अनुमति दें" + "+%1$s और" + "thread create" + "अवरुद्ध" + "वेक वर्ड या वाक्यांश" + "Gateway को डिवाइस की स्वीकृति चाहिए" + "बाहरी माइक्रोफ़ोन" + "%1$s/%2$s तैयार" + "कनेक्टेड (ऑपरेटर ऑफ़लाइन)" + "क्षमता अस्वीकृत है" + "इससे ऑटोमेशन और उसका शेड्यूल Gateway से स्थायी रूप से हट जाएगा।" + "इमेज लोड हो रही है…" + "कनेक्ट करें" + "नोड एक्सेस स्वीकृत करें" + "Gateway जोड़ें" + "ट्रांसक्रिप्शन उपलब्ध नहीं है: %1$s" + "इमेज" + "ज्वार उठना" + "इमेज का पूर्वावलोकन बंद करें" + "eval" + "पिछला कमांड: %1$s" + "OpenClaw चला रहे डिवाइस पर terminal खुला रखें।" + "कोई आइटम अनुपलब्ध नहीं है" + "कैनवास आउटपुट के लिए सक्रिय Gateway कनेक्शन आवश्यक है।" + "%1$s · %2$s" + "पृथक" + "© 2026 OpenClaw Foundation — MIT लाइसेंस।" + "PDF" + "Conversations" + "मेमोरी समेकन और ड्रीम डायरी।" + "Create Goal" + "आपके संपादन के दौरान यह ऑटोमेशन बदल गया। सेव करने से पहले Gateway के नवीनतम संस्करण पर वापस जाएँ।" + "कनेक्ट होने पर, Gateway हमेशा चालू रहने वाला सत्र बनाए रखने के बजाय साइलेंट पुश से फ़ोन को सक्रिय कर सकता है।" + "वेक मोड" + "पेयर किया गया डिवाइस हटाएँ?" + "सिस्टम इवेंट टेक्स्ट" + "विजेट इमेज कॉपी नहीं की जा सकी" + "नहीं" + "वैकल्पिक पथ" + "कतार में लगी आवाज़ भेजी जा रही है" + "बिल्ट-इन" + "hide" + "runs" + "Gateway पासवर्ड आवश्यक है। इसे फिर से दर्ज करें या इस कनेक्शन को संपादित करें।" + "इवेंट टेक्स्ट" + "लाइव ट्रांसक्रिप्ट" + "प्रदाता मॉडल कॉन्फ़िगरेशन लोड नहीं किया जा सका।" + "%1$s ऐप को फ़ॉरवर्ड करने की अनुमति है।" + "वॉइस सेटअप" + "वीडियो संलग्न करें" + "छिपाई गई अतिरिक्त इमेज: %1$s" + "पेयरिंग अनुरोध अस्वीकार करें?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "प्रकाशक" + "यहाँ से फ़ोर्क करें" + " · बातचीत: बोल रहा है" + "वैकल्पिक ओवरराइड" + "डिफ़ॉल्ट" + "कमांड अनुमोदन" + "ऐप और Gateway असंगत प्रोटोकॉल संस्करणों का उपयोग करते हैं। दोनों पर OpenClaw अपडेट करें, फिर दोबारा कोशिश करें।" + "स्क्रीन रीफ़्रेश करें" + "हाल की फ़ोटो और मीडिया पढ़ें" + "सुनें" + "कनेक्टेड" + "पूर्ण" + "आपका फ़ोन %1$s के साथ युग्मित है। नोड एक्सेस पूरा करने के लिए जारी रखें।" + "वर्तमान थ्रेड" + "सोच-विचार %1$s" + "म्यूट" + "OpenClaw ओपन-सोर्स समुदाय में अपने भागीदारों की सराहना करता है।" + "खुला" + "Gateway से कनेक्ट किया जा रहा है" + "गेटवे इस पथ को बदल सकता है लेकिन मौजूदा पथ को हटा नहीं सकता।" + "TTS" + "सहेजा जा रहा है…" + "एंकर %1$s" + "search" + "सक्रिय करें" + "शेड्यूल किए गए Gateway कार्य का निरीक्षण और प्रबंधन करें।" + "पिछले उत्तर में इस कमांड की पहले ही एक बार अनुमति दी गई थी।" + "generate" + "केवल विश्वसनीय निजी नेटवर्क पर उपयोग करें।" + "सेटिंग्स खोजें" + "बातचीत लाइव है" + "Gateway प्रमाणीकरण कॉन्फ़िगर नहीं किया गया है। इस कनेक्शन को संपादित करें और फिर से प्रयास करें।" + "विफल: सुरक्षित endpoint तक पहुँचा गया, लेकिन TLS fingerprint verification का समय समाप्त हो गया। Tailscale Serve या gateway TLS जाँचें और फिर से प्रयास करें।" + "चरण 1" + "डिक्टेशन" + "ऐप पिकर खोलें" + "कोई अनुमोदन लंबित नहीं है" + "edit" + "अपने Gateway से कनेक्ट करें" + "openclaw qr से सेटअप कोड दर्ज करें।" + "डायग्नोस्टिक्स" + "अन्य ऐप्स अछूते रहते हैं।" + "चटकना" + "यह थ्रेड और इसकी प्रतिलिपि को स्थायी रूप से हटा देता है।" + "डिवाइस स्वीकृत किया गया।" + "अपने-आप फिर से प्रयास किया जा रहा है" + "विजेट इमेज कॉपी की गई" + "%1$s भूमिकाएँ" + "1 आइटम अनुपलब्ध है" + "%1$s निर्धारित" + "react" + "एजेंट" + "ऑटोमेशन लोड करने के लिए Gateway कनेक्ट करें।" + "फिर से कनेक्ट किया जा रहा है…" + "सेटअप पर वापस जाएँ" + "send" + "कनेक्शन का परीक्षण नहीं किया जा सका" + "सत्यापित करें और इंस्टॉल करें" + "इस डिवाइस को chat, voice, camera, और device tools के लिए सुरक्षित OpenClaw नोड में बदलें।" + "मैनुअल सेटअप" + "मौजूदा थ्रेड शुरू करने या फिर से जारी रखने के लिए चैट खोलें।" + "सुझाव: कैप्चर किया गया टर्न भेजने के लिए सुनना बंद करें।" + "छोड़ें" + "वॉइस अनुरोध विफल रहा" + "यह \"%1$s\" अस्वीकार करेगा और Gateway से Skill Workshop की स्थिति रीफ़्रेश करेगा।" + "कवच बनाना" + "update" + "शेयर करें" + "कैमरा सक्षम है" + "सेटअप के बाद Telegram, WhatsApp, email, और अन्य चैनल यहाँ दिखाई देते हैं।" + "नेटवर्क त्रुटि" + "ज्वारीय कुंड खोजे जा रहे हैं" + "session=%1$s source=%2$s के लिए अभी कैनवास पुनर्स्थापित करें। यदि मौजूदा A2UI स्थिति उपलब्ध है, तो उसे तुरंत फिर से चलाएँ। यदि नहीं, तो Canvas में मोबाइल के अनुकूल एक संक्षिप्त डैशबोर्ड बनाएँ और रेंडर करें।" + "शुरू करना विफल रहा: %1$s" + "अनुरोध नहीं किया गया" + "Gateway पर कोई %1$s प्रदाता कॉन्फ़िगर करें" + "kill" + "अनुमोदन" + "फ़ाइलें उपलब्ध नहीं हैं" + "अपठित चिह्नित करें" + "लोगों और संपर्क विवरणों को खोजें" + "डिवाइस पहचान आवश्यक है" + "OpenClaw थ्रेड" + "फ़ोटो लाइब्रेरी एक्सेस की अनुमति दें।" + "पिछले उत्तर में इस स्वीकृति को पहले ही हल कर दिया गया था।" + "कोई हालिया थ्रेड नहीं" + "टाइमआउट %1$s सेकंड" + "कोई मिलान नहीं" + "चुने गए ऐप की सूचनाएँ पढ़ें" + "उपलब्धता अज्ञात" + "Talk सेट अप करें" + "अतिरिक्त" + "Gateway पेयर हो गया है। ऑपरेटर एक्सेस की प्रतीक्षा है।" + "छवि संलग्न करें" + "चुनें कि OpenClaw तक क्या पहुँचे।" + "क्षमता की पुनः स्वीकृति लंबित है" + "हाइलाइट किए गए आइटम की समीक्षा करें" + "सुना जा रहा है..." + "मुझे अब तक की जानकारी दें" + "संदेश" + "संपर्क पढ़ें" + "ऑफ़लाइन अटैचमेंट स्टोरेज भर गया है; पहले कतारबद्ध आइटम हटाएँ।" + "एक बार" + "नाम बदलें" + "कोई चैनल नहीं मिला।" + "सभी देखें" + "नया डिवाइस" + "Session Status" + "छवि पूर्वावलोकन खोलें" + "सत्र ब्रांच बदल गई; इस संदेश की समीक्षा करें और पुनः प्रयास करें।" + "close" + "यह सेटअप कोड जैसा दिखता है। वापस जाएँ और Setup Gateway चुनें, फिर Use setup code चुनें।" + "✦" + "एजेंट और ऑटोमेशन" + "लागू करें" + "ऑटोमेशन रन छोड़ दिया गया।" + "जारी रखें" + "निगरानी जारी · %1$s शेड्यूल किए गए जॉब" + "ब्राउज़ करें" + "tabs" + "लंबित" + "बातचीत: %1$s" + "read" + "टेक्स्ट चुनें" + "गतिविधि गति" + "description: %1$s" + "ऑडियो चलाएं" + "समय" + "असत्यापित" + "Yield" + "अनुमोदन कमांड कॉपी करें" + "वर्तमान स्क्रीन आउटपुट और इंटरैक्टिव ऐप सतह।" + "सेवा कनेक्ट हो गई" + "प्रदर्शन" + "जब आप तैयार हों" + "प्रदाता कैटलॉग लोड नहीं किया जा सका।" + "बोल रहा है · जवाब का इंतज़ार है" + "नहीं दी गई" + "परिवर्तन सहेजें" + "Gateway ने ऑटोमेशन रन अस्वीकार कर दिया।" + "Session Send" + "ClawHub पर खोजें" + "OpenClaw के बैकग्राउंड में होने पर अनुरोधित स्थान जांचों की हमेशा अनुमति देता है; Android इसे लगातार दिखाई देने वाली नोड सूचना में दिखाता है।" + "सिस्टम इवेंट" + "प्रोवाइडर देखने के लिए Gateway से कनेक्ट करें" + "अगली हार्टबीट" + "Gateway पेयर हो गया है। नोड क्षमता की स्वीकृति की प्रतीक्षा है।" + "खारे पानी में रखना" + "Canvas बंद करें" + "संपर्क लिखें" + "कोई भी इंस्टॉल किया गया Skill इस खोज से मेल नहीं खाता।" + "Talk Provider सेटअप" + "Music Generation" + "टॉक सेटिंग्स" + "निगरानी · 1 थ्रेड" + "पेलोड टेक्स्ट" + "टेक्स्ट सेट करें" + "अनुमोदन %1$s" + "Gateway ने %1$s की तैयारी स्थिति नहीं लौटाई" + "%1$s मॉडल कॉन्फ़िगर किए गए हैं। उपलब्धता दोबारा जाँचने के लिए रीफ़्रेश करें।" + "Conversation Send" + "कैनवास" + "1 प्रदाता" + "Gateway प्रमाणपत्र को स्वचालित रूप से पढ़ा नहीं जा सका। Gateway होस्ट पर प्राप्त SHA-256 फ़िंगरप्रिंट पेस्ट करें।" + "भेजना विफल रहा: %1$s" + "ब्रिज" + "डिलीवरी त्रुटि" + "अपने फ़ोन से OpenClaw का उपयोग करें" + "दिखावट" + "Skill वर्कशॉप" + "टोकन आवश्यक है" + "पूर्वावलोकन · %1$s" + "माइक्रोफ़ोन की अनुमति आवश्यक है" + "Skill Workshop प्रस्ताव लोड करने के लिए Gateway कनेक्ट करें।" + "सभी सिस्टम चालू हैं" + "Gateway उपलब्ध नहीं है" + "OC" + "अपडेट किया गया" + "कनेक्टेड (नोड ऑफ़लाइन)" + "होम" + "डिक्टेशन सुन रहा है" + "कोई संग्रहीत थ्रेड नहीं" + "इस gateway पर उपलब्ध सहायकों को चुनें और उनका निरीक्षण करें।" + "टॉक मोड सक्रिय" + "कार्यरत · 1 सक्रिय रन" + "सहमत हों और सक्षम करें" + "Gateway अपडेट आवश्यक" + "इमेज कॉपी करें" + "Gateway URL" + "main, isolated, current, या session:<id>" + "मीडिया अनुपलब्ध" + "एजेंट वर्कस्पेस में शेल खोलने के लिए अपने Gateway से कनेक्ट करें।" + "%1$s://%2$s:%3$s" + "स्वीकृति का विवरण लोड नहीं किया जा सका। रीफ़्रेश करके फिर से प्रयास करें।" + "मैं Gateway की स्थिति जांच सकता हूं, कॉन्फ़िगरेशन ठीक कर सकता हूं, मॉडल बदल सकता हूं, या चैनल कनेक्ट कर सकता हूं।" + "Tool Call" + "थ्रेड" + "Write" + "किसी प्रॉम्प्ट से शुरू करें, या आवाज़ का उपयोग करें।" + "D" + "सेटिंग्स खोलें" + "देखा जा रहा है…" + "बातचीत समाप्त करें" + "पिछली त्रुटि" + "उन कार्रवाइयों की समीक्षा करें जिन पर आपका ध्यान चाहिए।" + "सभी एजेंट के लिए अक्षम।" + "वॉइस शुरू करें" + "बैकग्राउंड टास्क पर वापस जाएँ" + "एक अन्य क्रॉन कार्रवाई अभी भी पूरी हो रही है।" + "कूलडाउन %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "डिवाइस पर स्पीच रिकग्निशन उपलब्ध नहीं है।" + "स्क्रिप्ट · केवल पढ़ने के लिए" + "एक संदेश के लिए अटैचमेंट कतार में लगाने हेतु बहुत बड़े हैं; कुछ हटाकर फिर से कोशिश करें।" + "1 मॉडल कॉन्फ़िगर किया गया है। उपलब्धता दोबारा जाँचने के लिए रीफ़्रेश करें।" + "विजेट उपलब्ध नहीं है" + "इस होस्ट के लिए सुरक्षित कनेक्शन आवश्यक है।" + "हाल के" + "कोई मेल खाता ऑटोमेशन नहीं मिला।" + "फ़ोन Gateway तक पहुँच सकता है" + "Gateway" + "समय समाप्त" + "आपके gateway से निर्धारित OpenClaw कार्य।" + "Sub-agent" + "डिवाइस की स्वीकृति की प्रतीक्षा है" + "थ्रेड लोड हो रहा है" + "यह Gateway अब इस डिवाइस द्वारा विश्वसनीय प्रमाणपत्र प्रस्तुत करता है।" + "विलंब ms" + "event create" + "दस्तावेज़" + "Gateway सेट अप करें" + "वीडियो चलाएँ" + "सहेजा गया प्रमाणीकरण अमान्य है। फिर से प्रमाणित करें या इस gateway कनेक्शन को रीसेट करें।" + "उपयोग करते समय" + "screenshot" + "यहाँ तक रिवाइंड करें" + "Cron एक्सप्रेशन, जैसे 0 9 * * *" + "वॉयस पर वापस जाएँ" + "बात करें" + "विवरण" + "%1$s/%2$s ऑनलाइन" + "%1$s ऐप्स को फ़ॉरवर्ड करने की अनुमति है।" + "चैट" + "माइक्रोफ़ोन एक्सेस आवश्यक है।" + "सरपट चलना" + "संपादित करें" + "शांत समय" + "डायग्नोस्टिक्स कॉपी करें" + "शेड्यूल किया गया" + "बनाएँ" + "समाप्ति %1$s" + "खारिज करें" + "प्रस्ताव अस्वीकार करें?" + "वाक् त्रुटि (%1$s)" + "समस्या" + "रजिस्ट्री मेटाडेटा खोजें। किसी भी डाउनलोड से पहले Gateway विश्वसनीयता को फिर से सत्यापित करता है।" + "स्थानीय सेटअप के लिए निजी LAN IP का उपयोग करें, या रिमोट एक्सेस के लिए Tailscale Serve सक्षम करें / wss:// Gateway URL उपलब्ध कराएँ।" + "सबमिट किया जा रहा है…" + "खाता %1$s" + "Suggest Task" + "खोजें" + "सुन रहा है" + "ऑटोमेशन लोड नहीं हुआ।" + "Gateway का अपडेट उपलब्ध है। तैयार होने पर Web UI या CLI से अपडेट चलाएँ।" + "जल्द" + "कोई Gateway स्वीकृतियाँ नहीं।" + "होस्ट" + "हर फ़ील्ड में एक वेक वर्ड या वाक्यांश जोड़ें। फिर अपना कमांड देने से पहले उनमें से एक बोलें।" + "ट्रांसक्राइब करें फिर भेजें" + "इस समय चलाएं" + "ऑडियो रोकें" + "Gateway डिवाइस तक पहुँच" + "कोई प्रीव्यू नहीं" + "डिवाइस" + "Android के लिए OpenClaw।" + "क्षमता की स्वीकृति लंबित है" + "इस ऑटोमेशन को चलाने, सक्षम करने, अक्षम करने, हटाने या रीफ़्रेश करने से पहले अपने संपादन सेव करें या उन्हें वापस लाएँ।" + "अभी कोई ऑटोमेशन नहीं है।" + "इस skill के लिए %1$s सेटअप आइटम आवश्यक हैं। Android दिखाता है कि क्या इंस्टॉल है; सेटअप/कॉन्फ़िगरेशन में बदलाव केवल desktop या CLI पर किए जा सकते हैं।" + "%1$s हाल के" + "चैनल" + "Unphased" + "इस फ़ोन पर सक्रिय" + "नोड एक्सेस की जाँच की जा रही है" + "टाइमज़ोन" + "Skill Workshop inspect और apply क्रियाएं" + "हमेशा अनुमति दें" + "present" + "Gateway पर इंस्टॉल किए गए Skills यहां दिखाई देंगे।" + "कोड की समय-सीमा समाप्त हो गई हो सकती है या यह किसी अन्य Gateway के लिए जनरेट किया गया हो सकता है।" + "अनुमति आवश्यक है" + "ऑटोमेशन का कॉन्फ़िगरेशन अमान्य है।" + "अनुमति सूची" + "सेटअप, स्थिति और मरम्मत" + "groups" + "सार्वजनिक कुंजी" + "परिचय" + "उस इमेज में कोई सेटअप QR कोड नहीं मिला। openclaw qr से जनरेट किया गया QR चुनें या सेटअप कोड मैन्युअल रूप से दर्ज करें।" + "permissions" + "नोड और पेयर किए गए डिवाइस लोड करने के लिए Gateway कनेक्ट करें।" + "ब्रांच बदलें" + "कोई Skills नहीं" + "जवाब आवाज़ में चलेंगे" + "पढ़ा हुआ चिह्नित करें" + "नोड अनुमोदन लंबित है" + "wake" + "%1$s प्रस्ताव" + "Gateway प्रमाणीकरण पर ध्यान देने की आवश्यकता है।" + "कनेक्शन विवरण" + "मिलीसेकंड" + "वाक् पहचान" + "विवरण" + "हाल की बातचीत" + "आपका फ़ोन यह जानकारी आपके Gateway को भेजता है, न कि OpenClaw द्वारा चलाए जाने वाले किसी सर्वर को। आपका Gateway इसे आपके चुने हुए AI प्रदाता के अनुरोधों में शामिल कर सकता है।" + "डिलीवरी" + "स्पीकर म्यूट करें" + "%1$s चल रहे · %2$s पूर्ण · %3$s विफल" + "Gateway कनेक्शन खोला जा रहा है" + "निगरानी · %1$s थ्रेड" + "ऑटोमेशन रन पूरा हुआ।" + "कोई मेल खाते ऐप्स नहीं।" + "Chat में भेजें" + "ऑटोमेशन हटा दिया गया।" + "सक्षम करें" + "हाल के रन" + "QR कोड को वर्ग के अंदर संरेखित करें।" + "स्वीकृतियाँ लोड नहीं की जा सकीं।" + "मैंने स्वीकृत कर दिया है" + "प्रोवाइडर की तैयारी लोड करने के लिए अपना Gateway कनेक्ट करें।" + "पेयर नहीं किया गया" + "यह स्वीकृति हल होने से पहले ही समाप्त हो गई।" + "%1$s सेकंड में देखा जा रहा है — लक्ष्य ऐप पर स्विच करें" + "एजेंट प्रॉम्प्ट" + "emoji list" + "दोहराया जाने वाला" + "OpenClaw में खोजें" + "%1$s लंबित" + "डिवाइस पर वाक् पहचान उपलब्ध नहीं है" + "कोई भी ऐप यह संदेश साझा नहीं कर सकता" + "खोज बंद करें" + "देखने के लिए कमांड" + "स्वास्थ्य" + "सूचना लिसनर" + "स्पीकर म्यूट है" + "थ्रेड खोजें" + "ठीक है" + "सेटअप गाइड नहीं खुल सका।" + "OpenClaw से %1$s पूछें" + "Wait for Agents" + "पता" + "Gateway पर बनाया गया शेड्यूल किया हुआ कार्य यहाँ दिखाई देगा।" + "नवीनतम लॉग खंड दिखाया जा रहा है।" + "सेटअप कोड का उपयोग करें" + "sticker" + "एक सुरक्षित wss:// या Tailscale Serve Gateway का उपयोग करें, Control UI में या openclaw qr के साथ एक फुल-एक्सेस सेटअप कोड जनरेट करें, फिर उसे नीचे स्कैन या पेस्ट करें और सेटिंग्स तथा अपग्रेड सक्षम करने के लिए फिर से कनेक्ट करें।" + "steer" + "चयनित" + "Android मौजूदा सेटअप कोड को स्कैन या पेस्ट कर सकता है, लेकिन यह gateway अभी ऐप को सेटअप-कोड जनरेशन उपलब्ध नहीं कराता है। gateway host पर openclaw qr के साथ QR/कोड जनरेट करें, फिर उसे यहाँ स्कैन करें या नीचे सेटअप कोड पेस्ट करें।" + "कैनवास की स्थिति" + "कनेक्शन ठीक करें" + "इमेज सेव करें" + "नोड %1$s" + "Gateway पासवर्ड आवश्यक है" + "Update Plan" + "संलग्नक हटाएँ" + "ऑटोमेशन रन विफल रहा।" + "प्रदाता सीमाएँ और कोटा स्थिति।" + "Gateway टॉक कैटलॉग लोड नहीं हुआ" + "यह Gateway" + "अभी तक कोई हाल का रन नहीं।" + "डिवाइस पर भाषा मॉडल उपलब्ध नहीं है" + "डैशबोर्ड के लिए कनेक्ट किया हुआ Gateway आवश्यक है" + "एजेंट्स द्वारा पुन: उपयोग योग्य स्किल ड्राफ़्ट बनाने के बाद मिलान करने वाले प्रस्ताव यहाँ दिखाई देंगे." + "Session Search" + "OpenClaw बोल रहा है" + "QR स्कैन करें" + "चुने गए ऐप्स" + "परिवर्तन पूर्ववत करें" + "स्वीकृति कमांड कॉपी किया गया" + "डिलीवरी की स्थिति" + "QR कोड स्वीकार नहीं किया गया" + "आपका वॉइस कमांड सेंटर।" + "कनेक्शन का परीक्षण करें" + "OPENCLAW" + "Web Fetch" + "प्रॉम्प्ट" + "डिवाइस स्वीकृत करें?" + "इस सत्र का डैशबोर्ड खोलने के लिए अपने Gateway से कनेक्ट करें।" + "इस फ़ोन से %1$s और इसके सहेजे गए क्रेडेंशियल हटाएँ?" + "QR कोड एक असुरक्षित रिमोट gateway की ओर इशारा करता है। %1$s %2$s" + "स्क्रीन सतह तैयार है" + "Gateway पेयर करें" + "चैनल लोड करने के लिए gateway कनेक्ट करें।" + "अन्य वॉइस गतिविधि के दौरान रुक जाता है।" + "मॉडल" + "फ़ोटो" + "Setup Code पेस्ट करें" + "OpenClaw बोल रहा है" + "कनेक्ट हो रहा है..." + " · स्थान: हमेशा" + "संदेश: %1$s" + "प्रवाल-भित्ति बनाना" + "Gateway से लोड करें" + "text: %1$s" + "आवश्यक" + "rename group" + "तैयार" + "डायरी अपनी पहली प्रविष्टि की प्रतीक्षा कर रही है।" + "स्वीकृत करें" + "लाइव पेज" + "ऑटोमेशन पहले से चल रहा है।" + "एक बार सफलतापूर्वक चलने के बाद इस ऑटोमेशन को हटा दें।" + "चैट और वॉइस के लिए तैयार" + "कनेक्टेड (ऑपरेटर: %1$s)" + "Gateway पेयरिंग पूरी हो गई है। इस फ़ोन को नोड के रूप में स्वीकृत करें ताकि OpenClaw आपके द्वारा सक्षम की गई डिवाइस क्षमताओं का उपयोग कर सके।" + "जवाब रोक दिया गया" + "इमेज" + "%1$s रोके गए" + "कोई मेल खाता थ्रेड नहीं" + "delete" + "लेआउट: कॉम्पैक्ट" + "channels" + "अनुमति दी गई" + "हर %1$s मिनट" + "1 टोकन" + "%1$s %2$s" + "इंस्टॉल किए गए ऐप्स" + "लंबित" + "वॉइस नोट तैयार किया जा रहा है…" + "कभी नहीं" + "उप-प्रणाली" + "कमांड बाहर निकलने पर" + "कनेक्शन" + "ऑटोमेशन रन का इतिहास लोड नहीं किया जा सका।" + "ऑटोमेशन का नाम" + "चरण 2" + "निदान करें" + "कुछ चैनल स्टेटस जाँच पूरी नहीं हुईं।" + "pin" + "%1$s कॉपी करें" + "पेयर किया गया" + "वेक वर्ड सहेजे नहीं जा सके" + "यह \"%1$s\" को क्वारंटीन करेगा और Gateway से Skill Workshop की स्थिति रीफ़्रेश करेगा।" + "वॉइस नोट रिकॉर्ड करें" + "कतार में" + "उत्तर दिया गया" + "अनुरोध किए जाने पर कैमरा टूल्स की अनुमति दें।" + "समस्याएँ" + "वॉइस वेक" + "पेयरिंग अनुरोध अस्वीकार किया गया।" + "%1$sदि. पहले" + "roles" + "Skills" + "आर्काइव करें" + "नोड ऑफ़लाइन है। दोबारा कनेक्ट करें और पुनः प्रयास करें।" + "सिस्टम" + "रिमोट IP" + "असमूहीकृत" + "शेड्यूल का विवरण" + "फ़ोन की क्षमताएँ" + "उपलब्ध नहीं" + "डैशबोर्ड" + "टोकन पेस्ट करें" + "कोई प्रदाता नहीं" + "SHA-256 फ़िंगरप्रिंट" + "अभी कोई थ्रेड नहीं है" + "Bluetooth माइक्रोफ़ोन" + "हाल के" + "थ्रेड का नाम बदलें" + "समाधान का परिणाम अज्ञात है। Gateway रिकॉर्ड सत्यापित होने तक कार्रवाइयाँ अक्षम रहती हैं।" + "dialog" + "वेक वर्ड सुनें" + "camera snap" + "प्लेबैक तैयार हो रहा है…" + "Gateway ने अज्ञात प्रदाता %1$s चुना" + "delete group" + "Android का अनुसरण करें · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "एजेंट लोड करने के लिए Gateway कनेक्ट करें।" + "वापस जाएँ" + "संदेश साझा करें" + "QR कोड जनरेट करें।" + "पुनः आरंभ करें" + "स्पीकर चालू है" + "समूह हटाएँ?" + "अनुपलब्ध" + "प्रस्ताव खोजें" + "stop" + "सुरक्षित (TLS)" + "कोई नोड या पेयर किया गया डिवाइस नहीं।" + "%1$s%% शेष %2$s" + "सेटअप कोड समाप्त हो गया" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "डायरी" + "notify" + "यह फ़ोन तब तक निष्क्रिय रहता है जब तक Gateway को इसकी आवश्यकता नहीं होती, फिर यह सक्रिय होता है, सिंक करता है और वापस निष्क्रिय हो जाता है।" + "%1$s कॉन्फ़िगर किए गए मॉडल" + "लाइसेंस" + "ClawHub Skills खोजने के लिए Gateway कनेक्ट करें।" + "Skill" + "Gateway कनेक्शन बदल गया है। फिर से कनेक्ट करने के लिए OpenClaw को रीस्टार्ट करें।" + "डिवाइस ID" + "Gateway ने सक्रिय %1$s प्रदाता की पहचान नहीं की" + "प्रतीक्षा में" + "वेक वर्ड सहेजे गए" + "सबसे पुराने पहले" + "स्क्रीन" + "तब से चल रहा है" + "IPv6 ज़ोन ID समर्थित नहीं हैं। बिना स्कोप वाला IPv6 पता या LAN होस्टनाम उपयोग करें।" + "भेज दिया गया — डिलीवरी की पुष्टि हो रही है…" + "ऑडियो" + "This gateway connection needs operator.admin to update skills." + "सेटअप कोड" + "Gateway चेतावनी स्वीकार करें और इंस्टॉल करें" + "चैट रीफ़्रेश करें" + "अंतराल" + "Skill Workshop प्रस्ताव की कार्रवाइयों के लिए operator.admin स्कोप आवश्यक है।" + "सत्र" + "नाम बदलें…" + "ड्रीमिंग लोड करने के लिए gateway कनेक्ट करें।" + "सेटअप" + "बातचीत खोलें" + "poll" + "अपने एजेंट लोड करने के लिए कनेक्ट करें" + "role remove" + " · बातचीत: सुन रहा है" + "ClawHub ने %1$s के लिए इंस्टॉल करने योग्य संस्करण नहीं दिया।" + "कमांड" + "यह स्वीकृति हल होने से पहले ही रद्द कर दी गई।" + "माइक चालू · gateway की प्रतीक्षा में" + "टेक्स्ट" + "%2$s में से %1$s दिखाए जा रहे हैं। और अधिक के लिए खोज को परिष्कृत करें।" + "v%1$s उपलब्ध है" + "%1$s://%2$s" + "%1$s... (ठीक)" + "प्रदाता और कॉन्फ़िगर किए गए मॉडल" + "कनेक्ट हो रहा है…" + "वेक वर्ड सहेजने के लिए Gateway से कनेक्ट करें" + "प्रोफ़ाइल खोलें" + "अपना Gateway शुरू करें।" + "इस लक्ष्य को एक व्यावहारिक चेकलिस्ट में बदलने में मेरी मदद करें: " + "सत्र खोज साफ़ करें" + "पोर्ट" + "सेटअप कोड दर्ज करें" + "Gateway लॉग लोड नहीं किए जा सके।" + "%1$s प्रोवाइडर तैयार" + "आपके एजेंट तैयार हैं" + "Gateway पर कोई %1$s प्रदाता कॉन्फ़िगर नहीं है" + "एक बार के लिए सुना जा रहा है" + "देखें" + "एपोक मिलीसेकंड (वैकल्पिक)" + "कोई मॉडल कॉन्फ़िगर नहीं किया गया है। उपलब्धता दोबारा जाँचने के लिए रीफ़्रेश करें।" + "सेटिंग्स" + "पिछला कैमरा" + "approve" + "शुरू करने से पहले" + "Skills लोड नहीं किए जा सके।" + "अक्षम" + "अभी भी स्वीकृति की प्रतीक्षा है" + "बैकग्राउंड टास्क लोड नहीं किए जा सके" + "जाँचें कि OpenClaw इस फ़ोन पर स्पष्ट रूप से बोल सकता है।" + "कार्यरत · %1$s सक्रिय रन" + "कमांड की कार्यशील डायरेक्टरी" + "समूह का नाम" + "गैलरी से चुनें" + "Version %1$s" + "वापस" + "Connect the gateway to update skills." + "रन के बाद हटाएँ" + "सेटअप कोड एक असुरक्षित रिमोट gateway की ओर इशारा करता है। %1$s %2$s" + "Computer" + "Gateway डिस्कनेक्ट हो गया।" + "Session Settings" + "शुरू करने के लिए Gateway कनेक्ट करें" + "सुरक्षा सूचना" + "अन्य उत्तर" + "साझा-छवि चेतावनी खारिज करें" + "Gateway ने किसी अन्य ClawHub रिलीज़ का मूल्यांकन किया था। इंस्टॉल करने से पहले Skill की दोबारा समीक्षा करें।" + "सिस्टम एक्सेस खोलें" + "पूर्ण" + "इमेज उपलब्ध नहीं है" + "सूचनाएँ" + "Apply, reject और quarantine के लिए operator.admin स्कोप आवश्यक है। lifecycle क्रियाएं सक्षम करने के लिए साझा gateway auth से पुनः कनेक्ट करें या operator.admin डिवाइस स्कोप अपग्रेड को स्वीकृत करें।" + "sticker upload" + "लॉबस्टर पकड़ रहे हैं" + "Messages to recover" + "openclaw devices approve %1$s" + "पढ़ने योग्य gateway लॉग विवरण।" + "जनरेट किए गए स्किल प्रस्तावों को लाइव Skills बनने से पहले समीक्षा करें." + "बंडल किया गया" + "%1$s उपलब्ध" + "नोड की स्वीकृति लंबित है" + "Gateway लंबित" + "प्रमाणीकरण आवश्यक" + "नोड" + "जागृत रखें" + "OpenClaw जवाब दे रहा है" + "दस्तावेज़" + "%1$s तैयार" + "अभी तक कोई आउटपुट नहीं है" + "डिवाइस की भाषा समर्थित नहीं है" + "कतार में — दोबारा कनेक्ट होने पर भेजा जाएगा" + "%1$sमि. पहले" + "वर्तमान ब्रांच" + "पेयरिंग एक्सेस की जाँच की जा रही है" + "सीमित Gateway एक्सेस" + "टूल चला रहा है..." + "स्वीकृति जाँची जा रही है…" + "इस फ़ोन से फ़ोटो और क्लिप कैप्चर करें" + "कनेक्टेड और तैयार" + "बंद करें" + "किसी लक्ष्य को अमल में लाई जा सकने वाली चेकलिस्ट में बदलें।" + "सेटअप कोड में अमान्य gateway URL है।" + "केवल वही एक्सेस सक्षम करें जिसे आप इस फ़ोन के कनेक्ट रहने के दौरान OpenClaw को उपयोग करने देने में सहज हों। आप इन्हें बाद में Android Settings में बदल सकते हैं।" + "खाता" + "remove" + "पासवर्ड वैकल्पिक" + "Gateway प्रमाणीकरण की समीक्षा आवश्यक है। gateway सेटिंग्स जाँचें, फिर पुनः प्रयास करें।" + "QR कोड IPv6 ज़ोन ID का उपयोग करता है। बिना स्कोप वाला IPv6 पता या LAN होस्टनाम उपयोग करें।" + "add" + "क्रिल बनना" + "स्वस्थ" + "%1$s में पूरा हुआ" + "आर्गुमेंट्स" + "इंस्टॉल विकल्प" + "%1$sघंटे में" + "Gateway की स्वीकृति लंबित है। इसे gateway होस्ट पर चलाएँ:" + "Admin एक्सेस आवश्यक" + "set groups" + "मॉडल पिन करें" + "खोज साफ़ करें" + "पात्र एजेंट के लिए सक्षम।" + "कोई मौजूदा थ्रेड नहीं है" + "bounds: %1$s" + "%1$s के बाद" + "शेड्यूलर को यह ऑटोमेशन चलाने की अनुमति दें।" + "%1$s लागू किए गए" + "अभी तक कोई ड्रीम डायरी नहीं है।" + "बैकग्राउंड टास्क रीफ़्रेश करें" + "हाल के थ्रेड और अगले चरणों का सारांश दें।" + "OpenClaw दिखाई देने के दौरान डिवाइस पर चलता है।" + "%1$s काम कर रहा है" + "%1$s %2$s" + "कच्चा" + "रन" + "अभी चलाएँ" + "अनाम शाखा" + "कॉन्फ़िगर किया गया" + "camera list" + "1 लागू किया गया" + "camera clip" + "हाँ" + "ऑडियो टेस्ट" + "रोका गया" + "events" + "कार्यशील डायरेक्टरी" + "नवीनतम पर जाएँ" + "हर समय अनुमति दें" + "QR या सेटअप कोड स्कैन करें" + "Installing" + "लाइव नोड्स, पेयर किए गए फ़ोन, और लंबित डिवाइस अनुरोध।" + "स्नैपशॉट: %1$s" + "पिछले उत्तर में इस कमांड की पहले ही अनुमति दी गई थी और चयन सहेजा गया था।" + "लंबित अनुरोध" + "स्वीकृत" + "वर्कस्पेस" + "वॉइस" + "बात करने के लिए तैयार" + "Subagents" + "विफल: कोई सुरक्षित gateway endpoint नहीं मिला। gateway TLS या Tailscale Serve सक्षम करें, या Unencrypted चयनित के साथ किसी विश्वसनीय निजी LAN पते का उपयोग करें।" + "सिग्नल" + "सेशन लक्ष्य" + "Gateway ने अस्वीकृति दर्ज की।" + "स्वीकार करें" + "OpenClaw से कुछ भी पूछें" + "जारी रखने के लिए फिर से कनेक्ट करें" + "%1$s युग्मित" + "यह \"%1$s\" लागू करेगा और Gateway से Skill Workshop की स्थिति रीफ़्रेश करेगा।" + "Gateway ऑफ़लाइन" + "openclaw devices list" + "OpenClaw नोड की कनेक्शन स्थिति" + "अलर्ट इस फ़ोन पर रहते हैं।" + "OpenClaw चयनित अलर्ट प्राप्त कर सकता है।" + "Screen खोलें" + "चैट कार्रवाइयाँ" + "अन्य ऐप्स के नियंत्रण की अनुमति दें?" + "Inspect हो रहा है" + "कोई और gateway जोड़ने के लिए सेटअप कोड स्कैन करें या पेस्ट करें।" + "Swarm" + "TLS का समय समाप्त हो गया" + "हाल के सत्र" + "पेयर किया गया डिवाइस हटा दिया गया।" + "Gateway पेयर हो गया। नोड क्षमता की स्वीकृति जाँची जा रही है।" + "गतिविधि" + "क्रॉन कार्रवाई विफल रही।" + "Gateway कंप्यूटर पर, चलाएँ:" + "सत्र खोजें" + "लॉग रीफ़्रेश करें" + "इमेज उपलब्ध नहीं है · फिर से प्रयास करने के लिए टैप करें" + "openclaw nodes approve %1$s" + "वॉइस नोट · %1$s" + "उपयोग" + "नॉटिलस बनना" + "संदर्भ %1$s%%" + "वॉइस प्रॉम्प्ट को लिखित रूप में बदलें" + "म्यूट करें" + "नई बातचीत शुरू करें और वह यहाँ दिखाई देगी।" + "कनेक्शन में समस्या" + "मध्यम" + "फ़ोर्क करें" + "स्पीकर सक्षम करें" + "सिस्टम इवेंट टेक्स्ट" + "क्रमबद्ध करें: %1$s" + "%1$s प्रतीक्षारत" + "Image Generation" + "वॉइस नोट" + "आपके ध्यान देने की कोई ज़रूरत नहीं है" + "जारी रखने के लिए OpenClaw को %1$s अनुमतियों की आवश्यकता है।" + "वायर्ड हेडसेट माइक्रोफ़ोन" + "पेज" + "डिलीवर किया गया" + "देय" + "वर्तमान Skills स्थिति में Skill विवरण उपलब्ध नहीं है।" + "चुनें कि यह फ़ोन क्या शेयर कर सकता है।" + "इस ऑटोमेशन का एक रन पहले से कतार में है।" + "ऑटोमेशन प्रबंधित करने के लिए Gateway कनेक्ट करें।" + "ऑटोमेशन का समय अभी नहीं हुआ है।" + "कोई विवरण नहीं" + "स्वीकृति की प्रक्रिया जारी है।\nOpenClaw अपने-आप फिर से कनेक्ट हो जाएगा।" + "प्रदाता की तैयारी देखने के लिए अपने Gateway को कनेक्ट करें।" + "पेयरिंग की प्रतीक्षा है" + "बातचीत शुरू करें या जारी रखें" + "कोई निर्धारित जॉब नहीं है" + "OpenClaw को उत्तर दें…" + "स्थिति" + "OpenClaw नोड · कनेक्टेड" + "सक्रिय" + "स्क्रीन शेयरिंग की डीबग स्थिति दिखाएँ।" + "कोई सीमा रिपोर्ट नहीं की गई" + "स्कैनर बंद करें" + "हर %1$s दिन" + "सक्षम" + "सक्षम करें और सेटिंग्स खोलें" + "ऑनलाइन और तैयार" + "Ask User" + "चैट त्रुटि" + "आगे स्क्रॉल करें" + "%1$s में से %2$s" + "काम की योजना बनाएँ" + "console" + "पुनः प्रयास करें" + "चैट शुरू करें और आपकी सक्रिय OpenClaw बातचीत यहाँ दिखाई देंगी।" + "ऑटोमेशन लोड नहीं किया जा सका।" + "एजेंट वर्कस्पेस में Shell" + "%1$s सक्रिय" + "डिवाइस अनुमतियाँ चुनें" + "पिछली अवधि" + "डिफ़ॉल्ट एजेंट" + "%1$sघं" + "बातचीत जारी है" + "ClawHub से %1$s इंस्टॉल नहीं किया जा सका।" + "OpenClaw में आपका स्वागत है" + "अन्य ऐप्स को नियंत्रित करें" + "सिग्नल इंडेक्स" + "गोपनीय कोड दर्ज करें…" + "%1$s:%2$s" + "OpenClaw को %1$s बताने के लिए कहें" + "खोजा गया" + "साइडबार छिपाएँ" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "ऑडियो प्लेबैक अनुपलब्ध है" + "लागू करें" + "उपयोग की जानकारी लोड नहीं की जा सकी।" + "सक्रिय कार्य के दौरान नोड को उपलब्ध रखें।" + "अगला सक्रियण" + "%1$s/%2$s" + "कोई हाल की लॉग प्रविष्टि नहीं।" + "मैनुअल Gateway" + "समूह का नाम बदलें" + "Update Goal" + "प्रदाता उपलब्धता अज्ञात" + "प्रदाता" + "समूह हटाएँ…" + "पेलोड" + "कॉल लॉग" + "Memory Search" + "%1$s प्रदाता" + "फ़ोन संदर्भ और गोपनीयता" + "%1$s/%2$s कनेक्टेड" + "%1$s %2$s" + "Gateway को पुनः आरंभ करने के बाद रिकवरी अभी भी जारी है।" + "सेटअप कोड बदलने पर दोबारा कनेक्ट करने से पहले इस फ़ोन पर सहेजे गए सेटअप क्रेडेंशियल और डिवाइस टोकन मिट जाते हैं। इस फ़ोन को नोड क्षमता की फिर से मंज़ूरी लेनी पड़ सकती है; केवल तभी जारी रखें, जब आप इसे नए Gateway सेटअप कोड से युग्मित करना चाहते हों।" + "किसी ऑटोमेशन का कॉन्फ़िगरेशन और रन इतिहास देखने के लिए उसे खोलें। एडमिन-स्कोप वाले कनेक्शन उसे चला, संपादित, सक्षम, अक्षम या हटा भी सकते हैं।" + "संदर्भ --" + "प्रस्ताव क्वारंटीन किया गया।" + "ऑटोमेशन रोका गया।" + "OpenClaw मोबाइल" + "A2UI reset" + "Gateway उपलब्ध नहीं है" + "Read" + "इस skill के लिए 1 सेटअप आइटम आवश्यक है। Android दिखाता है कि क्या इंस्टॉल है; सेटअप/कॉन्फ़िगरेशन में बदलाव केवल desktop या CLI पर किए जा सकते हैं।" + "पिछला रन" + "सेटअप QR स्कैन करने के लिए कैमरा एक्सेस आवश्यक है।" + "मॉडल अपडेट नहीं किया जा सका।" + "बुलबुले बनाना" + "thread reply" + "हटाएँ…" + "ऑटोमेशन का निरीक्षण करने के लिए Gateway से कनेक्ट करें।" + "हाल के लॉग" + "हाल के रन लोड हो रहे हैं…" + "इस फ़ाइल का पूर्वावलोकन नहीं किया जा सकता। यह बाइनरी हो सकती है या बहुत बड़ी हो सकती है।" + "जाँचें" + "इस फ़ोन का स्थान पढ़ें" + "Skill कुंजी" + "%1$s इंस्टॉल किया गया।" + "Gateways" + "actions: %1$s" + "फ़ॉरवर्डिंग मोड" + "%1$sk" + "थ्रेड लेआउट बदलें" + "कोई TLS एंडपॉइंट नहीं" + "OpenClaw Gateway" + "मैन्युअल रूप से सेट अप करें" + "सोच रहा है…" + "Gateway एक्सेस की समीक्षा आवश्यक है" + "1 रोका गया" + "%1$s सेकंड" + "प्रस्ताव लागू करें?" + "अभी नहीं" + "अस्वीकृत" + "ऐप्स खोजें" + "1 कॉन्फ़िगर किया गया मॉडल" + "अनुमोदन सूचना खारिज करें" + "·" + "ऑफ़लाइन" + "स्पीच प्रदाता" + "Gateway की स्वीकृति जारी है। OpenClaw अपने-आप फिर से प्रयास करेगा।" + "अधिकतम" + "क्रॉन में बदलाव के लिए operator.admin एक्सेस आवश्यक है।" + "सोच रहा है" + "screen snapshot" + "देखे गए नोड: %1$s" + "कोई कार्रवाई नहीं मिली" + "सहेजें और कनेक्ट करें" + "list" + "Gateway ने स्वीकृति दर्ज की और चयन सहेजा।" + "कनेक्ट करने के लिए एक मान्य मैनुअल एंडपॉइंट दर्ज करें।" + "सहायक" + "चैट में भेजा जा रहा है..." + "प्रोफ़ाइल सेव करें" + "लॉक किया गया" + "ऑटोमेशन संपादित करें" + "उसी नेटवर्क, या सुरक्षित रिमोट Gateway URL का उपयोग करें।" + "एंकर" + "भाषा" + "यह ऐप Gateway से पुराना है। इस डिवाइस पर OpenClaw अपडेट करें, फिर दोबारा कोशिश करें।" + "सभी" + "Gateway सत्र जारी है" + "समीक्षा की प्रतीक्षा में" + "कोई Skills इंस्टॉल नहीं हैं।" + "Gateway जाँच रहा है" + "अंतराल %1$s" + "%1$s का परिणाम अज्ञात है। फिर से कनेक्ट करें, Skills को रीफ़्रेश करें, फिर दोबारा कोशिश करें; Gateway अब भी चल रहे समान इंस्टॉलेशन से सुरक्षित रूप से जुड़ जाता है।" + "भूल जाएँ" + "कोई युग्मित gateways नहीं।" + "%1$s · %2$s" + "<गोपनीय जानकारी हटाई गई>" + "%1$s समस्याएँ" + "OpenClaw" + "सुन रहा है · %1$s कतार में" + "Assistant की आवाज़ म्यूट है" + "नोड कार्रवाइयाँ केवल तब चलती हैं जब लक्ष्य ऐप अग्रभूमि में हो (रिमोट पथ के माध्यम से सत्यापित)। वैश्विक कार्रवाइयाँ और समान-ऐप कार्रवाइयाँ यहाँ काम करती हैं।" + "अभी तक कोई Gateway नहीं मिला। यदि खोज अवरुद्ध है, तो मैन्युअल सेटअप का उपयोग करें।" + "थ्रेड खोलें" + "कार्य चल रहा है" + "बोलना शुरू करें..." + "फ़ोन नोड" + "Xhigh" + "Gateway होस्ट पर चलाएँ:" + "Skill में बदलाव के लिए operator.admin आवश्यक है। एडमिन-सक्षम gateway token के साथ फिर से कनेक्ट करें।" + "ClawHub Skills की जाँच करने के लिए Gateway कनेक्ट करें।" + "ऐप सूची इसी फ़ोन पर रहती है।" + "निष्क्रिय" + "Android एक्सेसिबिलिटी सेटिंग्स में दिखाया गया।" + "स्मार्ट डिलीवरी" + "अस्वीकार करें" + "%2$s के बाद Gateway ने \'%1$s\' स्थिति लौटाई।" + "Gateway टोकन कॉन्फ़िगर नहीं किया गया" + "Not available to this agent" + "फ़ाइलें" + "अनुमतियाँ" + "कैमरा शुरू नहीं किया जा सका। गैलरी से QR इमेज चुनें या सेटअप कोड मैन्युअल रूप से दर्ज करें।" + "कॉपी करने के लिए टैप करें" + "%1$sm प्रतीक्षा" + "%1$s." + "ClawHub Skills इंस्टॉल करने के लिए Gateway कनेक्ट करें।" + "वॉयस खोजें" + " · माइक: सुन रहा है" + "Gateway सेटिंग्स की समीक्षा और परिवर्तन करने के लिए operator.admin एक्सेस के साथ पुनः कनेक्ट करें।" + "और लोड करें" + "3 सेकंड में देखें" + "run" + "आवाज़ जनरेट हो रही है…" + "← वापस" + "डिस्कनेक्ट करें" + "Gateway कंप्यूटर पर approve कमांड चलाएँ, फिर दोबारा जाँचें।" + "स्वचालन" + "%1$sमि" + "भरोसा करें" + "वह QR कोड OpenClaw सेटअप QR नहीं है। openclaw qr से नया कोड जनरेट करें, फिर दोबारा कोशिश करें।" + "पसंदीदा माइक्रोफ़ोन उपलब्ध नहीं है; स्वचालित रूटिंग का उपयोग किया जा रहा है।" + "अस्वीकृत" + "Android और बैकग्राउंड पैकेज शामिल करें।" + "आपका Gateway तैयार है।" + "ट्रिगर किया गया" + "Structured Output" + "इसमें अपेक्षा से अधिक समय लग रहा है।\nजाँचें कि Gateway चल रहा है और उस तक पहुँचा जा सकता है।" + "इस एजेंट के लिए कोई बैकग्राउंड टास्क नहीं है।" + "दोबारा कनेक्ट हो रहा है" + "OpenClaw gateway और नोड एक्सेस की जाँच कर रहा है।" + "Code Execution" + "कोई प्रदाता उपयोग नहीं" + "समीक्षा करें" + "माइक्रोफ़ोन की अनुमति आवश्यक है।" + "%1$sदि" + "%1$s उपलब्ध" + "OpenClaw फिर से सिंक हो रहा है" + "इवेंट स्ट्रीम बाधित हुई; रीफ़्रेश करने का प्रयास करें।" + "नोड और डिवाइस लोड नहीं किए जा सके।" + "Skills लोड करने के लिए Gateway कनेक्ट करें।" + "अज्ञात" + "आउटपुट" + "टॉक विफल: रीयलटाइम प्रदाता अप्रत्याशित रूप से बंद हो गया।" + "OpenClaw समय-संवेदी" + "ban" + "Gateway टोकन आवश्यक है" + "युग्मित डिवाइस" + "फिर से स्वीकृति चाहिए" + "शेड्यूल नहीं किया गया" + "संपर्क" + "ज़रूरत पड़ने तक आपका फ़ोन शांत रहता है" + "सुन रहा है · कतार में लगी आवाज़ भेजी जा रही है" + "टास्क का विवरण लोड नहीं किया जा सका" + "एजेंट संदेश" + "Gateway को इस डिवाइस पहचान की आवश्यकता है। फिर से प्रमाणित करें या इस gateway कनेक्शन को रीसेट करें।" + "अगला सत्र" + "कनेक्शन सुरक्षा" + "अभी के लिए छोड़ें" + "वेबसाइट" + "ऐप में स्वीकृति अनुरोध लोड करने के लिए Gateway कनेक्ट करें।" + "%1$s कॉपी किया गया" + "कोई ऐप नहीं चुना गया है। जब तक आप ऐप नहीं जोड़ते, कुछ भी फ़ॉरवर्ड नहीं होगा।" + "%1$s %2$s" + "सेटअप आवश्यक" + "अनपेयर किया गया" + "Gateway को यह फ़ोन मिल गया" + "कोई कॉन्फ़िगर किए गए मॉडल नहीं" + "अक्षम करें" + "ऐप भाषा" + "Gateway पेयर किया जा रहा है" + "सहेजा गया प्रमाणीकरण अमान्य है" + "%1$s स्कोप" + "हाल के लॉग लोड करने के लिए Gateway कनेक्ट करें।" + "वेक वर्ड सहेजें" + "इंस्टॉल किए गए Skills प्रबंधित करें और ClawHub से विश्वसनीय रिलीज़ जोड़ें।" + "भेजा जा रहा है…" + "अभी तक कोई एजेंट लोड नहीं हुआ है।" + "ClawHub खोजें" + "चैट Gateway की स्थिति जाँच रही है।" + "पेयरिंग आवश्यक" + "सक्रिय रन" + "विफल — %1$s" + "इस फ़ोन और OpenClaw के बीच कनेक्शन।" + "summarize" + "विजेट इमेज Downloads में सेव की गई" + "शुरू हो रहा है…" + "%1$s टोकन" + "क्लाइंट त्रुटि" + "पहुँच देने से पहले अनुरोध करने वाले इस डिवाइस को सत्यापित करें।" + "Bluetooth LE माइक्रोफ़ोन" + "%1$s %2$s" + "ऑटोमेशन सक्षम किया गया।" + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "संग्रहीत" + "रीलोड करें" + "ऑटोमेशन खोजें" + "पेयरिंग के बाद लिंक किए गए फ़ोन और नोड होस्ट यहाँ दिखाई देंगे।" + "%1$s: %2$s" + "यह ऑटोमेशन Gateway पर बदल गया है। दोबारा सहेजने से पहले नवीनतम संस्करण की समीक्षा करें।" + "डिक्टेशन रोकें" + "पठनीय" + "OpenClaw को संदेश भेजें" + "Gateway पासवर्ड अमान्य है। इसे फिर से दर्ज करें या इस gateway कनेक्शन को रीसेट करें।" + "फिर से कनेक्ट करें" + "ISO समय, जैसे 2026-07-09T09:30:00Z" + "%1$s टूल" + "पिछले उत्तर में इस स्वीकृति को पहले ही अस्वीकार कर दिया गया था।" + "लिंक किया गया" + "%1$s खोलें" + "%1$s/%2$s" + "ऑटोमेशन रन अज्ञात स्थिति के साथ पूरा हुआ।" + "ऑफ़लाइन कतार भर गई है (%1$s संदेश); पहले कतारबद्ध आइटम हटाएँ।" + "सार्वजनिक Gateway के लिए wss:// या Tailscale Serve आवश्यक है। ws:// की अनुमति localhost, .local होस्ट, Android emulator और निजी LAN IP के लिए है।" + "Skill विवरण लोड करने के लिए Gateway कनेक्ट करें।" + "पूर्ण एक्सेस आवश्यक" + "वेक लिसनर" + "लिंक पूर्वावलोकन विस्तृत करें" + "थ्रेड खोज साफ़ करें" + "NULL (विफल)" + "अपडेट" + "एडमिन" + "ध्यान देने की आवश्यकता है" + "इस डिवाइस को अपने Gateway से पेयर करें, ताकि यह केवल वास्तविक काम के लिए सक्रिय हो, एजेंट की लाइव जानकारी आसानी से उपलब्ध रहे और बैटरी खर्च करने वाले बैकग्राउंड लूप से बचा जा सके।" + "भूमिकाएँ" + "जवाब दें" + "प्रोवाइडर कैटलॉग" + "Settings में अनुमति सक्षम करें" + "A2UI push" + "एक्सेस जांचें" + "यदि Gateway उपलब्ध है, तो दोबारा कनेक्ट करने की प्रक्रिया बिना किसी हस्तक्षेप के पूरी हो जानी चाहिए।" + "एजेंट टर्न" + "क्वारंटीन करें" + "ध्यान दें" + "खोजा जा रहा है…" + "मुझे सेटअप कोड कहाँ मिलेगा?" + "Skill को सक्षम नहीं किया जा सका।" + "pdf" + "हटाएँ" + "%1$s%% ऑनलाइन" + "कोई चैनल नहीं है" + "रीयल-टाइम वॉइस" + "Skill Workshop reject और quarantine क्रियाएं" + "नोड और डिवाइस" + "स्थानीय कमांड सेंटर" + "emoji upload" + "पूर्वावलोकन लोड हो रहा है…" + "उच्च" + "focus" + "describe" + "%1$s संदर्भ" + "जवाब सुना जा रहा है..." + "voice" + "%1$s से कनेक्टेड" + "role add" + "चैट पर ध्यान देने की आवश्यकता है" + "माइक्रोफ़ोन सक्षम करें" + "OpenClaw इस फ़ोन पर दिखाई देने वाले ऐप्स के नाम, पैकेज ID और स्थिति एकत्र करके भेजता है जब आपका पेयर किया गया OpenClaw Gateway उन्हें माँगता है। इससे आपका असिस्टेंट इंस्टॉल किए गए ऐप्स का उपयोग करके प्रश्नों का उत्तर दे सकता है और कार्रवाइयाँ कर सकता है।" + "Gateway कनेक्ट नहीं है" + "नीति" + "भेजे गए संदेश की पुष्टि का समय समाप्त हो गया; डिलीवरी जाँचने के लिए रीफ़्रेश करें।" + "सहायक फ़ाइलें" + "एक्सप्रेशन" + "बैकग्राउंड टास्क" + "सपना" + "कोई ऐप ब्लॉक नहीं किया गया है। जब तक आप ब्लॉक नहीं जोड़ते, ऐप्स फ़ॉरवर्ड कर सकते हैं।" + "वाक् पहचानकर्ता उपलब्ध नहीं है" + "प्लेटफ़ॉर्म" + "Gateway ने %1$s सेटअप नहीं लौटाया" + "Gateway भूलें?" + "वैकल्पिक विवरण" + "%1$s खोलें" + "होम कैनवास" + "ड्रीमिंग" + "%1$s से %2$s" + "फ़ाइल शेयर करें" + "रीयलटाइम" + "API" + "OpenClaw काम कर रहा है…" + "OpenClaw के साथ बोलें या डिक्टेट करें" + "इंस्टॉल किए गए ऐप की जानकारी साझा करें?" + "ऑटोमेशन लोड हो रहा है…" + "ऑटोमेशन हटाएँ" + "डिफ़ॉल्ट सहायक" + "Gateway पर समर्थित %1$s प्रदाता चुनें" + "अनुपलब्ध" + "खाली फ़ोल्डर" + "सेटिंग्स खोलें" + "बंद" + "टाइपोग्राफी" + "रोकें" + "अभी तक कोई मेल खाता थ्रेड नहीं है।" + "Gateway पेयरिंग सफल रही।\nऑपरेटर UI से इस फ़ोन की नोड क्षमताओं को स्वीकृत करें।" + "यह skill इंस्टॉल है, लेकिन फ़िलहाल चलने के योग्य नहीं है। कॉन्फ़िगरेशन में बदलाव के लिए desktop या CLI का उपयोग करें।" + "पहचानकर्ता व्यस्त है" + "होम Gateway" + "Gateway पर स्वीकृति कमांड चलाएँ" + "सेवा अक्षम है" + "Skill Workshop प्रस्ताव लोड नहीं किए जा सके।" + "मेरे हाल के OpenClaw थ्रेड की जानकारी दें और अगले चरण सुझाएँ।" + "अभी नहीं" + "openclaw qr" + "start" + "OpenClaw नोड · बातचीत" + "ईवेंट पढ़ें और अपडेट करें" + "टॉक विफल: रीयलटाइम प्रदाता बंद हुआ: %1$s" + "वर्कस्पेस फ़ाइलें ब्राउज़ करने के लिए Gateway कनेक्ट करें।" + "Gateway रिले के माध्यम से %1$s" + "Gateway टॉक कैटलॉग लोड नहीं किया जा सका" + "निगरानी जारी · 1 शेड्यूल किया गया जॉब" + "हर %1$s घंटे" + "स्क्रीन सतह" + "OpenClaw अनुवाद · %1$s" + "कमांड अनुरोध" + "अप टू डेट" + "चैनल" + "अनम्यूट करें" + "नया समूह…" + "ऑडियो तैयार किया जा रहा है…" + "अनुकूली" + "जल्द" + "%1$s और workers" + "Web Search" + "चैट, वॉइस, थ्रेड, प्रोवाइडर या सेटिंग्स आज़माएँ।" + "OpenClaw सक्रिय" + "navigate" + "%1$s को अनुरोध किया गया" + "ऑटोमेशन रन का इतिहास देखने के लिए Gateway कनेक्ट करें।" + "डिवाइस एक्सेस; Gateway में ऑप्ट-इन करना अभी भी आवश्यक है" + "निरस्त" + "मान्य सेटअप कोड या gateway पता दर्ज करें।" + "मॉडल" + "OpenClaw निष्क्रिय" + "Gateway पासवर्ड अमान्य है" + "डिवाइस पेयरिंग में बदलाव सत्यापित नहीं किया जा सका। रीफ़्रेश करके फिर से कोशिश करें।" + "विवरण देखें" + "Bash" + "टोकन" + "कनेक्ट किया गया OpenClaw एजेंट उन डिवाइस क्षमताओं का उपयोग कर सकता है जिन्हें आप सक्षम करते हैं। केवल तभी जारी रखें जब आप उस Gateway और एजेंट पर भरोसा करते हों जिससे आप कनेक्ट करते हैं।" + "बार्नकलिंग" + "चयनित या पूर्ण फ़ोटो एक्सेस दी गई।" + "एक्सेसिबिलिटी एग्ज़ीक्यूटर" + "%1$s आइटम अनुपलब्ध हैं" + "योजना चेकलिस्ट संक्षिप्त करें" + "नोड अनुमोदन आवश्यक है" + "Gateway कनेक्ट करें" + "... +%1$s और" + "योजना चेकलिस्ट विस्तृत करें" + "Browser" + "screen record" + "रन लंबित है" + "सक्षम करने पर OpenClaw आर्म होने पर अन्य ऐप्स की स्क्रीन देख और नियंत्रित कर सकता है। Android एक्सेसिबिलिटी एक्सेस आवश्यक है।" + "मूल" + "आपके डिवाइसों पर निजी AI" + "Attach" + "स्वचालित" + "अवलोकन" + "पुनर्स्थापना का अनुरोध नहीं किया जा सका। पुनः प्रयास करने के लिए टैप करें।" + "वीडियो" + "%1$s\n\n" + "अनएन्क्रिप्टेड" + "कैलेंडर" + "Gateway की स्थिति ठीक नहीं है; भेजा नहीं जा सकता" + "📎 %1$s" + "पिछली स्थिति" + "नई चैट शुरू करने से पहले मौजूदा जवाब पूरा होने की प्रतीक्षा करें।" + "प्रोफ़ाइल" + "जब आपका Gateway उन्हें रिपोर्ट करेगा, तो प्रदाता सीमाएँ यहाँ दिखाई देंगी।" + "1 समस्या" + "\"%1$s\" के थ्रेड रखे जाते हैं और वापस अवर्गीकृत में चले जाते हैं।" + "अनुशंसित" + "बनाया गया" + "%1$s/%2$s सक्रिय टोकन" + "कोई कार्रवाई परिणाम नहीं" + "तस्वीर ली जा रही है" + "%1$s…" + "Skill का विवरण खोलें" + "बोलना विफल रहा: %1$s" + "Talk शुरू करें" + "यह फ़ोल्डर लोड नहीं हो सका." + "QR कोड में मान्य सेटअप कोड नहीं था।" + "नोड एक्सेस की समीक्षा करें" + "वेक वाक्यांश जोड़ें" + "Gateway तक नहीं पहुँच सकते" + "ऑटोमेशन" + "कनेक्शन आवश्यक है" + "स्वीकृति का समाधान नहीं किया जा सका। रीफ़्रेश करके फिर से प्रयास करें।" + "import" + "यह फ़ोन OpenClaw में कैसे दिखाई देता है।" + "थ्रेड खोज पर फ़ोकस करें" + "Gateway कनेक्ट करें" + "कैलेंडर पढ़ें" + "दोबारा कनेक्ट होने और यह स्क्रीन खुलने पर अवलोकन रीफ़्रेश होता है।" + "Skill को अक्षम नहीं किया जा सका।" + "अब भी कनेक्ट किया जा रहा है" + "%1$sमिनट में" + "SMS पढ़ें" + "उपयोग लोड करने के लिए Gateway कनेक्ट करें।" + "अभी इस फ़ोन से आप क्या करने में मेरी मदद कर सकते हैं?" + "स्वीकृति चाहिए" + "नई चैट" + "Skill Workshop प्रस्तावों को अपडेट करने के लिए Gateway कनेक्ट करें।" + "OpenClaw अनुरोध विफल रहा।" + "अनुमति आवश्यक" + "प्रदाता की तैयारी\nऔर कॉन्फ़िगर किए गए मॉडलों की समीक्षा करें।" + "लोड हो रहा है" + "विफलता अलर्ट" + "थीम और अनुवादित Android टेक्स्ट." + "माइक बंद · भेजा जा रहा है…" + "कोई नहीं" + "देखें" + "नाम" + "संस्करण" + "Cron" + "OpenClaw खोलने से पहले इस फ़ोन को किसी Gateway से कनेक्ट करें।" + "वेक वाक्यांश हटाएँ" + "सेटअप कोड स्वीकार नहीं किया गया। openclaw qr से नया कोड जनरेट करें।" + "14 संदेश · Android" + "ट्रांसक्रिप्शन विफल रहा: %1$s" + "हमेशा" + "ड्रीमिंग लोड नहीं की जा सकी।" + "ऑटोमेशन रन कतार में जोड़ा गया।" + "Conversation Turn" + "ऑटोमेशन शुरू हुआ।" + "नया समूह" + "सर्वर त्रुटि" + "Video Generation" + "Gateway की स्वीकृति लंबित है। gateway होस्ट पर openclaw devices list चलाएँ, इस फ़ोन को स्वीकृत करें, फिर दोबारा कोशिश करें।" + "ड्रीमिंग चक्र द्वारा कथात्मक सारांश लिखने के बाद प्रविष्टियाँ दिखाई देंगी।" + "%1$s मि.से." + "मेमोरी स्टोर" + "असिस्टेंट काम कर रहा है" + "OpenClaw लॉन्चर में दिखाई देने वाले ऐप्स की सूची बना सकता है।" + "बात करना विफल रहा: %1$s" + "इंस्टॉल किए गए Skills खोजें" + "Inspect करें" + "Process" + "हाल के थ्रेड" + "टर्मिनल" + "वर्तमान" + "1 खाता" + "रोका गया" + "कैमरा की अनुमति दें" + "इस फ़ोन के कनेक्ट रहने के दौरान Exec अनुमोदन अनुरोध यहाँ दिखाई देंगे।" + " · माइक: लंबित" + "कॉपी करें" + "विवरण कॉपी किए गए" + "हटाएँ" + "OpenClaw से Android क्षमताओं का उपयोग करने के लिए कहें।" + "member" + "जाँच की जा रही है कि यह Gateway OpenClaw सेटिंग्स सहायक का समर्थन करता है या नहीं।" + "फिर से कनेक्ट करने के लिए नीचे दिए गए पुनर्प्राप्ति विकल्पों का उपयोग करें।" + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "चैनल लोड नहीं किए जा सके।" + "%1$sदिन में" + "लगातार त्रुटियाँ" + "उस इमेज से QR कोड पढ़ा नहीं जा सका। अधिक स्पष्ट इमेज चुनें या सेटअप कोड मैन्युअल रूप से दर्ज करें।" + "Gateway इस ऐप से पुराना है। Gateway होस्ट पर OpenClaw अपडेट करें, फिर दोबारा कोशिश करें।" + "चैट, वॉइस और लाइव स्थिति का उपयोग करने से पहले कनेक्ट करें।" + "Gateway फिर से कनेक्ट करें" + "तृतीय-पक्ष" + "तत्परता की समीक्षा करें" + "सीमित" + "OpenClaw लोगो" + "मॉडल अनपिन करें" + "इस gateway से जुड़ी मैसेजिंग सतहें।" + "भेजा जा रहा है" + "संग्रहीत थ्रेड यहाँ दिखाई देंगे।" + "कमांड कॉपी की गई" + "कोई पूर्वावलोकन उपलब्ध नहीं है" + "इस फ़ोन को Gateway पर स्वीकृत करें।\nफिर कनेक्शन का दोबारा प्रयास करें।" + "QR कोड स्कैन करें" + "कमांड की कार्यशील डायरेक्टरी · साफ़ नहीं की जा सकती" + "थ्रेड गतिविधि" + "उपलब्ध" + "ऑटोमेशन हटाएँ?" + "आज %1$s · कुल %2$s" + "पासवर्ड" + "प्रस्ताव को क्वारंटीन करें?" + "इस बिल्ड में कोई लाइसेंस नोटिस पैकेज नहीं किए गए हैं।" + "विजेट इमेज सेव नहीं की जा सकी" + "प्रतीक्षा %1$s" + "बोल रहा है…" + "प्रदाता और मॉडल" + "नोड" + "%1$s " + "प्रॉम्प्ट उपलब्ध नहीं है" + "लॉग" + "Skill Workshop प्रस्तावों की जाँच करने के लिए Gateway कनेक्ट करें।" + "टूल्स" + "Gateway स्विच" + "SMS भेजें" + "OpenClaw आपकी सामान्य चैट में जारी रखने के लिए तैयार है।" + "कोई कमांड नहीं मिली" + "अभी तक कोई कैनवास अपडेट नहीं है। पुनः प्रयास करने के लिए टैप करें।" + "टर्मिनल के लिए कनेक्टेड Gateway आवश्यक है" + "Exec" + "ऐप फ़िल्टर" + "मुख्य" + "%1$sk" + "Gateway आवश्यक" + "एक्सेस" + "पैकेज: snapshot=%1$s foreground=%2$s" + "कनेक्शन का दोबारा प्रयास करें" + "Cron शेड्यूलर रुका हुआ है।" + "खोलें" + "संदेश कॉपी किया गया" + "हम आपके Gateway तक नहीं पहुँच सके।\nआइए इसे ठीक करें।" + "अभी" + "चलाने के बाद हटाएं" + "इस फ़ोन पर चयनित" + "unpin" + "Session History" + "अनपिन करें" + "इस फ़ोन का उपयोग करें" + "%1$s के लिए ClawHub विवरण लोड नहीं किया जा सका।" + "टूल चल रहे हैं" + "स्थान सक्षम होने पर सटीक स्थान साझा करें।" + "Mobile UI" + "थीम" + "Gateway अभी भी इस स्वीकृति को लंबित दिखा रहा है। फिर से प्रयास करने से पहले इसकी समीक्षा करें।" + "वॉइस नोट पूरा करें" + "डिक्टेशन: %1$s" + "अनुमति नहीं है" + "दूसरी छवि चुनें" + "इमेज का पूर्वावलोकन" + "OpenClaw केवल तब सुनता है जब आप Talk या Dictation शुरू करते हैं।" + "कदमों और गतिविधि की जानकारी साझा करें" + "सेटअप आवश्यक" + "OpenClaw सेटिंग्स असिस्टेंट का उपयोग करने के लिए इस Gateway को अपडेट करें।" + "ClawHub Skills इंस्टॉल करने के लिए इस Gateway कनेक्शन को operator.admin की आवश्यकता है।" + "प्रस्ताव लागू किया गया." + "%1$s लंबित" + "%1$sघं. पहले" + "कॉल लॉग पढ़ें" + "%1$s कतार में · Gateway की प्रतीक्षा हो रही है" + "समूह में ले जाएँ" + "पेयर करने के लिए QR स्कैन करें" + "स्वीकृति अस्वीकृत की गई।" + "Skill Workshop प्रस्ताव की जाँच नहीं की जा सकी।" + "पिन किया गया" + "प्रोफ़ाइल और डिवाइस" + "थिंकिंग स्तर चयनकर्ता बंद करें" + "बाद में डिलीवरी के लिए संदेश को कतार में नहीं लगाया जा सका।" + "Quarantine करें" + "शेड्यूल · %1$s" + "चिंतन स्तर अपडेट नहीं किया जा सका।" + "थिंकिंग स्तर चयनकर्ता खोलें" + "वॉइस उत्तर का समय समाप्त हो गया; कतार में मौजूद अनुरोध का फिर से प्रयास किया जा रहा है" + "लेआउट: विस्तृत" + "इस इमेज को डिकोड नहीं किया जा सका." + "Gateway, वॉइस, सूचनाएँ, गोपनीयता" + "एजेंट वर्कस्पेस की फ़ाइलें" + "यह डिवाइस अपनी विश्वसनीय Gateway पहुँच खो देगा।" + "approve कमांड में pending कमांड से requestId का उपयोग करें।" + "शेड्यूल" + "दर सीमा" + "डिलीवर नहीं किया गया" + "पेलोड · %1$s" + "चल रहा है" + "पंजा चलाना" + "समाप्त करें" + "सिस्टम विश्वसनीयता का उपयोग करें" + "कोई तैयार प्रोवाइडर नहीं" + "कनेक्ट किए गए Bluetooth माइक्रोफ़ोन को प्राथमिकता देता है।" + "%1$s ऐप को फ़ॉरवर्ड करने से ब्लॉक किया गया है।" + "मैसेज कार्रवाइयाँ" + "प्रकार" + "अनआर्काइव करें" + "Transcripts" + "वेक वर्ड" + "Gateway पर %1$s कॉन्फ़िगर करें" + "QR कोड स्कैन करें या अपने OpenClaw Gateway से सेटअप कोड का उपयोग करें।" + "डिज़ाइन सिस्टम प्रोटोटाइप" + "छान रहे हैं" + " · बातचीत: चालू" + "अभी तक कोई उपयोग डेटा नहीं है।" + "रन शुरू होने से पहले चैट विफल हो गई; फिर से कोशिश करें।" + "भेजें" + "कुछ साझा की गई छवियाँ छोड़ दी गईं या जोड़ी नहीं जा सकीं।" + "कैलेंडर लिखें" + "timeout" + "कम" + "ब्लॉक सूची" + "act" + "Dismiss Task" + "चैट विफल रही" + "OpenClaw · लाइव" + "इंस्टॉल किया गया" + "जवाब की प्रतीक्षा का समय समाप्त हो गया; फिर से प्रयास करें या रीफ़्रेश करें।" + "पिछली बातचीत खोजें" + "थ्रेड ब्राउज़ करें" + "रीफ़्रेश हो रहा है" + "मोती खोज रहे हैं" + "कैमरा खोलें और openclaw qr से कोड को फ्रेम में लाएँ।" + "कोई डिवाइस नहीं है" + "सूचनाएँ फ़ॉरवर्ड करें" + "मैं इस बातचीत को सामान्य एजेंट चैट से अलग रखूँगा।" + "Gateway सत्र फिर से ऑनलाइन हो रहा है। एजेंट शॉर्टकट कुछ ही देर में अपने आप सामान्य हो जाने चाहिए।" + "कोई दूसरी खोज आज़माएँ या मौजूदा क्वेरी साफ़ करें।" + "बैकग्राउंड स्थान की अनुमति दें?" + "सतह पर आ रहा है" + "बूटस्ट्रैप" + "%1$s · %2$s · %3$s" + "वॉइस नोट रद्द करें" + "पीछे स्क्रॉल करें" + "openclaw gateway" + "Gateway पेयर हो गया" + "केंचुल बदलना" + "आपकी अगली बारी सुन रहे हैं।" + "OpenClaw काम कर रहा है" + "लॉग प्रविष्टि" + "विफल: इस होस्ट के लिए सुरक्षित gateway endpoint तक नहीं पहुँचा जा सका।" + "Gateway ऑफ़लाइन है। नीचे कनेक्शन ठीक करें या डायग्नोस्टिक्स कॉपी करें।" + "स्टैंडबाय" + "टेस्टिंग टेस्टिंग 1 2 3" + "ClawHub Skills खोजे नहीं जा सके।" + "कोई प्रॉम्प्ट नहीं" + "सामने का कैमरा" + "लॉग प्रविष्टि खोलें" + "नेटवर्क समय-सीमा समाप्त" + "अभी" + "समूह का नाम बदलें…" + "अधिक एजेंट्स" + "openclaw nodes approve REQUEST_ID" + "पिन करें" + "thread list" + "%1$s खोलें" + "upload" + "Gateway पासवर्ड कॉन्फ़िगर नहीं किया गया" + "श्रुतलेखन सेटिंग्स" + "प्रदाता मॉडल लोड हो गए हैं, लेकिन तत्परता की जानकारी उपलब्ध नहीं है।" + "थ्रेड हटाएँ?" + "OpenClaw इस फ़ोन को थ्रेड, वॉइस, प्रोवाइडर और Gateway के लिए एक सुव्यवस्थित मोबाइल कमांड इंटरफ़ेस में बदल देता है।" + "सबसे नए पहले" + "अगला चक्र" + diff --git a/app/src/main/res/values-in/assistant.xml b/app/src/main/res/values-in/assistant.xml new file mode 100644 index 0000000..667d2e9 --- /dev/null +++ b/app/src/main/res/values-in/assistant.xml @@ -0,0 +1,7 @@ + + + "tanyakan OpenClaw %1$s" + "beri tahu OpenClaw untuk %1$s" + "buka OpenClaw dan tanyakan %1$s" + + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..3048688 --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Percayai gateway ini? + Percayai dan lanjutkan + Batal + Chat baru di worktree + Verifikasi sidik jari sertifikat sebelum memercayai gateway ini.\n\n%1$s + Sertifikat gateway berubah. Lanjutkan hanya jika Anda mengharapkan perubahan ini.\n\nSHA-256 lama:\n%1$s\n\nSHA-256 baru:\n%2$s + Tidak diketahui + VERSI + COMMIT + DIBUAT + Versi %1$s + Commit Git %1$s + Dibuat pada %1$s UTC, stempel waktu %2$s + Tanggal build %1$s + Salin hash lengkap commit Git + Salin stempel waktu build lengkap + Commit Git OpenClaw + Stempel waktu build OpenClaw + Commit Git disalin + Stempel waktu build disalin + + "Tidak dapat menyiapkan lampiran untuk dikirim." + "Mikrofon mati" + "Tampilkan peringatan OpenClaw" + "Aktivitas utas" + "Penuh" + "Persetujuan diizinkan dan disimpan." + "Tampilkan riwayat panggilan terbaru" + "1 tertunda" + "Lampiran tidak didukung" + "0 = tepat" + "Hubungkan Gateway untuk mencari utas." + "%1$s akun" + "Perubahan Cron memerlukan operator.admin. Kode penyiapan sengaja tidak memberikannya. Hubungkan kembali dengan token bersama atau kata sandi Gateway untuk meminta akses admin. Jika perangkat ini masih belum memilikinya, setujui peningkatan cakupan yang tertunda dari klien admin yang sudah ada." + "Apply Patch" + "Menjepit" + "Nyalakan suara speaker" + "Pelewatan Berturut-turut" + "Folder ini belum memiliki file." + "Tidak terhubung" + "Periksa dan kelola status skill yang terinstal." + "Gagal" + "Agen Default" + "Kamera" + "Hapus dari grup" + "Mencari" + "Dijeda untuk pemutaran suara" + "Gateway akan memverifikasi rilis ini secara persis dengan ClawHub sebelum mengunduh. Jika rilis memerlukan pengakuan risiko secara eksplisit, Android akan menampilkan peringatan Gateway sebelum mencoba kembali." + "Kode penyiapan menggunakan ID zona IPv6. Gunakan alamat IPv6 tanpa cakupan atau nama host LAN." + "Lampiran" + "Konfigurasikan kata aktivasi, percakapan, dan pemutaran." + "Mendengarkan (PTT)" + "Proposal ditolak." + "Tampilkan Bilah Samping" + "pengguna" + "%1$s · %2$s" + "Minimal" + "Tolak" + "AGEN AKTIF" + "1 terjadwal" + "Tidak ada balasan" + "Dipilih %1$s" + "Larik JSON argv perintah" + "Gambar tersebut tidak dapat dibaca. Pilih tangkapan layar atau gambar QR yang jelas dari openclaw qr." + "Tidak dapat %1$s proposal Skill Workshop." + "Dijawab di tempat lain" + "Gateway mencatat persetujuan sekali." + "status" + "OpenClaw hanya memeriksa lokasi saat Gateway yang disandingkan memintanya. Di layar Android berikutnya, pilih %1$s untuk mengizinkan pemeriksaan saat aplikasi berjalan di latar belakang." + "tolak" + "Kontras" + "Ganti penyiapan gateway?" + "Tidak dapat memuat automasi." + "Anda" + "Mikrofon bawaan" + "Permukaan" + "Tidak ada proposal" + "Utas utama" + "Buka Chat" + "Tindakan pemasangan perangkat tidak tersedia dalam sesi Gateway ini. Jalankan openclaw devices list pada host Gateway dan kelola permintaan tersebut di sana. Persetujuan kapabilitas node dilakukan secara terpisah dan tetap menggunakan nodes approve <request id>." + "Permintaan Tindakan" + "list pins" + "Hubungkan ke Gateway untuk memuat proposal Skill Workshop." + "Kode penyiapan tidak diterima" + "Keluar" + "Penyedia transkripsi waktu nyata belum dikonfigurasi." + "Tampilkan Aplikasi Sistem" + "Perbarui Gateway Anda untuk melihat konfigurasi model penyedia." + "Mengirim dikte" + "Periksa usulan ini untuk memuat markdown-nya." + "buka OpenClaw dan tanyakan %1$s" + "penalaran" + "Klien" + "Diterapkan" + "video" + "Dipromosikan" + "Online" + "Cakupan" + "Penyedia suara realtime belum dikonfigurasi." + "%1$s · %2$s" + "kick" + "Gateway mengembalikan automasi yang tidak valid." + "ID Instance" + "Token Gateway diperlukan. Masukkan lagi atau edit koneksi ini." + "Sumber" + "Segarkan" + "%1$s dalam antrean" + "Mulai Chat" + "Panggilan alat Chat yang menunggu di utas aktif tetap terlihat di sini." + "Perlu peninjauan sertifikat" + "Buka permukaan Canvas saat ini untuk memeriksa atau berinteraksi dengannya." + "Automasi diperbarui." + "Tidak ada sesi terbaru" + "Skrip" + "Status Gateway, kesiapan node ponsel, dan aliran log terbaru." + "Buka detail otomatisasi" + "Runtime" + "1 worker lagi" + "Agen %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Harap aktifkan %1$s di Pengaturan Android untuk melanjutkan." + "Perbaiki" + "reactions" + "Selesai" + "Versi dan pembaruan" + "OpenClaw akan menampilkan persetujuan, pekerjaan yang gagal, dan masalah saluran di sini." + "Mikrofon USB" + "Terlalu banyak berbagi yang menunggu untuk ditambahkan." + "Dilewati" + "Gunakan alamat LAN komputer Gateway atau nama host jarak jauh yang aman." + "Aktif" + "Mencari utas" + "Bicara Realtime" + "· %1$s" + "OpenClaw sedang menyiapkan respons." + "Persetujuan diizinkan sekali." + "Penyiapan penyedia" + "tidak ada" + "Payload skrip dipertahankan tanpa perubahan. Gunakan CLI untuk mengedit skrip ini." + "Panduan penyiapan Android" + "%1$s aplikasi diblokir agar tidak meneruskan." + "Perangkat yang Dipasangkan" + ":%1$s" + "%1$s tertunda" + "Nama perangkat" + "Kirim" + "Lokasi" + "mis. America/New_York" + "Target sesi" + "Tinjau skill ClawHub" + "snapshot" + "Tolak permintaan pemasangan dari perangkat ini?" + "Mikrofon pilihan" + "Host node" + "Tingkat" + "Tutup Pemilih Aplikasi" + "Tempel token Gateway bersama atau token yang diterbitkan operator." + "Semua sistem berfungsi normal" + "Diagnostik gateway disalin" + "Kesalahan audio" + "Ganti penyiapan" + "Tindakan cepat" + "Gagal mengirim: Chat gagal sebelum proses dimulai; coba lagi." + "Mikrofon" + "Chat masih memeriksa kondisi Gateway." + "Lokasi Akurat" + "Izinkan Sekali" + "+%1$s lagi" + "thread create" + "Diblokir" + "Kata atau frasa aktivasi" + "Gateway memerlukan persetujuan perangkat" + "Mikrofon eksternal" + "%1$s/%2$s siap" + "Terhubung (operator offline)" + "Kapabilitas belum disetujui" + "Tindakan ini akan menghapus otomatisasi dan jadwalnya secara permanen dari Gateway." + "Memuat gambar…" + "Hubungkan" + "Setujui akses node" + "Tambahkan Gateway" + "Transkripsi tidak tersedia: %1$s" + "Gambar" + "Berpasang" + "Tutup pratinjau gambar" + "eval" + "Perintah terakhir: %1$s" + "Buka terminal di perangkat yang menjalankan OpenClaw." + "Tidak ada item yang belum tersedia" + "Output kanvas memerlukan koneksi Gateway aktif." + "%1$s · %2$s" + "Terisolasi" + "© 2026 OpenClaw Foundation — Lisensi MIT." + "PDF" + "Conversations" + "Konsolidasi memori dan buku harian mimpi." + "Create Goal" + "Otomatisasi ini berubah saat Anda mengeditnya. Kembalikan ke versi Gateway terbaru sebelum menyimpan." + "Saat terhubung, Gateway dapat membangunkan ponsel dengan notifikasi push senyap alih-alih mempertahankan sesi yang selalu aktif." + "Mode Aktivasi" + "Hapus perangkat yang dipasangkan?" + "Teks kejadian sistem" + "Tidak dapat menyalin gambar widget" + "Tidak" + "Jalur opsional" + "Mengirim suara dalam antrean" + "Bawaan" + "hide" + "runs" + "Kata sandi Gateway diperlukan. Masukkan lagi atau edit koneksi ini." + "Teks kejadian" + "Transkrip langsung" + "Tidak dapat memuat konfigurasi model penyedia." + "%1$s aplikasi diizinkan untuk meneruskan." + "Penyiapan suara" + "Lampirkan video" + "Gambar tambahan disembunyikan: %1$s" + "Tolak permintaan pemasangan?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Penerbit" + "Cabang dari sini" + " · Percakapan: Berbicara" + "Penggantian opsional" + "Bawaan" + "Persetujuan perintah" + "Aplikasi dan Gateway menggunakan versi protokol yang tidak kompatibel. Perbarui OpenClaw pada keduanya, lalu coba lagi." + "Segarkan Layar" + "Baca foto dan media terbaru" + "Dengarkan" + "Terhubung" + "Selesai" + "Ponsel Anda telah dipasangkan dengan %1$s. Lanjutkan untuk menyelesaikan akses node." + "Utas saat ini" + "Berpikir %1$s" + "Dibisukan" + "OpenClaw menghargai para mitranya di komunitas sumber terbuka." + "terbuka" + "Menghubungkan Gateway" + "Gateway dapat mengubah jalur ini tetapi tidak dapat menghapus jalur yang sudah ada." + "TTS" + "Menyimpan…" + "Patokan %1$s" + "search" + "Aktifkan" + "Periksa dan kelola pekerjaan Gateway terjadwal." + "Respons sebelumnya sudah mengizinkan perintah ini sekali." + "generate" + "Gunakan hanya pada jaringan privat tepercaya." + "Cari pengaturan" + "Percakapan sedang berlangsung" + "Autentikasi Gateway belum dikonfigurasi. Edit koneksi ini dan coba lagi." + "Gagal: endpoint aman tercapai, tetapi verifikasi sidik jari TLS kehabisan waktu. Periksa Tailscale Serve atau TLS gateway, lalu coba lagi." + "Langkah 1" + "Dikte" + "Buka Pemilih Aplikasi" + "Tidak ada persetujuan yang tertunda" + "edit" + "Hubungkan ke Gateway Anda" + "Masukkan kode penyiapan dari openclaw qr." + "Diagnostik" + "Aplikasi lain tidak tersentuh." + "Memecahkan" + "Tindakan ini akan menghapus utas dan transkripnya secara permanen." + "Perangkat disetujui." + "Mencoba lagi secara otomatis" + "Gambar widget disalin" + "%1$s peran" + "1 item belum tersedia" + "%1$s terjadwal" + "react" + "Agen" + "Hubungkan Gateway untuk memuat otomatisasi." + "Menghubungkan kembali…" + "Kembali ke penyiapan" + "send" + "Tidak dapat menguji koneksi" + "Verifikasi dan instal" + "Ubah perangkat ini menjadi node OpenClaw yang aman untuk chat, suara, kamera, dan alat perangkat." + "Penyiapan manual" + "Buka Chat untuk memulai atau melanjutkan utas saat ini." + "Tips: hentikan mendengarkan untuk mengirim giliran yang direkam." + "Lewati" + "Permintaan suara gagal" + "Tindakan ini akan menolak \"%1$s\" dan memuat ulang status Skill Workshop dari Gateway." + "Mengupas cangkang" + "update" + "Bagikan" + "Kamera diaktifkan" + "Telegram, WhatsApp, email, dan saluran lainnya muncul di sini setelah penyiapan." + "Kesalahan jaringan" + "Menjelajahi kolam pasang" + "Pulihkan canvas sekarang untuk session=%1$s source=%2$s. Jika status A2UI sudah ada, putar ulang segera. Jika tidak, buat dan render dasbor ringkas yang ramah perangkat seluler di Canvas." + "Gagal memulai: %1$s" + "Tidak diminta" + "Konfigurasikan penyedia %1$s di Gateway" + "kill" + "Persetujuan" + "File tidak tersedia" + "Tandai sebagai belum dibaca" + "Temukan orang dan detail kontak" + "Identitas perangkat diperlukan" + "Utas OpenClaw" + "Izinkan akses pustaka foto." + "Respons sebelumnya sudah menyelesaikan persetujuan ini." + "Tidak ada utas terbaru" + "Batas waktu %1$s dtk" + "Tidak ada kecocokan" + "Baca notifikasi aplikasi yang dipilih" + "Ketersediaan tidak diketahui" + "Siapkan Percakapan" + "Ekstra" + "Gateway telah dipasangkan. Menunggu akses operator." + "Lampirkan gambar" + "Pilih apa yang sampai ke OpenClaw." + "Persetujuan ulang kapabilitas tertunda" + "Tinjau item yang disorot" + "Mendengarkan..." + "Beri saya kabar terbaru" + "Pesan" + "Baca Kontak" + "Penyimpanan lampiran offline penuh; hapus item dalam antrean terlebih dahulu." + "Satu kali" + "Ubah Nama" + "Tidak ada saluran yang ditemukan." + "Lihat semua" + "Perangkat baru" + "Session Status" + "Buka pratinjau gambar" + "Cabang sesi berubah; tinjau dan coba lagi pesan ini." + "close" + "Itu tampak seperti kode penyiapan. Kembali dan pilih Siapkan Gateway, lalu Gunakan kode penyiapan." + "✦" + "Agen & otomatisasi" + "Terapkan" + "Proses automasi dilewati." + "Lanjutkan" + "Memantau · %1$s tugas terjadwal" + "Jelajahi" + "tabs" + "Menunggu" + "Percakapan: %1$s" + "read" + "Pilih teks" + "Aktivitas Gerakan" + "description: %1$s" + "Putar audio" + "Waktu" + "Belum diverifikasi" + "Yield" + "Salin perintah persetujuan" + "Output layar saat ini dan permukaan aplikasi interaktif." + "Layanan terhubung" + "Tampilan" + "Siap saat Anda siap" + "Tidak dapat memuat katalog penyedia." + "Berbicara · menunggu balasan" + "Belum diberikan" + "Simpan Perubahan" + "Gateway menolak proses automasi." + "Session Send" + "Temukan di ClawHub" + "Selalu mengizinkan pemeriksaan lokasi yang diminta saat OpenClaw berjalan di latar belakang; Android menampilkan ini dalam notifikasi node persisten." + "Peristiwa sistem" + "Hubungkan Gateway untuk melihat penyedia" + "Heartbeat berikutnya" + "Gateway telah dipasangkan. Menunggu persetujuan kapabilitas node." + "Mengasinkan" + "Tutup Canvas" + "Tulis Kontak" + "Tidak ada skill terinstal yang cocok dengan pencarian ini." + "Penyiapan Penyedia Talk" + "Music Generation" + "Pengaturan Bicara" + "Memantau · 1 utas" + "Teks Payload" + "Atur teks" + "Persetujuan %1$s" + "Gateway tidak mengembalikan kesiapan %1$s" + "%1$s model dikonfigurasi. Segarkan untuk memeriksa kembali ketersediaan." + "Conversation Send" + "Kanvas" + "1 penyedia" + "Sertifikat Gateway tidak dapat dibaca secara otomatis. Tempelkan sidik jari SHA-256 yang diperoleh di host Gateway." + "Gagal mengirim: %1$s" + "Jembatan" + "Kesalahan Pengiriman" + "Gunakan OpenClaw dari ponsel Anda" + "Tampilan" + "Lokakarya Skill" + "Memerlukan Token" + "Pratinjau · %1$s" + "Izin mikrofon diperlukan" + "Hubungkan Gateway untuk memuat proposal Skill Workshop." + "Semua sistem beroperasi" + "Gateway tidak dapat dijangkau" + "OC" + "Diperbarui" + "Terhubung (node offline)" + "Beranda" + "Dikte sedang mendengarkan" + "Tidak ada utas yang diarsipkan" + "Pilih dan periksa asisten yang tersedia di gateway ini." + "Mode bicara aktif" + "Bekerja · 1 proses aktif" + "Setuju dan Aktifkan" + "Pembaruan Gateway Diperlukan" + "Salin gambar" + "URL Gateway" + "main, isolated, current, atau session:<id>" + "Media tidak tersedia" + "Hubungkan ke Gateway Anda untuk membuka shell di ruang kerja agen." + "%1$s://%2$s:%3$s" + "Tidak dapat memuat detail persetujuan. Segarkan dan coba lagi." + "Saya dapat memeriksa status Gateway, memperbaiki konfigurasi, mengganti model, atau menghubungkan saluran." + "Tool Call" + "Utas" + "Write" + "Mulai dengan perintah, atau gunakan suara." + "H" + "Buka Pengaturan" + "Mengamati…" + "Akhiri Bicara" + "Kesalahan Terakhir" + "Tinjau tindakan yang memerlukan perhatian Anda." + "Dinonaktifkan untuk semua agen." + "Mulai Suara" + "Kembali ke tugas latar belakang" + "Tindakan cron lainnya masih dalam proses penyelesaian." + "Waktu tunggu %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Pengenalan ucapan di perangkat tidak tersedia." + "Skrip · hanya baca" + "Lampiran terlalu besar untuk dimasukkan ke antrean dalam satu pesan; hapus beberapa lampiran lalu coba lagi." + "1 model dikonfigurasi. Segarkan untuk memeriksa kembali ketersediaan." + "Widget tidak tersedia" + "Koneksi aman diperlukan untuk host ini." + "Terbaru" + "Tidak ada otomatisasi yang cocok." + "Ponsel dapat menjangkau Gateway" + "Gateway" + "Kedaluwarsa" + "Pekerjaan OpenClaw terjadwal dari gateway Anda." + "Sub-agent" + "Menunggu persetujuan perangkat" + "Memuat utas" + "Gateway ini sekarang menyajikan sertifikat yang dipercaya oleh perangkat ini." + "Jeda ms" + "event create" + "dokumen" + "Siapkan Gateway" + "Putar video" + "Autentikasi tersimpan tidak valid. Autentikasi ulang atau reset koneksi gateway ini." + "Saat Menggunakan" + "screenshot" + "Putar ulang ke sini" + "Ekspresi cron, mis. 0 9 * * *" + "Kembali ke suara" + "Bicara" + "Detail" + "%1$s/%2$s online" + "%1$s aplikasi diizinkan untuk meneruskan." + "Obrolan" + "Akses mikrofon diperlukan." + "Berlari menyamping" + "Edit" + "Jam Tenang" + "Salin diagnostik" + "Terjadwal" + "Buat" + "Kedaluwarsa dalam %1$s" + "Tutup" + "Tolak usulan?" + "Kesalahan ucapan (%1$s)" + "Masalah" + "Cari metadata registri. Gateway memverifikasi kembali kepercayaan sebelum pengunduhan apa pun." + "Gunakan IP LAN privat untuk penyiapan lokal, atau aktifkan Tailscale Serve / ekspos URL gateway wss:// untuk akses jarak jauh." + "Mengirim…" + "Akun %1$s" + "Suggest Task" + "Cari" + "Mendengarkan" + "Otomatisasi tidak dimuat." + "Pembaruan Gateway tersedia. Jalankan pembaruan dari Web UI atau CLI saat Anda siap." + "segera" + "Tidak ada persetujuan gateway." + "Host" + "Tambahkan satu kata atau frasa aktivasi per kolom. Kemudian ucapkan salah satunya sebelum perintah Anda." + "Transkripsikan lalu kirim" + "Jalankan pada" + "Jeda audio" + "Akses ke perangkat Gateway" + "Tidak ada pratinjau" + "Perangkat" + "OpenClaw untuk Android." + "Persetujuan kapabilitas tertunda" + "Simpan atau batalkan perubahan Anda sebelum menjalankan, mengaktifkan, menonaktifkan, menghapus, atau memuat ulang otomatisasi ini." + "Belum ada otomatisasi." + "Skill ini memerlukan %1$s item penyiapan. Android menampilkan apa yang sudah terinstal; perubahan penyiapan/konfigurasi tetap dilakukan melalui desktop atau CLI." + "%1$s terbaru" + "Saluran" + "Unphased" + "Aktif di ponsel ini" + "Memeriksa akses node" + "Zona waktu" + "Tindakan inspect dan apply Skill Workshop" + "Selalu Izinkan" + "present" + "Skills yang terinstal di gateway akan muncul di sini." + "Kode mungkin telah kedaluwarsa atau dibuat untuk Gateway lain." + "Izin diperlukan" + "Automasi memiliki konfigurasi yang tidak valid." + "Daftar izin" + "Penyiapan, status, dan perbaikan" + "groups" + "Kunci publik" + "Tentang" + "Kode QR penyiapan tidak ditemukan dalam gambar tersebut. Pilih QR yang dibuat oleh openclaw qr, atau masukkan kode penyiapan secara manual." + "permissions" + "Hubungkan gateway untuk memuat node dan perangkat yang dipasangkan." + "Ganti cabang" + "Tidak ada skill" + "Balasan diputar dengan suara" + "Tandai sebagai sudah dibaca" + "Persetujuan node tertunda" + "wake" + "%1$s proposal" + "Autentikasi Gateway memerlukan perhatian." + "Detail koneksi" + "Milidetik" + "Pengenalan ucapan" + "Deskripsi" + "Percakapan terbaru" + "Ponsel Anda mengirim informasi ini ke Gateway Anda, bukan ke server yang dijalankan oleh OpenClaw. Gateway Anda mungkin menyertakannya dalam permintaan ke penyedia AI yang Anda pilih." + "Pengiriman" + "Bisukan speaker" + "%1$s Berjalan · %2$s Selesai · %3$s Gagal" + "Membuka koneksi Gateway" + "Memantau · %1$s utas" + "Proses automasi selesai." + "Tidak ada aplikasi yang cocok." + "Kirim ke Chat" + "Automasi dihapus." + "Aktifkan" + "Proses Terbaru" + "Sejajarkan kode QR di dalam kotak." + "Tidak dapat memuat persetujuan." + "Saya sudah menyetujui" + "Hubungkan Gateway Anda untuk memuat kesiapan penyedia." + "Belum dipasangkan" + "Persetujuan ini kedaluwarsa sebelum dapat diselesaikan." + "Mengamati dalam %1$s dtk — beralih ke aplikasi target" + "Prompt Agen" + "emoji list" + "Berulang" + "Cari OpenClaw" + "%1$s tertunda" + "Pengenalan ucapan di perangkat tidak tersedia" + "Tidak ada aplikasi yang dapat membagikan pesan ini" + "Tutup pencarian" + "Perintah untuk dipantau" + "Kesehatan" + "Pemantau notifikasi" + "Speaker dibisukan" + "Cari utas" + "OK" + "Tidak dapat membuka panduan penyiapan." + "tanyakan OpenClaw %1$s" + "Wait for Agents" + "Alamat" + "Pekerjaan terjadwal yang dibuat di Gateway akan muncul di sini." + "Menampilkan potongan log terbaru." + "Gunakan kode penyiapan" + "sticker" + "Gunakan Gateway wss:// yang aman atau Tailscale Serve, buat kode penyiapan akses penuh di Control UI atau dengan openclaw qr, lalu pindai atau tempel di bawah dan sambungkan ulang untuk mengaktifkan pengaturan dan peningkatan." + "steer" + "Dipilih" + "Android dapat memindai atau menempelkan kode penyiapan yang sudah ada, tetapi gateway ini belum menyediakan pembuatan kode penyiapan ke aplikasi. Buat QR/kode di host gateway dengan openclaw qr, lalu pindai di sini atau tempelkan kode penyiapan di bawah." + "Status Kanvas" + "Perbaiki koneksi" + "Simpan gambar" + "Node %1$s" + "Kata sandi Gateway diperlukan" + "Update Plan" + "Hapus lampiran" + "Proses automasi gagal." + "Batas penyedia dan kesehatan kuota." + "Katalog bicara Gateway belum dimuat" + "gateway ini" + "Belum ada proses terbaru." + "Model bahasa di perangkat tidak tersedia" + "Dasbor memerlukan Gateway yang terhubung" + "Proposal yang cocok akan muncul di sini setelah agen membuat draf skill yang dapat digunakan kembali." + "Session Search" + "OpenClaw sedang berbicara" + "Pindai QR" + "Aplikasi Terpilih" + "Kembalikan Perubahan" + "Perintah persetujuan disalin" + "Status Pengiriman" + "Kode QR tidak diterima" + "Pusat perintah suara Anda." + "Uji koneksi" + "OPENCLAW" + "Web Fetch" + "Perintah" + "Setujui perangkat?" + "Hubungkan ke Gateway Anda untuk membuka dasbor sesi ini." + "Hapus %1$s dan kredensial tersimpannya dari ponsel ini?" + "Kode QR mengarah ke gateway jarak jauh yang tidak aman. %1$s %2$s" + "Permukaan layar siap" + "Pasangkan Gateway" + "Hubungkan gateway untuk memuat saluran." + "Dijeda selama ada aktivitas suara lain." + "Model" + "Foto" + "Tempel kode penyiapan" + "OpenClaw sedang berbicara" + "Menghubungkan..." + " · Lokasi: Selalu" + "Pesan: %1$s" + "Membentuk karang" + "Muat dari gateway" + "text: %1$s" + "Perlu" + "rename group" + "Siap" + "Diary sedang menunggu entri pertamanya." + "Setujui" + "Halaman langsung" + "Automasi sudah berjalan." + "Hapus otomatisasi ini setelah eksekusi satu kali berhasil." + "Siap untuk chat dan suara" + "Terhubung (operator: %1$s)" + "Pemasangan Gateway selesai. Setujui ponsel ini sebagai node agar OpenClaw dapat menggunakan kemampuan perangkat yang Anda aktifkan." + "Respons dibatalkan" + "gambar" + "%1$s ditahan" + "Tidak ada utas yang cocok" + "delete" + "Tata letak: Ringkas" + "channels" + "Diberikan" + "Setiap %1$s menit" + "1 token" + "%1$s %2$s" + "Aplikasi Terinstal" + "tertunda" + "Menyiapkan catatan suara…" + "Tidak pernah" + "Subsistem" + "Saat perintah keluar" + "Koneksi" + "Tidak dapat memuat riwayat eksekusi otomatisasi." + "Nama otomatisasi" + "Langkah 2" + "Diagnosis" + "Beberapa pemeriksaan status saluran tidak selesai." + "pin" + "Salin %1$s" + "Dipasangkan" + "Tidak dapat menyimpan kata aktivasi" + "Tindakan ini akan mengarantina \"%1$s\" dan memuat ulang status Skill Workshop dari Gateway." + "Rekam catatan suara" + "Dalam antrean" + "Sudah dijawab" + "Izinkan alat kamera saat diminta." + "Masalah" + "Bangun Suara" + "Permintaan pemasangan ditolak." + "%1$s hari lalu" + "roles" + "Skills" + "Arsipkan" + "Node offline. Hubungkan kembali dan coba lagi." + "Sistem" + "IP jarak jauh" + "Tidak dikelompokkan" + "Detail Jadwal" + "Kemampuan Ponsel" + "Tidak tersedia" + "Dasbor" + "Tempel token" + "Tidak ada penyedia" + "Sidik jari SHA-256" + "Belum ada utas" + "Mikrofon Bluetooth" + "Terbaru" + "Ganti nama utas" + "Hasil penyelesaian tidak diketahui. Tindakan tetap dinonaktifkan hingga catatan Gateway diverifikasi." + "dialog" + "Dengarkan kata aktivasi" + "camera snap" + "Menyiapkan pemutaran…" + "Gateway memilih penyedia tidak dikenal %1$s" + "delete group" + "Ikuti Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Hubungkan gateway untuk memuat agen." + "Kembali" + "Bagikan pesan" + "Buat kode QR." + "Mulai ulang" + "Speaker aktif" + "Hapus grup?" + "Tidak ada" + "Cari proposal" + "stop" + "Aman (TLS)" + "Tidak ada node atau perangkat yang dipasangkan." + "%1$s%% tersisa %2$s" + "Kode penyiapan kedaluwarsa" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "CATATAN HARIAN" + "notify" + "Ponsel ini tetap tidak aktif hingga Gateway membutuhkannya, lalu aktif, melakukan sinkronisasi, dan kembali tidak aktif." + "%1$s model yang dikonfigurasi" + "Lisensi" + "Hubungkan Gateway untuk mencari Skills ClawHub." + "Skill" + "Koneksi Gateway berubah. Mulai ulang OpenClaw untuk menyambung kembali." + "ID perangkat" + "Gateway tidak mengidentifikasi penyedia %1$s yang aktif" + "Menunggu" + "Kata aktivasi disimpan" + "Terlama dahulu" + "Layar" + "Berjalan Sejak" + "ID zona IPv6 tidak didukung. Gunakan alamat IPv6 tanpa cakupan atau nama host LAN." + "Terkirim — mengonfirmasi pengiriman…" + "audio" + "This gateway connection needs operator.admin to update skills." + "Kode penyiapan" + "Akui peringatan Gateway dan instal" + "Segarkan chat" + "Interval" + "Tindakan proposal Skill Workshop memerlukan cakupan operator.admin." + "Sesi" + "Ganti nama…" + "Hubungkan gateway untuk memuat dreaming." + "Penyiapan" + "Buka Bicara" + "poll" + "Hubungkan untuk memuat agen Anda" + "role remove" + " · Percakapan: Mendengarkan" + "ClawHub tidak mengembalikan versi yang dapat diinstal untuk %1$s." + "Perintah" + "Persetujuan ini dibatalkan sebelum dapat diselesaikan." + "Mikrofon aktif · menunggu gateway" + "Teks" + "Menampilkan %1$s dari %2$s. Persempit pencarian untuk melihat lebih banyak." + "v%1$s tersedia" + "%1$s://%2$s" + "%1$s... (OK)" + "Penyedia dan model yang dikonfigurasi" + "Menghubungkan…" + "Hubungkan ke Gateway untuk menyimpan kata aktivasi" + "Buka profil" + "Mulai Gateway Anda." + "Bantu saya mengubah tujuan ini menjadi daftar periksa yang praktis: " + "Hapus pencarian sesi" + "Port" + "Masukkan kode penyiapan" + "Tidak dapat memuat log Gateway." + "%1$s penyedia siap" + "Agen Anda siap" + "Tidak ada penyedia %1$s yang dikonfigurasi di Gateway" + "Mendengarkan untuk satu giliran" + "Amati" + "Epoch milidetik (opsional)" + "Tidak ada model yang dikonfigurasi. Segarkan untuk memeriksa kembali ketersediaan." + "Pengaturan" + "Kamera belakang" + "approve" + "Sebelum Anda mulai" + "Tidak dapat memuat Skills." + "Dinonaktifkan" + "Masih menunggu persetujuan" + "Tidak dapat memuat tugas latar belakang" + "Pastikan OpenClaw dapat berbicara dengan jelas di ponsel ini." + "Bekerja · %1$s proses aktif" + "Direktori kerja perintah" + "Nama grup" + "Pilih dari galeri" + "Version %1$s" + "Kembali" + "Connect the gateway to update skills." + "Hapus Setelah Eksekusi" + "Kode penyiapan mengarah ke gateway jarak jauh yang tidak aman. %1$s %2$s" + "Computer" + "Gateway terputus." + "Session Settings" + "Hubungkan Gateway untuk memulai" + "Pemberitahuan keamanan" + "Jawaban lain" + "Tutup peringatan gambar yang dibagikan" + "Gateway mengevaluasi rilis ClawHub yang berbeda. Tinjau kembali skill sebelum menginstalnya." + "Buka Akses Sistem" + "Selesai" + "Gambar tidak tersedia" + "Notifikasi" + "Apply, reject, dan quarantine memerlukan scope operator.admin. Sambungkan ulang dengan autentikasi gateway bersama atau setujui peningkatan scope perangkat operator.admin untuk mengaktifkan tindakan siklus hidup." + "sticker upload" + "Menangkap lobster" + "Messages to recover" + "openclaw devices approve %1$s" + "Detail log gateway yang dapat dibaca." + "Tinjau proposal skill yang dihasilkan sebelum menjadi Skills aktif." + "Dibundel" + "%1$s tersedia" + "Persetujuan Node Tertunda" + "Gateway Tertunda" + "Autentikasi diperlukan" + "Node" + "Tetap Aktif" + "OpenClaw sedang membalas" + "Dokumentasi" + "%1$s siap" + "Belum ada output" + "Bahasa perangkat tidak didukung" + "Dalam antrean — dikirim saat terhubung kembali" + "%1$s mnt lalu" + "Cabang saat ini" + "Memeriksa akses pemasangan" + "Akses Gateway terbatas" + "Menjalankan alat..." + "Memeriksa persetujuan…" + "Ambil foto dan klip dari ponsel ini" + "Terhubung dan siap" + "Tutup" + "Ubah tujuan menjadi daftar periksa yang dapat ditindaklanjuti." + "Kode penyiapan memiliki URL gateway yang tidak valid." + "Aktifkan hanya akses yang Anda izinkan untuk digunakan OpenClaw saat ponsel ini terhubung. Anda dapat mengubahnya nanti di Setelan Android." + "Akun" + "remove" + "Kata sandi opsional" + "Autentikasi Gateway perlu ditinjau. Periksa setelan gateway, lalu coba lagi." + "Kode QR menggunakan ID zona IPv6. Gunakan alamat IPv6 tanpa cakupan atau nama host LAN." + "add" + "Mencari kril" + "Sehat" + "Selesai dalam %1$s" + "Argumen" + "Opsi Penginstalan" + "Dalam %1$s jam" + "Persetujuan Gateway tertunda. Jalankan ini di host gateway:" + "Akses admin diperlukan" + "set groups" + "Sematkan model" + "Hapus Pencarian" + "Diaktifkan untuk agen yang memenuhi syarat." + "Tidak ada utas saat ini" + "bounds: %1$s" + "Setelah %1$s" + "Izinkan penjadwal menjalankan otomatisasi ini." + "%1$s diterapkan" + "Belum ada diary mimpi." + "Segarkan tugas latar belakang" + "Rangkum utas terbaru dan langkah berikutnya." + "Berjalan di perangkat saat OpenClaw terlihat." + "%1$s sedang bekerja" + "%1$s %2$s" + "Mentah" + "Berjalan" + "Jalankan Sekarang" + "Cabang tanpa judul" + "Dikonfigurasi" + "camera list" + "1 diterapkan" + "camera clip" + "Ya" + "Tes Audio" + "Ditahan" + "events" + "Direktori kerja" + "Lompat ke terbaru" + "Izinkan sepanjang waktu" + "Pindai QR atau kode penyiapan" + "Installing" + "Node aktif, ponsel yang dipasangkan, dan permintaan perangkat yang tertunda." + "Snapshot: %1$s" + "Respons sebelumnya sudah mengizinkan perintah ini dan menyimpan pilihan tersebut." + "Permintaan Tertunda" + "Disetujui" + "Ruang kerja" + "Suara" + "Siap berbicara" + "Subagents" + "Gagal: tidak ada endpoint gateway aman yang terdeteksi. Aktifkan TLS gateway atau Tailscale Serve, atau gunakan alamat LAN pribadi tepercaya dengan Unencrypted terpilih." + "Sinyal" + "Target Sesi" + "Gateway mencatat penolakan." + "Terima" + "Tanyakan apa saja kepada OpenClaw" + "Hubungkan kembali untuk melanjutkan" + "%1$s dipasangkan" + "Tindakan ini akan menerapkan \"%1$s\" dan memuat ulang status Skill Workshop dari Gateway." + "Gateway luring" + "openclaw devices list" + "Status koneksi node OpenClaw" + "Peringatan tetap di ponsel ini." + "OpenClaw dapat menerima peringatan yang dipilih." + "Buka Layar" + "Tindakan chat" + "Izinkan kontrol aplikasi lain?" + "Menginspeksi" + "Pindai atau tempel kode penyiapan untuk menambahkan gateway lain." + "Swarm" + "TLS kehabisan waktu" + "Sesi terbaru" + "Perangkat yang dipasangkan telah dihapus." + "Gateway telah dipasangkan. Memeriksa persetujuan kapabilitas node." + "Gerakan" + "Tindakan cron gagal." + "Di komputer Gateway, jalankan:" + "Cari sesi" + "Segarkan Log" + "Gambar tidak tersedia · Ketuk untuk mencoba lagi" + "openclaw nodes approve %1$s" + "Catatan suara · %1$s" + "Penggunaan" + "Menautilus" + "Konteks %1$s%%" + "Transkripsikan perintah suara" + "Bisukan" + "Mulai percakapan baru dan percakapan itu akan muncul di sini." + "Masalah koneksi" + "Sedang" + "Buat Cabang" + "Aktifkan speaker" + "Teks Peristiwa Sistem" + "Urutkan: %1$s" + "%1$s menunggu" + "Image Generation" + "Catatan suara" + "Tidak ada yang perlu Anda perhatikan" + "OpenClaw memerlukan izin %1$s untuk melanjutkan." + "Mikrofon headset berkabel" + "Halaman" + "Terkirim" + "Jatuh tempo" + "Detail skill tidak tersedia dalam status skills saat ini." + "Pilih apa yang dapat dibagikan ponsel ini." + "Automasi ini sudah memiliki proses dalam antrean." + "Hubungkan Gateway untuk mengelola otomatisasi." + "Automasi belum waktunya dijalankan." + "Tidak ada detail" + "Persetujuan sedang diproses.\nOpenClaw akan terhubung kembali secara otomatis." + "Hubungkan Gateway Anda untuk melihat kesiapan penyedia." + "Menunggu pemasangan" + "Mulai atau lanjutkan percakapan" + "Tidak ada tugas terjadwal" + "Balas ke OpenClaw…" + "Status" + "Node OpenClaw · Terhubung" + "Aktif" + "Tampilkan status debug berbagi layar." + "Tidak ada batas yang dilaporkan" + "Tutup pemindai" + "Setiap %1$s hari" + "Diaktifkan" + "Aktifkan dan Buka Pengaturan" + "Online dan siap" + "Ask User" + "Kesalahan chat" + "Gulir maju" + "%1$s dari %2$s" + "Rencanakan pekerjaan" + "console" + "Coba lagi" + "Mulai chat dan percakapan OpenClaw aktif Anda akan muncul di sini." + "Tidak dapat memuat automasi." + "Shell di ruang kerja agen" + "%1$s aktif" + "Pilih izin perangkat" + "Durasi Terakhir" + "Agen default" + "%1$sj" + "Percakapan sedang berlangsung" + "Tidak dapat menginstal %1$s dari ClawHub." + "Selamat datang di OpenClaw" + "Kontrol aplikasi lain" + "Indeks Sinyal" + "Masukkan secret…" + "%1$s:%2$s" + "beri tahu OpenClaw untuk %1$s" + "Ditemukan" + "Sembunyikan Bilah Samping" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Pemutaran audio tidak tersedia" + "terapkan" + "Tidak dapat memuat penggunaan." + "Pertahankan ketersediaan node selama pekerjaan aktif." + "Aktivasi Berikutnya" + "%1$s/%2$s" + "Tidak ada entri log terbaru." + "Gateway Manual" + "Ganti nama grup" + "Update Goal" + "Ketersediaan penyedia tidak diketahui" + "Penyedia" + "Hapus grup…" + "Muatan" + "Log Panggilan" + "Memory Search" + "%1$s provider" + "Konteks ponsel & privasi" + "%1$s/%2$s terhubung" + "%1$s %2$s" + "Pemulihan setelah Gateway dimulai ulang masih berlangsung." + "Mengganti kode penyiapan akan menghapus kredensial penyiapan dan token perangkat yang tersimpan di ponsel ini sebelum menghubungkan kembali. Ponsel ini mungkin perlu mendapatkan persetujuan kemampuan node lagi; lanjutkan hanya jika Anda memang ingin memasangkannya dengan kode penyiapan gateway baru." + "Buka otomatisasi untuk memeriksa konfigurasi dan riwayat eksekusinya. Koneksi dengan cakupan admin juga dapat menjalankan, mengedit, mengaktifkan, menonaktifkan, atau menghapusnya." + "Konteks --" + "Proposal dikarantina." + "Automasi dijeda." + "OpenClaw seluler" + "A2UI reset" + "Gateway tidak tersedia" + "Read" + "Skill ini memerlukan 1 item penyiapan. Android menampilkan apa yang sudah terinstal; perubahan penyiapan/konfigurasi tetap dilakukan melalui desktop atau CLI." + "Eksekusi Terakhir" + "Akses kamera diperlukan untuk memindai QR penyiapan." + "Tidak dapat memperbarui model." + "Menggelembung" + "thread reply" + "Hapus…" + "Hubungkan Gateway untuk memeriksa automasi." + "LOG TERBARU" + "Memuat proses terbaru…" + "File ini tidak dapat dipratinjau. File mungkin berupa biner atau terlalu besar." + "Periksa" + "Baca lokasi ponsel ini" + "Kunci Skill" + "%1$s telah diinstal." + "Gateway" + "actions: %1$s" + "Mode Penerusan" + "%1$sk" + "Ubah tata letak utas" + "Tidak ada endpoint TLS" + "Gateway OpenClaw" + "Siapkan secara manual" + "Berpikir…" + "Akses Gateway perlu ditinjau" + "1 ditahan" + "%1$s dtk" + "Terapkan usulan?" + "Jangan sekarang" + "Belum disetujui" + "Cari aplikasi" + "1 model yang dikonfigurasi" + "Tutup pemberitahuan persetujuan" + "·" + "Luring" + "Penyedia ucapan" + "Persetujuan Gateway sedang diproses. OpenClaw akan mencoba lagi secara otomatis." + "Maks" + "Perubahan cron memerlukan akses operator.admin." + "Berpikir" + "screen snapshot" + "Node yang diamati: %1$s" + "Tidak ada tindakan yang ditemukan" + "Simpan & Hubungkan" + "list" + "Gateway mencatat persetujuan dan menyimpan pilihan tersebut." + "Masukkan endpoint manual yang valid untuk terhubung." + "asisten" + "Mengirim ke chat..." + "Simpan Profil" + "Terkunci" + "Edit Otomatisasi" + "Gunakan jaringan yang sama, atau URL Gateway jarak jauh yang aman." + "Acuan" + "Bahasa" + "Aplikasi ini lebih lama daripada Gateway. Perbarui OpenClaw di perangkat ini, lalu coba lagi." + "Semua" + "Sesi Gateway sedang berlangsung" + "Menunggu peninjauan" + "Tidak ada skills yang terinstal." + "Memeriksa Gateway" + "Jeda bertahap %1$s" + "Hasil untuk %1$s tidak diketahui. Hubungkan kembali, muat ulang Skills, lalu coba lagi; Gateway akan bergabung dengan aman ke proses instalasi yang cocok dan masih berjalan." + "Lupakan" + "Tidak ada gateway yang dipasangkan." + "%1$s · %2$s" + "<rahasia disunting>" + "%1$s masalah" + "OpenClaw" + "Mendengarkan · %1$s dalam antrean" + "Ucapan asisten dibisukan" + "Aksi node hanya berjalan saat aplikasi target berada di latar depan (divalidasi melalui jalur jarak jauh). Aksi global dan aksi aplikasi yang sama berfungsi di sini." + "Belum ada Gateway yang ditemukan. Gunakan penyiapan manual jika penemuan diblokir." + "Buka utas" + "Bekerja" + "Mulai berbicara..." + "Node Ponsel" + "Xhigh" + "Jalankan di host Gateway:" + "Perubahan skill memerlukan operator.admin. Hubungkan kembali dengan token gateway yang memiliki hak admin." + "Hubungkan Gateway untuk memeriksa Skills ClawHub." + "Daftar aplikasi tetap berada di ponsel ini." + "Menganggur" + "Ditampilkan di pengaturan Aksesibilitas Android." + "Pengiriman cerdas" + "Tolak" + "Gateway mengembalikan status \'%1$s\' setelah %2$s." + "Token Gateway belum dikonfigurasi" + "Not available to this agent" + "File" + "Izin" + "Kamera tidak dapat dimulai. Pilih gambar QR dari galeri atau masukkan kode penyiapan secara manual." + "Ketuk untuk menyalin" + "Menunggu %1$sm" + "%1$s." + "Hubungkan Gateway untuk menginstal Skills ClawHub." + "Cari suara" + " · Mikrofon: Mendengarkan" + "Sambungkan kembali dengan akses operator.admin untuk meninjau dan mengubah pengaturan Gateway." + "Muat lebih banyak" + "Amati dalam 3 dtk" + "run" + "Membuat suara…" + "← Kembali" + "Putuskan Koneksi" + "Jalankan perintah approve di komputer Gateway, lalu periksa lagi." + "Otomatisasi" + "%1$s mnt" + "Percayai" + "Kode QR tersebut bukan QR penyiapan OpenClaw. Buat kode baru dengan openclaw qr, lalu coba lagi." + "Mikrofon pilihan tidak tersedia; menggunakan perutean otomatis." + "Ditolak" + "Sertakan paket Android dan latar belakang." + "Gateway Anda siap." + "Terpicu" + "Structured Output" + "Proses ini memakan waktu lebih lama dari yang diperkirakan.\nPastikan Gateway berjalan dan dapat dijangkau." + "Tidak ada tugas latar belakang untuk agen ini." + "Menyambungkan kembali" + "OpenClaw sedang memeriksa akses Gateway dan node." + "Code Execution" + "Tidak ada penggunaan provider" + "Tinjau" + "Izin mikrofon diperlukan." + "%1$sh" + "%1$s tersedia" + "OpenClaw sedang menyinkronkan kembali" + "Aliran peristiwa terputus; coba segarkan." + "Tidak dapat memuat node dan perangkat." + "Hubungkan gateway untuk memuat skills." + "tidak diketahui" + "Output" + "Bicara gagal: Penyedia Realtime tertutup secara tidak terduga." + "OpenClaw Sensitif terhadap Waktu" + "ban" + "Token Gateway diperlukan" + "Perangkat yang dipasangkan" + "Perlu persetujuan ulang" + "Tidak dijadwalkan" + "Kontak" + "Ponsel Anda tetap senyap hingga diperlukan" + "Mendengarkan · mengirim suara dalam antrean" + "Tidak dapat memuat detail tugas" + "Pesan agen" + "Gateway memerlukan identitas perangkat ini. Autentikasi ulang atau reset koneksi gateway ini." + "Sesi berikutnya" + "Keamanan koneksi" + "Lewati untuk saat ini" + "Situs web" + "Hubungkan gateway untuk memuat permintaan persetujuan di aplikasi." + "%1$s disalin" + "Tidak ada aplikasi yang dipilih. Tidak ada yang diteruskan hingga Anda menambahkan aplikasi." + "%1$s %2$s" + "Perlu penyiapan" + "Belum dipasangkan" + "Gateway menerima ponsel ini" + "Tidak ada model yang dikonfigurasi" + "Nonaktifkan" + "Bahasa aplikasi" + "Memasangkan Gateway" + "Autentikasi yang disimpan tidak valid" + "%1$s cakupan" + "Hubungkan gateway untuk memuat log terbaru." + "Simpan kata aktivasi" + "Kelola skill yang terinstal dan tambahkan rilis tepercaya dari ClawHub." + "Mengirim…" + "Belum ada agen yang dimuat." + "Cari di ClawHub" + "Chat sedang memeriksa kondisi Gateway." + "Perlu pemasangan" + "Proses Aktif" + "Gagal — %1$s" + "Koneksi antara ponsel ini dan OpenClaw." + "summarize" + "Gambar widget disimpan ke Downloads" + "Memulai…" + "%1$s token" + "Kesalahan klien" + "Verifikasi perangkat yang meminta ini sebelum memberikan akses." + "Mikrofon Bluetooth LE" + "%1$s %2$s" + "Automasi diaktifkan." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Diarsipkan" + "Muat ulang" + "Cari otomatisasi" + "Ponsel tertaut dan host node akan muncul di sini setelah dipasangkan." + "%1$s: %2$s" + "Automasi ini telah berubah di Gateway. Tinjau versi terbaru sebelum menyimpan lagi." + "Hentikan Dikte" + "Mudah Dibaca" + "Kirim pesan ke OpenClaw" + "Kata sandi Gateway tidak valid. Masukkan ulang atau reset koneksi gateway ini." + "Hubungkan ulang" + "Waktu ISO, mis. 2026-07-09T09:30:00Z" + "%1$s alat" + "Respons sebelumnya sudah menolak persetujuan ini." + "Ditautkan" + "Buka %1$s" + "%1$s/%2$s" + "Proses automasi selesai dengan status yang tidak diketahui." + "Antrean offline penuh (%1$s pesan); hapus item yang mengantre terlebih dahulu." + "Gateway publik memerlukan wss:// atau Tailscale Serve. ws:// diizinkan untuk localhost, host .local, emulator Android, dan IP LAN privat." + "Hubungkan gateway untuk memuat detail skill." + "Akses Penuh Diperlukan" + "Pendengar kata aktivasi" + "Perluas pratinjau tautan" + "Hapus pencarian utas" + "NULL (GAGAL)" + "Perbarui" + "Admin" + "Perlu perhatian" + "Pasangkan perangkat ini dengan Gateway Anda agar hanya dibangunkan untuk pekerjaan nyata, memudahkan pemantauan agen secara langsung, dan menghindari proses latar belakang berulang yang menguras baterai." + "Peran" + "Balas" + "Katalog penyedia" + "Aktifkan izin di Pengaturan" + "A2UI push" + "Periksa Akses" + "Jika Gateway dapat dijangkau, penyambungan ulang akan selesai tanpa intervensi." + "Giliran agen" + "karantina" + "Perhatian" + "Mencari…" + "Di mana saya bisa mendapatkan kode penyiapan?" + "Tidak dapat mengaktifkan skill." + "pdf" + "Hapus" + "%1$s%% online" + "Tidak ada saluran" + "Suara waktu nyata" + "Tindakan reject dan quarantine Skill Workshop" + "Node & Perangkat" + "Pusat perintah lokal" + "emoji upload" + "Memuat pratinjau…" + "Tinggi" + "focus" + "describe" + "konteks %1$s" + "Mendengarkan respons..." + "voice" + "Terhubung ke %1$s" + "role add" + "Chat perlu perhatian" + "Aktifkan Mikrofon" + "OpenClaw mengumpulkan dan mengirim nama, ID paket, dan status aplikasi yang terlihat di ponsel ini saat OpenClaw Gateway yang dipasangkan memintanya. Ini memungkinkan asisten Anda menjawab pertanyaan dan mengambil tindakan menggunakan aplikasi yang terpasang." + "Gateway tidak terhubung" + "Kebijakan" + "Waktu tunggu konfirmasi pesan terkirim habis; segarkan untuk memeriksa pengiriman." + "File Pendukung" + "Ekspresi" + "Tugas latar belakang" + "Mimpi" + "Tidak ada aplikasi yang diblokir. Aplikasi dapat meneruskan hingga Anda menambahkan pemblokiran." + "Pengenal ucapan tidak tersedia" + "Platform" + "Gateway tidak mengembalikan penyiapan %1$s" + "Lupakan gateway?" + "Deskripsi opsional" + "Buka %1$s" + "Kanvas beranda" + "Bermimpi" + "%1$s hingga %2$s" + "Bagikan file" + "Waktu nyata" + "API" + "OpenClaw sedang bekerja…" + "Bicara atau dikte dengan OpenClaw" + "Bagikan informasi aplikasi yang terpasang?" + "Memuat otomatisasi…" + "Hapus Otomatisasi" + "Asisten default" + "Pilih penyedia %1$s yang didukung di Gateway" + "Tidak tersedia" + "Folder kosong" + "Buka pengaturan" + "Nonaktif" + "Tipografi" + "Berhenti" + "Belum ada utas yang cocok." + "Pemasangan Gateway berhasil.\nSetujui kapabilitas node ponsel ini dari UI operator." + "Skill ini sudah terinstal, tetapi saat ini belum memenuhi syarat untuk dijalankan. Gunakan desktop atau CLI untuk mengubah konfigurasi." + "Pengenal sedang sibuk" + "Gateway Rumah" + "Jalankan perintah persetujuan di Gateway" + "Layanan dinonaktifkan" + "Tidak dapat memuat proposal Skill Workshop." + "Beri saya ringkasan utas OpenClaw terbaru saya dan sarankan langkah berikutnya." + "Jangan Sekarang" + "openclaw qr" + "start" + "Node OpenClaw · Percakapan" + "Baca dan perbarui acara" + "Bicara gagal: Penyedia Realtime tertutup: %1$s" + "Hubungkan Gateway untuk menelusuri file ruang kerja." + "%1$s melalui relay Gateway" + "Tidak dapat memuat katalog bicara Gateway" + "Memantau · 1 tugas terjadwal" + "Setiap %1$s jam" + "Permukaan layar" + "Terjemahan OpenClaw · %1$s" + "Permintaan perintah" + "Sudah terbaru" + "Saluran" + "Suarakan" + "Grup baru…" + "Menyiapkan audio…" + "Adaptif" + "Segera" + "%1$s worker lagi" + "Web Search" + "Coba Chat, Suara, Utas, Penyedia, atau Pengaturan." + "OpenClaw Aktif" + "navigate" + "diminta %1$s" + "Hubungkan Gateway untuk memeriksa riwayat eksekusi otomatisasi." + "Akses perangkat; persetujuan Gateway tetap diperlukan" + "Dibatalkan" + "Masukkan kode penyiapan atau alamat gateway yang valid." + "Model" + "OpenClaw Pasif" + "Kata sandi Gateway tidak valid" + "Tidak dapat memverifikasi perubahan pemasangan perangkat. Muat ulang dan coba lagi." + "Lihat detail" + "Bash" + "Token" + "Agen OpenClaw yang terhubung dapat menggunakan kemampuan perangkat yang Anda aktifkan. Lanjutkan hanya jika Anda memercayai Gateway dan agen yang Anda hubungkan." + "Mencari teritip" + "Akses foto yang dipilih atau penuh telah diberikan." + "Eksekutor aksesibilitas" + "%1$s item belum tersedia" + "Ciutkan daftar periksa rencana" + "Persetujuan node diperlukan" + "Hubungkan Gateway" + "... +%1$s lagi" + "Luaskan daftar periksa rencana" + "Browser" + "screen record" + "Eksekusi Tertunda" + "Mengaktifkan memungkinkan OpenClaw mengamati dan mengontrol layar aplikasi lain saat aktif. Akses aksesibilitas Android diperlukan." + "Asal" + "AI pribadi di perangkat Anda" + "Attach" + "Otomatis" + "Ikhtisar" + "Gagal meminta pemulihan. Ketuk untuk mencoba lagi." + "Video" + "%1$s\n\n" + "Tidak terenkripsi" + "Kalender" + "Kondisi Gateway tidak baik; tidak dapat mengirim" + "📎 %1$s" + "Status Terakhir" + "Tunggu hingga respons saat ini selesai sebelum memulai obrolan baru." + "Profil" + "Batas penyedia akan muncul di sini saat gateway Anda melaporkannya." + "1 masalah" + "Utas di \"%1$s\" tetap dipertahankan dan dipindahkan kembali ke Tidak dikelompokkan." + "Direkomendasikan" + "Dibuat" + "%1$s/%2$s token aktif" + "Tidak ada hasil aksi" + "Menjepret" + "%1$s…" + "Buka detail skill" + "Gagal mengucapkan: %1$s" + "Mulai Percakapan" + "Tidak dapat memuat folder ini." + "Kode QR tidak berisi kode penyiapan yang valid." + "Tinjau akses node" + "Tambahkan frasa aktivasi" + "Tidak dapat menjangkau gateway" + "Otomatisasi" + "Memerlukan koneksi" + "Tidak dapat menyelesaikan persetujuan. Segarkan dan coba lagi." + "import" + "Bagaimana ponsel ini ditampilkan kepada OpenClaw." + "Fokuskan pencarian utas" + "Hubungkan Gateway" + "Baca Kalender" + "Ringkasan diperbarui saat tersambung kembali dan ketika layar ini dibuka." + "Tidak dapat menonaktifkan skill." + "Masih menghubungkan" + "Dalam %1$s menit" + "Baca SMS" + "Hubungkan gateway untuk memuat penggunaan." + "Apa yang bisa Anda bantu saya lakukan dari ponsel ini sekarang?" + "Perlu persetujuan" + "Chat baru" + "Hubungkan Gateway untuk memperbarui proposal Skill Workshop." + "Permintaan OpenClaw gagal." + "Izin diperlukan" + "Tinjau kesiapan penyedia\ndan model yang dikonfigurasi." + "Memuat" + "Peringatan Kegagalan" + "Tema dan teks Android yang diterjemahkan." + "Mikrofon mati · mengirim…" + "Tidak ada" + "Lihat" + "Nama" + "Versi" + "Cron" + "Hubungkan ponsel ini ke Gateway sebelum membuka OpenClaw." + "Hapus frasa aktivasi" + "Kode penyiapan tidak diterima. Buat kode baru dengan openclaw qr." + "14 pesan · Android" + "Transkripsi gagal: %1$s" + "Selalu" + "Tidak dapat memuat dreaming." + "Proses automasi masuk antrean." + "Conversation Turn" + "Automasi dimulai." + "Grup baru" + "Kesalahan server" + "Video Generation" + "Persetujuan Gateway tertunda. Jalankan openclaw devices list di host gateway, setujui ponsel ini, lalu coba lagi." + "Entri akan muncul setelah siklus dreaming menulis ringkasan naratif." + "%1$s md" + "Penyimpanan Memori" + "Asisten sedang bekerja" + "OpenClaw dapat menampilkan daftar aplikasi yang terlihat di launcher." + "Gagal berbicara: %1$s" + "Cari Skills yang terinstal" + "Periksa" + "Process" + "Utas Terbaru" + "Terminal" + "Saat ini" + "1 akun" + "Dijeda" + "Izinkan kamera" + "Permintaan persetujuan exec akan muncul di sini saat ponsel ini terhubung." + " · Mikrofon: Tertunda" + "Salin" + "Detail disalin" + "Hapus" + "Minta OpenClaw menggunakan kemampuan Android." + "member" + "Memeriksa apakah Gateway ini mendukung asisten pengaturan OpenClaw." + "Gunakan opsi pemulihan di bawah untuk terhubung kembali." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Tidak dapat memuat saluran." + "Dalam %1$s hari" + "Kesalahan Berturut-turut" + "Kode QR dalam gambar tersebut tidak dapat dibaca. Pilih gambar yang lebih jelas atau masukkan kode penyiapan secara manual." + "Gateway lebih lama daripada aplikasi ini. Perbarui OpenClaw di host Gateway, lalu coba lagi." + "Hubungkan sebelum menggunakan chat, suara, dan status langsung." + "Hubungkan ulang gateway" + "Pihak ketiga" + "Tinjau kesiapan" + "Terbatas" + "Logo OpenClaw" + "Lepas sematan model" + "Permukaan perpesanan yang terhubung ke gateway ini." + "Mengirim" + "Utas yang diarsipkan akan muncul di sini." + "Perintah disalin" + "Pratinjau tidak tersedia" + "Setujui ponsel ini di Gateway.\nLalu coba lagi koneksinya." + "Pindai kode QR" + "Direktori kerja perintah · tidak dapat dikosongkan" + "Aktivitas Utas" + "Tersedia" + "Hapus otomatisasi?" + "%1$s hari ini · %2$s total" + "Kata Sandi" + "Karantina usulan?" + "Tidak ada pemberitahuan lisensi yang disertakan dalam build ini." + "Tidak dapat menyimpan gambar widget" + "Menunggu %1$s" + "Berbicara…" + "Penyedia & Model" + "Node" + "%1$s " + "Perintah tidak tersedia" + "Log" + "Hubungkan Gateway untuk memeriksa proposal Skill Workshop." + "Alat" + "Sakelar Gateway" + "Kirim SMS" + "OpenClaw siap melanjutkan di obrolan biasa Anda." + "Tidak ada perintah yang ditemukan" + "Belum ada pembaruan kanvas. Ketuk untuk mencoba lagi." + "Terminal memerlukan Gateway yang terhubung" + "Exec" + "Filter Aplikasi" + "Utama" + "%1$sk" + "Gateway Diperlukan" + "Akses" + "Paket: snapshot=%1$s foreground=%2$s" + "Coba lagi koneksi" + "Penjadwal Cron dihentikan." + "Buka" + "Pesan disalin" + "Kami tidak dapat menjangkau Gateway Anda.\nMari kita perbaiki." + "sekarang" + "Hapus setelah dijalankan" + "Dipilih di ponsel ini" + "unpin" + "Session History" + "Lepas sematan" + "Gunakan ponsel ini" + "Tidak dapat memuat detail ClawHub untuk %1$s." + "Alat sedang berjalan" + "Bagikan lokasi akurat saat lokasi diaktifkan." + "Mobile UI" + "Tema" + "Gateway masih menampilkan persetujuan ini sebagai tertunda. Tinjau sebelum mencoba lagi." + "Selesaikan catatan suara" + "Dikte: %1$s" + "Tidak diizinkan" + "Pilih gambar lain" + "Pratinjau gambar" + "OpenClaw hanya mendengarkan saat Anda memulai Bicara atau Dikte." + "Bagikan langkah dan aktivitas" + "Perlu Penyiapan" + "Perbarui Gateway ini untuk menggunakan asisten pengaturan OpenClaw." + "Koneksi Gateway ini memerlukan operator.admin untuk menginstal Skills ClawHub." + "Proposal diterapkan." + "%1$s tertunda" + "%1$s jam lalu" + "Baca Log Panggilan" + "%1$s dalam antrean · menunggu Gateway" + "Pindahkan ke grup" + "Pindai QR untuk Memasangkan" + "Persetujuan ditolak." + "Tidak dapat memeriksa proposal Skill Workshop." + "Disematkan" + "Profil & perangkat" + "Tutup pemilih tingkat berpikir" + "Tidak dapat mengantrekan pesan untuk dikirim nanti." + "Karantina" + "Jadwal · %1$s" + "Tidak dapat memperbarui tingkat pemikiran." + "Buka pemilih tingkat berpikir" + "Waktu tunggu balasan suara habis; mencoba kembali giliran yang diantrekan" + "Tata letak: Terperinci" + "Gambar ini tidak dapat didekode." + "Gateway, suara, notifikasi, privasi" + "File ruang kerja agen" + "Perangkat ini akan kehilangan akses tepercaya ke Gateway." + "Gunakan requestId dari perintah tertunda dalam perintah approve." + "Jadwal" + "Batas Laju" + "Tidak terkirim" + "Muatan · %1$s" + "Berjalan" + "Mencapit" + "Akhiri" + "Gunakan kepercayaan sistem" + "Tidak ada penyedia yang siap" + "Memprioritaskan mikrofon Bluetooth yang terhubung." + "%1$s aplikasi diblokir agar tidak meneruskan." + "Tindakan pesan" + "Jenis" + "Batalkan arsip" + "Transcripts" + "Kata aktivasi" + "Konfigurasikan %1$s di Gateway" + "Pindai kode QR atau gunakan kode penyiapan dari OpenClaw Gateway Anda." + "Prototipe sistem desain" + "Menyaring" + " · Percakapan: Aktif" + "Belum ada data penggunaan." + "Obrolan gagal sebelum proses dimulai; coba lagi." + "Kirim" + "Beberapa gambar yang dibagikan dihilangkan atau tidak dapat ditambahkan." + "Tulis Kalender" + "timeout" + "Rendah" + "Daftar blokir" + "act" + "Dismiss Task" + "Chat gagal" + "OpenClaw · Langsung" + "Terinstal" + "Waktu tunggu balasan habis; coba lagi atau segarkan." + "Temukan percakapan sebelumnya" + "Jelajahi Utas" + "Menyegarkan" + "Mencari mutiara" + "Buka kamera dan posisikan kode dari openclaw qr dalam bingkai." + "Tidak ada perangkat" + "Teruskan Notifikasi" + "Saya akan memisahkan percakapan ini dari obrolan agen biasa." + "Sesi Gateway sedang kembali online. Pintasan agen akan kembali normal secara otomatis dalam beberapa saat." + "Coba pencarian lain atau hapus kueri saat ini." + "Izinkan lokasi latar belakang?" + "Muncul ke permukaan" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Batalkan catatan suara" + "Gulir mundur" + "openclaw gateway" + "Gateway telah dipasangkan" + "Berganti kulit" + "Mendengarkan giliran Anda berikutnya." + "OpenClaw sedang bekerja" + "Entri Log" + "Gagal: tidak dapat menjangkau endpoint gateway aman untuk host ini." + "Gateway sedang offline. Perbaiki koneksi di bawah atau salin diagnostik." + "Siaga" + "Tes tes 1 2 3" + "Tidak dapat mencari Skills ClawHub." + "Tanpa prompt" + "Kamera depan" + "Buka entri log" + "Waktu jaringan habis" + "Sekarang" + "Ganti nama grup…" + "Agen Lainnya" + "openclaw nodes approve REQUEST_ID" + "Sematkan" + "thread list" + "Buka %1$s" + "upload" + "Kata sandi Gateway belum dikonfigurasi" + "Pengaturan dikte" + "Model penyedia telah dimuat, tetapi status kesiapan tidak tersedia." + "Hapus utas?" + "OpenClaw mengubah ponsel ini menjadi antarmuka perintah seluler yang rapi untuk utas, suara, penyedia, dan Gateway." + "Terbaru dahulu" + "Siklus Berikutnya" + diff --git a/app/src/main/res/values-it/assistant.xml b/app/src/main/res/values-it/assistant.xml new file mode 100644 index 0000000..67e9b37 --- /dev/null +++ b/app/src/main/res/values-it/assistant.xml @@ -0,0 +1,7 @@ + + + "chiedi a OpenClaw %1$s" + "di\' a OpenClaw di %1$s" + "apri OpenClaw e chiedi %1$s" + + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..4dff804 --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Considerare attendibile questo gateway? + Considera attendibile e continua + Annulla + Nuova chat nel worktree + Verifica l’impronta digitale del certificato prima di considerare attendibile questo gateway.\n\n%1$s + Il certificato del gateway è cambiato. Continua solo se ti aspettavi questa modifica.\n\nSHA-256 precedente:\n%1$s\n\nNuovo SHA-256:\n%2$s + Sconosciuto + VERSIONE + COMMIT + COMPILATO + Versione %1$s + Commit Git %1$s + Compilato il %1$s UTC, timestamp %2$s + Data di compilazione %1$s + Copia l’hash completo del commit Git + Copia il timestamp completo della compilazione + Commit Git di OpenClaw + Timestamp di compilazione di OpenClaw + Commit Git copiato + Timestamp di compilazione copiato + + "Impossibile preparare un allegato per l\'invio." + "Microfono disattivato" + "Mostra gli avvisi di OpenClaw" + "Attività del thread" + "Completo" + "Approvazione consentita e salvata." + "Mostra la cronologia delle chiamate recenti" + "1 in sospeso" + "Allegato non supportato" + "0 = esatto" + "Connetti il Gateway per cercare le conversazioni." + "%1$s account" + "Le modifiche Cron richiedono operator.admin. I codici di configurazione non lo concedono intenzionalmente. Riconnettiti con il token condiviso o la password del Gateway per richiedere l\'accesso amministratore. Se questo dispositivo continua a non disporne, approva l\'aggiornamento dell\'ambito in sospeso da un client amministratore esistente." + "Apply Patch" + "Pizzicando" + "Riattiva altoparlante" + "Esecuzioni ignorate consecutive" + "Questa cartella non contiene ancora file." + "Non connesso" + "Esamina e gestisci lo stato delle skill installate." + "Non riuscito" + "Agente predefinito" + "Fotocamera" + "Rimuovi dal gruppo" + "Ricerca in corso" + "In pausa per la riproduzione vocale" + "Il Gateway verificherà questa specifica versione con ClawHub prima del download. Se la versione richiede un\'accettazione esplicita del rischio, Android mostrerà l\'avviso del Gateway prima di riprovare." + "Il codice di configurazione usa un ID di zona IPv6. Usa un indirizzo IPv6 senza ambito o un hostname LAN." + "Allegato" + "Configura le parole di attivazione, la conversazione e la riproduzione." + "In ascolto (PTT)" + "Proposta rifiutata." + "Mostra barra laterale" + "utente" + "%1$s · %2$s" + "Minimo" + "Nega" + "AGENTE ATTIVO" + "1 pianificata" + "Nessuna risposta" + "Selezionato %1$s" + "Array JSON argv del comando" + "Impossibile leggere l\'immagine. Scegli uno screenshot nitido o un\'immagine del QR generato da openclaw qr." + "Impossibile %1$s la proposta di Skill Workshop." + "Risposto altrove" + "Il Gateway ha registrato l\'approvazione una volta." + "status" + "OpenClaw controlla la posizione solo quando il Gateway associato la richiede. Nella schermata Android successiva, scegli %1$s per consentire i controlli mentre l\'app è in background." + "rifiuta" + "Contrasto" + "Sostituire la configurazione del gateway?" + "Impossibile caricare le automazioni." + "Tu" + "Microfono integrato" + "Superficie" + "Nessuna proposta" + "Thread principale" + "Apri chat" + "Le azioni di associazione del dispositivo non sono disponibili in questa sessione del Gateway. Esegui openclaw devices list sull\'host del Gateway e gestisci lì la richiesta. L\'approvazione delle funzionalità del nodo è separata e continua a utilizzare nodes approve <request id>." + "Richiesta di azione" + "list pins" + "Connettiti a un Gateway per caricare le proposte di Skill Workshop." + "Il codice di configurazione non è stato accettato" + "Esci" + "Il provider di trascrizione in tempo reale non è configurato." + "Mostra app di sistema" + "Aggiorna il Gateway per visualizzare la configurazione dei modelli del provider." + "Invio della dettatura" + "Esamina questa proposta per caricarne il markdown." + "apri OpenClaw e chiedi %1$s" + "ragionamento" + "Client" + "Applicato" + "video" + "Promosso" + "Connesso" + "Ambiti" + "Il provider vocale in tempo reale non è configurato." + "%1$s · %2$s" + "kick" + "Il Gateway ha restituito un\'automazione non valida." + "ID istanza" + "Il token del Gateway è obbligatorio. Inseriscilo di nuovo o modifica questa connessione." + "Origine" + "Aggiorna" + "%1$s in coda" + "Avvia chat" + "Le chiamate agli strumenti di Chat in attesa nel thread attivo rimangono visibili qui." + "Revisione del certificato necessaria" + "Apri la superficie Canvas corrente per ispezionarla o interagirvi." + "Automazione aggiornata." + "Nessuna sessione recente" + "Script" + "Stato del Gateway, prontezza del nodo del telefono e stream dei log recenti." + "Apri i dettagli dell\'automazione" + "Runtime" + "1 altro worker" + "Agente %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Abilita %1$s nelle Impostazioni di Android per continuare." + "Ripara" + "reactions" + "Fine" + "Versione e aggiornamento" + "OpenClaw mostrerà qui approvazioni, processi non riusciti e problemi dei canali." + "Microfono USB" + "Troppe condivisioni in attesa di essere aggiunte." + "Ignorato" + "Usa l\'indirizzo LAN del computer Gateway o il nome host remoto sicuro." + "Attivato" + "Ricerca delle conversazioni" + "Conversazione in tempo reale" + "· %1$s" + "OpenClaw sta preparando una risposta." + "Approvazione consentita una volta." + "Configurazione del provider" + "nessuno" + "I payload dello script vengono conservati invariati. Usa la CLI per modificare questo script." + "Guida alla configurazione Android" + "%1$s app bloccate dall\'inoltro." + "Dispositivi associati" + ":%1$s" + "%1$s in sospeso" + "Nome dispositivo" + "Invia" + "Posizione" + "es. America/New_York" + "Destinazione della sessione" + "Esamina la skill di ClawHub" + "snapshot" + "Rifiutare la richiesta di associazione da questo dispositivo?" + "Microfono preferito" + "Host del nodo" + "Livello" + "Chiudi selettore app" + "Incolla un token Gateway condiviso o un token emesso dall\'operatore." + "Tutti i sistemi funzionano correttamente" + "Diagnostica del gateway copiata" + "Errore audio" + "Sostituisci configurazione" + "Azioni rapide" + "Invio non riuscito: la chat ha riscontrato un errore prima dell\'avvio dell\'esecuzione; riprova." + "Microfono" + "La chat sta ancora verificando lo stato del Gateway." + "Posizione precisa" + "Consenti una volta" + "+%1$s altri" + "thread create" + "Bloccata" + "Parola o frase di attivazione" + "Il Gateway richiede l\'approvazione del dispositivo" + "Microfono esterno" + "%1$s/%2$s pronte" + "Connesso (operatore offline)" + "Funzionalità non approvata" + "Questa operazione rimuove definitivamente l\'automazione e la relativa pianificazione dal Gateway." + "Caricamento dell’immagine…" + "Connetti" + "Approva accesso nodo" + "Aggiungi Gateway" + "Trascrizione non disponibile: %1$s" + "Immagine" + "Ondeggiando" + "Chiudi anteprima immagine" + "eval" + "Ultimo comando: %1$s" + "Tieni aperto un terminale sul dispositivo che esegue OpenClaw." + "Nessun elemento mancante" + "L’output del canvas richiede una connessione al gateway attiva." + "%1$s · %2$s" + "Isolato" + "© 2026 OpenClaw Foundation — Licenza MIT." + "PDF" + "Conversations" + "Consolidamento della memoria e diario dei sogni." + "Create Goal" + "Questa automazione è stata modificata durante la modifica. Ripristina la versione più recente del Gateway prima di salvare." + "Quando è connesso, il Gateway può riattivare il telefono con una notifica push silenziosa anziché mantenere una sessione sempre attiva." + "Modalità di attivazione" + "Rimuovere il dispositivo associato?" + "Testo evento di sistema" + "Impossibile copiare l\'immagine del widget" + "No" + "Percorso opzionale" + "Invio della voce in coda" + "Integrata" + "hide" + "runs" + "La password del Gateway è obbligatoria. Inseriscila di nuovo o modifica questa connessione." + "Testo dell\'evento" + "Trascrizione live" + "Impossibile caricare la configurazione dei modelli del provider." + "%1$s app autorizzata all\'inoltro." + "Configurazione voce" + "Allega video" + "Immagini aggiuntive nascoste: %1$s" + "Rifiutare la richiesta di associazione?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Editore" + "Dirama da qui" + " · Conversazione: sta parlando" + "Override opzionale" + "Predefinito" + "Approvazione comando" + "L\'app e il Gateway utilizzano versioni del protocollo incompatibili. Aggiorna OpenClaw su entrambi, quindi riprova." + "Aggiorna schermata" + "Leggi foto e contenuti multimediali recenti" + "Ascolta" + "Connesso" + "Completata" + "Il tuo telefono è associato a %1$s. Continua per completare l\'accesso al nodo." + "Conversazione corrente" + "Elaborazione %1$s" + "Disattivato" + "OpenClaw apprezza i suoi partner nella community open-source." + "aperto" + "Connessione al Gateway" + "Il gateway può modificare questo percorso ma non può cancellare un percorso esistente." + "TTS" + "Salvataggio…" + "Ancoraggio %1$s" + "search" + "Attiva" + "Visualizza e gestisci le attività pianificate del Gateway." + "Una risposta precedente ha già consentito questo comando una volta." + "generate" + "Utilizza solo su una rete privata attendibile." + "Cerca nelle impostazioni" + "Talk è attivo" + "L\'autenticazione del Gateway non è configurata. Modifica questa connessione e riprova." + "Operazione non riuscita: endpoint sicuro raggiunto, ma la verifica dell\'impronta digitale TLS è scaduta. Controlla Tailscale Serve o il TLS del gateway e riprova." + "Passaggio 1" + "Dettatura" + "Apri selettore app" + "Nessuna approvazione in sospeso" + "edit" + "Connettiti al tuo Gateway" + "Inserisci il codice di configurazione da openclaw qr." + "Diagnostica" + "Le altre app restano intatte." + "Schioccando" + "Questa operazione elimina definitivamente il thread e la relativa trascrizione." + "Dispositivo approvato." + "Nuovo tentativo automatico" + "Immagine del widget copiata" + "%1$s ruoli" + "1 elemento mancante" + "%1$s pianificate" + "react" + "Agenti" + "Connetti il Gateway per caricare le automazioni." + "Riconnessione…" + "Torna alla configurazione" + "send" + "Impossibile testare la connessione" + "Verifica e installa" + "Trasforma questo dispositivo in un nodo OpenClaw sicuro per chat, voce, fotocamera e strumenti del dispositivo." + "Configurazione manuale" + "Apri Chat per avviare o riprendere il thread corrente." + "Suggerimento: interrompi l\'ascolto per inviare il turno acquisito." + "Salta" + "Richiesta vocale non riuscita" + "Verrà rifiutata \"%1$s\" e lo stato di Skill Workshop verrà aggiornato dal gateway." + "Sgusciando" + "update" + "Condividi" + "Fotocamera abilitata" + "Telegram, WhatsApp, email e altri canali vengono visualizzati qui dopo la configurazione." + "Errore di rete" + "Esplorazione delle pozze di marea" + "Ripristina ora Canvas per session=%1$s source=%2$s. Se esiste già uno stato A2UI, riproducilo immediatamente. In caso contrario, crea e visualizza in Canvas una dashboard compatta e ottimizzata per dispositivi mobili." + "Avvio non riuscito: %1$s" + "Non richiesto" + "Configura un provider %1$s sul Gateway" + "kill" + "Approvazioni" + "File non disponibili" + "Segna come non letto" + "Trova persone e dettagli di contatto" + "Identità del dispositivo richiesta" + "Conversazione OpenClaw" + "Consenti l\'accesso alla libreria foto." + "Una risposta precedente ha già risolto questa approvazione." + "Nessun thread recente" + "Timeout %1$ss" + "Nessuna corrispondenza" + "Leggi le notifiche delle app selezionate" + "Disponibilità sconosciuta" + "Configura Talk" + "Extra" + "Gateway associato. In attesa dell\'accesso dell\'operatore." + "Allega immagine" + "Scegli cosa arriva a OpenClaw." + "Nuova approvazione della funzionalità in sospeso" + "Controlla gli elementi evidenziati" + "In ascolto..." + "Aggiornami" + "Messaggio" + "Leggi contatti" + "Lo spazio di archiviazione offline degli allegati è pieno; elimina prima gli elementi in coda." + "Una volta" + "Rinomina" + "Nessun canale trovato." + "Visualizza tutto" + "Nuovo dispositivo" + "Session Status" + "Apri anteprima immagine" + "Il ramo della sessione è cambiato; controlla e riprova questo messaggio." + "close" + "Sembra un codice di configurazione. Torna indietro e scegli Configura Gateway, quindi Usa codice di configurazione." + "✦" + "Agenti e automazione" + "Applica" + "Esecuzione dell\'automazione ignorata." + "Continua" + "Monitoraggio · %1$s attività pianificate" + "Sfoglia" + "tabs" + "In sospeso" + "Conversazione: %1$s" + "read" + "Seleziona testo" + "Attività di movimento" + "descrizione: %1$s" + "Riproduci l\'audio" + "Ora" + "Non verificato" + "Yield" + "Copia comando di approvazione" + "Output della schermata corrente e superficie interattiva dell\'app." + "Servizio connesso" + "Visualizzazione" + "Pronto quando vuoi" + "Impossibile caricare il catalogo dei provider." + "Parla · in attesa di risposta" + "Non concessa" + "Salva modifiche" + "Il Gateway ha rifiutato l\'esecuzione dell\'automazione." + "Session Send" + "Trova su ClawHub" + "Consente sempre i controlli della posizione richiesti mentre OpenClaw è in background; Android lo mostra nella notifica persistente del nodo." + "Evento di sistema" + "Connetti Gateway per visualizzare i provider" + "Prossimo heartbeat" + "Gateway associato. In attesa dell\'approvazione delle funzionalità del nodo." + "Messa in salamoia" + "Chiudi Canvas" + "Scrivi contatti" + "Nessuna skill installata corrisponde a questa ricerca." + "Configurazione del provider Talk" + "Music Generation" + "Impostazioni conversazione" + "Monitoraggio · 1 thread" + "Testo del payload" + "Imposta testo" + "Approvazione %1$s" + "Gateway non ha restituito lo stato di prontezza di %1$s" + "%1$s modelli configurati. Aggiorna per verificare nuovamente la disponibilità." + "Conversation Send" + "Canvas" + "1 provider" + "Non è stato possibile leggere automaticamente il certificato del Gateway. Incolla l\'impronta digitale SHA-256 ottenuta sull\'host del Gateway." + "Invio non riuscito: %1$s" + "Bridge" + "Errore di consegna" + "Usa OpenClaw dal tuo telefono" + "Aspetto" + "Laboratorio Skill" + "Token necessario" + "Anteprima · %1$s" + "Autorizzazione per il microfono necessaria" + "Connetti il Gateway per caricare le proposte di Skill Workshop." + "Tutti i sistemi operativi" + "Gateway non raggiungibile" + "OC" + "Aggiornato" + "Connesso (nodo offline)" + "Home" + "La dettatura è in ascolto" + "Nessun thread archiviato" + "Scegli e ispeziona gli assistenti disponibili su questo Gateway." + "Modalità conversazione attiva" + "In esecuzione · 1 esecuzione attiva" + "Accetta e abilita" + "Aggiornamento del Gateway richiesto" + "Copia immagine" + "URL del Gateway" + "main, isolated, current o session:<id>" + "Contenuto multimediale non disponibile" + "Connettiti al tuo Gateway per aprire una shell nell\'area di lavoro dell\'agente." + "%1$s://%2$s:%3$s" + "Impossibile caricare i dettagli dell\'approvazione. Aggiorna e riprova." + "Posso controllare lo stato del Gateway, riparare la configurazione, cambiare modelli o connettere canali." + "Tool Call" + "Thread" + "Write" + "Inizia con un prompt oppure usa la voce." + "D" + "Apri Impostazioni" + "Osservazione in corso…" + "Termina conversazione" + "Ultimo errore" + "Rivedi le azioni che richiedono la tua attenzione." + "Disabilitata per tutti gli agenti." + "Avvia voce" + "Torna alle attività in background" + "Un\'altra azione cron è ancora in fase di completamento." + "Attesa %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Il riconoscimento vocale sul dispositivo non è disponibile." + "Script · sola lettura" + "Gli allegati sono troppo grandi per essere messi in coda in un solo messaggio; rimuovine alcuni e riprova." + "1 modello configurato. Aggiorna per verificare nuovamente la disponibilità." + "Widget non disponibile" + "Per questo host è necessaria una connessione sicura." + "Recenti" + "Nessuna automazione corrispondente." + "Il telefono può raggiungere il Gateway" + "Gateway" + "Scaduto" + "Lavoro OpenClaw pianificato dal tuo Gateway." + "Sub-agent" + "In attesa dell\'approvazione del dispositivo" + "Caricamento del thread" + "Questo Gateway ora presenta un certificato considerato attendibile da questo dispositivo." + "Sfalsamento ms" + "event create" + "documento" + "Configura Gateway" + "Riproduci il video" + "L\'autenticazione salvata non è valida. Esegui di nuovo l\'autenticazione o reimposta questa connessione al gateway." + "Durante l\'uso" + "screenshot" + "Riavvolgi fino a qui" + "Espressione cron, es. 0 9 * * *" + "Torna alla voce" + "Parla" + "Dettagli" + "%1$s/%2$s online" + "%1$s app autorizzate all\'inoltro." + "Chat" + "È necessario l\'accesso al microfono." + "Zampettando" + "Modifica" + "Ore di silenzio" + "Copia diagnostica" + "Pianificato" + "Crea" + "Scade tra %1$s" + "Ignora" + "Rifiutare la proposta?" + "Errore di riconoscimento vocale (%1$s)" + "Problema" + "Cerca nei metadati del registro. Il Gateway verifica nuovamente l\'attendibilità prima di qualsiasi download." + "Usa un IP LAN privato per la configurazione locale oppure abilita Tailscale Serve / esponi un URL del gateway wss:// per l\'accesso remoto." + "Invio in corso…" + "Account %1$s" + "Suggest Task" + "Cerca" + "In ascolto" + "Automazione non caricata." + "È disponibile un aggiornamento del Gateway. Quando vuoi, avvia l\'aggiornamento dalla Web UI o dalla CLI." + "a breve" + "Nessuna approvazione Gateway." + "Host" + "Aggiungi una parola o una frase di attivazione per campo. Quindi pronunciane una prima del comando." + "Trascrivi e poi invia" + "Esegui alle" + "Metti in pausa l\'audio" + "Accesso al dispositivo Gateway" + "Nessuna anteprima" + "Dispositivi" + "OpenClaw per Android." + "Approvazione della funzionalità in sospeso" + "Salva o annulla le modifiche prima di eseguire, attivare, disattivare, eliminare o aggiornare questa automazione." + "Non ci sono ancora automazioni." + "Questa skill richiede %1$s elementi di configurazione. Android mostra ciò che è installato; le modifiche alla configurazione devono essere effettuate da desktop o tramite CLI." + "%1$s recenti" + "Canali" + "Non in fase" + "Attivo su questo telefono" + "Verifica dell\'accesso al nodo" + "Fuso orario" + "Azioni di ispezione e applicazione di Skill Workshop" + "Consenti sempre" + "present" + "Le Skills installate sul gateway appariranno qui." + "Il codice potrebbe essere scaduto o essere stato generato per un altro Gateway." + "Autorizzazione necessaria" + "L\'automazione ha una configurazione non valida." + "Elenco consentiti" + "Configurazione, stato e riparazione" + "groups" + "Chiave pubblica" + "Informazioni" + "Nell\'immagine non è stato trovato alcun codice QR di configurazione. Scegli il QR generato da openclaw qr oppure inserisci manualmente il codice di configurazione." + "permissions" + "Connetti il Gateway per caricare i nodi e i dispositivi associati." + "Cambia ramo" + "Nessuna skill" + "Le risposte vengono riprodotte ad alta voce" + "Segna come letto" + "Approvazione del nodo in sospeso" + "wake" + "%1$s proposte" + "L\'autenticazione del Gateway richiede attenzione." + "Dettagli connessione" + "Millisecondi" + "Riconoscimento vocale" + "Descrizione" + "Conversazioni recenti" + "Il tuo telefono invia queste informazioni al tuo Gateway, non a un server gestito da OpenClaw. Il tuo Gateway può includerle nelle richieste al provider AI che hai scelto." + "Consegna" + "Disattiva altoparlante" + "%1$s In esecuzione · %2$s Completati · %3$s Falliti" + "Apertura della connessione al Gateway" + "Monitoraggio · %1$s thread" + "Esecuzione dell\'automazione completata." + "Nessuna app corrispondente." + "Invia alla chat" + "Automazione eliminata." + "Abilita" + "Esecuzioni recenti" + "Allinea il codice QR all\'interno del quadrato." + "Impossibile caricare le approvazioni." + "Ho approvato" + "Connetti il tuo Gateway per caricare la prontezza dei provider." + "Non abbinato" + "Questa approvazione è scaduta prima di poter essere risolta." + "Osservazione tra %1$ss — passa all\'app di destinazione" + "Prompt dell\'agente" + "emoji list" + "Ricorrente" + "Cerca in OpenClaw" + "%1$s in sospeso" + "Riconoscimento vocale sul dispositivo non disponibile" + "Nessuna app può condividere questo messaggio" + "Chiudi ricerca" + "Comando da monitorare" + "Stato" + "Accesso alle notifiche" + "Altoparlante disattivato" + "Cerca conversazioni" + "OK" + "Impossibile aprire la guida alla configurazione." + "chiedi a OpenClaw %1$s" + "Wait for Agents" + "Indirizzo" + "Il lavoro pianificato creato sul gateway apparirà qui." + "Visualizzazione dell\'ultimo blocco di log." + "Usa codice di configurazione" + "sticker" + "Usa un Gateway sicuro wss:// o Tailscale Serve, genera un codice di configurazione con accesso completo nella Control UI o con openclaw qr, quindi scansionalo o incollalo qui sotto e riconnettiti per abilitare impostazioni e aggiornamenti." + "steer" + "Selezionato" + "Android può scansionare o incollare un codice di configurazione esistente, ma questo gateway non espone ancora all\'app la generazione dei codici di configurazione. Genera il QR/codice sull\'host del gateway con openclaw qr, quindi scansionalo qui o incolla il codice di configurazione qui sotto." + "Stato del canvas" + "Correggi connessione" + "Salva immagine" + "Nodo %1$s" + "Password del Gateway necessaria" + "Update Plan" + "Rimuovi allegato" + "Esecuzione dell\'automazione non riuscita." + "Limiti del provider e stato della quota." + "Catalogo conversazioni Gateway non caricato" + "questo Gateway" + "Ancora nessuna esecuzione recente." + "Modello linguistico sul dispositivo non disponibile" + "La Dashboard richiede un Gateway connesso" + "Le proposte corrispondenti appariranno qui dopo che gli agenti avranno creato bozze di skill riutilizzabili." + "Session Search" + "OpenClaw sta parlando" + "Scansiona QR" + "App selezionate" + "Annulla modifiche" + "Comando di approvazione copiato" + "Stato della consegna" + "Codice QR non accettato" + "Il tuo centro di comando vocale." + "Testa connessione" + "OPENCLAW" + "Web Fetch" + "Prompt" + "Approvare il dispositivo?" + "Connettiti al tuo Gateway per aprire la dashboard di questa sessione." + "Rimuovere %1$s e le relative credenziali salvate da questo telefono?" + "Il codice QR punta a un gateway remoto non sicuro. %1$s %2$s" + "Superficie dello schermo pronta" + "Associa Gateway" + "Connetti il Gateway per caricare i canali." + "Si mette in pausa durante altre attività vocali." + "Modello" + "Foto" + "Incolla codice di configurazione" + "OpenClaw sta parlando" + "Connessione in corso..." + " · Posizione: Sempre" + "Messaggi: %1$s" + "Esplorando la barriera" + "Carica dal Gateway" + "testo: %1$s" + "Richiede" + "rename group" + "Pronto" + "Il diario è in attesa della sua prima voce." + "Approva" + "Pagina live" + "L\'automazione è già in esecuzione." + "Rimuovi questa automazione dopo un\'esecuzione singola completata correttamente." + "Pronto per chat e voce" + "Connesso (operatore: %1$s)" + "L\'associazione al Gateway è completa. Approva questo telefono come nodo in modo che OpenClaw possa usare le funzionalità del dispositivo che abiliti." + "Risposta interrotta" + "immagine" + "%1$s in attesa" + "Nessuna conversazione corrispondente" + "delete" + "Layout: compatto" + "channels" + "Concesso" + "Ogni %1$s min" + "1 token" + "%1$s %2$s" + "App installate" + "in sospeso" + "Preparazione nota vocale…" + "Mai" + "Sottosistema" + "All\'uscita dal comando" + "Connessione" + "Impossibile caricare la cronologia delle esecuzioni delle automazioni." + "Nome dell\'automazione" + "Passaggio 2" + "Diagnostica" + "Alcuni controlli dello stato dei canali non sono stati completati." + "pin" + "Copia %1$s" + "Associato" + "Impossibile salvare le parole di attivazione" + "Verrà messa in quarantena \"%1$s\" e lo stato di Skill Workshop verrà aggiornato dal gateway." + "Registra nota vocale" + "In coda" + "Risposto" + "Consenti gli strumenti della fotocamera quando richiesto." + "Problemi" + "Attivazione vocale" + "Richiesta di associazione rifiutata." + "%1$s g fa" + "roles" + "Skills" + "Archivia" + "Nodo offline. Riconnettiti e riprova." + "Sistema" + "IP remoto" + "Non raggruppati" + "Dettagli pianificazione" + "Funzionalità del telefono" + "Non disponibile" + "Dashboard" + "Incolla token" + "Nessun provider" + "Impronta digitale SHA-256" + "Nessun thread" + "Microfono Bluetooth" + "Recenti" + "Rinomina conversazione" + "Esito della risoluzione sconosciuto. Le azioni restano disabilitate finché il record del Gateway non viene verificato." + "dialog" + "Ascolta le parole di attivazione" + "camera snap" + "Preparazione della riproduzione…" + "Gateway ha selezionato il provider sconosciuto %1$s" + "delete group" + "Segui Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Connetti il Gateway per caricare gli agenti." + "Torna indietro" + "Condividi messaggio" + "Genera un codice QR." + "Riavvia" + "Altoparlante attivo" + "Eliminare il gruppo?" + "Mancante" + "Cerca proposte" + "stop" + "Sicuro (TLS)" + "Nessun nodo o dispositivo associato." + "%1$s%% rimanente %2$s" + "Codice di configurazione scaduto" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DIARIO" + "notify" + "Questo telefono rimane inattivo finché il Gateway non ne ha bisogno, quindi si riattiva, si sincronizza e torna in sospensione." + "%1$s modelli configurati" + "Licenze" + "Connetti il Gateway per cercare le Skills di ClawHub." + "Skill" + "La connessione al Gateway è cambiata. Riavvia OpenClaw per riconnetterti." + "ID dispositivo" + "Gateway non ha identificato il provider %1$s attivo" + "In attesa" + "Parole di attivazione salvate" + "Prima i più vecchi" + "Schermo" + "In esecuzione da" + "Gli ID di zona IPv6 non sono supportati. Usa un indirizzo IPv6 senza ambito o un hostname LAN." + "Inviato — conferma della consegna in corso…" + "audio" + "This gateway connection needs operator.admin to update skills." + "Codice di configurazione" + "Accetta l\'avviso del Gateway e installa" + "Aggiorna chat" + "Intervallo" + "Le azioni sulle proposte di Skill Workshop richiedono l\'ambito operator.admin." + "Sessioni" + "Rinomina…" + "Connetti il gateway per caricare il dreaming." + "Configurazione" + "Apri Talk" + "poll" + "Connettiti per caricare i tuoi agenti" + "role remove" + " · Conversazione: in ascolto" + "ClawHub non ha restituito una versione installabile per %1$s." + "Comando" + "Questa approvazione è stata annullata prima di poter essere risolta." + "Microfono attivo · in attesa del gateway" + "Testo" + "Visualizzazione di %1$s su %2$s. Affina la ricerca per altri risultati." + "v%1$s disponibile" + "%1$s://%2$s" + "%1$s... (OK)" + "Provider e modelli configurati" + "Connessione…" + "Connettiti a un Gateway per salvare le parole di attivazione" + "Apri profilo" + "Avvia il tuo Gateway." + "Aiutami a trasformare questo obiettivo in una checklist pratica: " + "Cancella ricerca sessioni" + "Porta" + "Inserisci codice di configurazione" + "Impossibile caricare i log del Gateway." + "%1$s provider pronti" + "I tuoi agenti sono pronti" + "Nessun provider %1$s è configurato sul Gateway" + "In ascolto per un turno" + "Osserva" + "Millisecondi epoch (facoltativo)" + "Nessun modello configurato. Aggiorna per verificare nuovamente la disponibilità." + "Impostazioni" + "Fotocamera posteriore" + "approve" + "Prima di iniziare" + "Impossibile caricare le Skills." + "Disabilitata" + "Ancora in attesa di approvazione" + "Impossibile caricare le attività in background" + "Verifica che OpenClaw possa parlare chiaramente su questo telefono." + "In esecuzione · %1$s esecuzioni attive" + "Directory di lavoro del comando" + "Nome del gruppo" + "Scegli dalla galleria" + "Version %1$s" + "Indietro" + "Connect the gateway to update skills." + "Elimina dopo l\'esecuzione" + "Il codice di configurazione punta a un gateway remoto non sicuro. %1$s %2$s" + "Computer" + "Gateway disconnesso." + "Session Settings" + "Connetti il gateway per iniziare" + "Avviso di sicurezza" + "Altra risposta" + "Ignora avviso immagine condivisa" + "Il Gateway ha valutato una release diversa di ClawHub. Esamina nuovamente la skill prima di installarla." + "Apri accesso di sistema" + "Completate" + "Immagine non disponibile" + "Notifiche" + "Le azioni di applicazione, rifiuto e quarantena richiedono lo scope operator.admin. Riconnettiti con l\'autenticazione gateway condivisa o approva un aggiornamento dello scope del dispositivo operator.admin per abilitare le azioni del ciclo di vita." + "sticker upload" + "Pesca di aragoste" + "Messages to recover" + "openclaw devices approve %1$s" + "Dettaglio leggibile del log del gateway." + "Rivedi le proposte di skill generate prima che diventino skill attive." + "Inclusa" + "%1$s disponibili" + "Approvazione del nodo in sospeso" + "Gateway in attesa" + "Autenticazione necessaria" + "Nodi" + "Mantieni attivo" + "OpenClaw sta rispondendo" + "Documentazione" + "%1$s pronti" + "Nessun output per ora" + "Lingua del dispositivo non supportata" + "In coda — verrà inviato alla riconnessione" + "%1$s min fa" + "Ramo corrente" + "Verifica dell\'accesso all\'associazione" + "Accesso Gateway limitato" + "Esecuzione degli strumenti..." + "Verifica dell\'approvazione…" + "Scatta foto e registra clip con questo telefono" + "Connesso e pronto" + "Chiudi" + "Trasforma un obiettivo in una checklist operativa." + "Il codice di configurazione contiene un URL del gateway non valido." + "Abilita solo l\'accesso che ti senti a tuo agio a lasciare usare a OpenClaw mentre questo telefono è connesso. Puoi modificarli in seguito nelle Impostazioni Android." + "Account" + "remove" + "Password facoltativa" + "L\'autenticazione del Gateway richiede una verifica. Controlla le impostazioni del gateway, quindi riprova." + "Il codice QR usa un ID di zona IPv6. Usa un indirizzo IPv6 senza ambito o un hostname LAN." + "add" + "Krillando" + "Integro" + "Completato in %1$s" + "Argomenti" + "Opzioni di installazione" + "Tra %1$sh" + "L\'approvazione del Gateway è in sospeso. Esegui questo comando sull\'host del Gateway:" + "Accesso amministratore richiesto" + "set groups" + "Fissa modello" + "Cancella ricerca" + "Abilitata per gli agenti idonei." + "Nessun thread corrente" + "limiti: %1$s" + "Dopo %1$s" + "Consenti allo strumento di pianificazione di eseguire questa automazione." + "%1$s applicati" + "Nessun diario dei sogni al momento." + "Aggiorna le attività in background" + "Riassumi i thread recenti e i prossimi passaggi." + "Viene eseguito sul dispositivo mentre OpenClaw è visibile." + "%1$s è al lavoro" + "%1$s %2$s" + "Non elaborato" + "Esecuzioni" + "Esegui ora" + "Ramo senza titolo" + "Configurato" + "camera list" + "1 applicato" + "camera clip" + "Sì" + "Test audio" + "In attesa" + "events" + "Directory di lavoro" + "Vai all\'ultimo" + "Consenti sempre" + "Scansiona QR o codice di configurazione" + "Installing" + "Nodi live, telefoni associati e richieste di dispositivi in sospeso." + "Istantanea: %1$s" + "Una risposta precedente ha già consentito questo comando e salvato la scelta." + "Richieste in sospeso" + "Approvato" + "Area di lavoro" + "Voce" + "Pronto per parlare" + "Subagents" + "Operazione non riuscita: nessun endpoint gateway sicuro rilevato. Abilita il TLS del gateway o Tailscale Serve, oppure usa un indirizzo LAN privato attendibile con Non crittografato selezionato." + "Segnali" + "Destinazione sessione" + "Il Gateway ha registrato un rifiuto." + "Accetta" + "Chiedi qualsiasi cosa a OpenClaw" + "Riconnettiti per continuare" + "%1$s associati" + "Verrà applicata \"%1$s\" e lo stato di Skill Workshop verrà aggiornato dal gateway." + "Gateway offline" + "openclaw devices list" + "Stato della connessione del nodo OpenClaw" + "Gli avvisi restano su questo telefono." + "OpenClaw può ricevere gli avvisi selezionati." + "Apri schermo" + "Azioni chat" + "Consentire il controllo di altre app?" + "Ispezione in corso" + "Scansiona o incolla un codice di configurazione per aggiungere un altro gateway." + "Swarm" + "TLS scaduto" + "Sessioni recenti" + "Dispositivo associato rimosso." + "Gateway associato. Verifica dell\'approvazione delle funzionalità del nodo." + "Movimento" + "Azione cron non riuscita." + "Sul computer Gateway, esegui:" + "Cerca sessioni" + "Aggiorna log" + "Immagine non disponibile · Tocca per riprovare" + "openclaw nodes approve %1$s" + "Nota vocale · %1$s" + "Utilizzo" + "Nautilando" + "Contesto %1$s%%" + "Trascrivi i comandi vocali" + "Disattiva audio" + "Avvia una nuova conversazione e verrà visualizzata qui." + "Problema di connessione" + "Media" + "Crea fork" + "Attiva altoparlante" + "Testo dell\'evento di sistema" + "Ordine: %1$s" + "%1$s in attesa" + "Image Generation" + "Nota vocale" + "Niente richiede la tua attenzione" + "OpenClaw ha bisogno delle autorizzazioni %1$s per continuare." + "Microfono delle cuffie cablate" + "Pagine" + "Consegnato" + "Scadenza" + "I dettagli della Skill non sono disponibili nello stato attuale delle Skills." + "Scegli cosa può condividere questo telefono." + "Questa automazione ha già un\'esecuzione in coda." + "Connetti il Gateway per gestire le automazioni." + "L\'automazione non è ancora prevista." + "Nessun dettaglio" + "Approvazione in corso.\nOpenClaw si riconnetterà automaticamente." + "Connetti il tuo Gateway per visualizzare lo stato di preparazione dei provider." + "In attesa di associazione" + "Avvia o continua una conversazione" + "Nessuna attività pianificata" + "Rispondi a OpenClaw…" + "Stato" + "Nodo OpenClaw · Connesso" + "Attive" + "Mostra lo stato di debug della condivisione dello schermo." + "Nessun limite segnalato" + "Chiudi scanner" + "Ogni %1$s g" + "Abilitato" + "Abilita e apri le impostazioni" + "Online e pronto" + "Ask User" + "Errore chat" + "Scorri in avanti" + "%1$s di %2$s" + "Pianifica il lavoro" + "console" + "Riprova" + "Avvia una chat e le tue conversazioni OpenClaw attive appariranno qui." + "Impossibile caricare l\'automazione." + "Shell nell\'area di lavoro dell\'agente" + "%1$s attive" + "Scegli le autorizzazioni del dispositivo" + "Durata ultima esecuzione" + "Agente predefinito" + "%1$s h" + "La conversazione è attiva" + "Impossibile installare %1$s da ClawHub." + "Benvenuto in OpenClaw" + "Controlla altre app" + "Indice segnali" + "Inserisci il segreto…" + "%1$s:%2$s" + "di\' a OpenClaw di %1$s" + "Rilevato" + "Nascondi barra laterale" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "La riproduzione audio non è disponibile" + "applica" + "Impossibile caricare i dati di utilizzo." + "Mantieni il nodo disponibile durante le attività in corso." + "Prossima attivazione" + "%1$s/%2$s" + "Nessuna voce di log recente." + "Gateway manuale" + "Rinomina gruppo" + "Update Goal" + "Disponibilità del provider sconosciuta" + "Provider" + "Elimina gruppo…" + "Contenuto" + "Registro chiamate" + "Memory Search" + "%1$s provider" + "Contesto del telefono e privacy" + "%1$s/%2$s connessi" + "%1$s %2$s" + "Il ripristino dopo il riavvio del Gateway è ancora in corso." + "La sostituzione del codice di configurazione cancella le credenziali di configurazione e i token del dispositivo salvati su questo telefono prima della riconnessione. Potrebbe essere necessario approvare nuovamente le funzionalità del nodo per questo telefono; continua solo se intendi associarlo usando un nuovo codice di configurazione del Gateway." + "Apri un\'automazione per esaminarne la configurazione e la cronologia delle esecuzioni. Le connessioni con ambito amministratore possono anche eseguirla, modificarla, attivarla, disattivarla o eliminarla." + "Contesto --" + "Proposta messa in quarantena." + "Automazione sospesa." + "OpenClaw mobile" + "A2UI reset" + "Gateway non disponibile" + "Read" + "Questa skill richiede 1 elemento di configurazione. Android mostra ciò che è installato; le modifiche alla configurazione devono essere effettuate da desktop o tramite CLI." + "Ultima esecuzione" + "È necessario l\'accesso alla fotocamera per scansionare il QR di configurazione." + "Impossibile aggiornare il modello." + "Gorgogliando" + "thread reply" + "Elimina…" + "Connetti il Gateway per esaminare le automazioni." + "LOG RECENTI" + "Caricamento esecuzioni recenti…" + "Non è possibile visualizzare l\'anteprima di questo file. Potrebbe essere binario o troppo grande." + "Controlla" + "Leggi la posizione di questo telefono" + "Chiave della skill" + "%1$s installata." + "Gateway" + "azioni: %1$s" + "Modalità di inoltro" + "%1$sk" + "Cambia layout delle conversazioni" + "Nessun endpoint TLS" + "Gateway OpenClaw" + "Configura manualmente" + "Elaborazione…" + "L\'accesso al Gateway richiede una revisione" + "1 in attesa" + "%1$ss" + "Applicare la proposta?" + "Non ora" + "Non approvato" + "Cerca app" + "1 modello configurato" + "Ignora avviso di approvazione" + "·" + "Non in linea" + "Provider vocale" + "L\'approvazione del Gateway è in corso. OpenClaw riproverà automaticamente." + "Massimo" + "Le modifiche ai processi cron richiedono l\'accesso operator.admin." + "Elaborazione" + "screen snapshot" + "Nodi osservati: %1$s" + "Nessuna azione trovata" + "Salva e connetti" + "list" + "Il Gateway ha registrato l\'approvazione e salvato la scelta." + "Inserisci un endpoint manuale valido per connetterti." + "assistente" + "Invio alla chat..." + "Salva profilo" + "Bloccato" + "Modifica automazione" + "Usa la stessa rete o un URL Gateway remoto sicuro." + "Ancora" + "Lingua" + "Questa app è meno recente del Gateway. Aggiorna OpenClaw su questo dispositivo, quindi riprova." + "Tutti" + "Sessione del Gateway in corso" + "In attesa di revisione" + "Nessuna Skills installata." + "Verifica del Gateway" + "Sfalsamento %1$s" + "Il risultato per %1$s è sconosciuto. Riconnettiti, aggiorna Skills, quindi riprova; il Gateway si unisce in modo sicuro a un\'installazione corrispondente ancora in esecuzione." + "Dimentica" + "Nessun gateway associato." + "%1$s · %2$s" + "<segreto oscurato>" + "%1$s problemi" + "OpenClaw" + "In ascolto · %1$s in coda" + "Voce dell\'assistente disattivata" + "Le azioni sui nodi vengono eseguite solo quando l\'app di destinazione è in primo piano (convalidato tramite il percorso remoto). Le azioni globali e quelle nella stessa app funzionano qui." + "Nessun Gateway ancora trovato. Usa la configurazione manuale se il rilevamento è bloccato." + "Apri conversazione" + "Elaborazione in corso" + "Inizia a parlare..." + "Nodo telefono" + "Altissimo" + "Esegui sull\'host del Gateway:" + "Le modifiche alle skill richiedono operator.admin. Riconnettiti con un token del Gateway dotato di privilegi di amministratore." + "Connetti il Gateway per esaminare le Skills di ClawHub." + "L\'elenco delle app resta su questo telefono." + "Inattivo" + "Mostrato nelle impostazioni di accessibilità di Android." + "Consegna intelligente" + "Rifiuta" + "Il Gateway ha restituito lo stato \'%1$s\' dopo %2$s." + "Token del Gateway non configurato" + "Not available to this agent" + "File" + "Autorizzazioni" + "Impossibile avviare la fotocamera. Scegli un\'immagine QR dalla galleria oppure inserisci manualmente il codice di configurazione." + "Tocca per copiare" + "In attesa %1$sm" + "%1$s." + "Connetti il Gateway per installare le Skills di ClawHub." + "Cerca voce" + " · Microfono: in ascolto" + "Riconnettiti con accesso operator.admin per esaminare e modificare le impostazioni del Gateway." + "Carica altro" + "Osserva tra 3s" + "run" + "Generazione della voce…" + "← Indietro" + "Disconnetti" + "Esegui il comando approve sul computer Gateway, quindi controlla di nuovo." + "Automazioni" + "%1$s min" + "Autorizza" + "Quel codice QR non è un QR di configurazione di OpenClaw. Genera un nuovo codice con openclaw qr, quindi riprova." + "Il microfono preferito non è disponibile; viene usato l\'instradamento automatico." + "Rifiutato" + "Includi Android e i pacchetti in background." + "Il tuo Gateway è pronto." + "Attivato" + "Structured Output" + "L\'operazione sta richiedendo più tempo del previsto.\nVerifica che il Gateway sia in esecuzione e raggiungibile." + "Nessuna attività in background per questo agente." + "Riconnessione" + "OpenClaw sta verificando l\'accesso al Gateway e al nodo." + "Code Execution" + "Nessun utilizzo del provider" + "Rivedi" + "È richiesta l\'autorizzazione per il microfono." + "%1$s g" + "%1$s disponibili" + "OpenClaw sta ripristinando la sincronizzazione" + "Flusso di eventi interrotto; prova ad aggiornare." + "Impossibile caricare nodi e dispositivi." + "Connetti il gateway per caricare le Skills." + "sconosciuto" + "Output" + "Conversazione non riuscita: il provider in tempo reale si è chiuso in modo imprevisto." + "OpenClaw urgente" + "ban" + "Token del Gateway necessario" + "Dispositivo associato" + "Richiede nuova approvazione" + "Non pianificato" + "Contatti" + "Il telefono rimane inattivo finché non serve" + "In ascolto · invio della voce in coda" + "Impossibile caricare i dettagli dell’attività" + "Messaggio dell\'agente" + "Il Gateway richiede questa identità del dispositivo. Esegui di nuovo l\'autenticazione o reimposta questa connessione al gateway." + "Prossima sessione" + "Sicurezza della connessione" + "Salta per ora" + "Sito web" + "Connetti il Gateway per caricare le richieste di approvazione nell\'app." + "%1$s copiato" + "Nessuna app selezionata. Non verrà inoltrato nulla finché non aggiungi app." + "%1$s %2$s" + "Richiede configurazione" + "Non associato" + "Il Gateway ha ricevuto questo telefono" + "Nessun modello configurato" + "Disabilita" + "Lingua dell\'app" + "Associazione del Gateway" + "Autenticazione salvata non valida" + "%1$s ambiti" + "Connetti il Gateway per caricare i log recenti." + "Salva le parole di attivazione" + "Gestisci le skill installate e aggiungi release attendibili da ClawHub." + "Invio in corso…" + "Nessun agente ancora caricato." + "Cerca in ClawHub" + "La chat sta verificando lo stato del Gateway." + "Associazione necessaria" + "Esecuzioni attive" + "Operazione non riuscita — %1$s" + "Connessione tra questo telefono e OpenClaw." + "summarize" + "Immagine del widget salvata in Download" + "Avvio…" + "%1$s token" + "Errore del client" + "Verifica il dispositivo che ha inviato la richiesta prima di concedere l\'accesso." + "Microfono Bluetooth LE" + "%1$s %2$s" + "Automazione attivata." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Archiviato" + "Ricarica" + "Cerca automazioni" + "I telefoni collegati e gli host dei nodi appariranno qui dopo l\'associazione." + "%1$s: %2$s" + "Questa automazione è stata modificata sul Gateway. Controlla la versione più recente prima di salvarla di nuovo." + "Interrompi dettatura" + "Leggibile" + "Invia un messaggio a OpenClaw" + "La password del Gateway non è valida. Inseriscila di nuovo o reimposta questa connessione al gateway." + "Riconnetti" + "Ora ISO, es. 2026-07-09T09:30:00Z" + "%1$s strumenti" + "Una risposta precedente ha già negato questa approvazione." + "Collegato" + "Apri %1$s" + "%1$s/%2$s" + "Esecuzione dell\'automazione completata con uno stato sconosciuto." + "La coda offline è piena (%1$s messaggi); elimina prima gli elementi in coda." + "I gateway pubblici richiedono wss:// o Tailscale Serve. ws:// è consentito per localhost, host .local, l\'emulatore Android e gli IP LAN privati." + "Connetti il gateway per caricare i dettagli della Skill." + "Accesso completo richiesto" + "Rilevamento dell\'attivazione" + "Espandi anteprima link" + "Cancella la ricerca delle conversazioni" + "NULL (NON RIUSCITO)" + "Aggiorna" + "Amministratore" + "Richiede attenzione" + "Abbina questo dispositivo al tuo Gateway per riattivarlo solo quando c\'è davvero del lavoro da svolgere, tenere a portata di mano una panoramica in tempo reale degli agenti ed evitare cicli in background che consumano la batteria." + "Ruoli" + "Rispondi" + "Catalogo dei provider" + "Abilita l\'autorizzazione in Impostazioni" + "A2UI push" + "Verifica accesso" + "Se il Gateway è raggiungibile, la riconnessione dovrebbe completarsi senza interventi." + "Turno dell\'agente" + "metti in quarantena" + "Attenzione" + "Ricerca…" + "Dove posso ottenere un codice di configurazione?" + "Impossibile abilitare la skill." + "pdf" + "Rimuovi" + "%1$s%% online" + "Nessun canale" + "Voce in tempo reale" + "Azioni di rifiuto e quarantena di Skill Workshop" + "Nodi e dispositivi" + "Centro di comando locale" + "emoji upload" + "Caricamento anteprima…" + "Alto" + "focus" + "describe" + "contesto %1$s" + "In ascolto della risposta..." + "voice" + "Connesso a %1$s" + "role add" + "La chat richiede attenzione" + "Abilita microfono" + "OpenClaw raccoglie e invia i nomi, gli ID pacchetto e lo stato delle app visibili su questo telefono quando il Gateway OpenClaw associato li richiede. Questo consente al tuo assistente di rispondere a domande ed eseguire azioni utilizzando le app installate." + "Gateway non connesso" + "Criterio" + "Tempo scaduto durante la conferma del messaggio inviato; aggiorna per verificare la consegna." + "File di supporto" + "Espressione" + "Attività in background" + "Sogno" + "Nessuna app bloccata. Le app possono inoltrare finché non aggiungi blocchi." + "Riconoscimento vocale non disponibile" + "Piattaforma" + "Gateway non ha restituito la configurazione di %1$s" + "Dimenticare il gateway?" + "Descrizione facoltativa" + "Apri %1$s" + "Canvas principale" + "Sognando" + "Da %1$s a %2$s" + "Condividi file" + "In tempo reale" + "API" + "OpenClaw sta lavorando…" + "Parla o detta con OpenClaw" + "Condividere le informazioni sulle app installate?" + "Caricamento dell\'automazione…" + "Elimina automazione" + "Assistente predefinito" + "Scegli un provider %1$s supportato sul Gateway" + "Non disponibile" + "Cartella vuota" + "Apri impostazioni" + "Disattivato" + "Tipografia" + "Interrompi" + "Non ci sono ancora conversazioni corrispondenti." + "L\'associazione del Gateway è riuscita.\nApprova le funzionalità del nodo di questo telefono da un\'interfaccia operatore." + "Questa skill è installata, ma al momento non può essere eseguita. Usa il desktop o la CLI per modificare la configurazione." + "Riconoscitore occupato" + "Gateway domestico" + "Esegui il comando di approvazione sul Gateway" + "Servizio disabilitato" + "Impossibile caricare le proposte di Skill Workshop." + "Aggiornami sui miei thread recenti di OpenClaw e suggerisci i prossimi passaggi." + "Non ora" + "openclaw qr" + "start" + "Nodo OpenClaw · Conversazione" + "Leggi e aggiorna gli eventi" + "Conversazione non riuscita: provider in tempo reale chiuso: %1$s" + "Connetti il Gateway per sfogliare i file dell\'area di lavoro." + "%1$s tramite relay Gateway" + "Impossibile caricare il catalogo conversazioni Gateway" + "Monitoraggio · 1 attività pianificata" + "Ogni %1$s h" + "Superficie dello schermo" + "Traduzioni OpenClaw · %1$s" + "Richiesta comando" + "Aggiornato" + "Canale" + "Riattiva audio" + "Nuovo gruppo…" + "Preparazione audio…" + "Adattivo" + "A breve" + "%1$s altri worker" + "Web Search" + "Prova Chat, Voce, Conversazioni, Provider o Impostazioni." + "OpenClaw attivo" + "navigate" + "richiesto %1$s" + "Connetti il Gateway per esaminare la cronologia delle esecuzioni delle automazioni." + "Accesso al dispositivo; è comunque necessario il consenso nel Gateway" + "Interrotto" + "Inserisci un codice di configurazione o un indirizzo Gateway valido." + "Modelli" + "OpenClaw passivo" + "Password del Gateway non valida" + "Impossibile verificare la modifica all\'associazione del dispositivo. Aggiorna e riprova." + "Visualizza dettagli" + "Bash" + "Token" + "L\'agente OpenClaw connesso può usare le funzionalità del dispositivo che abiliti. Continua solo se ti fidi del Gateway e dell\'agente a cui ti connetti." + "Incrostazione di cirripedi" + "Accesso selezionato o completo alle foto concesso." + "Executor di accessibilità" + "%1$s elementi mancanti" + "Comprimi l\'elenco di controllo del piano" + "Approvazione del nodo richiesta" + "Connetti Gateway" + "... +%1$s altri" + "Espandi l\'elenco di controllo del piano" + "Browser" + "screen record" + "Esecuzione in sospeso" + "L\'abilitazione consente a OpenClaw di osservare e controllare le schermate di altre app quando è attivo. È richiesto l\'accesso all\'accessibilità di Android." + "Origine" + "AI personale sui tuoi dispositivi" + "Attach" + "Automatico" + "Panoramica" + "Richiesta di ripristino non riuscita. Tocca per riprovare." + "Video" + "%1$s\n\n" + "Non crittografato" + "Calendario" + "Lo stato del Gateway non è OK; impossibile inviare" + "📎 %1$s" + "Ultimo stato" + "Attendi che la risposta corrente sia completata prima di avviare una nuova chat." + "Profilo" + "I limiti del provider appariranno qui quando il tuo Gateway li segnalerà." + "1 problema" + "Le conversazioni in \"%1$s\" vengono mantenute e spostate nuovamente in Senza gruppo." + "Consigliato" + "Creato" + "%1$s/%2$s token attivi" + "Nessun risultato dell\'azione" + "Scatto" + "%1$s…" + "Apri i dettagli della skill" + "Riproduzione vocale non riuscita: %1$s" + "Avvia Talk" + "Impossibile caricare questa cartella." + "Il codice QR non conteneva un codice di configurazione valido." + "Controlla l\'accesso al nodo" + "Aggiungi frase di attivazione" + "Impossibile raggiungere il gateway" + "Automazione" + "Connessione necessaria" + "Impossibile risolvere l\'approvazione. Aggiorna e riprova." + "import" + "Come questo telefono appare a OpenClaw." + "Vai alla ricerca delle conversazioni" + "Connetti il gateway" + "Leggi calendario" + "La panoramica si aggiorna alla riconnessione e all\'apertura di questa schermata." + "Impossibile disabilitare la skill." + "Connessione ancora in corso" + "Tra %1$smin" + "Leggi SMS" + "Connetti il Gateway per caricare l\'utilizzo." + "Cosa puoi aiutarmi a fare da questo telefono in questo momento?" + "Richiede approvazione" + "Nuova chat" + "Connetti il Gateway per aggiornare le proposte di Skill Workshop." + "Richiesta OpenClaw non riuscita." + "Autorizzazione richiesta" + "Rivedi lo stato di preparazione dei provider\ne i modelli configurati." + "Caricamento" + "Avviso di errore" + "Tema e testo Android tradotto." + "Microfono disattivato · invio…" + "Nessuno" + "Visualizza" + "Nome" + "Versione" + "Cron" + "Connetti questo telefono a un Gateway prima di aprire OpenClaw." + "Rimuovi frase di attivazione" + "Il codice di configurazione non è stato accettato. Genera un nuovo codice con openclaw qr." + "14 messaggi · Android" + "Trascrizione non riuscita: %1$s" + "Sempre" + "Impossibile caricare i sogni." + "Esecuzione dell\'automazione aggiunta alla coda." + "Conversation Turn" + "Automazione avviata." + "Nuovo gruppo" + "Errore del server" + "Video Generation" + "L\'approvazione del Gateway è in sospeso. Esegui openclaw devices list sull\'host del Gateway, approva questo telefono, quindi riprova." + "Le voci compaiono dopo che un ciclo di dreaming scrive un riepilogo narrativo." + "%1$s ms" + "Archivio memoria" + "Assistente al lavoro" + "OpenClaw può elencare le app visibili nel launcher." + "Conversazione non riuscita: %1$s" + "Cerca nelle skill installate" + "Ispeziona" + "Process" + "Thread recenti" + "Terminale" + "Attuale" + "1 account" + "In pausa" + "Consenti fotocamera" + "Le richieste di approvazione dell\'esecuzione verranno visualizzate qui mentre questo telefono è connesso." + " · Microfono: in sospeso" + "Copia" + "Dettagli copiati" + "Elimina" + "Chiedi a OpenClaw di usare le funzionalità di Android." + "member" + "Verifica se questo Gateway supporta l\'assistente delle impostazioni di OpenClaw." + "Usa le opzioni di ripristino qui sotto per riconnetterti." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Impossibile caricare i canali." + "Tra %1$sg" + "Errori consecutivi" + "Impossibile leggere un codice QR dall\'immagine. Scegli un\'immagine più nitida oppure inserisci manualmente il codice di configurazione." + "Il Gateway è meno recente di questa app. Aggiorna OpenClaw sull\'host del Gateway, quindi riprova." + "Connettiti prima di usare chat, voce e stato in tempo reale." + "Riconnetti gateway" + "Di terze parti" + "Verifica disponibilità" + "Limitato" + "Logo OpenClaw" + "Rimuovi modello dagli elementi in evidenza" + "Superfici di messaggistica connesse a questo gateway." + "Invio in corso" + "I thread archiviati verranno visualizzati qui." + "Comando copiato" + "Nessuna anteprima disponibile" + "Approva questo telefono sul Gateway.\nQuindi riprova a connetterti." + "Scansiona il codice QR" + "Directory di lavoro del comando · impossibile cancellare" + "Attività del thread" + "Disponibile" + "Eliminare l\'automazione?" + "%1$s oggi · %2$s totali" + "Password" + "Mettere in quarantena la proposta?" + "Nessun avviso di licenza è incluso in questa build." + "Impossibile salvare l\'immagine del widget" + "In attesa da %1$s" + "In riproduzione…" + "Provider e modelli" + "Nodo" + "%1$s " + "Prompt non disponibile" + "Log" + "Connetti il Gateway per esaminare le proposte di Skill Workshop." + "Strumenti" + "Interruttore del Gateway" + "Invia SMS" + "OpenClaw è pronto a continuare nella tua chat abituale." + "Nessun comando trovato" + "Nessun aggiornamento del canvas. Tocca per riprovare." + "Il terminale richiede un Gateway connesso" + "Exec" + "Filtro app" + "Principale" + "%1$sk" + "Gateway richiesto" + "Accesso" + "Pacchetti: snapshot=%1$s foreground=%2$s" + "Riprova la connessione" + "Il pianificatore cron è arrestato." + "Apri" + "Messaggio copiato" + "Non è stato possibile raggiungere il tuo Gateway.\nRisolviamo il problema." + "ora" + "Elimina dopo l\'esecuzione" + "Selezionato su questo telefono" + "unpin" + "Session History" + "Rimuovi" + "Usa questo telefono" + "Impossibile caricare i dettagli di ClawHub per %1$s." + "Strumenti in esecuzione" + "Condividi la posizione precisa quando la localizzazione è attiva." + "Mobile UI" + "Tema" + "Il Gateway mostra ancora questa approvazione come in sospeso. Esaminala prima di riprovare." + "Completa nota vocale" + "Dettatura: %1$s" + "Non consentito" + "Scegli un\'altra immagine" + "Anteprima dell\'immagine" + "OpenClaw ascolta solo quando avvii Conversazione o Dettatura." + "Condividi passi e attività" + "Configurazione necessaria" + "Aggiorna questo Gateway per usare l\'assistente delle impostazioni di OpenClaw." + "Questa connessione al Gateway richiede operator.admin per installare le Skills di ClawHub." + "Proposta applicata." + "%1$s in sospeso" + "%1$s h fa" + "Leggi registro chiamate" + "%1$s in coda · in attesa del gateway" + "Sposta nel gruppo" + "Scansiona il codice QR per abbinare" + "Approvazione negata." + "Impossibile esaminare la proposta di Skill Workshop." + "In evidenza" + "Profilo e dispositivo" + "Chiudi selettore livello di riflessione" + "Impossibile mettere il messaggio in coda per inviarlo in seguito." + "Quarantena" + "Pianificazione · %1$s" + "Impossibile aggiornare il livello di ragionamento." + "Apri selettore livello di riflessione" + "La risposta vocale è scaduta; nuovo tentativo per il turno in coda" + "Layout: dettagliato" + "Impossibile decodificare questa immagine." + "Gateway, voce, notifiche, privacy" + "File dell\'area di lavoro dell\'agente" + "Questo dispositivo perderà l\'accesso attendibile al Gateway." + "Usa il requestId dal comando in sospeso nel comando approve." + "Pianificazione" + "Limite di frequenza" + "Non consegnato" + "Payload · %1$s" + "In esecuzione" + "Artigliando" + "Termina" + "Usa l\'attendibilità di sistema" + "Nessun provider pronto" + "Dà priorità ai microfoni Bluetooth connessi." + "%1$s app bloccata dall\'inoltro." + "Azioni del messaggio" + "Tipo" + "Annulla archiviazione" + "Transcripts" + "Parole di attivazione" + "Configura %1$s sul Gateway" + "Scansiona un codice QR o usa il codice di configurazione dal tuo OpenClaw Gateway." + "Prototipo del design system" + "Setacciamento" + " · Conversazione: attiva" + "Ancora nessun dato di utilizzo." + "La chat non è riuscita prima dell\'avvio dell\'esecuzione; riprova." + "Invia" + "Alcune immagini condivise sono state omesse o non è stato possibile aggiungerle." + "Scrivi calendario" + "timeout" + "Basso" + "Elenco bloccati" + "act" + "Dismiss Task" + "Chat non riuscita" + "OpenClaw · In diretta" + "Installata" + "Tempo scaduto in attesa di una risposta; riprova o aggiorna." + "Trova conversazioni precedenti" + "Sfoglia le conversazioni" + "Aggiornamento" + "Raccolta di perle" + "Apri la fotocamera e inquadra il codice da openclaw qr." + "Nessun dispositivo" + "Inoltra notifiche" + "Manterrò questa conversazione separata dalla normale chat dell\'agente." + "La sessione del Gateway sta tornando online. Le scorciatoie degli agenti dovrebbero ripristinarsi automaticamente tra poco." + "Prova una ricerca diversa o cancella la query corrente." + "Consentire la posizione in background?" + "Emersione" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Annulla nota vocale" + "Scorri indietro" + "openclaw gateway" + "Gateway associato" + "Facendo la muta" + "In ascolto per il tuo prossimo turno." + "OpenClaw è al lavoro" + "Voce di log" + "Operazione non riuscita: impossibile raggiungere l\'endpoint gateway sicuro per questo host." + "Il Gateway è offline. Correggi la connessione qui sotto o copia la diagnostica." + "In standby" + "Prova prova 1 2 3" + "Impossibile cercare le Skills di ClawHub." + "Nessun prompt" + "Fotocamera anteriore" + "Apri voce di log" + "Timeout della rete" + "Ora" + "Rinomina gruppo…" + "Altri agenti" + "openclaw nodes approve REQUEST_ID" + "Fissa" + "thread list" + "Apri %1$s" + "upload" + "Password del Gateway non configurata" + "Impostazioni dettatura" + "I modelli dei provider sono stati caricati, ma lo stato di disponibilità non è accessibile." + "Eliminare la conversazione?" + "OpenClaw trasforma questo telefono in un\'interfaccia mobile essenziale per thread, voce, provider e Gateway." + "Prima i più recenti" + "Prossimo ciclo" + diff --git a/app/src/main/res/values-ja/assistant.xml b/app/src/main/res/values-ja/assistant.xml new file mode 100644 index 0000000..c24a79c --- /dev/null +++ b/app/src/main/res/values-ja/assistant.xml @@ -0,0 +1,7 @@ + + + "OpenClaw に %1$s を尋ねる" + "OpenClaw に %1$s するよう伝える" + "OpenClaw を開いて %1$s を尋ねる" + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..2737017 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + このゲートウェイを信頼しますか? + 信頼して続行 + キャンセル + worktree で新規チャット + このゲートウェイを信頼する前に、証明書のフィンガープリントを確認してください。\n\n%1$s + ゲートウェイの証明書が変更されました。想定した変更である場合のみ続行してください。\n\n以前の SHA-256:\n%1$s\n\n新しい SHA-256:\n%2$s + 不明 + バージョン + コミット + ビルド日 + バージョン %1$s + Git コミット %1$s + ビルド日 %1$s UTC、タイムスタンプ %2$s + ビルド日 %1$s + Git コミットの完全なハッシュをコピー + 完全なビルドタイムスタンプをコピー + OpenClaw の Git コミット + OpenClaw のビルドタイムスタンプ + Git コミットをコピーしました + ビルドタイムスタンプをコピーしました + + "送信用の添付ファイルを準備できませんでした。" + "マイク オフ" + "OpenClawのアラートを表示" + "スレッドのアクティビティ" + "フル" + "承認を許可して保存しました。" + "最近の通話履歴を表示" + "1 件保留中" + "サポートされていない添付ファイル" + "0 = 正確" + "スレッドを検索するにはGatewayに接続してください。" + "%1$s件のアカウント" + "Cronの変更にはoperator.adminが必要です。セットアップコードでは意図的にこの権限は付与されません。管理者アクセスを要求するには、Gatewayの共有トークンまたはパスワードを使用して再接続してください。このデバイスにまだ権限がない場合は、既存の管理者クライアントから保留中のスコープ昇格を承認してください。" + "Apply Patch" + "つまみ中" + "スピーカーのミュートを解除" + "連続スキップ数" + "このフォルダにはまだファイルがありません。" + "未接続" + "インストール済みスキルの状態を確認、管理します。" + "失敗" + "デフォルトエージェント" + "カメラ" + "グループから削除" + "検索中" + "音声再生のため一時停止しました" + "ダウンロード前に、GatewayがClawHubでこのリリースを正確に検証します。リリースにリスクへの明示的な同意が必要な場合、Androidは再試行する前にGatewayの警告を表示します。" + "セットアップコードに IPv6 ゾーン ID が使用されています。スコープなしの IPv6 アドレスまたは LAN ホスト名を使用してください。" + "添付ファイル" + "ウェイクワード、会話、再生を設定します。" + "聞き取り中(PTT)" + "提案が却下されました。" + "サイドバーを表示" + "ユーザー" + "%1$s · %2$s" + "最小" + "拒否" + "アクティブなエージェント" + "1件スケジュール済み" + "応答なし" + "%1$s を選択しました" + "コマンド argv JSON配列" + "この画像を読み取れませんでした。openclaw qr のQRが鮮明に写っているスクリーンショットまたは画像を選択してください。" + "Skill Workshopの提案を%1$sできませんでした。" + "他で回答済み" + "Gateway が承認を1回記録しました。" + "status" + "OpenClawは、ペアリング済みのGatewayから要求された場合にのみ位置情報を確認します。アプリがバックグラウンドにある間も確認できるようにするには、次のAndroid画面で%1$sを選択してください。" + "却下" + "コントラスト" + "Gateway の設定を置き換えますか?" + "自動化を読み込めませんでした。" + "あなた" + "内蔵マイク" + "サーフェス" + "提案はありません" + "メインスレッド" + "チャットを開く" + "この Gateway セッションでは、デバイスのペアリング操作を利用できません。Gateway ホストで openclaw devices list を実行し、そこでリクエストを管理してください。ノード機能の承認は別の操作であり、引き続き nodes approve <request id> を使用します。" + "アクションリクエスト" + "list pins" + "Gateway に接続して Skill Workshop の提案を読み込みます。" + "セットアップコードが受け付けられませんでした" + "サインアウト" + "リアルタイム文字起こしプロバイダーが設定されていません。" + "システムアプリを表示" + "プロバイダーモデルの設定を表示するには、Gatewayを更新してください。" + "音声入力を送信中" + "この提案を確認してMarkdownを読み込みます。" + "OpenClaw を開いて %1$s を尋ねる" + "推論" + "クライアント" + "適用済み" + "動画" + "プロモート済み" + "オンライン" + "スコープ" + "リアルタイム音声プロバイダーが設定されていません。" + "%1$s · %2$s" + "kick" + "Gatewayから無効な自動化が返されました。" + "インスタンス ID" + "Gateway トークンが必要です。もう一度入力するか、この接続を編集してください。" + "ソース" + "更新" + "%1$s件が待機中" + "チャットを開始" + "アクティブなスレッドで待機中のチャットツール呼び出しは、引き続きここに表示されます。" + "証明書の確認が必要です" + "現在のCanvasサーフェスを開いて検査または操作します。" + "自動化を更新しました。" + "最近のセッションはありません" + "スクリプト" + "Gateway の状態、電話ノードの準備状況、最近のログストリーム。" + "自動化の詳細を開く" + "ランタイム" + "他 1 件のワーカー" + "エージェント %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "続行するには、Android の設定で %1$s を有効にしてください。" + "修復" + "reactions" + "完了" + "バージョンとアップデート" + "OpenClaw は承認、失敗したジョブ、チャネルの問題をここに表示します。" + "USBマイク" + "追加待ちの共有が多すぎます。" + "スキップ済み" + "GatewayコンピューターのLANアドレスまたは安全なリモートホスト名を使用してください。" + "オン" + "スレッドを検索中" + "リアルタイムトーク" + "· %1$s" + "OpenClawが応答を準備しています。" + "承認を1回だけ許可しました。" + "プロバイダー設定" + "なし" + "スクリプトのペイロードは変更されずに保持されます。このスクリプトを編集するには CLI を使用してください。" + "Android セットアップガイド" + "%1$s 個のアプリの転送をブロックしました。" + "ペアリング済みデバイス" + ":%1$s" + "%1$s件保留中" + "デバイス名" + "送信" + "位置情報" + "例: America/New_York" + "セッションターゲット" + "ClawHubスキルを確認" + "snapshot" + "このデバイスからのペアリング要求を拒否しますか?" + "優先マイク" + "ノードホスト" + "レベル" + "アプリピッカーを閉じる" + "共有されたGatewayトークンまたはオペレーター発行のトークンを貼り付けてください。" + "すべてのシステムは正常です" + "Gateway 診断情報をコピーしました" + "音声エラー" + "設定を置き換え" + "クイックアクション" + "送信に失敗しました: 実行開始前にチャットが失敗しました。もう一度お試しください。" + "マイク" + "チャットは引き続きGatewayの稼働状況を確認しています。" + "正確な位置情報" + "今回のみ許可" + "+%1$s 件追加" + "thread create" + "ブロック済み" + "ウェイクワードまたはフレーズ" + "Gatewayでデバイスの承認が必要です" + "外部マイク" + "%1$s/%2$s件準備完了" + "接続済み(オペレーターがオフライン)" + "機能未承認" + "この操作により、自動化とそのスケジュールがGatewayから完全に削除されます。" + "画像を読み込み中…" + "接続" + "ノードアクセスを承認" + "Gatewayを追加" + "文字起こしを利用できません: %1$s" + "画像" + "潮に乗り中" + "画像プレビューを閉じる" + "eval" + "最後のコマンド: %1$s" + "OpenClaw を実行しているデバイスでターミナルを開いておいてください。" + "不足している項目はありません" + "キャンバス出力には、有効なGateway接続が必要です。" + "%1$s · %2$s" + "分離" + "© 2026 OpenClaw Foundation — MITライセンス。" + "PDF" + "Conversations" + "メモリ統合と夢日記。" + "Create Goal" + "編集中にこの自動化が変更されました。保存する前に、Gatewayの最新バージョンに戻してください。" + "接続すると、Gatewayは常時接続セッションを維持する代わりに、サイレントプッシュでスマートフォンを起動できます。" + "ウェイクモード" + "ペアリング済みデバイスを削除しますか?" + "システムイベントのテキスト" + "ウィジェット画像をコピーできませんでした" + "いいえ" + "オプションのパス" + "キュー内の音声を送信中" + "組み込み" + "hide" + "runs" + "Gateway パスワードが必要です。もう一度入力するか、この接続を編集してください。" + "イベントテキスト" + "ライブ文字起こし" + "プロバイダーモデルの設定を読み込めませんでした。" + "%1$s 個のアプリに転送が許可されています。" + "音声設定" + "動画を添付" + "非表示の追加画像: %1$s" + "ペアリング要求を拒否しますか?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "発行元" + "ここから分岐" + " · トーク:発話中" + "オプションの上書き" + "デフォルト" + "コマンドの承認" + "アプリとGatewayのプロトコルバージョンに互換性がありません。両方のOpenClawを更新してから、再試行してください。" + "画面を更新" + "最近の写真とメディアを読み取る" + "聞く" + "接続済み" + "完了" + "スマートフォンは%1$sとペアリングされています。続行してノードへのアクセス設定を完了してください。" + "現在のスレッド" + "思考 %1$s" + "ミュート中" + "OpenClaw は、オープンソースコミュニティのパートナーに感謝します。" + "開く" + "Gatewayに接続中" + "gatewayはこのパスを変更できますが、既存のパスを消去することはできません。" + "TTS" + "保存中…" + "基準 %1$s" + "search" + "有効化" + "スケジュールされたGatewayの処理を確認・管理します。" + "以前の応答でこのコマンドが既に1回許可されています。" + "generate" + "信頼できるプライベートネットワークでのみ使用してください。" + "設定を検索" + "トーク中" + "Gateway 認証が構成されていません。この接続を編集して、もう一度お試しください。" + "失敗: セキュアエンドポイントに到達しましたが、TLS フィンガープリントの検証がタイムアウトしました。Tailscale Serve または gateway TLS を確認してから再試行してください。" + "ステップ1" + "ディクテーション" + "アプリピッカーを開く" + "保留中の承認はありません" + "edit" + "Gateway に接続" + "openclaw qr のセットアップコードを入力してください。" + "診断" + "他のアプリには影響しません。" + "殻を割っています" + "このスレッドとその記録を完全に削除します。" + "デバイスを承認しました。" + "自動的に再試行しています" + "ウィジェット画像をコピーしました" + "%1$s件のロール" + "不足している項目が1件あります" + "%1$s件スケジュール済み" + "react" + "エージェント" + "自動化を読み込むには、Gatewayに接続してください。" + "再接続中…" + "セットアップに戻る" + "send" + "接続をテストできませんでした" + "検証してインストール" + "このデバイスを、チャット、音声、カメラ、デバイスツール用の安全な OpenClaw ノードにします。" + "手動セットアップ" + "チャットを開いて、現在のスレッドを開始または再開してください。" + "ヒント: 聞き取りを停止すると、キャプチャした発話が送信されます。" + "スキップ" + "音声リクエストに失敗しました" + "「%1$s」を却下し、GatewayからSkill Workshopの状態を更新します。" + "殻づくり中" + "update" + "共有" + "カメラ有効" + "設定後、Telegram、WhatsApp、メール、その他のチャンネルがここに表示されます。" + "ネットワークエラー" + "潮だまり探索中" + "session=%1$s source=%2$s のCanvasを今すぐ復元してください。既存のA2UI状態がある場合は、直ちに再生してください。ない場合は、Canvasにコンパクトでモバイル向けのダッシュボードを作成して表示してください。" + "開始に失敗しました: %1$s" + "未リクエスト" + "Gateway で %1$s プロバイダーを設定してください" + "kill" + "承認" + "ファイルを利用できません" + "未読にする" + "人物と連絡先の詳細を検索" + "デバイス ID が必要です" + "OpenClawスレッド" + "写真ライブラリへのアクセスを許可してください。" + "以前の応答でこの承認は既に解決されています。" + "最近のスレッドはありません" + "タイムアウト %1$s秒" + "一致する項目はありません" + "選択したアプリの通知を読み取る" + "利用可否は不明" + "トークを設定" + "追加" + "Gatewayがペアリングされました。オペレーターアクセスを待機しています。" + "画像を添付" + "OpenClaw に届く内容を選択します。" + "機能の再承認待ち" + "ハイライトされた項目を確認" + "聞き取り中..." + "最新情報を教えて" + "メッセージ" + "連絡先を読む" + "オフライン添付ファイルのストレージがいっぱいです。先にキュー内の項目を削除してください。" + "1回のみ" + "名前を変更" + "チャンネルが見つかりません。" + "すべて表示" + "新しいデバイス" + "Session Status" + "画像プレビューを開く" + "セッションのブランチが変更されました。このメッセージを確認して再試行してください。" + "close" + "これはセットアップコードのようです。戻って「Gatewayをセットアップ」を選択し、「セットアップコードを使用」を選択してください。" + "✦" + "エージェントと自動化" + "適用" + "自動化の実行がスキップされました。" + "続行" + "監視中 · %1$s 件のスケジュール済みジョブ" + "参照" + "tabs" + "保留中" + "トーク: %1$s" + "read" + "テキストを選択" + "モーションアクティビティ" + "description: %1$s" + "音声を再生" + "時刻" + "未検証" + "Yield" + "承認コマンドをコピー" + "現在の画面出力とインタラクティブなアプリ画面。" + "サービスに接続しました" + "表示" + "準備ができています" + "プロバイダーカタログを読み込めませんでした。" + "発話中 · 返信を待機中" + "未許可" + "変更を保存" + "Gatewayが自動化の実行を拒否しました。" + "Session Send" + "ClawHubで探す" + "OpenClaw がバックグラウンドにある間、要求された位置情報の確認を常に許可します。Android では、これが常駐ノード通知に表示されます。" + "システムイベント" + "プロバイダーを表示するには Gateway に接続してください" + "次のハートビート" + "Gatewayがペアリングされました。ノード機能の承認を待機しています。" + "塩水に浸しています" + "Canvas を閉じる" + "連絡先を書き込む" + "この検索に一致するインストール済みスキルはありません。" + "トークプロバイダーの設定" + "Music Generation" + "Talk 設定" + "監視中 · 1件のスレッド" + "ペイロードテキスト" + "テキストを設定" + "承認 %1$s" + "Gateway から %1$s の準備状態が返されませんでした" + "設定済みのモデルが%1$s件あります。更新して利用可能か再確認してください。" + "Conversation Send" + "キャンバス" + "プロバイダー1件" + "Gateway証明書を自動的に読み取れませんでした。Gatewayホストで取得したSHA-256フィンガープリントを貼り付けてください。" + "送信に失敗しました: %1$s" + "ブリッジ" + "配信エラー" + "スマートフォンから OpenClaw を使用" + "外観" + "Skill ワークショップ" + "トークンが必要" + "プレビュー · %1$s" + "マイクの権限が必要です" + "Skill Workshopの提案を読み込むには、Gatewayに接続してください。" + "すべてのシステムは正常に稼働しています" + "Gatewayに接続できません" + "OC" + "更新日時" + "接続済み(ノードがオフライン)" + "ホーム" + "音声入力を聞き取り中" + "アーカイブ済みのスレッドはありません" + "この Gateway で利用可能なアシスタントを選択して確認します。" + "トークモードが有効です" + "処理中 · 1 件の実行がアクティブ" + "同意して有効化" + "Gateway の更新が必要です" + "画像をコピー" + "Gateway URL" + "main、isolated、current、または session:<id>" + "メディアを利用できません" + "Gateway に接続して、エージェントのワークスペースでシェルを開きます。" + "%1$s://%2$s:%3$s" + "承認の詳細を読み込めませんでした。更新して、もう一度お試しください。" + "Gateway のステータス確認、構成の修復、モデルの変更、チャンネルの接続ができます。" + "Tool Call" + "スレッド" + "Write" + "プロンプトを入力するか、音声を使用してください。" + "D" + "設定を開く" + "監視中…" + "会話を終了" + "最終エラー" + "確認が必要なアクションを確認します。" + "すべてのエージェントに対して無効です。" + "音声を開始" + "バックグラウンドタスクに戻る" + "別のcron操作がまだ完了していません。" + "クールダウン %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "デバイス上の音声認識は利用できません。" + "スクリプト · 読み取り専用" + "添付ファイルが大きすぎるため、1件のメッセージとしてキューに追加できません。一部を削除して、もう一度お試しください。" + "設定済みのモデルが1件あります。更新して利用可能か再確認してください。" + "ウィジェットを利用できません" + "このホストには安全な接続が必要です。" + "最近のアプリ" + "一致する自動化はありません。" + "スマートフォンが Gateway に到達できること" + "Gateway" + "期限切れ" + "Gateway からスケジュールされた OpenClaw の作業。" + "Sub-agent" + "デバイスの承認を待っています" + "スレッドを読み込み中" + "このGatewayは現在、このデバイスによって信頼されている証明書を提示しています。" + "ずらしミリ秒" + "event create" + "ドキュメント" + "Gateway をセットアップ" + "動画を再生" + "保存された認証情報が無効です。再認証するか、この Gateway 接続をリセットしてください。" + "使用中のみ" + "screenshot" + "ここまで巻き戻す" + "Cron式(例: 0 9 * * *)" + "音声に戻る" + "話す" + "詳細" + "%1$s/%2$s台がオンライン" + "%1$s 個のアプリに転送を許可しました。" + "チャット" + "マイクへのアクセスが必要です。" + "カサカサ移動中" + "編集" + "通知休止時間" + "診断情報をコピー" + "スケジュール済み" + "作成" + "有効期限 %1$s" + "閉じる" + "提案を却下しますか?" + "音声認識エラー (%1$s)" + "問題" + "レジストリのメタデータを検索します。Gatewayはダウンロード前に信頼性を再度検証します。" + "ローカル設定にはプライベートLAN IPを使用するか、リモートアクセス用にTailscale Serveを有効にするか、wss:// Gateway URLを公開してください。" + "送信中…" + "アカウント %1$s" + "Suggest Task" + "検索" + "聞き取り中" + "自動化が読み込まれていません。" + "Gateway のアップデートが利用可能です。準備ができたら、Web UI または CLI からアップデートを実行してください。" + "まもなく" + "Gateway の承認はありません。" + "ホスト" + "各フィールドにウェイクワードまたはフレーズを1つずつ追加してください。その後、コマンドの前にそのいずれかを話してください。" + "文字起こしして送信" + "実行時刻" + "音声を一時停止" + "Gateway デバイスへのアクセス" + "プレビューなし" + "デバイス" + "Android 版 OpenClaw。" + "機能の承認待ち" + "この自動化を実行、有効化、無効化、削除、または更新する前に、編集内容を保存するか元に戻してください。" + "自動化はまだありません。" + "このスキルには%1$s件のセットアップ項目が必要です。Androidではインストール済みの内容を確認できます。セットアップや設定の変更はデスクトップかCLIから行ってください。" + "最近 %1$s 件" + "チャンネル" + "Unphased" + "このスマートフォンで有効" + "ノードアクセスを確認中" + "タイムゾーン" + "Skill Workshop の検査および適用アクション" + "常に許可" + "present" + "GatewayにインストールされたSkillsがここに表示されます。" + "コードの有効期限が切れているか、別の Gateway 用に生成された可能性があります。" + "権限が必要です" + "自動化の設定が無効です。" + "許可リスト" + "セットアップ、ステータス、修復" + "groups" + "公開鍵" + "情報" + "この画像にセットアップQRコードが見つかりませんでした。openclaw qr で生成されたQRを選択するか、セットアップコードを手動で入力してください。" + "permissions" + "ノードとペアリング済みデバイスを読み込むには、Gatewayに接続してください。" + "ブランチを切り替え" + "スキルはありません" + "返信を音声で再生" + "既読にする" + "ノードの承認待ち" + "wake" + "%1$s件の提案" + "Gatewayの認証を確認する必要があります。" + "接続の詳細" + "ミリ秒" + "音声認識" + "説明" + "最近の会話" + "この情報は、OpenClaw が運営するサーバーではなく、お使いの Gateway に送信されます。Gateway は、選択した AI プロバイダーへのリクエストにこの情報を含める場合があります。" + "配信" + "スピーカーをミュート" + "%1$s 実行中 · %2$s 完了 · %3$s 失敗" + "Gatewayへの接続を開始中" + "監視中 · %1$s件のスレッド" + "自動化の実行が完了しました。" + "一致するアプリはありません。" + "チャットに送信" + "自動化を削除しました。" + "有効化" + "最近の実行" + "QRコードを四角形の内側に合わせてください。" + "承認を読み込めませんでした。" + "承認しました" + "プロバイダーの準備状況を読み込むには、Gateway に接続してください。" + "ペアリングされていません" + "この承認は解決される前に有効期限が切れました。" + "%1$s秒後に監視 — 対象アプリに切り替えてください" + "エージェントプロンプト" + "emoji list" + "繰り返し" + "OpenClaw を検索" + "%1$s件保留中" + "デバイス上の音声認識は利用できません" + "このメッセージを共有できるアプリがありません" + "検索を閉じる" + "監視するコマンド" + "ヘルス" + "通知リスナー" + "スピーカーはミュート中" + "スレッドを検索" + "OK" + "セットアップガイドを開けませんでした。" + "OpenClaw に %1$s を尋ねる" + "Wait for Agents" + "アドレス" + "Gateway で作成されたスケジュール済みの作業がここに表示されます。" + "最新のログチャンクを表示しています。" + "セットアップコードを使用" + "sticker" + "セキュアな wss:// または Tailscale Serve Gateway を使用し、Control UI または openclaw qr でフルアクセスのセットアップコードを生成してから、以下でスキャンまたは貼り付けて再接続すると、設定とアップグレードが有効になります。" + "steer" + "選択済み" + "Android では既存のセットアップコードをスキャンまたは貼り付けできますが、この Gateway はまだアプリにセットアップコード生成機能を公開していません。Gateway ホストで openclaw qr を使用して QR/code を生成し、ここでスキャンするか、下にセットアップコードを貼り付けてください。" + "キャンバスのステータス" + "接続を修正" + "画像を保存" + "ノード %1$s" + "Gateway パスワードが必要です" + "Update Plan" + "添付ファイルを削除" + "自動化の実行に失敗しました。" + "プロバイダーの制限とクォータの健全性。" + "Gateway のトークカタログが読み込まれていません" + "このGateway" + "最近の実行はまだありません。" + "デバイス上の言語モデルは利用できません" + "ダッシュボードには接続済みの Gateway が必要です" + "エージェントが再利用可能な Skill のドラフトを作成すると、一致する提案がここに表示されます。" + "Session Search" + "OpenClawが話しています" + "QRをスキャン" + "選択したアプリ" + "変更を元に戻す" + "承認コマンドをコピーしました" + "配信ステータス" + "QR コードが受け付けられません" + "音声コマンドセンター。" + "接続をテスト" + "OPENCLAW" + "Web Fetch" + "プロンプト" + "デバイスを承認しますか?" + "このセッションのダッシュボードを開くには、Gateway に接続してください。" + "このスマートフォンから%1$sと保存済みの認証情報を削除しますか?" + "QRコードが安全でないリモートGatewayを指しています。%1$s %2$s" + "画面サーフェスの準備ができました" + "Gatewayをペアリング" + "チャンネルを読み込むには Gateway に接続してください。" + "他の音声アクティビティ中は一時停止します。" + "モデル" + "写真" + "セットアップコードを貼り付け" + "OpenClawが話しています" + "接続中..." + " · 位置情報:常に許可" + "メッセージ: %1$s" + "サンゴ礁を探索中" + "Gateway から読み込む" + "text: %1$s" + "必要" + "rename group" + "準備完了" + "日記は最初のエントリを待っています。" + "承認" + "ライブページ" + "自動化はすでに実行中です。" + "1回限りの実行が成功した後、この自動化を削除します。" + "チャットと音声を使用できます" + "接続済み(オペレーター: %1$s)" + "Gateway のペアリングが完了しました。このスマートフォンをノードとして承認すると、有効にしたデバイス機能を OpenClaw が使用できるようになります。" + "応答が中止されました" + "画像" + "%1$s 件保留" + "一致するスレッドはありません" + "delete" + "レイアウト: コンパクト" + "channels" + "許可済み" + "%1$s分ごと" + "1トークン" + "%1$s %2$s" + "インストール済みアプリ" + "保留中" + "ボイスノートを準備中…" + "なし" + "サブシステム" + "コマンド終了時" + "接続" + "自動化の実行履歴を読み込めませんでした。" + "オートメーション名" + "ステップ2" + "診断" + "一部のチャンネルのステータス確認が完了しませんでした。" + "pin" + "%1$sをコピー" + "ペアリング済み" + "ウェイクワードを保存できませんでした" + "「%1$s」を隔離し、GatewayからSkill Workshopの状態を更新します。" + "ボイスノートを録音" + "待機中" + "回答済み" + "要求された場合にカメラツールを許可します。" + "問題" + "音声ウェイク" + "ペアリング要求を拒否しました。" + "%1$s日前" + "roles" + "Skills" + "アーカイブ" + "ノードはオフラインです。再接続してから再試行してください。" + "システム" + "リモートIP" + "グループなし" + "スケジュールの詳細" + "電話の機能" + "利用できません" + "ダッシュボード" + "トークンを貼り付け" + "プロバイダーがありません" + "SHA-256フィンガープリント" + "スレッドはまだありません" + "Bluetoothマイク" + "最近" + "スレッド名を変更" + "解決結果が不明です。Gateway の記録が確認されるまで操作は無効のままです。" + "dialog" + "ウェイクワードを聞き取る" + "camera snap" + "再生を準備中…" + "Gateway が不明なプロバイダー %1$s を選択しました" + "delete group" + "Androidに従う · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Gateway に接続してエージェントを読み込みます。" + "戻る" + "メッセージを共有" + "QR コードを生成します。" + "再起動" + "スピーカーオン" + "グループを削除しますか?" + "不足" + "提案を検索" + "stop" + "セキュア (TLS)" + "ノードまたはペアリング済みデバイスはありません。" + "残り %1$s%% %2$s" + "セットアップコードの有効期限が切れました" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "日記" + "notify" + "このスマートフォンはGatewayに必要とされるまで休止し、必要になると起動して同期した後、再びスリープ状態に戻ります。" + "設定済みモデル %1$s 件" + "ライセンス" + "ClawHub の Skills を検索するには、Gateway に接続してください。" + "Skill" + "Gateway 接続が変更されました。OpenClaw を再起動して再接続してください。" + "デバイスID" + "Gateway がアクティブな %1$s プロバイダーを識別しませんでした" + "待機中" + "ウェイクワードを保存しました" + "古い順" + "画面" + "実行開始日時" + "IPv6 ゾーン ID はサポートされていません。スコープなしの IPv6 アドレスまたは LAN ホスト名を使用してください。" + "送信済み — 配信を確認中…" + "音声" + "This gateway connection needs operator.admin to update skills." + "セットアップコード" + "Gatewayの警告を確認してインストール" + "チャットを更新" + "間隔" + "Skill Workshopの提案に対する操作にはoperator.adminスコープが必要です。" + "セッション" + "名前を変更…" + "夢見を読み込むにはGatewayを接続してください。" + "セットアップ" + "Talk を開く" + "poll" + "接続してエージェントを読み込む" + "role remove" + " · トーク:聞き取り中" + "ClawHubから%1$sのインストール可能なバージョンが返されませんでした。" + "コマンド" + "この承認は解決される前にキャンセルされました。" + "マイクオン · Gatewayを待機中" + "テキスト" + "%2$s 件中 %1$s 件を表示中。さらに表示するには検索条件を絞り込んでください。" + "v%1$sが利用可能です" + "%1$s://%2$s" + "%1$s... (OK)" + "プロバイダーと設定済みモデル" + "接続中…" + "ウェイクワードを保存するには Gateway に接続してください" + "プロフィールを開く" + "Gateway を起動します。" + "この目標を実用的なチェックリストに変換してください: " + "セッション検索をクリア" + "ポート" + "セットアップコードを入力" + "Gatewayのログを読み込めませんでした。" + "%1$s 件のプロバイダーが準備完了" + "エージェントの準備ができました" + "Gateway に %1$s プロバイダーが設定されていません" + "1回の発話を聞き取り中" + "監視" + "エポックミリ秒(任意)" + "設定済みのモデルがありません。更新して利用可能か再確認してください。" + "設定" + "背面カメラ" + "approve" + "始める前に" + "Skillsを読み込めませんでした。" + "無効" + "まだ承認を待っています" + "バックグラウンドタスクを読み込めませんでした" + "OpenClaw がこのスマートフォンで明瞭に話せることを確認します。" + "処理中 · %1$s 件の実行がアクティブ" + "コマンドの作業ディレクトリ" + "グループ名" + "ギャラリーから選択" + "Version %1$s" + "戻る" + "Connect the gateway to update skills." + "実行後に削除" + "セットアップコードが安全でないリモートGatewayを指しています。%1$s %2$s" + "Computer" + "Gateway が切断されました。" + "Session Settings" + "開始するにはGatewayに接続してください" + "セキュリティに関する通知" + "その他の回答" + "共有画像の警告を閉じる" + "Gatewayが別のClawHubリリースを評価しました。インストールする前に、Skillをもう一度確認してください。" + "システムアクセスを開く" + "完了" + "画像を利用できません" + "通知" + "適用、拒否、隔離には operator.admin スコープが必要です。共有 gateway 認証で再接続するか、operator.admin デバイススコープのアップグレードを承認して、ライフサイクル操作を有効にしてください。" + "sticker upload" + "ロブスター漁" + "Messages to recover" + "openclaw devices approve %1$s" + "読み取り可能な Gateway ログ詳細。" + "生成された Skill の提案をライブ Skill になる前に確認します。" + "同梱" + "%1$s 件が利用可能" + "ノードの承認待ち" + "Gateway 保留中" + "認証が必要です" + "ノード" + "スリープさせない" + "OpenClawが応答中" + "ドキュメント" + "%1$s 件が準備完了" + "出力はまだありません" + "デバイスの言語はサポートされていません" + "キューに追加済み — 再接続時に送信されます" + "%1$s分前" + "現在のブランチ" + "ペアリングアクセスを確認中" + "Gateway アクセスが制限されています" + "ツールを実行中..." + "承認を確認中…" + "このスマートフォンで写真や動画を撮影" + "接続済み、準備完了" + "閉じる" + "目標を実行可能なチェックリストに変換します。" + "セットアップコードのGateway URLが無効です。" + "このスマートフォンが接続されている間に OpenClaw に使用を許可してもよいアクセス権のみを有効にしてください。これらは後で Android 設定で変更できます。" + "アカウント" + "remove" + "パスワード(任意)" + "Gateway 認証の確認が必要です。Gateway 設定を確認してから、再試行してください。" + "QR コードに IPv6 ゾーン ID が使用されています。スコープなしの IPv6 アドレスまたは LAN ホスト名を使用してください。" + "add" + "オキアミのように遊泳中" + "正常" + "%1$s で完了" + "引数" + "インストールオプション" + "%1$s時間後" + "Gatewayの承認待ちです。Gatewayホストで次を実行してください:" + "管理者アクセスが必要です" + "set groups" + "モデルをピン留め" + "検索をクリア" + "対象となるエージェントに対して有効です。" + "現在のスレッドはありません" + "bounds: %1$s" + "%1$s 後" + "スケジューラーによるこの自動化の実行を許可します。" + "%1$s 件適用済み" + "夢日記はまだありません。" + "バックグラウンドタスクを更新" + "最近のスレッドと次のステップを要約してください。" + "OpenClaw の表示中にデバイス上で動作します。" + "%1$s が作業中です" + "%1$s %2$s" + "未加工" + "実行" + "今すぐ実行" + "無題のブランチ" + "設定済み" + "camera list" + "1 件適用済み" + "camera clip" + "はい" + "音声テスト" + "保留" + "events" + "作業ディレクトリ" + "最新に移動" + "常に許可" + "QR またはセットアップコードをスキャン" + "Installing" + "ライブノード、ペアリング済みの電話、保留中のデバイスリクエスト。" + "スナップショット: %1$s" + "以前の応答でこのコマンドが既に許可され、選択が保存されています。" + "保留中のリクエスト" + "承認済み" + "ワークスペース" + "音声" + "会話の準備ができました" + "Subagents" + "失敗: セキュアなgatewayエンドポイントが検出されませんでした。gateway TLSまたはTailscale Serveを有効にするか、[Unencrypted]を選択して信頼できるプライベートLANアドレスを使用してください。" + "シグナル" + "セッションターゲット" + "Gateway が拒否を記録しました。" + "承諾" + "OpenClaw に何でも質問" + "続行するには再接続してください" + "%1$s台ペアリング済み" + "「%1$s」を適用し、GatewayからSkill Workshopの状態を更新します。" + "Gateway オフライン" + "openclaw devices list" + "OpenClaw node の接続ステータス" + "アラートはこの電話に留まります。" + "OpenClaw は選択したアラートを受信できます。" + "画面を開く" + "チャットアクション" + "他のアプリの操作を許可しますか?" + "検査中" + "セットアップコードをスキャンするか貼り付けて、別のGatewayを追加します。" + "Swarm" + "TLS がタイムアウトしました" + "最近のセッション" + "ペアリング済みデバイスを削除しました。" + "Gatewayをペアリングしました。ノード機能の承認を確認しています。" + "モーション" + "cron操作に失敗しました。" + "Gateway コンピューターで次を実行します:" + "セッションを検索" + "ログを更新" + "画像を表示できません · タップして再試行" + "openclaw nodes approve %1$s" + "ボイスメモ · %1$s" + "使用状況" + "オウムガイのように進行中" + "コンテキスト %1$s%%" + "音声プロンプトを文字起こし" + "ミュート" + "新しい会話を開始すると、ここに表示されます。" + "接続の問題" + "中" + "フォーク" + "スピーカーを有効にする" + "システムイベントテキスト" + "並び順: %1$s" + "%1$s件待機中" + "Image Generation" + "ボイスノート" + "対応が必要なものはありません" + "続行するには、OpenClaw に %1$s の権限が必要です。" + "有線ヘッドセットのマイク" + "ページ" + "配信済み" + "期限" + "現在のSkillsステータスでは、Skillの詳細を利用できません。" + "この電話が共有できる内容を選択します。" + "この自動化の実行はすでにキューに登録されています。" + "自動化を管理するには、Gatewayに接続してください。" + "自動化の実行時刻にはまだ達していません。" + "詳細なし" + "承認処理中です。\nOpenClawは自動的に再接続します。" + "プロバイダーの準備状況を表示するには、Gateway を接続してください。" + "ペアリングを待機中" + "会話を開始または続行" + "スケジュール済みのジョブはありません" + "OpenClaw に返信…" + "ステータス" + "OpenClaw Node · 接続済み" + "有効" + "画面共有のデバッグ状態を表示します。" + "制限は報告されていません" + "スキャナーを閉じる" + "%1$s日ごと" + "有効" + "有効にして設定を開く" + "オンラインで準備完了" + "Ask User" + "チャット エラー" + "前方へスクロール" + "%1$s / %2$s" + "作業を計画" + "console" + "再試行" + "チャットを開始すると、アクティブな OpenClaw の会話がここに表示されます。" + "自動化を読み込めませんでした。" + "エージェントワークスペース内のシェル" + "%1$s 件がアクティブ" + "デバイス権限を選択" + "前回の所要時間" + "デフォルトのエージェント" + "%1$s時間" + "会話中" + "ClawHub から %1$s をインストールできませんでした。" + "OpenClaw へようこそ" + "他のアプリを操作" + "Signalインデックス" + "シークレットを入力…" + "%1$s:%2$s" + "OpenClaw に %1$s するよう伝える" + "検出済み" + "サイドバーを非表示" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "音声の再生を利用できません" + "適用" + "使用状況を読み込めませんでした。" + "作業中はノードを利用可能な状態に保ちます。" + "次回起動" + "%1$s/%2$s" + "最近のログエントリはありません。" + "手動 Gateway" + "グループ名を変更" + "Update Goal" + "プロバイダーの利用可否が不明です" + "プロバイダー" + "グループを削除…" + "ペイロード" + "通話履歴" + "Memory Search" + "%1$s 件のプロバイダー" + "スマートフォンのコンテキストとプライバシー" + "%1$s/%2$s件接続済み" + "%1$s %2$s" + "Gateway の再起動からの復旧はまだ進行中です。" + "セットアップコードを置き換えると、再接続前にこのスマートフォンに保存されているセットアップ認証情報とデバイストークンが消去されます。このスマートフォンではノード機能の承認が再度必要になる場合があります。新しいGatewayセットアップコードとペアリングする場合にのみ続行してください。" + "自動化を開いて、その設定と実行履歴を確認します。管理者権限の接続では、実行、編集、有効化、無効化、削除もできます。" + "コンテキスト --" + "提案を隔離しました。" + "自動化を一時停止しました。" + "OpenClaw モバイル" + "A2UI reset" + "Gatewayは利用できません" + "Read" + "このスキルには1件のセットアップ項目が必要です。Androidではインストール済みの内容を確認できます。セットアップや設定の変更はデスクトップかCLIから行ってください。" + "前回の実行" + "セットアップ用QRをスキャンするにはカメラへのアクセスが必要です。" + "モデルを更新できませんでした。" + "泡立ち中" + "thread reply" + "削除…" + "自動化を確認するにはGatewayに接続してください。" + "最近のログ" + "最近の実行を読み込み中…" + "このファイルはプレビューできません。バイナリ形式か、サイズが大きすぎる可能性があります。" + "確認" + "このスマートフォンの位置情報を読み取る" + "Skillキー" + "%1$sをインストールしました。" + "Gateway" + "actions: %1$s" + "転送モード" + "%1$sk" + "スレッドのレイアウトを切り替え" + "TLS エンドポイントがありません" + "OpenClaw Gateway" + "手動で設定" + "考え中…" + "Gateway アクセスの確認が必要です" + "1 件保留" + "%1$s秒" + "提案を適用しますか?" + "今はしない" + "未承認" + "アプリを検索" + "設定済みモデル 1 件" + "承認通知を閉じる" + "·" + "オフライン" + "音声認識プロバイダー" + "Gatewayの承認処理中です。OpenClawが自動的に再試行します。" + "最大" + "cronを変更するには、operator.adminアクセス権が必要です。" + "思考中" + "screen snapshot" + "検出されたノード: %1$s" + "アクションが見つかりません" + "保存して接続" + "list" + "Gateway が承認を記録し、選択を保存しました。" + "接続する有効な手動エンドポイントを入力してください。" + "アシスタント" + "チャットに送信中..." + "プロフィールを保存" + "ロック中" + "自動化を編集" + "同じネットワーク、または安全なリモート Gateway URL を使用してください。" + "アンカー" + "言語" + "このアプリはGatewayより古いバージョンです。このデバイスのOpenClawを更新してから、再試行してください。" + "すべて" + "Gatewayセッション進行中" + "レビュー待ち" + "Skillsがインストールされていません。" + "Gateway を確認中" + "時間差 %1$s" + "%1$sの結果は不明です。再接続してSkillsを更新してから、再試行してください。Gatewayは、まだ実行中の一致するインストールに安全に合流します。" + "削除" + "ペアリング済みのGatewayはありません。" + "%1$s · %2$s" + "<編集済みのシークレット>" + "%1$s件の問題" + "OpenClaw" + "聞き取り中 · %1$s 件がキューに追加済み" + "アシスタントの音声はミュートされています" + "ノードアクションは対象アプリがフォアグラウンドのときのみ実行されます(リモートパス経由で検証)。グローバルアクションと同一アプリのアクションはここで動作します。" + "Gateway がまだ見つかっていません。検出がブロックされている場合は、手動セットアップを使用してください。" + "スレッドを開く" + "処理中" + "話し始めてください..." + "スマートフォンノード" + "最高" + "Gateway ホストで実行:" + "スキルの変更にはoperator.adminが必要です。管理者権限を持つGatewayトークンで再接続してください。" + "ClawHub の Skills を確認するには、Gateway に接続してください。" + "アプリ一覧はこのスマートフォンに保持されます。" + "アイドル" + "Android のユーザー補助設定に表示されます。" + "スマート配信" + "拒否" + "%2$s後、Gatewayからステータス「%1$s」が返されました。" + "Gateway トークンが設定されていません" + "Not available to this agent" + "ファイル" + "権限" + "カメラを起動できませんでした。ギャラリーからQR画像を選択するか、セットアップコードを手動で入力してください。" + "タップしてコピー" + "%1$s分待機中" + "%1$s." + "ClawHub の Skills をインストールするには、Gateway に接続してください。" + "音声を検索" + " · マイク: リスニング中" + "Gateway 設定を確認・変更するには、operator.admin アクセスで再接続してください。" + "さらに読み込む" + "3秒後に監視" + "run" + "音声を生成中…" + "← 戻る" + "切断" + "Gateway コンピューターで approve コマンドを実行してから、もう一度確認してください。" + "自動化" + "%1$s分" + "信頼" + "このQRコードはOpenClawのセットアップQRではありません。openclaw qr で新しいコードを生成してから、もう一度お試しください。" + "優先マイクを利用できないため、自動ルーティングを使用しています。" + "拒否済み" + "Android とバックグラウンドのパッケージを含めます。" + "Gatewayの準備ができました。" + "トリガーされました" + "Structured Output" + "予想より時間がかかっています。\nGatewayが起動していて、接続可能であることを確認してください。" + "このエージェントにはバックグラウンドタスクがありません。" + "再接続中" + "OpenClaw が Gateway とノードへのアクセスを確認しています。" + "Code Execution" + "プロバイダーの使用なし" + "レビュー" + "マイクの権限が必要です。" + "%1$s日" + "%1$s件利用可能" + "OpenClawを再同期しています" + "イベントストリームが中断されました。再読み込みしてください。" + "ノードとデバイスを読み込めませんでした。" + "Skillsを読み込むには、Gatewayに接続してください。" + "不明" + "出力" + "トークに失敗しました: Realtime プロバイダーが予期せず終了しました。" + "OpenClaw 緊急" + "ban" + "Gateway トークンが必要です" + "ペアリング済みデバイス" + "再承認が必要です" + "スケジュール未設定" + "連絡先" + "必要になるまでスマートフォンは静かなままです" + "聞き取り中 · キュー内の音声を送信中" + "タスクの詳細を読み込めませんでした" + "エージェントメッセージ" + "Gateway にはこのデバイス ID が必要です。再認証するか、この Gateway 接続をリセットしてください。" + "次のセッション" + "接続のセキュリティ" + "今はスキップ" + "Webサイト" + "Gateway に接続して、アプリで承認リクエストを読み込みます。" + "%1$s をコピーしました" + "アプリが選択されていません。アプリを追加するまで転送されません。" + "%1$s %2$s" + "セットアップが必要" + "未ペアリング" + "Gatewayがこのスマートフォンを認識しました" + "設定済みのモデルはありません" + "無効化" + "アプリの言語" + "Gatewayをペアリング中" + "保存済みの認証情報が無効です" + "%1$s件のスコープ" + "最近のログを読み込むには、Gatewayに接続してください。" + "ウェイクワードを保存" + "インストール済みのスキルを管理し、ClawHubから信頼できるリリースを追加します。" + "送信中…" + "エージェントはまだ読み込まれていません。" + "ClawHubを検索" + "チャットがGatewayの稼働状態を確認しています。" + "ペアリングが必要です" + "実行中" + "失敗 — %1$s" + "この電話と OpenClaw の接続。" + "summarize" + "ウィジェット画像をダウンロードに保存しました" + "起動中…" + "%1$sトークン" + "クライアントエラー" + "アクセスを許可する前に、要求元のデバイスを確認してください。" + "Bluetooth LEマイク" + "%1$s %2$s" + "自動化を有効にしました。" + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "アーカイブ済み" + "再読み込み" + "オートメーションを検索" + "ペアリング後、リンクされたスマートフォンとノードホストがここに表示されます。" + "%1$s: %2$s" + "この自動化はGateway上で変更されています。再度保存する前に、最新バージョンを確認してください。" + "ディクテーションを停止" + "読みやすい" + "OpenClaw にメッセージ" + "Gateway パスワードが無効です。再入力するか、この Gateway 接続をリセットしてください。" + "再接続" + "ISO時刻(例: 2026-07-09T09:30:00Z)" + "%1$s 個のツール" + "以前の応答でこの承認は既に拒否されています。" + "リンク済み" + "%1$s を開く" + "%1$s/%2$s" + "自動化の実行が不明なステータスで完了しました。" + "オフラインキューが上限(%1$s件)に達しています。先にキュー内の項目を削除してください。" + "公開Gatewayにはwss://またはTailscale Serveが必要です。ws://はlocalhost、.localホスト、Androidエミュレーター、およびプライベートLAN IPで使用できます。" + "Skillsの詳細を読み込むには、Gatewayに接続してください。" + "フルアクセスが必要です" + "ウェイクリスナー" + "リンクのプレビューを展開" + "スレッド検索をクリア" + "NULL (FAILED)" + "更新" + "管理" + "対応が必要" + "このデバイスをGatewayとペアリングすると、実際の作業時にのみ起動し、エージェントの稼働状況をいつでも確認でき、バッテリーを消耗するバックグラウンドループを回避できます。" + "ロール" + "返信" + "プロバイダーカタログ" + "設定で権限を有効にする" + "A2UI push" + "アクセスを確認" + "Gatewayに接続可能であれば、操作しなくても再接続が完了します。" + "エージェントターン" + "隔離" + "注意" + "検索中…" + "セットアップコードはどこで入手できますか?" + "スキルを有効にできませんでした。" + "pdf" + "削除" + "%1$s%% がオンライン" + "チャンネルはありません" + "リアルタイム音声" + "Skill Workshop の拒否および隔離アクション" + "ノードとデバイス" + "ローカルコマンドセンター" + "emoji upload" + "プレビューを読み込み中…" + "高" + "focus" + "describe" + "%1$s コンテキスト" + "応答を待機中..." + "voice" + "%1$sに接続済み" + "role add" + "チャットに対応が必要です" + "マイクを有効にする" + "OpenClaw は、ペアリングされた OpenClaw Gateway が要求したときに、この端末に表示されているアプリの名前、パッケージ ID、ステータスを収集して送信します。これにより、アシスタントがインストール済みアプリを使って質問に答えたり操作を実行したりできます。" + "Gatewayに接続されていません" + "ポリシー" + "送信メッセージの確認中にタイムアウトしました。再読み込みして配信状況を確認してください。" + "サポートファイル" + "式" + "バックグラウンドタスク" + "夢" + "ブロックされているアプリはありません。ブロックを追加しない限り、アプリは転送できます。" + "音声認識を利用できません" + "プラットフォーム" + "Gateway から %1$s の設定が返されませんでした" + "Gatewayを削除しますか?" + "任意の説明" + "%1$s を開く" + "ホームキャンバス" + "ドリーミング" + "%1$s~%2$s" + "ファイルを共有" + "リアルタイム" + "API" + "OpenClaw が処理中です…" + "OpenClawで会話または音声入力" + "インストール済みアプリの情報を共有しますか?" + "自動化を読み込み中…" + "自動化を削除" + "デフォルトのアシスタント" + "Gateway でサポートされている %1$s プロバイダーを選択してください" + "利用不可" + "空のフォルダ" + "設定を開く" + "オフ" + "タイポグラフィ" + "停止" + "一致するスレッドはまだありません。" + "Gatewayのペアリングに成功しました。\nオペレーターUIから、このスマートフォンのノード機能を承認してください。" + "このスキルはインストールされていますが、現在は実行できません。設定の変更にはデスクトップかCLIを使用してください。" + "音声認識機能がビジー状態です" + "ホームGateway" + "Gatewayで承認コマンドを実行してください" + "サービスが無効です" + "Skill Workshopの提案を読み込めませんでした。" + "最近のOpenClawスレッドの内容をまとめ、次のステップを提案してください。" + "今はしない" + "openclaw qr" + "start" + "OpenClaw Node · トーク" + "予定を読み取り、更新する" + "トークに失敗しました: Realtime プロバイダーが終了しました: %1$s" + "ワークスペースのファイルを参照するには、Gatewayに接続してください。" + "Gateway リレー経由の %1$s" + "Gateway のトークカタログを読み込めませんでした" + "監視中 · 1 件のスケジュール済みジョブ" + "%1$s時間ごと" + "画面サーフェス" + "OpenClawの翻訳 · %1$s" + "コマンドリクエスト" + "最新です" + "チャンネル" + "ミュート解除" + "新しいグループ…" + "音声を準備中…" + "適応" + "まもなく" + "他%1$s件のワーカー" + "Web Search" + "チャット、音声、スレッド、プロバイダー、または設定をお試しください。" + "OpenClaw が有効です" + "navigate" + "%1$sにリクエスト" + "自動化の実行履歴を確認するには、Gatewayに接続してください。" + "デバイスへのアクセス(Gatewayでのオプトインも必要)" + "中止されました" + "有効なセットアップコードまたは Gateway アドレスを入力してください。" + "モデル" + "OpenClaw パッシブ" + "Gateway パスワードが無効です" + "デバイスのペアリング変更を確認できませんでした。更新して、もう一度お試しください。" + "詳細を表示" + "Bash" + "トークン" + "接続された OpenClaw エージェントは、有効にしたデバイス機能を使用できます。接続先の Gateway とエージェントを信頼できる場合にのみ続行してください。" + "フジツボ採り" + "選択した写真またはすべての写真へのアクセスが許可されています。" + "アクセシビリティ実行機能" + "不足している項目が%1$s件あります" + "計画チェックリストを折りたたむ" + "ノードの承認が必要です" + "Gatewayに接続" + "... +%1$s 件追加" + "計画チェックリストを展開" + "ブラウザ" + "screen record" + "実行保留中" + "有効にすると、アーム中に OpenClaw が他のアプリの画面を観察および操作できるようになります。Android のユーザー補助アクセスが必要です。" + "オリジン" + "あなたのデバイス上のパーソナル AI" + "Attach" + "自動" + "概要" + "復元をリクエストできませんでした。タップして再試行してください。" + "動画" + "%1$s\n\n" + "暗号化なし" + "カレンダー" + "Gatewayの状態が正常ではないため、送信できません" + "📎 %1$s" + "前回のステータス" + "新しいチャットを開始する前に、現在の応答が完了するまでお待ちください。" + "プロフィール" + "Gateway がプロバイダーの制限を報告すると、ここに表示されます。" + "1件の問題" + "「%1$s」内のスレッドは保持され、未分類に戻ります。" + "おすすめ" + "作成日時" + "%1$s/%2$s 個の有効なトークン" + "アクション結果なし" + "スナップ中" + "%1$s…" + "Skillの詳細を開く" + "読み上げに失敗しました: %1$s" + "トークを開始" + "このフォルダを読み込めませんでした。" + "QRコードに有効なセットアップコードが含まれていませんでした。" + "ノードへのアクセスを確認" + "ウェイクフレーズを追加" + "Gateway に到達できません" + "自動化" + "接続が必要です" + "承認を解決できませんでした。更新して、もう一度お試しください。" + "import" + "この電話が OpenClaw にどのように表示されるか。" + "スレッド検索にフォーカス" + "Gatewayを接続" + "カレンダーを読む" + "概要は、再接続時とこの画面を開いたときに更新されます。" + "スキルを無効にできませんでした。" + "接続を続行中" + "%1$s分後" + "SMSを読む" + "Gateway に接続して使用状況を読み込みます。" + "今、このスマートフォンから何を手伝ってもらえますか?" + "承認が必要" + "新しいチャット" + "Skill Workshopの提案を更新するにはGatewayに接続してください。" + "OpenClaw のリクエストに失敗しました。" + "権限が必要です" + "プロバイダーの準備状況と\n設定済みモデルを確認します。" + "読み込み中" + "失敗アラート" + "テーマと翻訳された Android テキスト。" + "マイク オフ · 送信中…" + "なし" + "表示" + "名前" + "バージョン" + "Cron" + "OpenClaw を開く前に、この電話を Gateway に接続してください。" + "ウェイクフレーズを削除" + "セットアップコードが受け付けられませんでした。openclaw qr で新しいコードを生成してください。" + "14 件のメッセージ · Android" + "文字起こしに失敗しました: %1$s" + "常に許可" + "ドリーミングを読み込めませんでした。" + "自動化の実行がキューに登録されました。" + "Conversation Turn" + "自動化を開始しました。" + "新しいグループ" + "サーバーエラー" + "Video Generation" + "Gatewayの承認待ちです。Gatewayホストでopenclaw devices listを実行し、このスマートフォンを承認してから、再試行してください。" + "夢見サイクルが物語形式の要約を書き込むと、エントリが表示されます。" + "%1$sミリ秒" + "メモリストア" + "アシスタントが作業中" + "OpenClaw はランチャーに表示されるアプリを一覧表示できます。" + "会話に失敗しました: %1$s" + "インストール済みのSkillsを検索" + "検査" + "Process" + "最近のスレッド" + "ターミナル" + "現在" + "1件のアカウント" + "一時停止中" + "カメラを許可" + "このスマートフォンが接続されている間、Exec 承認リクエストがここに表示されます。" + " · マイク: 保留中" + "コピー" + "詳細をコピーしました" + "削除" + "Androidの機能を使用するようOpenClawに依頼します。" + "member" + "この Gateway が OpenClaw 設定アシスタントに対応しているかを確認中です。" + "再接続するには、以下の復旧オプションを使用してください。" + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "チャンネルを読み込めませんでした。" + "%1$s日後" + "連続エラー数" + "この画像からQRコードを読み取れませんでした。より鮮明な画像を選択するか、セットアップコードを手動で入力してください。" + "Gatewayはこのアプリより古いバージョンです。GatewayホストのOpenClawを更新してから、再試行してください。" + "チャット、音声、ライブステータスを使用する前に接続してください。" + "Gateway に再接続" + "サードパーティ" + "準備状況を確認" + "制限あり" + "OpenClaw ロゴ" + "モデルのピン留めを解除" + "この Gateway に接続されているメッセージング画面。" + "送信中" + "アーカイブ済みのスレッドがここに表示されます。" + "コマンドをコピーしました" + "利用できるプレビューはありません" + "Gatewayでこのスマートフォンを承認してください。\nその後、接続を再試行してください。" + "QRコードをスキャン" + "コマンドの作業ディレクトリ · クリアできません" + "スレッドのアクティビティ" + "利用可能" + "自動化を削除しますか?" + "今日 %1$s 件 · 合計 %2$s 件" + "パスワード" + "提案を隔離しますか?" + "このビルドにはライセンス通知が含まれていません。" + "ウィジェット画像を保存できませんでした" + "待機時間 %1$s" + "発話中…" + "プロバイダーとモデル" + "ノード" + "%1$s " + "プロンプトを利用できません" + "ログ" + "Skill Workshopの提案を確認するには、Gatewayに接続してください。" + "ツール" + "Gatewayスイッチ" + "SMSを送信" + "OpenClaw は通常のチャットで続行する準備ができています。" + "コマンドが見つかりません" + "キャンバスはまだ更新されていません。タップして再試行してください。" + "ターミナルには接続済みの Gateway が必要です" + "Exec" + "アプリフィルター" + "メイン" + "%1$sk" + "Gateway が必要です" + "アクセス" + "パッケージ: snapshot=%1$s foreground=%2$s" + "接続を再試行" + "Cron スケジューラは停止しています。" + "開く" + "メッセージをコピーしました" + "Gatewayに接続できませんでした。\n問題を解決しましょう。" + "たった今" + "実行後に削除" + "このスマートフォンで選択済み" + "unpin" + "Session History" + "ピン留めを解除" + "このスマートフォンを使用" + "%1$s の ClawHub の詳細を読み込めませんでした。" + "ツールを実行中" + "位置情報が有効な間、正確な位置情報を共有します。" + "Mobile UI" + "テーマ" + "Gateway ではこの承認がまだ保留中と表示されています。再試行する前に確認してください。" + "ボイスノートを完了" + "音声入力: %1$s" + "許可されていません" + "別の画像を選択" + "画像プレビュー" + "OpenClawは、あなたが会話またはディクテーションを開始したときのみ聞き取ります。" + "歩数とアクティビティを共有" + "セットアップが必要" + "OpenClaw 設定アシスタントを使用するには、この Gateway を更新してください。" + "ClawHub の Skills をインストールするには、この Gateway 接続に operator.admin が必要です。" + "提案が適用されました。" + "%1$s 件保留中" + "%1$s時間前" + "通話履歴を読む" + "%1$s 件がキューに追加済み · Gateway を待機中" + "グループに移動" + "QR コードをスキャンしてペアリング" + "承認が拒否されました。" + "Skill Workshopの提案を確認できませんでした。" + "ピン留め済み" + "プロフィールとデバイス" + "思考レベルセレクターを閉じる" + "後で配信するメッセージをキューに追加できませんでした。" + "隔離" + "スケジュール · %1$s" + "思考レベルを更新できませんでした。" + "思考レベルセレクターを開く" + "音声応答がタイムアウトしました。キューに入ったターンを再試行しています" + "レイアウト: 詳細" + "この画像をデコードできませんでした。" + "Gateway、音声、通知、プライバシー" + "エージェントのワークスペースファイル" + "このデバイスは、信頼済みのGatewayアクセスを失います。" + "保留中のコマンドの requestId を approve コマンドで使用します。" + "スケジュール" + "レート制限" + "未配信" + "ペイロード · %1$s" + "実行中" + "ハサミを振るい中" + "終了" + "システムの信頼設定を使用" + "準備完了のプロバイダーはありません" + "接続済みのBluetoothマイクを優先します。" + "%1$s 個のアプリの転送をブロックしました。" + "メッセージアクション" + "種類" + "アーカイブを解除" + "Transcripts" + "ウェイクワード" + "Gateway で %1$s を設定してください" + "QR コードをスキャンするか、OpenClaw Gateway からのセットアップコードを使用します。" + "デザインシステムのプロトタイプ" + "ふるい分け中" + " · トーク:オン" + "使用状況データはまだありません。" + "実行開始前にチャットが失敗しました。もう一度お試しください。" + "送信" + "一部の共有画像は省略されたか、追加できませんでした。" + "カレンダーを書き込む" + "timeout" + "低" + "ブロックリスト" + "act" + "Dismiss Task" + "チャットに失敗しました" + "OpenClaw · ライブ" + "インストール済み" + "返信の待機中にタイムアウトしました。もう一度試すか、再読み込みしてください。" + "以前の会話を検索" + "スレッドを参照" + "更新中" + "真珠採り" + "カメラを開き、openclaw qrのコードを枠内に収めてください。" + "デバイスはありません" + "通知を転送" + "この会話は通常のエージェントチャットとは分けて扱います。" + "Gatewayセッションはオンラインに復帰中です。エージェントのショートカットはまもなく自動的に安定します。" + "別の検索を試すか、現在の検索条件をクリアしてください。" + "バックグラウンドでの位置情報を許可しますか?" + "浮上中" + "ブートストラップ" + "%1$s · %2$s · %3$s" + "ボイスノートをキャンセル" + "後方へスクロール" + "openclaw gateway" + "Gatewayをペアリングしました" + "脱皮中" + "次の発話を待っています。" + "OpenClaw が作業中です" + "ログエントリ" + "失敗: このホストのセキュア Gateway エンドポイントに到達できませんでした。" + "Gatewayはオフラインです。以下で接続を修正するか、診断情報をコピーしてください。" + "スタンバイ" + "テスト テスト 1 2 3" + "ClawHub の Skills を検索できませんでした。" + "プロンプトなし" + "前面カメラ" + "ログエントリを開く" + "ネットワークがタイムアウトしました" + "今すぐ" + "グループ名を変更…" + "その他のエージェント" + "openclaw nodes approve REQUEST_ID" + "ピン留め" + "thread list" + "%1$sを開く" + "upload" + "Gateway パスワードが設定されていません" + "音声入力設定" + "プロバイダーモデルは読み込まれましたが、準備状況を確認できません。" + "スレッドを削除しますか?" + "OpenClawは、このスマートフォンをスレッド、音声、プロバイダー、Gatewayのためのシンプルなモバイルコマンド画面に変えます。" + "新しい順" + "次のサイクル" + diff --git a/app/src/main/res/values-ko/assistant.xml b/app/src/main/res/values-ko/assistant.xml new file mode 100644 index 0000000..f3511af --- /dev/null +++ b/app/src/main/res/values-ko/assistant.xml @@ -0,0 +1,7 @@ + + + "OpenClaw에 %1$s 요청" + "OpenClaw에 %1$s 지시" + "OpenClaw을 열고 %1$s 요청" + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..ebf8882 --- /dev/null +++ b/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + 이 게이트웨이를 신뢰하시겠습니까? + 신뢰하고 계속 + 취소 + worktree에서 새 채팅 + 이 게이트웨이를 신뢰하기 전에 인증서 지문을 확인하세요.\n\n%1$s + 게이트웨이 인증서가 변경되었습니다. 예상한 변경인 경우에만 계속하세요.\n\n이전 SHA-256:\n%1$s\n\n새 SHA-256:\n%2$s + 알 수 없음 + 버전 + 커밋 + 빌드 날짜 + 버전 %1$s + Git 커밋 %1$s + 빌드 날짜 %1$s UTC, 타임스탬프 %2$s + 빌드 날짜 %1$s + Git 커밋 전체 해시 복사 + 전체 빌드 타임스탬프 복사 + OpenClaw Git 커밋 + OpenClaw 빌드 타임스탬프 + Git 커밋을 복사했습니다 + 빌드 타임스탬프를 복사했습니다 + + "전송할 첨부 파일을 준비할 수 없습니다." + "마이크 꺼짐" + "OpenClaw 알림 표시" + "스레드 활동" + "전체" + "승인이 허용되고 저장되었습니다." + "최근 통화 기록 표시" + "1개 대기 중" + "지원되지 않는 첨부 파일" + "0 = 정확히" + "스레드를 검색하려면 Gateway를 연결하세요." + "계정 %1$s개" + "Cron을 변경하려면 operator.admin이 필요합니다. 설정 코드는 의도적으로 이 권한을 부여하지 않습니다. 관리자 액세스를 요청하려면 Gateway의 공유 토큰 또는 비밀번호로 다시 연결하세요. 이 기기에 여전히 권한이 없다면 기존 관리자 클라이언트에서 대기 중인 범위 업그레이드를 승인하세요." + "Apply Patch" + "꼬집는 중" + "스피커 음소거 해제" + "연속 건너뛰기" + "이 폴더에는 아직 파일이 없습니다." + "연결되지 않음" + "설치된 Skill의 상태를 확인하고 관리하세요." + "실패" + "기본 에이전트" + "카메라" + "그룹에서 제거" + "검색 중" + "음성 재생을 위해 일시정지됨" + "Gateway는 다운로드 전에 ClawHub에서 이 정확한 릴리스를 확인합니다. 릴리스에 명시적인 위험 확인이 필요한 경우, Android는 재시도하기 전에 Gateway 경고를 표시합니다." + "설정 코드가 IPv6 영역 ID를 사용합니다. 범위가 지정되지 않은 IPv6 주소 또는 LAN 호스트 이름을 사용하세요." + "첨부 파일" + "호출어, 대화 및 재생을 설정합니다." + "듣는 중(PTT)" + "제안이 거부되었습니다." + "사이드바 표시" + "사용자" + "%1$s · %2$s" + "최소" + "거부" + "활성 에이전트" + "1개 예약됨" + "응답 없음" + "%1$s 선택됨" + "명령 argv JSON 배열" + "해당 이미지를 읽을 수 없습니다. openclaw qr의 QR이 선명하게 보이는 스크린샷이나 이미지를 선택하세요." + "Skill Workshop 제안을 %1$s하지 못했습니다." + "다른 곳에서 응답 완료" + "Gateway가 한 번 승인을 기록했습니다." + "status" + "OpenClaw는 페어링된 Gateway가 요청할 때만 위치를 확인합니다. 앱이 백그라운드에 있는 동안에도 위치를 확인할 수 있도록 다음 Android 화면에서 %1$s을 선택하세요." + "거부" + "대비" + "Gateway 설정을 교체할까요?" + "자동화를 불러올 수 없습니다." + "나" + "내장 마이크" + "표면" + "제안 없음" + "기본 스레드" + "채팅 열기" + "이 Gateway 세션에서는 기기 페어링 작업을 사용할 수 없습니다. Gateway 호스트에서 openclaw devices list를 실행하고 해당 요청을 관리하세요. 노드 기능 승인은 별도로 처리되며 계속해서 nodes approve <request id>를 사용합니다." + "작업 요청" + "list pins" + "Skill Workshop 제안을 불러오려면 Gateway에 연결하세요." + "설정 코드가 승인되지 않았습니다" + "로그아웃" + "실시간 음성 인식 제공업체가 구성되지 않았습니다." + "시스템 앱 표시" + "제공자 모델 구성을 보려면 Gateway를 업데이트하세요." + "받아쓰기 전송 중" + "이 제안을 검토하여 Markdown을 불러오세요." + "OpenClaw을 열고 %1$s 요청" + "추론" + "클라이언트" + "적용됨" + "동영상" + "승격됨" + "온라인" + "범위" + "실시간 음성 제공업체가 구성되지 않았습니다." + "%1$s · %2$s" + "kick" + "Gateway에서 잘못된 자동화를 반환했습니다." + "인스턴스 ID" + "Gateway 토큰이 필요합니다. 다시 입력하거나 이 연결을 편집하세요." + "소스" + "새로 고침" + "%1$s개 대기 중" + "채팅 시작" + "활성 스레드에서 대기 중인 채팅 도구 호출이 여기에 계속 표시됩니다." + "인증서 검토 필요" + "현재 Canvas 화면을 열어 확인하거나 상호작용합니다." + "자동화가 업데이트되었습니다." + "최근 세션 없음" + "스크립트" + "Gateway 상태, 전화 노드 준비 상태 및 최근 로그 스트림." + "자동화 세부 정보 열기" + "런타임" + "1개 더 많은 워커" + "에이전트 %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "계속하려면 Android 설정에서 %1$s을(를) 사용 설정하세요." + "복구" + "reactions" + "완료" + "버전 및 업데이트" + "OpenClaw은 승인, 실패한 작업 및 채널 문제를 여기에 표시합니다." + "USB 마이크" + "추가 대기 중인 공유가 너무 많습니다." + "건너뜀" + "Gateway 컴퓨터의 LAN 주소 또는 안전한 원격 호스트 이름을 사용하세요." + "켜짐" + "스레드 검색 중" + "실시간 대화" + "· %1$s" + "OpenClaw가 응답을 준비하고 있습니다." + "한 번 승인이 허용되었습니다." + "제공업체 설정" + "없음" + "스크립트 페이로드는 변경 없이 보존됩니다. 이 스크립트를 편집하려면 CLI를 사용하세요." + "Android 설정 가이드" + "%1$s개 앱의 전달이 차단되었습니다." + "페어링된 기기" + ":%1$s" + "%1$s개 대기 중" + "기기 이름" + "제출" + "위치" + "예: America/New_York" + "세션 대상" + "ClawHub Skill 검토" + "snapshot" + "이 기기의 페어링 요청을 거부하시겠습니까?" + "선호하는 마이크" + "노드 호스트" + "수준" + "앱 선택기 닫기" + "공유된 Gateway 토큰 또는 운영자가 발급한 토큰을 붙여넣으세요." + "모든 시스템이 정상입니다" + "Gateway 진단이 복사되었습니다" + "오디오 오류" + "설정 교체" + "빠른 작업" + "전송 실패: 실행이 시작되기 전에 채팅에 실패했습니다. 다시 시도하세요." + "마이크" + "채팅에서 아직 Gateway 상태를 확인하고 있습니다." + "정확한 위치" + "한 번 허용" + "+%1$s개 더" + "thread create" + "차단됨" + "호출어 또는 문구" + "Gateway에 기기 승인이 필요합니다" + "외부 마이크" + "%1$s/%2$s개 준비됨" + "연결됨(운영자 오프라인)" + "기능이 승인되지 않음" + "이 작업은 Gateway에서 자동화와 해당 일정을 영구적으로 제거합니다." + "이미지 로드 중…" + "연결" + "노드 액세스 승인" + "Gateway 추가" + "텍스트 변환을 사용할 수 없습니다: %1$s" + "이미지" + "물결치는 중" + "이미지 미리 보기 닫기" + "eval" + "마지막 명령: %1$s" + "OpenClaw를 실행 중인 기기에서 터미널을 열어 두세요." + "누락된 항목 없음" + "캔버스 출력에는 활성 Gateway 연결이 필요합니다." + "%1$s · %2$s" + "격리됨" + "© 2026 OpenClaw Foundation — MIT 라이선스." + "PDF" + "Conversations" + "메모리 통합 및 꿈 일기." + "Create Goal" + "편집하는 동안 이 자동화가 변경되었습니다. 저장하기 전에 Gateway의 최신 버전으로 되돌리세요." + "연결되면 Gateway는 상시 연결 세션을 유지하는 대신 무음 푸시로 휴대전화를 깨울 수 있습니다." + "깨우기 모드" + "페어링된 기기를 제거하시겠습니까?" + "시스템 이벤트 텍스트" + "위젯 이미지를 복사할 수 없습니다" + "아니요" + "선택적 경로" + "대기 중인 음성 보내는 중" + "기본 제공" + "hide" + "runs" + "Gateway 비밀번호가 필요합니다. 다시 입력하거나 이 연결을 편집하세요." + "이벤트 텍스트" + "실시간 스크립트" + "제공자 모델 구성을 불러올 수 없습니다." + "%1$s개 앱의 전달이 허용되었습니다." + "음성 설정" + "동영상 첨부" + "숨겨진 추가 이미지: %1$s" + "페어링 요청을 거부하시겠습니까?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "게시자" + "여기서 분기" + " · 대화: 말하는 중" + "선택적 재정의" + "기본값" + "명령 승인" + "앱과 Gateway가 호환되지 않는 프로토콜 버전을 사용합니다. 양쪽의 OpenClaw를 업데이트한 후 다시 시도하세요." + "화면 새로 고침" + "최근 사진 및 미디어 읽기" + "듣기" + "연결됨" + "완료됨" + "휴대전화가 %1$s에 페어링되었습니다. 계속하여 노드 액세스 설정을 완료하세요." + "현재 스레드" + "사고 과정 %1$s" + "음소거됨" + "OpenClaw는 오픈 소스 커뮤니티의 파트너 여러분께 감사드립니다." + "열기" + "Gateway 연결 중" + "gateway는 이 경로를 변경할 수 있지만 기존 경로를 지울 수는 없습니다." + "TTS" + "저장 중…" + "기준 %1$s" + "search" + "활성화" + "예약된 Gateway 작업을 확인하고 관리합니다." + "이전 응답에서 이미 이 명령을 한 번 허용했습니다." + "generate" + "신뢰할 수 있는 비공개 네트워크에서만 사용하세요." + "설정 검색" + "대화가 진행 중" + "Gateway 인증이 구성되어 있지 않습니다. 이 연결을 편집한 후 다시 시도하세요." + "실패: 보안 엔드포인트에 도달했지만 TLS 지문 확인 시간이 초과되었습니다. Tailscale Serve 또는 gateway TLS를 확인한 후 다시 시도하세요." + "1단계" + "받아쓰기" + "앱 선택기 열기" + "대기 중인 승인이 없습니다" + "edit" + "Gateway에 연결" + "openclaw qr의 설정 코드를 입력하세요." + "진단" + "다른 앱은 변경되지 않습니다." + "껍데기 깨는 중" + "이 작업은 스레드와 해당 대화 내용을 영구적으로 삭제합니다." + "기기가 승인되었습니다." + "자동으로 다시 시도 중" + "위젯 이미지가 복사되었습니다" + "역할 %1$s개" + "누락된 항목 1개" + "%1$s개 예약됨" + "react" + "에이전트" + "자동화를 불러오려면 Gateway에 연결하세요." + "다시 연결 중…" + "설정으로 돌아가기" + "send" + "연결을 테스트할 수 없습니다" + "확인 및 설치" + "이 기기를 채팅, 음성, 카메라 및 기기 도구를 위한 안전한 OpenClaw 노드로 전환하세요." + "수동 설정" + "현재 스레드를 시작하거나 재개하려면 채팅을 여세요." + "팁: 캡처된 턴을 보내려면 듣기를 중지하세요." + "건너뛰기" + "음성 요청 실패" + "이 작업은 \"%1$s\"을(를) 거부하고 Gateway에서 Skill Workshop 상태를 새로 고칩니다." + "껍데기 다듬는 중" + "update" + "공유" + "카메라 활성화됨" + "설정 후 Telegram, WhatsApp, 이메일 및 기타 채널이 여기에 표시됩니다." + "네트워크 오류" + "조수 웅덩이 탐사" + "session=%1$s source=%2$s에 대해 지금 Canvas를 복원하세요. 기존 A2UI 상태가 있으면 즉시 재생하세요. 없으면 Canvas에서 간결하고 모바일 친화적인 대시보드를 만들어 렌더링하세요." + "시작 실패: %1$s" + "요청되지 않음" + "Gateway에서 %1$s 제공자를 구성하세요" + "kill" + "승인" + "파일을 사용할 수 없음" + "읽지 않음으로 표시" + "사람 및 연락처 정보 찾기" + "기기 ID가 필요합니다" + "OpenClaw 스레드" + "사진 보관함 접근을 허용하세요." + "이전 응답에서 이미 이 승인을 처리했습니다." + "최근 스레드 없음" + "시간 초과 %1$s초" + "일치하는 항목 없음" + "선택한 앱의 알림 읽기" + "사용 가능 여부 알 수 없음" + "대화 설정" + "추가" + "Gateway가 페어링되었습니다. 운영자 액세스를 기다리는 중입니다." + "이미지 첨부" + "OpenClaw에 도달할 항목을 선택하세요." + "기능 재승인 대기 중" + "강조 표시된 항목을 검토하세요" + "듣는 중..." + "최근 내용 확인" + "메시지" + "연락처 읽기" + "오프라인 첨부 파일 저장 공간이 가득 찼습니다. 먼저 대기 중인 항목을 삭제하세요." + "한 번" + "이름 변경" + "채널을 찾을 수 없습니다." + "모두 보기" + "새 기기" + "Session Status" + "이미지 미리 보기 열기" + "세션 브랜치가 변경되었습니다. 이 메시지를 검토하고 다시 시도하세요." + "close" + "설정 코드인 것 같습니다. 뒤로 돌아가 Gateway 설정을 선택한 다음 설정 코드 사용을 선택하세요." + "✦" + "에이전트 및 자동화" + "적용" + "자동화 실행을 건너뛰었습니다." + "계속" + "모니터링 중 · 예약된 작업 %1$s개" + "찾아보기" + "tabs" + "대기 중" + "대화: %1$s" + "read" + "텍스트 선택" + "모션 활동" + "description: %1$s" + "오디오 재생" + "시간" + "확인되지 않음" + "Yield" + "승인 명령 복사" + "현재 화면 출력 및 대화형 앱 표면." + "서비스 연결됨" + "표시" + "준비되면 시작하세요" + "제공자 카탈로그를 불러올 수 없습니다." + "말하는 중 · 응답 대기 중" + "허용되지 않음" + "변경 사항 저장" + "Gateway에서 자동화 실행을 거부했습니다." + "Session Send" + "ClawHub에서 찾기" + "OpenClaw가 백그라운드에 있는 동안 요청된 위치 확인을 항상 허용합니다. Android는 이를 지속적인 노드 알림에 표시합니다." + "시스템 이벤트" + "제공자를 보려면 Gateway에 연결하세요" + "다음 하트비트" + "Gateway가 페어링되었습니다. 노드 기능 승인을 기다리는 중입니다." + "소금물에 담그는 중" + "Canvas 닫기" + "연락처 쓰기" + "이 검색과 일치하는 설치된 Skill이 없습니다." + "Talk Provider 설정" + "Music Generation" + "Talk 설정" + "모니터링 · 스레드 1개" + "페이로드 텍스트" + "텍스트 설정" + "승인 %1$s" + "Gateway에서 %1$s 준비 상태를 반환하지 않았습니다" + "구성된 모델이 %1$s개 있습니다. 새로 고쳐 사용 가능 여부를 다시 확인하세요." + "Conversation Send" + "캔버스" + "공급자 1개" + "Gateway 인증서를 자동으로 읽을 수 없습니다. Gateway 호스트에서 확인한 SHA-256 지문을 붙여넣으세요." + "전송 실패: %1$s" + "브리지" + "전달 오류" + "휴대폰에서 OpenClaw 사용" + "화면 모드" + "Skill 워크숍" + "토큰 필요" + "미리보기 · %1$s" + "마이크 권한이 필요합니다" + "Skill Workshop 제안을 불러오려면 Gateway를 연결하세요." + "모든 시스템이 정상 작동 중" + "Gateway에 연결할 수 없음" + "OC" + "업데이트됨" + "연결됨(노드 오프라인)" + "홈" + "받아쓰기를 듣는 중입니다" + "보관된 스레드 없음" + "이 Gateway에서 사용할 수 있는 어시스턴트를 선택하고 검사합니다." + "대화 모드 활성화됨" + "작업 중 · 활성 실행 1개" + "동의하고 활성화" + "Gateway 업데이트 필요" + "이미지 복사" + "Gateway URL" + "main, isolated, current 또는 session:<id>" + "미디어를 사용할 수 없음" + "에이전트 워크스페이스에서 셸을 열려면 gateway에 연결하세요." + "%1$s://%2$s:%3$s" + "승인 세부 정보를 불러오지 못했습니다. 새로 고침 후 다시 시도하세요." + "Gateway 상태 확인, 구성 복구, 모델 변경 또는 채널 연결을 할 수 있습니다." + "Tool Call" + "스레드" + "Write" + "프롬프트로 시작하거나 음성을 사용하세요." + "D" + "설정 열기" + "관찰 중…" + "대화 종료" + "마지막 오류" + "주의가 필요한 작업을 검토하세요." + "모든 에이전트에 대해 비활성화되었습니다." + "음성 시작" + "백그라운드 작업으로 돌아가기" + "다른 cron 작업이 아직 완료 중입니다." + "대기 시간 %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "기기 내 음성 인식을 사용할 수 없습니다." + "스크립트 · 읽기 전용" + "첨부 파일이 너무 커서 하나의 메시지로 대기열에 추가할 수 없습니다. 일부를 제거한 후 다시 시도하세요." + "구성된 모델이 1개 있습니다. 새로 고쳐 사용 가능 여부를 다시 확인하세요." + "위젯을 사용할 수 없음" + "이 호스트에는 보안 연결이 필요합니다." + "최근 항목" + "일치하는 자동화가 없습니다." + "휴대폰에서 Gateway에 연결할 수 있음" + "Gateway" + "만료됨" + "Gateway에서 예약된 OpenClaw 작업." + "Sub-agent" + "기기 승인 대기 중" + "스레드 불러오는 중" + "이제 이 Gateway는 이 기기에서 신뢰하는 인증서를 제공합니다." + "지연 ms" + "event create" + "문서" + "Gateway 설정" + "동영상 재생" + "저장된 인증이 올바르지 않습니다. 다시 인증하거나 이 gateway 연결을 재설정하세요." + "사용 중" + "screenshot" + "여기로 되감기" + "Cron 표현식, 예: 0 9 * * *" + "음성으로 돌아가기" + "말하기" + "세부 정보" + "%1$s/%2$s개 온라인" + "%1$s개 앱의 전달이 허용되었습니다." + "채팅" + "마이크 접근 권한이 필요합니다." + "종종걸음 치는 중" + "편집" + "방해 금지 시간" + "진단 정보 복사" + "예약됨" + "생성" + "%1$s 후 만료" + "닫기" + "제안을 거부할까요?" + "음성 오류 (%1$s)" + "문제" + "레지스트리 메타데이터를 검색합니다. Gateway는 다운로드 전에 신뢰성을 다시 확인합니다." + "로컬 설정에는 사설 LAN IP를 사용하거나, 원격 액세스를 위해 Tailscale Serve를 활성화하거나 wss:// Gateway URL을 노출하세요." + "제출 중…" + "계정 %1$s" + "Suggest Task" + "검색" + "듣는 중" + "자동화를 불러오지 못했습니다." + "Gateway 업데이트를 사용할 수 있습니다. 준비가 되면 Web UI 또는 CLI에서 업데이트를 실행하세요." + "곧" + "Gateway 승인이 없습니다." + "호스트" + "필드마다 호출어나 문구를 하나씩 추가하세요. 그런 다음 명령 전에 하나를 말하세요." + "전사한 후 보내기" + "실행 시각" + "오디오 일시정지" + "Gateway 기기에 대한 액세스" + "미리보기 없음" + "기기" + "Android용 OpenClaw." + "기능 승인 대기 중" + "이 자동화를 실행, 활성화, 비활성화, 삭제 또는 새로 고침하기 전에 편집 내용을 저장하거나 되돌리세요." + "아직 자동화가 없습니다." + "이 Skills에는 설정 항목 %1$s개가 필요합니다. Android에서는 설치된 항목을 확인할 수 있습니다. 설정/구성 변경은 데스크톱 또는 CLI에서만 가능합니다." + "최근 %1$s개" + "채널" + "미분류" + "이 휴대전화에서 활성화됨" + "노드 액세스 확인 중" + "시간대" + "Skill Workshop 검사 및 적용 작업" + "항상 허용" + "present" + "Gateway에 설치된 Skills가 여기에 표시됩니다." + "코드가 만료되었거나 다른 Gateway용으로 생성되었을 수 있습니다." + "권한 필요" + "자동화 구성이 잘못되었습니다." + "허용 목록" + "설정, 상태 및 복구" + "groups" + "공개 키" + "정보" + "해당 이미지에서 설정 QR 코드를 찾을 수 없습니다. openclaw qr로 생성한 QR을 선택하거나 설정 코드를 직접 입력하세요." + "permissions" + "노드와 페어링된 기기를 불러오려면 Gateway를 연결하세요." + "브랜치 전환" + "Skills가 없습니다" + "응답을 소리 내어 재생" + "읽음으로 표시" + "노드 승인 대기 중" + "wake" + "제안 %1$s개" + "Gateway 인증을 확인해야 합니다." + "연결 세부 정보" + "밀리초" + "음성 인식" + "설명" + "최근 대화" + "휴대폰은 이 정보를 OpenClaw가 운영하는 서버가 아니라 사용자의 Gateway로 전송합니다. Gateway는 사용자가 선택한 AI 제공자에 대한 요청에 이 정보를 포함할 수 있습니다." + "전달" + "스피커 음소거" + "%1$s 실행 중 · %2$s 완료 · %3$s 실패" + "Gateway 연결 여는 중" + "모니터링 · 스레드 %1$s개" + "자동화 실행이 완료되었습니다." + "일치하는 앱이 없습니다." + "채팅으로 보내기" + "자동화가 삭제되었습니다." + "활성화" + "최근 실행" + "QR 코드를 사각형 안에 맞추세요." + "승인 목록을 불러올 수 없습니다." + "승인했습니다" + "제공자 준비 상태를 불러오려면 Gateway를 연결하세요." + "페어링되지 않음" + "이 승인은 처리되기 전에 만료되었습니다." + "%1$s초 후 관찰 — 대상 앱으로 전환하세요" + "에이전트 프롬프트" + "emoji list" + "반복" + "OpenClaw 검색" + "%1$s개 대기 중" + "기기 내 음성 인식을 사용할 수 없음" + "이 메시지를 공유할 수 있는 앱이 없습니다" + "검색 닫기" + "감시할 명령" + "상태" + "알림 리스너" + "스피커 음소거됨" + "스레드 검색" + "확인" + "설정 가이드를 열 수 없습니다." + "OpenClaw에 %1$s 요청" + "Wait for Agents" + "주소" + "Gateway에서 생성된 예약 작업이 여기에 표시됩니다." + "최신 로그 청크를 표시하고 있습니다." + "설정 코드 사용" + "sticker" + "보안 wss:// 또는 Tailscale Serve Gateway를 사용하고, Control UI 또는 openclaw qr로 전체 접근 설정 코드를 생성한 다음, 아래에서 스캔하거나 붙여넣고 다시 연결하여 설정 및 업그레이드를 활성화하세요." + "steer" + "선택됨" + "Android는 기존 설정 코드를 스캔하거나 붙여넣을 수 있지만, 이 gateway는 아직 앱에 설정 코드 생성을 제공하지 않습니다. gateway 호스트에서 openclaw qr로 QR/코드를 생성한 다음 여기에서 스캔하거나 아래에 설정 코드를 붙여넣으세요." + "Canvas 상태" + "연결 수정" + "이미지 저장" + "노드 %1$s" + "Gateway 비밀번호가 필요합니다" + "Update Plan" + "첨부 파일 제거" + "자동화 실행에 실패했습니다." + "공급자 제한 및 할당량 상태." + "Gateway 토크 카탈로그가 로드되지 않음" + "이 Gateway" + "아직 최근 실행이 없습니다." + "기기 내 언어 모델을 사용할 수 없음" + "대시보드를 사용하려면 Gateway 연결이 필요합니다" + "에이전트가 재사용 가능한 스킬 초안을 만들면 일치하는 제안이 여기에 표시됩니다." + "Session Search" + "OpenClaw가 말하는 중" + "QR 스캔" + "선택한 앱" + "변경 사항 되돌리기" + "승인 명령이 복사되었습니다" + "전달 상태" + "QR 코드가 허용되지 않음" + "음성 명령 센터입니다." + "연결 테스트" + "OPENCLAW" + "Web Fetch" + "프롬프트" + "기기를 승인하시겠습니까?" + "이 세션 대시보드를 열려면 Gateway에 연결하세요." + "이 휴대전화에서 %1$s 및 저장된 자격 증명을 삭제하시겠습니까?" + "QR 코드가 안전하지 않은 원격 gateway를 가리킵니다. %1$s %2$s" + "화면 표면 준비됨" + "Gateway 페어링" + "채널을 불러오려면 Gateway를 연결하세요." + "다른 음성 활동 중에는 일시 중지됩니다." + "모델" + "사진" + "설정 코드 붙여넣기" + "OpenClaw가 말하는 중" + "연결 중..." + " · 위치: 항상" + "메시지: %1$s" + "산호초를 누비는 중" + "Gateway에서 불러오기" + "text: %1$s" + "필요" + "rename group" + "준비됨" + "일기가 첫 항목을 기다리고 있습니다." + "승인" + "라이브 페이지" + "자동화가 이미 실행 중입니다." + "일회성 실행이 성공하면 이 자동화를 제거합니다." + "채팅 및 음성 사용 준비 완료" + "연결됨(운영자: %1$s)" + "Gateway 페어링이 완료되었습니다. OpenClaw가 활성화한 기기 기능을 사용할 수 있도록 이 휴대폰을 노드로 승인하세요." + "응답이 중단되었습니다" + "이미지" + "%1$s개 보류됨" + "일치하는 스레드 없음" + "delete" + "레이아웃: 간단히" + "channels" + "허용됨" + "%1$s분마다" + "토큰 1개" + "%1$s %2$s" + "설치된 앱" + "대기 중" + "음성 메모 준비 중…" + "안 함" + "하위 시스템" + "명령 종료 시" + "연결" + "자동화 실행 기록을 불러올 수 없습니다." + "자동화 이름" + "2단계" + "진단" + "일부 채널 상태 확인이 완료되지 않았습니다." + "pin" + "%1$s 복사" + "페어링됨" + "호출어를 저장할 수 없습니다" + "이 작업은 \"%1$s\"을(를) 격리하고 Gateway에서 Skill Workshop 상태를 새로 고칩니다." + "음성 메모 녹음" + "대기 중" + "응답 완료" + "요청 시 카메라 도구를 허용합니다." + "문제" + "음성 깨우기" + "페어링 요청이 거부되었습니다." + "%1$s일 전" + "roles" + "Skills" + "보관" + "노드가 오프라인입니다. 다시 연결한 후 재시도하세요." + "시스템" + "원격 IP" + "그룹 없음" + "일정 상세 정보" + "휴대전화 기능" + "사용할 수 없음" + "대시보드" + "토큰 붙여넣기" + "제공자 없음" + "SHA-256 지문" + "아직 스레드가 없습니다" + "Bluetooth 마이크" + "최근" + "스레드 이름 변경" + "처리 결과를 알 수 없습니다. Gateway 기록이 확인될 때까지 작업이 비활성화됩니다." + "dialog" + "호출어 감지" + "camera snap" + "재생 준비 중…" + "Gateway에서 알 수 없는 제공자 %1$s을(를) 선택했습니다" + "delete group" + "Android 설정 따르기 · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "에이전트를 불러오려면 Gateway를 연결하세요." + "뒤로 가기" + "메시지 공유" + "QR 코드를 생성하세요." + "재시작" + "스피커 켜짐" + "그룹을 삭제하시겠습니까?" + "누락됨" + "제안 검색" + "stop" + "보안 (TLS)" + "노드 또는 페어링된 기기가 없습니다." + "%1$s%% 남음 %2$s" + "설정 코드가 만료되었습니다" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "일기" + "notify" + "이 휴대전화는 Gateway가 필요로 할 때까지 대기 상태를 유지하다가, 깨어나 동기화한 후 다시 절전 상태로 돌아갑니다." + "구성된 모델 %1$s개" + "라이선스" + "ClawHub Skills를 검색하려면 Gateway를 연결하세요." + "Skill" + "Gateway 연결이 변경되었습니다. 다시 연결하려면 OpenClaw를 재시작하세요." + "기기 ID" + "Gateway에서 활성 %1$s 제공자를 식별하지 않았습니다" + "대기 중" + "호출어가 저장되었습니다" + "오래된순" + "화면" + "실행 시작 시각" + "IPv6 영역 ID는 지원되지 않습니다. 범위가 지정되지 않은 IPv6 주소 또는 LAN 호스트 이름을 사용하세요." + "전송됨 — 전달 확인 중…" + "오디오" + "This gateway connection needs operator.admin to update skills." + "설정 코드" + "Gateway 경고를 확인하고 설치" + "채팅 새로고침" + "간격" + "Skill Workshop 제안 작업에는 operator.admin 범위가 필요합니다." + "세션" + "이름 변경…" + "꿈꾸기를 로드하려면 gateway를 연결하세요." + "설정" + "Talk 열기" + "poll" + "에이전트를 불러오려면 연결하세요" + "role remove" + " · 대화: 듣는 중" + "ClawHub에서 %1$s에 대해 설치 가능한 버전을 반환하지 않았습니다." + "명령" + "이 승인은 처리되기 전에 취소되었습니다." + "마이크 켜짐 · Gateway 대기 중" + "텍스트" + "%2$s개 중 %1$s개 표시 중입니다. 더 보려면 검색어를 구체화하세요." + "v%1$s 사용 가능" + "%1$s://%2$s" + "%1$s... (정상)" + "제공자 및 구성된 모델" + "연결 중…" + "호출어를 저장하려면 Gateway에 연결하세요" + "프로필 열기" + "Gateway를 시작하세요." + "이 목표를 실용적인 체크리스트로 만드는 것을 도와주세요: " + "세션 검색 지우기" + "포트" + "설정 코드 입력" + "Gateway 로그를 불러올 수 없습니다." + "%1$s개 제공자 준비됨" + "에이전트가 준비되었습니다" + "Gateway에 %1$s 제공자가 구성되어 있지 않습니다" + "한 차례의 발화를 듣는 중입니다" + "관찰" + "에포크 밀리초 (선택 사항)" + "구성된 모델이 없습니다. 새로 고쳐 사용 가능 여부를 다시 확인하세요." + "설정" + "후면 카메라" + "approve" + "시작하기 전에" + "Skills를 불러올 수 없습니다." + "비활성화됨" + "아직 승인을 기다리는 중" + "백그라운드 작업을 불러올 수 없습니다" + "OpenClaw가 이 휴대폰에서 선명하게 말할 수 있는지 확인하세요." + "작업 중 · 활성 실행 %1$s개" + "명령 작업 디렉터리" + "그룹 이름" + "갤러리에서 선택" + "Version %1$s" + "뒤로" + "Connect the gateway to update skills." + "실행 후 삭제" + "설정 코드가 안전하지 않은 원격 gateway를 가리킵니다. %1$s %2$s" + "Computer" + "Gateway 연결이 끊어졌습니다." + "Session Settings" + "시작하려면 Gateway를 연결하세요" + "보안 알림" + "기타 답변" + "공유 이미지 경고 닫기" + "Gateway가 다른 ClawHub 릴리스를 평가했습니다. 설치하기 전에 해당 Skill을 다시 검토하세요." + "시스템 접근 권한 열기" + "완료됨" + "이미지를 사용할 수 없음" + "알림" + "적용, 거부, 격리에는 operator.admin 범위가 필요합니다. 공유 Gateway 인증으로 다시 연결하거나 operator.admin 기기 범위 업그레이드를 승인하여 수명 주기 작업을 활성화하세요." + "sticker upload" + "바닷가재 잡기" + "Messages to recover" + "openclaw devices approve %1$s" + "읽기 쉬운 Gateway 로그 세부 정보." + "생성된 스킬 제안을 라이브 스킬이 되기 전에 검토합니다." + "번들 포함" + "%1$s개 사용 가능" + "노드 승인 대기 중" + "Gateway 대기 중" + "인증 필요" + "노드" + "절전 모드 방지" + "OpenClaw가 응답 중입니다" + "문서" + "%1$s개 준비됨" + "아직 출력이 없습니다" + "기기 언어가 지원되지 않음" + "대기 중 — 다시 연결되면 전송" + "%1$s분 전" + "현재 브랜치" + "페어링 액세스 확인 중" + "제한된 Gateway 접근" + "도구 실행 중..." + "승인 확인 중…" + "이 휴대전화에서 사진 및 클립 촬영" + "연결되었으며 준비됨" + "닫기" + "목표를 실행 가능한 체크리스트로 만듭니다." + "설정 코드에 유효하지 않은 gateway URL이 있습니다." + "이 휴대폰이 연결되어 있는 동안 OpenClaw가 사용해도 괜찮은 액세스만 활성화하세요. 나중에 Android 설정에서 변경할 수 있습니다." + "계정" + "remove" + "비밀번호 선택 사항" + "Gateway 인증을 검토해야 합니다. gateway 설정을 확인한 다음 다시 시도하세요." + "QR 코드가 IPv6 영역 ID를 사용합니다. 범위가 지정되지 않은 IPv6 주소 또는 LAN 호스트 이름을 사용하세요." + "add" + "크릴을 잡는 중" + "정상" + "%1$s 만에 완료" + "인수" + "설치 옵션" + "%1$s시간 후" + "Gateway 승인이 대기 중입니다. Gateway 호스트에서 다음을 실행하세요:" + "관리자 권한 필요" + "set groups" + "모델 고정" + "검색 지우기" + "적격 에이전트에 대해 활성화되었습니다." + "현재 스레드가 없습니다" + "bounds: %1$s" + "%1$s 후" + "스케줄러가 이 자동화를 실행하도록 허용합니다." + "%1$s개 적용됨" + "아직 꿈 일기가 없습니다." + "백그라운드 작업 새로고침" + "최근 스레드와 다음 단계를 요약해 주세요." + "OpenClaw가 표시되는 동안 기기에서 실행됩니다." + "%1$s 작업 중" + "%1$s %2$s" + "원시" + "실행" + "지금 실행" + "제목 없는 브랜치" + "구성됨" + "camera list" + "1개 적용됨" + "camera clip" + "예" + "오디오 테스트" + "보류됨" + "events" + "작업 디렉터리" + "최신 항목으로 이동" + "항상 허용" + "QR 또는 설정 코드 스캔" + "Installing" + "라이브 노드, 페어링된 전화 및 대기 중인 기기 요청." + "스냅샷: %1$s" + "이전 응답에서 이미 이 명령을 허용하고 선택을 저장했습니다." + "대기 중인 요청" + "승인됨" + "워크스페이스" + "음성" + "대화 준비 완료" + "Subagents" + "실패: 보안 gateway 엔드포인트가 감지되지 않았습니다. gateway TLS 또는 Tailscale Serve를 활성화하거나, 암호화 안 함을 선택한 상태로 신뢰할 수 있는 사설 LAN 주소를 사용하세요." + "신호" + "세션 대상" + "Gateway가 거부를 기록했습니다." + "수락" + "OpenClaw에 무엇이든 물어보세요" + "계속하려면 다시 연결하세요" + "%1$s개 페어링됨" + "이 작업은 \"%1$s\"을(를) 적용하고 Gateway에서 Skill Workshop 상태를 새로 고칩니다." + "Gateway 오프라인" + "openclaw devices list" + "OpenClaw 노드 연결 상태" + "알림은 이 휴대폰에 유지됩니다." + "OpenClaw에서 선택한 알림을 받을 수 있습니다." + "화면 열기" + "채팅 작업" + "다른 앱 제어를 허용하시겠습니까?" + "검사하는 중" + "다른 Gateway를 추가하려면 설정 코드를 스캔하거나 붙여넣으세요." + "Swarm" + "TLS 시간 초과" + "최근 세션" + "페어링된 기기가 제거되었습니다." + "Gateway가 페어링되었습니다. 노드 기능 승인을 확인하는 중입니다." + "동작" + "cron 작업에 실패했습니다." + "Gateway 컴퓨터에서 다음을 실행하세요:" + "세션 검색" + "로그 새로 고침" + "이미지를 사용할 수 없음 · 탭하여 다시 시도" + "openclaw nodes approve %1$s" + "음성 메시지 · %1$s" + "사용량" + "앵무조개처럼 헤엄치는 중" + "컨텍스트 %1$s%%" + "음성 프롬프트 받아쓰기" + "음소거" + "새 대화를 시작하면 여기에 표시됩니다." + "연결 문제" + "중간" + "포크" + "스피커 켜기" + "시스템 이벤트 텍스트" + "정렬: %1$s" + "%1$s개 대기 중" + "Image Generation" + "음성 메모" + "주의가 필요한 항목이 없습니다" + "계속하려면 OpenClaw에 %1$s 권한이 필요합니다." + "유선 헤드셋 마이크" + "페이지" + "전달됨" + "기한" + "현재 Skills 상태에서 스킬 세부 정보를 사용할 수 없습니다." + "이 휴대폰이 공유할 수 있는 항목을 선택하세요." + "이 자동화에는 이미 대기 중인 실행이 있습니다." + "자동화를 관리하려면 Gateway에 연결하세요." + "아직 자동화 실행 시간이 아닙니다." + "세부 정보 없음" + "승인이 진행 중입니다.\nOpenClaw가 자동으로 다시 연결됩니다." + "제공자 준비 상태를 보려면 Gateway를 연결하세요." + "페어링 대기 중" + "대화 시작 또는 계속하기" + "예약된 작업이 없습니다" + "OpenClaw에 답장…" + "상태" + "OpenClaw Node · 연결됨" + "활성" + "화면 공유 디버그 상태를 표시합니다." + "보고된 제한 없음" + "스캐너 닫기" + "%1$s일마다" + "활성화됨" + "활성화하고 설정 열기" + "온라인 및 준비됨" + "Ask User" + "채팅 오류" + "앞으로 스크롤" + "%1$s / %2$s" + "작업 계획 세우기" + "console" + "다시 시도" + "채팅을 시작하면 활성 OpenClaw 대화가 여기에 표시됩니다." + "자동화를 불러올 수 없습니다." + "에이전트 작업 공간의 셸" + "%1$s개 활성" + "기기 권한 선택" + "최근 실행 시간" + "기본 에이전트" + "%1$s시간" + "대화가 진행 중입니다" + "ClawHub에서 %1$s을(를) 설치할 수 없습니다." + "OpenClaw에 오신 것을 환영합니다" + "다른 앱 제어" + "신호 인덱스" + "시크릿 입력…" + "%1$s:%2$s" + "OpenClaw에 %1$s 지시" + "발견됨" + "사이드바 숨기기" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "오디오 재생을 사용할 수 없습니다" + "적용" + "사용량을 불러올 수 없습니다." + "작업 중에는 노드를 사용 가능한 상태로 유지합니다." + "다음 실행" + "%1$s/%2$s" + "최근 로그 항목이 없습니다." + "수동 Gateway" + "그룹 이름 변경" + "Update Goal" + "제공자 가용성 알 수 없음" + "제공업체" + "그룹 삭제…" + "페이로드" + "통화 기록" + "Memory Search" + "공급자 %1$s개" + "휴대전화 컨텍스트 및 개인정보 보호" + "%1$s/%2$s개 연결됨" + "%1$s %2$s" + "Gateway 재시작 복구가 아직 진행 중입니다." + "설정 코드를 교체하면 다시 연결하기 전에 이 휴대전화에 저장된 설정 자격 증명과 기기 토큰이 삭제됩니다. 이 휴대전화는 노드 기능 승인이 다시 필요할 수 있습니다. 새로운 Gateway 설정 코드와 페어링하려는 경우에만 계속하세요." + "자동화를 열어 구성과 실행 기록을 확인하세요. 관리자 범위 연결을 사용하면 자동화를 실행, 편집, 활성화, 비활성화 또는 삭제할 수도 있습니다." + "컨텍스트 --" + "제안이 격리되었습니다." + "자동화가 일시 중지되었습니다." + "OpenClaw 모바일" + "A2UI reset" + "Gateway를 사용할 수 없음" + "Read" + "이 Skills에는 설정 항목 1개가 필요합니다. Android에서는 설치된 항목을 확인할 수 있습니다. 설정/구성 변경은 데스크톱 또는 CLI에서만 가능합니다." + "마지막 실행" + "설정 QR을 스캔하려면 카메라 접근 권한이 필요합니다." + "모델을 업데이트할 수 없습니다." + "거품을 내는 중" + "thread reply" + "삭제…" + "자동화를 확인하려면 Gateway에 연결하세요." + "최근 로그" + "최근 실행을 로드하는 중…" + "이 파일은 미리 볼 수 없습니다. 바이너리 파일이거나 너무 클 수 있습니다." + "확인" + "이 휴대전화의 위치 확인" + "Skill 키" + "%1$s을(를) 설치했습니다." + "Gateway" + "actions: %1$s" + "전달 모드" + "%1$sk" + "스레드 레이아웃 전환" + "TLS 엔드포인트 없음" + "OpenClaw Gateway" + "수동으로 설정" + "생각 중…" + "Gateway 액세스 검토가 필요합니다" + "1개 보류됨" + "%1$s초" + "제안을 적용할까요?" + "나중에" + "승인되지 않음" + "앱 검색" + "구성된 모델 1개" + "승인 알림 닫기" + "·" + "오프라인" + "음성 제공자" + "Gateway 승인이 진행 중입니다. OpenClaw가 자동으로 다시 시도합니다." + "최대" + "cron을 변경하려면 operator.admin 액세스 권한이 필요합니다." + "생각 중" + "screen snapshot" + "관찰된 노드: %1$s" + "작업을 찾을 수 없음" + "저장 및 연결" + "list" + "Gateway가 승인을 기록하고 선택을 저장했습니다." + "연결할 유효한 수동 엔드포인트를 입력하세요." + "어시스턴트" + "채팅으로 보내는 중..." + "프로필 저장" + "잠김" + "자동화 편집" + "동일한 네트워크 또는 안전한 원격 Gateway URL을 사용하세요." + "기준점" + "언어" + "이 앱이 Gateway보다 오래된 버전입니다. 이 기기의 OpenClaw를 업데이트한 후 다시 시도하세요." + "전체" + "Gateway 세션 진행 중" + "검토 대기 중" + "설치된 Skills가 없습니다." + "Gateway 확인 중" + "분산 %1$s" + "%1$s에 대한 결과를 알 수 없습니다. 다시 연결하고 Skills를 새로 고친 다음 다시 시도하세요. Gateway는 아직 실행 중인 일치하는 설치에 안전하게 참여합니다." + "잊어버리기" + "페어링된 Gateway가 없습니다." + "%1$s · %2$s" + "<가려진 비밀 값>" + "문제 %1$s개" + "OpenClaw" + "듣는 중 · %1$s개 대기 중" + "어시스턴트 음성이 음소거됨" + "노드 작업은 대상 앱이 포그라운드일 때만 실행됩니다(원격 경로를 통해 검증됨). 전역 작업과 동일 앱 작업은 여기에서 작동합니다." + "아직 Gateway를 찾지 못했습니다. 검색이 차단된 경우 수동 설정을 사용하세요." + "스레드 열기" + "작업 중" + "말하기 시작하세요..." + "전화 노드" + "Xhigh" + "Gateway 호스트에서 실행:" + "Skill을 변경하려면 operator.admin이 필요합니다. 관리자 권한이 있는 gateway 토큰으로 다시 연결하세요." + "ClawHub Skills를 살펴보려면 Gateway를 연결하세요." + "앱 목록은 이 휴대폰에 유지됩니다." + "유휴" + "Android 접근성 설정에 표시됩니다." + "스마트 전송" + "거부" + "%2$s 후 Gateway가 상태 \'%1$s\'을(를) 반환했습니다." + "Gateway 토큰이 구성되지 않았습니다" + "Not available to this agent" + "파일" + "권한" + "카메라를 시작할 수 없습니다. 갤러리에서 QR 이미지를 선택하거나 설정 코드를 직접 입력하세요." + "탭하여 복사" + "%1$s분 대기 중" + "%1$s." + "ClawHub Skills를 설치하려면 Gateway를 연결하세요." + "음성 검색" + " · 마이크: 듣는 중" + "Gateway 설정을 검토하고 변경하려면 operator.admin 액세스로 다시 연결하세요." + "더 불러오기" + "3초 후 관찰" + "run" + "음성 생성 중…" + "← 뒤로" + "연결 해제" + "Gateway 컴퓨터에서 approve 명령을 실행한 다음 다시 확인하세요." + "자동화" + "%1$s분" + "신뢰" + "해당 QR 코드는 OpenClaw 설정 QR이 아닙니다. openclaw qr로 새 코드를 생성한 후 다시 시도하세요." + "선호하는 마이크를 사용할 수 없어 자동 라우팅을 사용합니다." + "거부됨" + "Android 및 백그라운드 패키지를 포함합니다." + "Gateway가 준비되었습니다." + "트리거됨" + "Structured Output" + "예상보다 오래 걸리고 있습니다.\nGateway가 실행 중이며 연결 가능한지 확인하세요." + "이 에이전트에는 백그라운드 작업이 없습니다." + "다시 연결 중" + "OpenClaw가 Gateway 및 노드 액세스를 확인하고 있습니다." + "Code Execution" + "공급자 사용량 없음" + "검토" + "마이크 권한이 필요합니다." + "%1$s일" + "%1$s개 사용 가능" + "OpenClaw가 다시 동기화 중입니다" + "이벤트 스트림이 중단되었습니다. 새로고침해 보세요." + "노드와 기기를 불러올 수 없습니다." + "Skills를 불러오려면 Gateway를 연결하세요." + "알 수 없음" + "출력" + "토크 실패: 실시간 제공자가 예기치 않게 닫혔습니다." + "OpenClaw 시간 민감" + "ban" + "Gateway 토큰이 필요합니다" + "페어링된 기기" + "재승인 필요" + "예약되지 않음" + "연락처" + "필요할 때까지 휴대전화는 조용히 대기합니다" + "듣는 중 · 대기 중인 음성 보내는 중" + "작업 세부 정보를 불러올 수 없습니다" + "에이전트 메시지" + "Gateway에 이 기기 ID가 필요합니다. 다시 인증하거나 이 gateway 연결을 재설정하세요." + "다음 세션" + "연결 보안" + "지금은 건너뛰기" + "웹사이트" + "앱에서 승인 요청을 불러오려면 Gateway를 연결하세요." + "%1$s 복사됨" + "선택된 앱이 없습니다. 앱을 추가하기 전까지 아무것도 전달되지 않습니다." + "%1$s %2$s" + "설정 필요" + "페어링 해제됨" + "Gateway가 이 휴대전화를 인식했습니다" + "구성된 모델 없음" + "비활성화" + "앱 언어" + "Gateway 페어링 중" + "저장된 인증이 올바르지 않습니다" + "범위 %1$s개" + "최근 로그를 불러오려면 Gateway를 연결하세요." + "호출어 저장" + "설치된 Skill을 관리하고 ClawHub에서 신뢰할 수 있는 릴리스를 추가하세요." + "전송 중…" + "아직 불러온 에이전트가 없습니다." + "ClawHub 검색" + "채팅에서 Gateway 상태를 확인하고 있습니다." + "페어링 필요" + "활성 실행" + "실패 — %1$s" + "이 휴대폰과 OpenClaw 간의 연결입니다." + "summarize" + "위젯 이미지가 다운로드에 저장되었습니다" + "시작 중…" + "토큰 %1$s개" + "클라이언트 오류" + "접근 권한을 부여하기 전에 요청한 기기를 확인하세요." + "Bluetooth LE 마이크" + "%1$s %2$s" + "자동화가 활성화되었습니다." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "보관됨" + "다시 로드" + "자동화 검색" + "페어링 후 연결된 휴대폰과 노드 호스트가 여기에 표시됩니다." + "%1$s: %2$s" + "Gateway에서 이 자동화가 변경되었습니다. 다시 저장하기 전에 최신 버전을 검토하세요." + "받아쓰기 중지" + "읽기 쉬움" + "OpenClaw에 메시지 보내기" + "Gateway 비밀번호가 올바르지 않습니다. 다시 입력하거나 이 gateway 연결을 재설정하세요." + "다시 연결" + "ISO 시간, 예: 2026-07-09T09:30:00Z" + "도구 %1$s개" + "이전 응답에서 이미 이 승인을 거부했습니다." + "연동됨" + "%1$s 열기" + "%1$s/%2$s" + "자동화 실행이 알 수 없는 상태로 완료되었습니다." + "오프라인 대기열이 가득 찼습니다(%1$s개 메시지). 먼저 대기 중인 항목을 삭제하세요." + "공개 Gateway에는 wss:// 또는 Tailscale Serve가 필요합니다. ws://는 localhost, .local 호스트, Android 에뮬레이터 및 사설 LAN IP에서 허용됩니다." + "스킬 세부 정보를 불러오려면 Gateway를 연결하세요." + "전체 액세스 필요" + "호출어 감지" + "링크 미리보기 펼치기" + "스레드 검색 지우기" + "NULL (실패)" + "업데이트" + "관리자" + "주의 필요" + "이 기기를 Gateway와 페어링하여 실제 작업에 필요할 때만 깨우고, 실시간 에이전트 개요를 간편하게 확인하며, 배터리를 소모하는 백그라운드 루프를 방지하세요." + "역할" + "답장" + "제공업체 카탈로그" + "설정에서 권한 사용 설정" + "A2UI push" + "접근 권한 확인" + "Gateway에 연결할 수 있으면 별도의 조작 없이 재연결이 완료됩니다." + "에이전트 턴" + "격리" + "주의" + "검색 중…" + "설정 코드는 어디에서 받을 수 있나요?" + "Skill을 활성화할 수 없습니다." + "pdf" + "제거" + "%1$s%% 온라인" + "채널이 없습니다" + "실시간 음성" + "Skill Workshop 거부 및 격리 작업" + "노드 및 기기" + "로컬 명령 센터" + "emoji upload" + "미리보기 로드 중…" + "높음" + "focus" + "describe" + "%1$s 컨텍스트" + "응답을 듣는 중..." + "voice" + "%1$s에 연결됨" + "role add" + "채팅에 주의가 필요합니다" + "마이크 활성화" + "OpenClaw는 페어링된 OpenClaw Gateway가 요청할 때 이 휴대폰에 표시되는 앱의 이름, 패키지 ID, 상태를 수집하여 전송합니다. 이를 통해 어시스턴트가 설치된 앱을 사용하여 질문에 답하고 작업을 수행할 수 있습니다." + "Gateway가 연결되지 않음" + "정책" + "보낸 메시지를 확인하는 동안 시간이 초과되었습니다. 새로고침하여 전송 여부를 확인하세요." + "지원 파일" + "표현식" + "백그라운드 작업" + "꿈" + "차단된 앱이 없습니다. 차단 항목을 추가하지 않으면 앱에서 전달할 수 있습니다." + "음성 인식기를 사용할 수 없습니다" + "플랫폼" + "Gateway에서 %1$s 설정을 반환하지 않았습니다" + "Gateway를 잊어버리시겠습니까?" + "선택적 설명" + "%1$s 열기" + "홈 캔버스" + "꿈꾸는 중" + "%1$s~%2$s" + "파일 공유" + "실시간" + "API" + "OpenClaw가 작업 중입니다…" + "OpenClaw로 대화하거나 받아쓰기" + "설치된 앱 정보를 공유하시겠습니까?" + "자동화 불러오는 중…" + "자동화 삭제" + "기본 어시스턴트" + "Gateway에서 지원되는 %1$s 제공자를 선택하세요" + "사용할 수 없음" + "빈 폴더" + "설정 열기" + "꺼짐" + "서체" + "중지" + "아직 일치하는 스레드가 없습니다." + "Gateway 페어링이 완료되었습니다.\n운영자 UI에서 이 휴대폰의 노드 기능을 승인하세요." + "이 Skills는 설치되어 있지만 현재 실행할 수 없습니다. 구성 변경은 데스크톱 또는 CLI를 사용하세요." + "음성 인식기가 사용 중입니다" + "홈 Gateway" + "Gateway에서 승인 명령을 실행하세요" + "서비스 비활성화됨" + "Skill Workshop 제안을 불러올 수 없습니다." + "최근 OpenClaw 스레드의 내용을 알려 주고 다음 단계를 제안해 주세요." + "나중에" + "openclaw qr" + "start" + "OpenClaw Node · 대화" + "이벤트 읽기 및 업데이트" + "토크 실패: 실시간 제공자가 닫혔습니다: %1$s" + "작업 공간 파일을 탐색하려면 Gateway에 연결하세요." + "Gateway 릴레이를 통한 %1$s" + "Gateway 토크 카탈로그를 로드할 수 없음" + "모니터링 중 · 예약된 작업 1개" + "%1$s시간마다" + "화면 영역" + "OpenClaw 번역 · %1$s" + "명령 요청" + "최신 상태" + "채널" + "음소거 해제" + "새 그룹…" + "오디오 준비 중…" + "적응형" + "곧" + "%1$s개 더 많은 워커" + "Web Search" + "채팅, 음성, 스레드, 제공업체 또는 설정을 사용해 보세요." + "OpenClaw 활성" + "navigate" + "%1$s에 요청됨" + "자동화 실행 기록을 확인하려면 Gateway에 연결하세요." + "기기 액세스. Gateway에서 별도로 허용해야 합니다" + "중단됨" + "유효한 설정 코드 또는 Gateway 주소를 입력하세요." + "모델" + "OpenClaw 수동" + "Gateway 비밀번호가 올바르지 않습니다" + "기기 페어링 변경 사항을 확인할 수 없습니다. 새로고침한 후 다시 시도하세요." + "세부 정보 보기" + "Bash" + "토큰" + "연결된 OpenClaw 에이전트는 사용자가 활성화한 기기 기능을 사용할 수 있습니다. 연결하려는 Gateway와 에이전트를 신뢰하는 경우에만 계속하세요." + "따개비 붙이기" + "선택한 사진 또는 전체 사진 접근 권한이 허용되었습니다." + "접근성 실행기" + "누락된 항목 %1$s개" + "계획 체크리스트 접기" + "노드 승인이 필요합니다" + "Gateway 연결" + "... +%1$s개 더" + "계획 체크리스트 펼치기" + "브라우저" + "screen record" + "실행 대기 중" + "활성화하면 OpenClaw가 준비 상태일 때 다른 앱의 화면을 관찰하고 제어할 수 있습니다. Android 접근성 액세스가 필요합니다." + "출처" + "기기에서 사용하는 개인 AI" + "Attach" + "자동" + "개요" + "복원을 요청하지 못했습니다. 탭하여 재시도하세요." + "동영상" + "%1$s\n\n" + "암호화되지 않음" + "캘린더" + "Gateway 상태가 정상이 아니므로 전송할 수 없습니다." + "📎 %1$s" + "최근 상태" + "새 채팅을 시작하기 전에 현재 응답이 완료될 때까지 기다리세요." + "프로필" + "Gateway가 제공자 제한을 보고하면 여기에 표시됩니다." + "문제 1개" + "\"%1$s\"의 스레드는 유지되며 그룹 미지정으로 다시 이동합니다." + "추천" + "생성됨" + "활성 토큰 %1$s/%2$s개" + "작업 결과 없음" + "집게질하는 중" + "%1$s…" + "Skill 세부 정보 열기" + "음성 출력 실패: %1$s" + "대화 시작" + "이 폴더를 불러올 수 없습니다." + "QR 코드에 유효한 설정 코드가 포함되어 있지 않습니다." + "노드 액세스를 검토하세요" + "호출 문구 추가" + "Gateway에 연결할 수 없음" + "자동화" + "연결 필요" + "승인을 처리하지 못했습니다. 새로 고침 후 다시 시도하세요." + "import" + "이 휴대폰이 OpenClaw에 표시되는 방식입니다." + "스레드 검색에 포커스" + "Gateway 연결" + "캘린더 읽기" + "개요는 다시 연결되거나 이 화면이 열릴 때 새로고침됩니다." + "Skill을 비활성화할 수 없습니다." + "아직 연결 중" + "%1$s분 후" + "SMS 읽기" + "사용량을 불러오려면 Gateway를 연결하세요." + "지금 이 휴대전화에서 무엇을 도와줄 수 있어?" + "승인 필요" + "새 채팅" + "Skill Workshop 제안을 업데이트하려면 Gateway에 연결하세요." + "OpenClaw 요청이 실패했습니다." + "권한 필요" + "제공자 준비 상태와\n구성된 모델을 검토하세요." + "로드 중" + "실패 알림" + "테마 및 번역된 Android 텍스트." + "마이크 꺼짐 · 보내는 중…" + "없음" + "보기" + "이름" + "버전" + "Cron" + "OpenClaw를 열기 전에 이 휴대전화를 Gateway에 연결하세요." + "호출 문구 제거" + "설정 코드가 승인되지 않았습니다. openclaw qr로 새 코드를 생성하세요." + "메시지 14개 · Android" + "텍스트 변환 실패: %1$s" + "항상" + "드리밍을 불러올 수 없습니다." + "자동화 실행이 대기열에 추가되었습니다." + "Conversation Turn" + "자동화가 시작되었습니다." + "새 그룹" + "서버 오류" + "Video Generation" + "Gateway 승인이 대기 중입니다. Gateway 호스트에서 openclaw devices list를 실행하고 이 휴대전화를 승인한 후 다시 시도하세요." + "꿈꾸기 주기가 서술형 요약을 작성하면 항목이 표시됩니다." + "%1$sms" + "메모리 저장소" + "어시스턴트 작업 중" + "OpenClaw는 런처에 표시되는 앱을 나열할 수 있습니다." + "대화 실패: %1$s" + "설치된 Skills 검색" + "검사" + "Process" + "최근 스레드" + "터미널" + "현재" + "계정 1개" + "일시 중지됨" + "카메라 허용" + "이 휴대폰이 연결되어 있는 동안 Exec 승인 요청이 여기에 표시됩니다." + " · 마이크: 대기 중" + "복사" + "세부 정보가 복사되었습니다" + "삭제" + "OpenClaw에 Android 기능 사용을 요청하세요." + "member" + "이 Gateway가 OpenClaw 설정 도우미를 지원하는지 확인 중입니다." + "아래 복구 옵션을 사용하여 다시 연결하세요." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "채널을 불러올 수 없습니다." + "%1$s일 후" + "연속 오류" + "해당 이미지에서 QR 코드를 읽을 수 없습니다. 더 선명한 이미지를 선택하거나 설정 코드를 직접 입력하세요." + "Gateway가 이 앱보다 오래된 버전입니다. Gateway 호스트의 OpenClaw를 업데이트한 후 다시 시도하세요." + "채팅, 음성 및 실시간 상태를 사용하기 전에 연결하세요." + "Gateway 다시 연결" + "서드 파티" + "준비 상태 검토" + "제한됨" + "OpenClaw 로고" + "모델 고정 해제" + "이 Gateway에 연결된 메시징 표면." + "전송 중" + "보관된 스레드가 여기에 표시됩니다." + "명령이 복사됨" + "사용 가능한 미리보기가 없습니다" + "Gateway에서 이 휴대폰을 승인하세요.\n그런 다음 연결을 다시 시도하세요." + "QR 코드 스캔" + "명령 작업 디렉터리 · 지울 수 없음" + "스레드 활동" + "사용 가능" + "자동화를 삭제하시겠습니까?" + "오늘 %1$s개 · 총 %2$s개" + "비밀번호" + "제안을 격리할까요?" + "이 빌드에는 라이선스 고지가 포함되어 있지 않습니다." + "위젯 이미지를 저장할 수 없습니다" + "%1$s 대기 중" + "말하는 중…" + "제공업체 및 모델" + "노드" + "%1$s " + "프롬프트를 사용할 수 없음" + "로그" + "Skill Workshop 제안을 확인하려면 Gateway를 연결하세요." + "도구" + "Gateway 스위치" + "SMS 보내기" + "OpenClaw가 일반 채팅에서 계속할 준비가 되었습니다." + "명령을 찾을 수 없습니다" + "아직 캔버스 업데이트가 없습니다. 탭하여 다시 시도하세요." + "터미널에는 연결된 gateway가 필요합니다" + "Exec" + "앱 필터" + "메인" + "%1$sk" + "Gateway 필요" + "접근" + "패키지: snapshot=%1$s foreground=%2$s" + "연결 다시 시도" + "Cron 스케줄러가 중지되었습니다." + "열기" + "메시지가 복사되었습니다" + "Gateway에 연결할 수 없습니다.\n문제를 해결해 보겠습니다." + "지금" + "실행 후 삭제" + "이 휴대전화에서 선택됨" + "unpin" + "Session History" + "고정 해제" + "이 휴대전화 사용" + "%1$s의 ClawHub 세부 정보를 불러올 수 없습니다." + "도구 실행 중" + "위치가 활성화되어 있는 동안 정확한 위치를 공유합니다." + "Mobile UI" + "테마" + "Gateway에서 이 승인이 아직 대기 중으로 표시됩니다. 다시 시도하기 전에 검토하세요." + "음성 메모 완료" + "받아쓰기: %1$s" + "허용되지 않음" + "다른 이미지 선택" + "이미지 미리보기" + "OpenClaw는 Talk 또는 받아쓰기를 시작할 때만 듣습니다." + "걸음 수 및 활동 공유" + "설정 필요" + "OpenClaw 설정 도우미를 사용하려면 이 Gateway를 업데이트하세요." + "ClawHub Skills를 설치하려면 이 Gateway 연결에 operator.admin 권한이 필요합니다." + "제안이 적용되었습니다." + "%1$s개 대기 중" + "%1$s시간 전" + "통화 기록 읽기" + "%1$s개 대기 중 · Gateway를 기다리는 중" + "그룹으로 이동" + "페어링하려면 QR 스캔" + "승인이 거부되었습니다." + "Skill Workshop 제안을 확인할 수 없습니다." + "고정됨" + "프로필 및 기기" + "생각 수준 선택기 닫기" + "나중에 전송하도록 메시지를 대기열에 추가할 수 없습니다." + "격리" + "일정 · %1$s" + "사고 수준을 업데이트할 수 없습니다." + "생각 수준 선택기 열기" + "음성 응답 시간이 초과되어 대기 중인 요청을 다시 시도합니다." + "레이아웃: 자세히" + "이 이미지를 디코딩할 수 없습니다." + "Gateway, 음성, 알림, 개인정보 보호" + "에이전트 작업 공간 파일" + "이 기기는 신뢰할 수 있는 Gateway 접근 권한을 잃게 됩니다." + "approve 명령에서 대기 중인 명령의 requestId를 사용하세요." + "일정" + "사용량 제한" + "전달되지 않음" + "페이로드 · %1$s" + "실행 중" + "집게발질하는 중" + "종료" + "시스템 신뢰 사용" + "준비된 제공자 없음" + "연결된 Bluetooth 마이크를 우선 사용합니다." + "%1$s개 앱의 전달이 차단되었습니다." + "메시지 작업" + "종류" + "보관 해제" + "Transcripts" + "호출어" + "Gateway에서 %1$s 구성" + "QR 코드를 스캔하거나 OpenClaw Gateway의 설정 코드를 사용하세요." + "디자인 시스템 프로토타입" + "걸러내는 중" + " · 대화: 켜짐" + "아직 사용량 데이터가 없습니다." + "실행이 시작되기 전에 채팅에 실패했습니다. 다시 시도하세요." + "보내기" + "일부 공유 이미지가 생략되었거나 추가되지 않았습니다." + "캘린더 쓰기" + "timeout" + "낮음" + "차단 목록" + "act" + "Dismiss Task" + "채팅 실패" + "OpenClaw · 라이브" + "설치됨" + "응답 대기 시간이 초과되었습니다. 다시 시도하거나 새로고침하세요." + "이전 대화 찾기" + "스레드 탐색" + "새로 고치는 중" + "진주 채취" + "카메라를 열고 openclaw qr의 코드를 프레임 안에 맞추세요." + "기기가 없습니다" + "알림 전달" + "이 대화는 일반 에이전트 채팅과 분리해서 유지합니다." + "Gateway 세션이 다시 온라인 상태로 전환되고 있습니다. 잠시 후 에이전트 바로가기가 자동으로 정상화됩니다." + "다른 검색어를 사용하거나 현재 검색어를 지워 보세요." + "백그라운드 위치를 허용하시겠어요?" + "수면 위로 떠오르는 중" + "부트스트랩" + "%1$s · %2$s · %3$s" + "음성 메모 취소" + "뒤로 스크롤" + "openclaw gateway" + "Gateway 페어링 완료" + "탈피하는 중" + "다음 발화를 듣는 중입니다." + "OpenClaw이 작업 중입니다" + "로그 항목" + "실패: 이 호스트의 보안 gateway 엔드포인트에 도달할 수 없습니다." + "Gateway가 오프라인입니다. 아래에서 연결 문제를 해결하거나 진단 정보를 복사하세요." + "대기" + "테스트 테스트 1 2 3" + "ClawHub Skills를 검색할 수 없습니다." + "프롬프트 없음" + "전면 카메라" + "로그 항목 열기" + "네트워크 시간 초과" + "지금" + "그룹 이름 변경…" + "더 많은 에이전트" + "openclaw nodes approve REQUEST_ID" + "고정" + "thread list" + "%1$s 열기" + "upload" + "Gateway 비밀번호가 구성되지 않았습니다" + "받아쓰기 설정" + "제공자 모델을 불러왔지만 준비 상태를 확인할 수 없습니다." + "스레드를 삭제하시겠습니까?" + "OpenClaw는 이 휴대전화를 스레드, 음성, 제공업체 및 Gateway를 위한 깔끔한 모바일 명령 인터페이스로 바꿔 줍니다." + "최신순" + "다음 주기" + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4f55d0b --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/values-nl/assistant.xml b/app/src/main/res/values-nl/assistant.xml new file mode 100644 index 0000000..c3512c0 --- /dev/null +++ b/app/src/main/res/values-nl/assistant.xml @@ -0,0 +1,7 @@ + + + "vraag OpenClaw %1$s" + "zeg tegen OpenClaw om %1$s" + "open OpenClaw en vraag %1$s" + + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..2fc934e --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Deze gateway vertrouwen? + Vertrouwen en doorgaan + Annuleren + Nieuwe chat in worktree + Controleer de certificaatvingerafdruk voordat je deze gateway vertrouwt.\n\n%1$s + Het gatewaycertificaat is gewijzigd. Ga alleen door als je deze wijziging verwachtte.\n\nOude SHA-256:\n%1$s\n\nNieuwe SHA-256:\n%2$s + Onbekend + VERSIE + COMMIT + GEBOUWD + Versie %1$s + Git-commit %1$s + Gebouwd op %1$s UTC, tijdstempel %2$s + Builddatum %1$s + Volledige Git-commithash kopiëren + Volledige buildtijdstempel kopiëren + Git-commit van OpenClaw + Buildtijdstempel van OpenClaw + Git-commit gekopieerd + Buildtijdstempel gekopieerd + + "Kan een bijlage niet gereedmaken voor verzending." + "Microfoon uit" + "OpenClaw-waarschuwingen tonen" + "Threadactiviteit" + "Volledig" + "Goedkeuring toegestaan en opgeslagen." + "Recente oproepgeschiedenis tonen" + "1 in behandeling" + "Niet-ondersteunde bijlage" + "0 = exact" + "Verbind de Gateway om threads te zoeken." + "%1$s accounts" + "Voor Cron-wijzigingen is operator.admin vereist. Installatiecodes verlenen dit bewust niet. Maak opnieuw verbinding met het gedeelde token of wachtwoord van de Gateway om beheerderstoegang aan te vragen. Als dit apparaat die toegang nog steeds niet heeft, keur je de wachtende scope-upgrade goed vanuit een bestaande beheerclient." + "Apply Patch" + "Knijpen" + "Luidspreker dempen opheffen" + "Opeenvolgende overgeslagen uitvoeringen" + "Deze map bevat nog geen bestanden." + "Niet verbonden" + "Bekijk en beheer de status van geïnstalleerde skills." + "Mislukt" + "Standaardagent" + "Camera" + "Uit groep verwijderen" + "Zoeken" + "Gepauzeerd voor spraakweergave" + "De Gateway verifieert deze exacte release vóór het downloaden bij ClawHub. Als voor de release expliciete risicoacceptatie vereist is, toont Android de Gateway-waarschuwing voordat het opnieuw probeert." + "Installatiecode gebruikt een IPv6-zone-ID. Gebruik een IPv6-adres zonder scope of een LAN-hostnaam." + "Bijlage" + "Configureer activeringswoorden, spraak en afspelen." + "Luisteren (PTT)" + "Voorstel afgewezen." + "Zijbalk tonen" + "gebruiker" + "%1$s · %2$s" + "Minimaal" + "Weigeren" + "ACTIEVE AGENT" + "1 gepland" + "Geen antwoord" + "%1$s geselecteerd" + "Commando argv JSON-array" + "Kan die afbeelding niet lezen. Kies een duidelijke schermafbeelding of afbeelding van de QR-code van openclaw qr." + "Kan het Skill Workshop-voorstel niet %1$s." + "Elders beantwoord" + "Gateway heeft eenmalig goedkeuring vastgelegd." + "status" + "OpenClaw controleert je locatie alleen wanneer je gekoppelde Gateway daarom vraagt. Kies op het volgende Android-scherm %1$s om controles toe te staan terwijl de app op de achtergrond actief is." + "afwijzen" + "Contrast" + "Gateway-configuratie vervangen?" + "Kan automatiseringen niet laden." + "Jij" + "Ingebouwde microfoon" + "Oppervlak" + "Geen voorstellen" + "Hoofdthread" + "Chat openen" + "Acties voor het koppelen van apparaten zijn niet beschikbaar in deze Gateway-sessie. Voer openclaw devices list uit op de Gateway-host en beheer het verzoek daar. Goedkeuring van nodefunctionaliteiten staat hier los van en gebruikt nog steeds nodes approve <request id>." + "Actieverzoek" + "list pins" + "Maak verbinding met een Gateway om Skills Workshop-voorstellen te laden." + "Setupcode is niet geaccepteerd" + "Uitloggen" + "Realtime-transcriptieprovider is niet geconfigureerd." + "Systeem-apps tonen" + "Werk je Gateway bij om de configuratie van providermodellen te bekijken." + "Dictee verzenden" + "Inspecteer dit voorstel om de markdown te laden." + "open OpenClaw en vraag %1$s" + "redenering" + "Client" + "Toegepast" + "video" + "Gepromoot" + "Online" + "Bereiken" + "Realtime-spraakprovider is niet geconfigureerd." + "%1$s · %2$s" + "kick" + "Gateway heeft een ongeldige automatisering geretourneerd." + "Instantie-ID" + "Gateway-token is vereist. Voer het opnieuw in of bewerk deze verbinding." + "Bron" + "Vernieuwen" + "%1$s in wachtrij" + "Chat starten" + "Aanroepen van Chat-tools die in de actieve thread wachten, blijven hier zichtbaar." + "Certificaatcontrole vereist" + "Open het huidige Canvas-oppervlak om het te inspecteren of ermee te interacteren." + "De automatisering is bijgewerkt." + "Geen recente sessies" + "Script" + "Gateway-status, gereedheid van telefoonknooppunten en recente logstream." + "Automatiseringsdetails openen" + "Runtime" + "1 extra worker" + "Agent %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Schakel %1$s in via Android-instellingen om door te gaan." + "Herstellen" + "reactions" + "Gereed" + "Versie en update" + "OpenClaw toont hier goedkeuringen, mislukte taken en kanaalproblemen." + "USB-microfoon" + "Er wachten te veel shares om toegevoegd te worden." + "Overgeslagen" + "Gebruik het LAN-adres of de beveiligde externe hostnaam van de Gateway-computer." + "Aan" + "Threads zoeken" + "Realtime gesprek" + "· %1$s" + "OpenClaw bereidt een antwoord voor." + "Eenmalig goedgekeurd." + "Provider instellen" + "geen" + "Scriptpayloads blijven ongewijzigd. Gebruik de CLI om dit script te bewerken." + "Android-installatiehandleiding" + "%1$s apps zijn geblokkeerd voor doorsturen." + "Gekoppelde apparaten" + ":%1$s" + "%1$s openstaand" + "Apparaatnaam" + "Indienen" + "Locatie" + "bijv. America/New_York" + "Sessiedoel" + "ClawHub-skill beoordelen" + "snapshot" + "Het koppelingsverzoek van dit apparaat afwijzen?" + "Voorkeursmicrofoon" + "Nodehost" + "Niveau" + "App-kiezer sluiten" + "Plak een gedeeld Gateway-token of een door de operator uitgegeven token." + "Alle systemen werken normaal" + "Gateway-diagnostiek gekopieerd" + "Audiofout" + "Configuratie vervangen" + "Snelle acties" + "Verzenden mislukt: Chat is mislukt voordat de uitvoering begon; probeer het opnieuw." + "Microfoon" + "Chat controleert nog steeds de status van de Gateway." + "Nauwkeurige locatie" + "Eenmalig toestaan" + "+%1$s meer" + "thread create" + "Geblokkeerd" + "Activeringswoord of activeringszin" + "Gateway vereist apparaatgoedkeuring" + "Externe microfoon" + "%1$s/%2$s gereed" + "Verbonden (operator offline)" + "Mogelijkheid niet goedgekeurd" + "Hiermee worden de automatisering en het bijbehorende schema permanent van de Gateway verwijderd." + "Afbeelding laden…" + "Verbinden" + "Nodetoegang goedkeuren" + "Gateway toevoegen" + "Transcriptie niet beschikbaar: %1$s" + "Afbeelding" + "Deinen" + "Afbeeldingsvoorbeeld sluiten" + "eval" + "Laatste opdracht: %1$s" + "Zorg dat er een terminal openstaat op het apparaat waarop OpenClaw draait." + "Geen ontbrekende items" + "Canvas-uitvoer vereist een actieve Gateway-verbinding." + "%1$s · %2$s" + "Geïsoleerd" + "© 2026 OpenClaw Foundation — MIT-licentie." + "PDF" + "Conversations" + "Geheugenconsolidatie en droomdagboek." + "Create Goal" + "Deze automatisering is gewijzigd terwijl u deze bewerkte. Herstel de nieuwste versie van de Gateway voordat u opslaat." + "Wanneer er verbinding is, kan de Gateway de telefoon met een stille pushmelding activeren in plaats van een permanente sessie open te houden." + "Activeringsmodus" + "Gekoppeld apparaat verwijderen?" + "Systeemgebeurtenistekst" + "Kan widgetafbeelding niet kopiëren" + "Nee" + "Optioneel pad" + "Spraak in wachtrij verzenden" + "Ingebouwd" + "hide" + "runs" + "Gateway-wachtwoord is vereist. Voer het opnieuw in of bewerk deze verbinding." + "Gebeurtenistekst" + "Live transcriptie" + "Kan de configuratie van providermodellen niet laden." + "%1$s app mag doorsturen." + "Spraakinstellingen" + "Video toevoegen" + "Extra afbeeldingen verborgen: %1$s" + "Koppelingsverzoek afwijzen?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Uitgever" + "Vertakken vanaf hier" + " · Gesprek: Spreekt" + "Optionele overschrijving" + "Standaard" + "Opdrachtgoedkeuring" + "De app en Gateway gebruiken incompatibele protocolversies. Werk OpenClaw op beide bij en probeer het opnieuw." + "Scherm vernieuwen" + "Recente foto\'s en media lezen" + "Luisteren" + "Verbonden" + "Voltooid" + "Je telefoon is gekoppeld aan %1$s. Ga verder om de toegang tot het knooppunt te voltooien." + "Huidige thread" + "Denken %1$s" + "Gedempt" + "OpenClaw waardeert zijn partners in de open-sourcecommunity." + "open" + "Verbinding maken met Gateway" + "De gateway kan dit pad wijzigen maar kan een bestaand pad niet wissen." + "TTS" + "Opslaan…" + "Anker %1$s" + "search" + "Activeren" + "Bekijk en beheer gepland Gateway-werk." + "Een eerdere reactie heeft deze opdracht al eenmalig toegestaan." + "generate" + "Alleen gebruiken op een vertrouwd privénetwerk." + "Instellingen zoeken" + "Talk is actief" + "Gateway-authenticatie is niet geconfigureerd. Bewerk deze verbinding en probeer het opnieuw." + "Mislukt: beveiligd eindpunt bereikt, maar verificatie van de TLS-vingerafdruk is verlopen. Controleer Tailscale Serve of gateway-TLS en probeer het opnieuw." + "Stap 1" + "Dicteren" + "App-kiezer openen" + "Geen openstaande goedkeuringen" + "edit" + "Verbinding maken met je Gateway" + "Voer de installatiecode van openclaw qr in." + "Diagnostiek" + "Andere apps blijven onaangeroerd." + "Kraken" + "Hiermee worden de thread en het transcript ervan permanent verwijderd." + "Apparaat goedgekeurd." + "Automatisch opnieuw proberen" + "Widgetafbeelding gekopieerd" + "%1$s rollen" + "1 ontbrekend item" + "%1$s gepland" + "react" + "Agenten" + "Verbind de Gateway om automatiseringen te laden." + "Opnieuw verbinden…" + "Terug naar configuratie" + "send" + "Kan verbinding niet testen" + "Verifiëren en installeren" + "Verander dit apparaat in een beveiligd OpenClaw-knooppunt voor chat, spraak, camera en apparaathulpmiddelen." + "Handmatige configuratie" + "Open Chat om de huidige thread te starten of te hervatten." + "Tip: stop met luisteren om de vastgelegde beurt te verzenden." + "Overslaan" + "Spraakverzoek mislukt" + "Hiermee wordt \"%1$s\" afgewezen en wordt de status van Skill Workshop vernieuwd vanuit de gateway." + "Pellen" + "update" + "Delen" + "Camera ingeschakeld" + "Telegram, WhatsApp, e-mail en andere kanalen verschijnen hier na de configuratie." + "Netwerkfout" + "Getijdenpoelen verkennen" + "Herstel Canvas nu voor session=%1$s source=%2$s. Als er een bestaande A2UI-status is, speel deze dan onmiddellijk opnieuw af. Zo niet, maak en render dan een compact, mobielvriendelijk dashboard in Canvas." + "Starten mislukt: %1$s" + "Niet aangevraagd" + "Configureer een %1$s-provider op de Gateway" + "kill" + "Goedkeuringen" + "Bestanden niet beschikbaar" + "Markeren als ongelezen" + "Personen en contactgegevens zoeken" + "Apparaatidentiteit vereist" + "OpenClaw-thread" + "Toegang tot fotobibliotheek toestaan." + "Een eerdere reactie heeft deze goedkeuring al afgehandeld." + "Geen recente threads" + "Time-out %1$ss" + "Geen overeenkomsten" + "Meldingen van geselecteerde apps lezen" + "Beschikbaarheid onbekend" + "Talk instellen" + "Extra" + "Gateway gekoppeld. Wachten op operatortoegang." + "Afbeelding toevoegen" + "Kies wat OpenClaw bereikt." + "Hergoedkeuring van mogelijkheid in afwachting" + "Gemarkeerde items controleren" + "Luisteren..." + "Praat me bij" + "Bericht" + "Contacten lezen" + "De offline opslag voor bijlagen is vol; verwijder eerst items uit de wachtrij." + "Eenmalig" + "Hernoemen" + "Geen kanalen gevonden." + "Alles bekijken" + "Nieuw apparaat" + "Session Status" + "Afbeeldingsvoorbeeld openen" + "Sessievertakking gewijzigd; controleer en probeer dit bericht opnieuw." + "close" + "Dat lijkt op een installatiecode. Ga terug, kies Gateway instellen en vervolgens Installatiecode gebruiken." + "✦" + "Agents en automatisering" + "Toepassen" + "De uitvoering van de automatisering is overgeslagen." + "Doorgaan" + "Monitoring · %1$s geplande taken" + "Bladeren" + "tabs" + "In behandeling" + "Praten: %1$s" + "read" + "Tekst selecteren" + "Bewegingsactiviteit" + "description: %1$s" + "Audio afspelen" + "Tijd" + "Niet geverifieerd" + "Yield" + "Goedkeuringsopdracht kopiëren" + "Huidige schermuitvoer en interactief app-oppervlak." + "Service verbonden" + "Weergave" + "Klaar wanneer jij dat bent" + "Kan providercatalogus niet laden." + "Spreekt · wacht op antwoord" + "Niet verleend" + "Wijzigingen opslaan" + "Gateway heeft de uitvoering van de automatisering geweigerd." + "Session Send" + "Zoeken op ClawHub" + "Staat aangevraagde locatiecontroles altijd toe terwijl OpenClaw op de achtergrond actief is; Android toont dit in de permanente node-melding." + "Systeemgebeurtenis" + "Verbind Gateway om providers te bekijken" + "Volgende heartbeat" + "Gateway gekoppeld. Wachten op goedkeuring van nodefunctionaliteit." + "Pekelen" + "Canvas sluiten" + "Contacten schrijven" + "Er zijn geen geïnstalleerde skills die overeenkomen met deze zoekopdracht." + "Talk Provider instellen" + "Music Generation" + "Praatinstellingen" + "Monitoren · 1 thread" + "Payloadtekst" + "Tekst instellen" + "Goedkeuring %1$s" + "Gateway heeft de gereedheid van %1$s niet geretourneerd" + "%1$s geconfigureerde modellen. Vernieuw om de beschikbaarheid opnieuw te controleren." + "Conversation Send" + "Canvas" + "1 provider" + "Het Gateway-certificaat kon niet automatisch worden gelezen. Plak de SHA-256-vingerafdruk die op de Gateway-host is verkregen." + "Verzenden mislukt: %1$s" + "Brug" + "Bezorgfout" + "Gebruik OpenClaw vanaf je telefoon" + "Weergave" + "Skill-workshop" + "Token vereist" + "Voorvertoning · %1$s" + "Microfoontoestemming vereist" + "Verbind de Gateway om voorstellen van Skill Workshop te laden." + "Alle systemen operationeel" + "Gateway niet bereikbaar" + "OC" + "Bijgewerkt" + "Verbonden (node offline)" + "Start" + "Dicteren luistert" + "Geen gearchiveerde gesprekken" + "Kies en inspecteer de assistenten die beschikbaar zijn op deze Gateway." + "Praatmodus actief" + "Bezig · 1 actieve uitvoering" + "Akkoord en inschakelen" + "Gateway-update vereist" + "Afbeelding kopiëren" + "Gateway-URL" + "main, isolated, current of session:<id>" + "Media niet beschikbaar" + "Maak verbinding met je Gateway om een shell in de agentwerkruimte te openen." + "%1$s://%2$s:%3$s" + "Kan de goedkeuringsdetails niet laden. Vernieuw en probeer het opnieuw." + "Ik kan de Gateway-status controleren, configuratie herstellen, modellen wijzigen of kanalen verbinden." + "Tool Call" + "Gesprekken" + "Write" + "Begin met een prompt of gebruik spraak." + "D" + "Instellingen openen" + "Bezig met waarnemen…" + "Gesprek beëindigen" + "Laatste fout" + "Bekijk acties die je aandacht nodig hebben." + "Uitgeschakeld voor alle agents." + "Spraak starten" + "Terug naar achtergrondtaken" + "Een andere cronactie wordt nog afgerond." + "Afkoelperiode %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "Sms" + "Spraakherkenning op het apparaat is niet beschikbaar." + "Script · alleen-lezen" + "De bijlagen zijn te groot om voor één bericht in de wachtrij te plaatsen; verwijder er enkele en probeer het opnieuw." + "1 geconfigureerd model. Vernieuw om de beschikbaarheid opnieuw te controleren." + "Widget niet beschikbaar" + "Voor deze host is een beveiligde verbinding vereist." + "Recent" + "Geen overeenkomende automatiseringen." + "Telefoon kan de Gateway bereiken" + "Gateway" + "Verlopen" + "Gepland OpenClaw-werk vanaf je Gateway." + "Sub-agent" + "Wachten op apparaatgoedkeuring" + "Thread laden" + "Deze Gateway gebruikt nu een certificaat dat door dit apparaat wordt vertrouwd." + "Spreiding ms" + "event create" + "document" + "Gateway instellen" + "Video afspelen" + "Opgeslagen authenticatie is ongeldig. Verifieer opnieuw of stel deze gatewayverbinding opnieuw in." + "Bij gebruik" + "screenshot" + "Terugspoelen naar hier" + "Cron-expressie, bijv. 0 9 * * *" + "Terug naar spraak" + "Praten" + "Details" + "%1$s/%2$s online" + "%1$s apps mogen doorsturen." + "Chat" + "Microfoontoegang is nodig." + "Scharrelen" + "Bewerken" + "Stille uren" + "Diagnostiek kopiëren" + "Gepland" + "Aanmaken" + "Verloopt over %1$s" + "Sluiten" + "Voorstel afwijzen?" + "Spraakfout (%1$s)" + "Probleem" + "Doorzoek registermetadata. De Gateway verifieert de betrouwbaarheid opnieuw vóór elke download." + "Gebruik een privé-IP-adres op het LAN voor lokale configuratie, of schakel Tailscale Serve in / maak een wss://-gateway-URL beschikbaar voor externe toegang." + "Bezig met indienen…" + "Account %1$s" + "Suggest Task" + "Zoeken" + "Luisteren" + "Automatisering niet geladen." + "Er is een update voor Gateway beschikbaar. Voer de update uit via de Web UI of CLI wanneer je er klaar voor bent." + "binnenkort" + "Geen Gateway-goedkeuringen." + "Host" + "Voeg per veld één activeringswoord of één activeringszin toe. Zeg er vervolgens één vóór uw opdracht." + "Transcriberen en daarna verzenden" + "Uitvoeren om" + "Audio pauzeren" + "Toegang tot het Gateway-apparaat" + "Geen voorbeeld" + "Apparaten" + "OpenClaw voor Android." + "Goedkeuring van mogelijkheid in afwachting" + "Sla uw wijzigingen op of draai ze terug voordat u deze automatisering uitvoert, inschakelt, uitschakelt, verwijdert of vernieuwt." + "Nog geen automatiseringen." + "Voor deze skill moeten %1$s items worden ingesteld. Android toont wat er is geïnstalleerd; wijzigingen in de installatie of configuratie kunnen alleen via desktop of CLI worden aangebracht." + "%1$s recent" + "Kanalen" + "Unphased" + "Actief op deze telefoon" + "Toegang tot node controleren" + "Tijdzone" + "Skill Workshop-acties voor inspecteren en toepassen" + "Altijd toestaan" + "present" + "Skills die op de gateway zijn geïnstalleerd, verschijnen hier." + "De code is mogelijk verlopen of gegenereerd voor een andere Gateway." + "Toestemming nodig" + "De automatisering heeft een ongeldige configuratie." + "Toegestaan-lijst" + "Installatie, status en herstel" + "groups" + "Openbare sleutel" + "Over" + "Er is geen installatie-QR-code gevonden in die afbeelding. Kies de QR-code die is gegenereerd door openclaw qr of voer de installatiecode handmatig in." + "permissions" + "Verbind de Gateway om nodes en gekoppelde apparaten te laden." + "Vertakking wisselen" + "Geen Skills" + "Antwoorden worden hardop afgespeeld" + "Markeren als gelezen" + "Node-goedkeuring in afwachting" + "wake" + "%1$s voorstellen" + "Gateway-authenticatie vereist aandacht." + "Verbindingsdetails" + "Milliseconden" + "Spraakherkenning" + "Beschrijving" + "Recente gesprekken" + "Je telefoon verzendt deze informatie naar je Gateway, niet naar een server die door OpenClaw wordt beheerd. Je Gateway kan deze opnemen in verzoeken aan de door jou gekozen AI-provider." + "Aflevering" + "Luidspreker dempen" + "%1$s Actief · %2$s Klaar · %3$s Mislukt" + "Gateway-verbinding openen" + "Monitoren · %1$s threads" + "De automatisering is voltooid." + "Geen overeenkomende apps." + "Naar chat verzenden" + "De automatisering is verwijderd." + "Inschakelen" + "Recente runs" + "Plaats de QR-code binnen het vierkant." + "Kan goedkeuringen niet laden." + "Ik heb goedgekeurd" + "Verbind je Gateway om de gereedheid van providers te laden." + "Niet gekoppeld" + "Deze goedkeuring is verlopen voordat ze kon worden afgehandeld." + "Waarnemen over %1$ss — schakel over naar de doel-app" + "Agentprompt" + "emoji list" + "Herhalend" + "Zoeken in OpenClaw" + "%1$s openstaand" + "Spraakherkenning op het apparaat niet beschikbaar" + "Geen enkele app kan dit bericht delen" + "Zoeken sluiten" + "Commando om te bewaken" + "Status" + "Meldingslistener" + "Luidspreker gedempt" + "Threads zoeken" + "OK" + "Kan de installatiehandleiding niet openen." + "vraag OpenClaw %1$s" + "Wait for Agents" + "Adres" + "Gepland werk dat op de Gateway is aangemaakt, verschijnt hier." + "De nieuwste logchunk wordt weergegeven." + "Setupcode gebruiken" + "sticker" + "Gebruik een beveiligde wss:// of Tailscale Serve Gateway, genereer een installatiecode met volledige toegang in de Control UI of met openclaw qr, scan of plak deze hieronder en maak opnieuw verbinding om instellingen en upgrades in te schakelen." + "steer" + "Geselecteerd" + "Android kan een bestaande setupcode scannen of plakken, maar deze gateway biedt de app nog geen mogelijkheid om setupcodes te genereren. Genereer de QR/code op de gatewayhost met openclaw qr en scan deze vervolgens hier of plak de setupcode hieronder." + "Canvasstatus" + "Verbinding herstellen" + "Afbeelding bewaren" + "Node %1$s" + "Gateway-wachtwoord vereist" + "Update Plan" + "Bijlage verwijderen" + "De uitvoering van de automatisering is mislukt." + "Limieten van providers en quotastatus." + "Gateway-talkcatalogus niet geladen" + "deze gateway" + "Nog geen recente runs." + "Taalmodel op het apparaat niet beschikbaar" + "Dashboard vereist een verbonden Gateway" + "Overeenkomende voorstellen verschijnen hier nadat agents herbruikbare skillconcepten hebben gemaakt." + "Session Search" + "OpenClaw spreekt" + "QR scannen" + "Geselecteerde apps" + "Wijzigingen ongedaan maken" + "Goedkeuringsopdracht gekopieerd" + "Bezorgstatus" + "QR-code niet geaccepteerd" + "Je centrale plek voor spraakopdrachten." + "Verbinding testen" + "OPENCLAW" + "Web Fetch" + "Prompt" + "Apparaat goedkeuren?" + "Maak verbinding met je Gateway om het dashboard van deze sessie te openen." + "%1$s en de opgeslagen inloggegevens van deze telefoon verwijderen?" + "De QR-code verwijst naar een onveilige externe Gateway. %1$s %2$s" + "Schermoppervlak gereed" + "Gateway koppelen" + "Verbind de gateway om kanalen te laden." + "Wordt gepauzeerd tijdens andere spraakactiviteit." + "Model" + "Foto\'s" + "Installatiecode plakken" + "OpenClaw spreekt" + "Verbinden..." + " · Locatie: Altijd" + "Berichten: %1$s" + "Reven" + "Laden vanuit Gateway" + "text: %1$s" + "Vereist" + "rename group" + "Gereed" + "Het dagboek wacht op het eerste item." + "Goedkeuren" + "Livepagina" + "De automatisering wordt al uitgevoerd." + "Verwijder deze automatisering na een geslaagde eenmalige uitvoering." + "Klaar voor chat en spraak" + "Verbonden (operator: %1$s)" + "Gateway-koppeling is voltooid. Keur deze telefoon goed als node zodat OpenClaw de apparaatmogelijkheden kan gebruiken die je inschakelt." + "Antwoord afgebroken" + "afbeelding" + "%1$s vastgehouden" + "Geen overeenkomende threads" + "delete" + "Lay-out: Compact" + "channels" + "Toegestaan" + "Elke %1$s min" + "1 token" + "%1$s %2$s" + "Geïnstalleerde apps" + "in behandeling" + "Spraaknotitie voorbereiden…" + "Nooit" + "Subsysteem" + "Bij afsluiten van commando" + "Verbinding" + "Kan de uitvoeringsgeschiedenis van automatiseringen niet laden." + "Naam van automatisering" + "Stap 2" + "Diagnose uitvoeren" + "Sommige controles van de kanaalstatus zijn niet voltooid." + "pin" + "%1$s kopiëren" + "Gekoppeld" + "Kan activeringswoorden niet opslaan" + "Hiermee wordt \"%1$s\" in quarantaine geplaatst en wordt de status van Skill Workshop vernieuwd vanuit de gateway." + "Spraakbericht opnemen" + "In wachtrij" + "Beantwoord" + "Cameratools toestaan wanneer daarom wordt gevraagd." + "Problemen" + "Spraakactivering" + "Koppelingsverzoek afgewezen." + "%1$sd geleden" + "roles" + "Skills" + "Archiveren" + "Node offline. Maak opnieuw verbinding en probeer het opnieuw." + "Systeem" + "Extern IP-adres" + "Niet gegroepeerd" + "Planningsdetails" + "Telefoonmogelijkheden" + "Niet beschikbaar" + "Dashboard" + "Token plakken" + "Geen providers" + "SHA-256-vingerafdruk" + "Nog geen threads" + "Bluetooth-microfoon" + "Recent" + "Thread hernoemen" + "Uitkomst van de afhandeling onbekend. Acties blijven uitgeschakeld totdat de Gateway-record is geverifieerd." + "dialog" + "Luisteren naar activeringswoorden" + "camera snap" + "Afspelen voorbereiden…" + "Gateway heeft onbekende provider %1$s geselecteerd" + "delete group" + "Volg Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Verbind de Gateway om agents te laden." + "Ga terug" + "Bericht delen" + "Genereer een QR-code." + "Opnieuw starten" + "Luidspreker aan" + "Groep verwijderen?" + "Ontbreekt" + "Voorstellen zoeken" + "stop" + "Beveiligd (TLS)" + "Geen nodes of gekoppelde apparaten." + "%1$s%% over %2$s" + "Installatiecode verlopen" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DAGBOEK" + "notify" + "Deze telefoon blijft inactief totdat de Gateway hem nodig heeft. Daarna wordt hij actief, synchroniseert hij en gaat hij weer in de slaapstand." + "%1$s geconfigureerde modellen" + "Licenties" + "Verbind de gateway om ClawHub-skills te zoeken." + "Skill" + "De Gateway-verbinding is gewijzigd. Start OpenClaw opnieuw om opnieuw verbinding te maken." + "Apparaat-ID" + "Gateway heeft de actieve %1$s-provider niet geïdentificeerd" + "Wachten" + "Activeringswoorden opgeslagen" + "Oudste eerst" + "Scherm" + "Actief sinds" + "IPv6-zone-ID\'s worden niet ondersteund. Gebruik een IPv6-adres zonder scope of een LAN-hostnaam." + "Verzonden — bezorging bevestigen…" + "audio" + "This gateway connection needs operator.admin to update skills." + "Installatiecode" + "Gateway-waarschuwing bevestigen en installeren" + "Chat vernieuwen" + "Interval" + "Voor acties op Skill Workshop-voorstellen is het bereik operator.admin vereist." + "Sessies" + "Naam wijzigen…" + "Verbind de Gateway om dromen te laden." + "Instellen" + "Talk openen" + "poll" + "Maak verbinding om je agents te laden" + "role remove" + " · Gesprek: Luistert" + "ClawHub heeft geen installeerbare versie voor %1$s geretourneerd." + "Opdracht" + "Deze goedkeuring is geannuleerd voordat ze kon worden afgehandeld." + "Microfoon aan · wachten op gateway" + "Tekst" + "%1$s van %2$s weergegeven. Verfijn de zoekopdracht voor meer resultaten." + "v%1$s beschikbaar" + "%1$s://%2$s" + "%1$s... (OK)" + "Providers en geconfigureerde modellen" + "Verbinden…" + "Maak verbinding met een Gateway om activeringswoorden op te slaan" + "Profiel openen" + "Start je Gateway." + "Help me dit doel om te zetten in een praktische checklist: " + "Sessiezoekopdracht wissen" + "Poort" + "Installatiecode invoeren" + "Kan Gateway-logboeken niet laden." + "%1$s providers gereed" + "Je agents zijn klaar" + "Er is geen %1$s-provider geconfigureerd op de Gateway" + "Luisteren naar één beurt" + "Waarnemen" + "Epoch-milliseconden (optioneel)" + "Geen geconfigureerde modellen. Vernieuw om de beschikbaarheid opnieuw te controleren." + "Instellingen" + "Camera aan de achterkant" + "approve" + "Voordat je begint" + "Kan Skills niet laden." + "Uitgeschakeld" + "Nog steeds wachten op goedkeuring" + "Kon achtergrondtaken niet laden" + "Controleer of OpenClaw duidelijk kan spreken op deze telefoon." + "Bezig · %1$s actieve uitvoeringen" + "Werkmap voor opdracht" + "Groepsnaam" + "Kiezen uit galerij" + "Version %1$s" + "Terug" + "Connect the gateway to update skills." + "Verwijderen na uitvoering" + "De setupcode verwijst naar een onveilige externe Gateway. %1$s %2$s" + "Computer" + "Gateway losgekoppeld." + "Session Settings" + "Verbind de Gateway om te beginnen" + "Beveiligingsmelding" + "Ander antwoord" + "Waarschuwing gedeelde afbeelding sluiten" + "De Gateway heeft een andere ClawHub-release beoordeeld. Bekijk de skill opnieuw voordat u deze installeert." + "Systeemtoegang openen" + "Voltooid" + "Afbeelding niet beschikbaar" + "Meldingen" + "Toepassen, afwijzen en in quarantaine plaatsen vereisen de operator.admin-scope. Maak opnieuw verbinding met gedeelde gateway-authenticatie of keur een operator.admin-apparaatscopeupgrade goed om levenscyclusacties in te schakelen." + "sticker upload" + "Kreeften vangen" + "Messages to recover" + "openclaw devices approve %1$s" + "Leesbare Gateway-logdetails." + "Bekijk gegenereerde skillvoorstellen voordat ze live skills worden." + "Gebundeld" + "%1$s beschikbaar" + "Goedkeuring van knooppunt in afwachting" + "Gateway in afwachting" + "Authenticatie vereist" + "Knooppunten" + "Actief houden" + "OpenClaw antwoordt" + "Documentatie" + "%1$s gereed" + "Nog geen uitvoer" + "Apparaattaal wordt niet ondersteund" + "In wachtrij — wordt verzonden zodra de verbinding is hersteld" + "%1$sm geleden" + "Huidige vertakking" + "Koppeltoegang controleren" + "Beperkte Gateway-toegang" + "Tools worden uitgevoerd..." + "Goedkeuring controleren…" + "Foto\'s en clips vastleggen met deze telefoon" + "Verbonden en gereed" + "Sluiten" + "Zet een doel om in een uitvoerbare checklist." + "De setupcode heeft een ongeldige Gateway-URL." + "Schakel alleen toegang in waarvan je het prettig vindt dat OpenClaw die gebruikt terwijl deze telefoon verbonden is. Je kunt dit later wijzigen in Android-instellingen." + "Account" + "remove" + "Wachtwoord optioneel" + "Gateway-authenticatie moet worden gecontroleerd. Controleer de gateway-instellingen en probeer het opnieuw." + "QR-code gebruikt een IPv6-zone-ID. Gebruik een IPv6-adres zonder scope of een LAN-hostnaam." + "add" + "Krillen" + "In orde" + "Klaar in %1$s" + "Argumenten" + "Installatieopties" + "Over %1$su" + "Goedkeuring van Gateway is in behandeling. Voer dit uit op de Gateway-host:" + "Beheerderstoegang vereist" + "set groups" + "Model vastzetten" + "Zoekopdracht wissen" + "Ingeschakeld voor geschikte agents." + "Geen huidige thread" + "bounds: %1$s" + "Na %1$s" + "Sta toe dat de planner deze automatisering uitvoert." + "%1$s toegepast" + "Nog geen droomdagboek." + "Achtergrondtaken vernieuwen" + "Vat recente threads en vervolgstappen samen." + "Wordt op het apparaat uitgevoerd zolang OpenClaw zichtbaar is." + "%1$s is bezig" + "%1$s %2$s" + "Onbewerkt" + "Uitvoeringen" + "Nu uitvoeren" + "Naamloze vertakking" + "Geconfigureerd" + "camera list" + "1 toegepast" + "camera clip" + "Ja" + "Audiotest" + "Vastgehouden" + "events" + "Werkmap" + "Naar nieuwste springen" + "Altijd toestaan" + "QR-code of installatiecode scannen" + "Installing" + "Live knooppunten, gekoppelde telefoons en openstaande apparaatverzoeken." + "Snapshot: %1$s" + "Een eerdere reactie heeft deze opdracht al toegestaan en de keuze opgeslagen." + "Aanvragen in behandeling" + "Goedgekeurd" + "Werkruimte" + "Spraak" + "Klaar om te praten" + "Subagents" + "Mislukt: er is geen veilig gateway-eindpunt gedetecteerd. Schakel gateway-TLS of Tailscale Serve in, of gebruik een vertrouwd privé-LAN-adres met Onversleuteld geselecteerd." + "Signalen" + "Sessiedoel" + "Gateway heeft een weigering vastgelegd." + "Accepteren" + "Vraag OpenClaw alles" + "Maak opnieuw verbinding om door te gaan" + "%1$s gekoppeld" + "Hiermee wordt \"%1$s\" toegepast en wordt de status van Skill Workshop vernieuwd vanuit de gateway." + "Gateway offline" + "openclaw devices list" + "Verbindingsstatus van OpenClaw Node" + "Waarschuwingen blijven op deze telefoon." + "OpenClaw kan geselecteerde waarschuwingen ontvangen." + "Scherm openen" + "Chatacties" + "Besturing van andere apps toestaan?" + "Inspecteren" + "Scan of plak een installatiecode om een andere gateway toe te voegen." + "Swarm" + "TLS-time-out" + "Recente sessies" + "Gekoppeld apparaat verwijderd." + "Gateway gekoppeld. De goedkeuring van knooppuntmogelijkheden wordt gecontroleerd." + "Beweging" + "Cronactie mislukt." + "Voer op de Gateway-computer uit:" + "Sessies zoeken" + "Logs vernieuwen" + "Afbeelding niet beschikbaar · Tik om opnieuw te proberen" + "openclaw nodes approve %1$s" + "Spraakbericht · %1$s" + "Gebruik" + "Nautilussen" + "Context %1$s%%" + "Spraakopdrachten transcriberen" + "Dempen" + "Start een nieuw gesprek en het verschijnt hier." + "Verbindingsprobleem" + "Gemiddeld" + "Afsplitsen" + "Luidspreker inschakelen" + "Systeemgebeurtenistekst" + "Sortering: %1$s" + "%1$s wachtend" + "Image Generation" + "Spraakbericht" + "Niets vereist je aandacht" + "OpenClaw heeft %1$s toestemmingen nodig om door te gaan." + "Microfoon van bedrade headset" + "Pagina\'s" + "Afgeleverd" + "Verschuldigd" + "Skilldetails zijn niet beschikbaar in de huidige skillstatus." + "Kies wat deze telefoon kan delen." + "Voor deze automatisering staat al een uitvoering in de wachtrij." + "Verbind de Gateway om automatiseringen te beheren." + "De automatisering hoeft nog niet te worden uitgevoerd." + "Geen details" + "De goedkeuring wordt verwerkt.\nOpenClaw maakt automatisch opnieuw verbinding." + "Verbind je Gateway om de gereedheid van providers te bekijken." + "Wachten op koppeling" + "Een gesprek starten of voortzetten" + "Geen geplande taken" + "Antwoord aan OpenClaw…" + "Status" + "OpenClaw Node · Verbonden" + "Actief" + "Debugstatus voor schermdeling weergeven." + "Geen limieten gemeld" + "Scanner sluiten" + "Elke %1$s d" + "Ingeschakeld" + "Inschakelen en instellingen openen" + "Online en gereed" + "Ask User" + "Chatfout" + "Vooruit scrollen" + "%1$s van %2$s" + "Plan het werk" + "console" + "Opnieuw proberen" + "Start een chat en je actieve OpenClaw-gesprekken verschijnen hier." + "Kan de automatisering niet laden." + "Shell in de agentwerkruimte" + "%1$s actief" + "Apparaatrechten kiezen" + "Laatste duur" + "Standaardagent" + "%1$s u." + "Gesprek is actief" + "Kan %1$s niet installeren vanuit ClawHub." + "Welkom bij OpenClaw" + "Andere apps besturen" + "Signaalindex" + "Voer geheim in…" + "%1$s:%2$s" + "zeg tegen OpenClaw om %1$s" + "Ontdekt" + "Zijbalk verbergen" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Audioweergave is niet beschikbaar" + "toepassen" + "Kan gebruiksgegevens niet laden." + "Houd het knooppunt beschikbaar tijdens actieve werkzaamheden." + "Volgende activering" + "%1$s/%2$s" + "Geen recente logvermeldingen." + "Handmatige Gateway" + "Groep hernoemen" + "Update Goal" + "Beschikbaarheid van provider onbekend" + "Providers" + "Groep verwijderen…" + "Payload" + "Oproeplogboek" + "Memory Search" + "%1$s providers" + "Telefooncontext en privacy" + "%1$s/%2$s verbonden" + "%1$s %2$s" + "Herstel na herstart van Gateway is nog bezig." + "Als je de installatiecode vervangt, worden de opgeslagen installatiegegevens en apparaattokens van deze telefoon gewist voordat opnieuw verbinding wordt gemaakt. Mogelijk moet de node-capaciteit van deze telefoon opnieuw worden goedgekeurd. Ga alleen verder als je de telefoon met een nieuwe installatiecode voor de Gateway wilt koppelen." + "Open een automatisering om de configuratie en uitvoeringsgeschiedenis te bekijken. Met verbindingen met beheerdersrechten kunt u deze ook uitvoeren, bewerken, inschakelen, uitschakelen of verwijderen." + "Context --" + "Voorstel in quarantaine geplaatst." + "De automatisering is gepauzeerd." + "OpenClaw mobiel" + "A2UI reset" + "Gateway niet beschikbaar" + "Read" + "Voor deze skill moet 1 item worden ingesteld. Android toont wat er is geïnstalleerd; wijzigingen in de installatie of configuratie kunnen alleen via desktop of CLI worden aangebracht." + "Laatste uitvoering" + "Cameratoegang is nodig om de setup-QR te scannen." + "Kan het model niet bijwerken." + "Borrelen" + "thread reply" + "Verwijderen…" + "Maak verbinding met de Gateway om automatiseringen te bekijken." + "RECENTE LOGS" + "Recente runs laden…" + "Van dit bestand kan geen voorbeeld worden weergegeven. Het is mogelijk binair of te groot." + "Controleren" + "Locatie van deze telefoon lezen" + "Skill-sleutel" + "%1$s is geïnstalleerd." + "Gateways" + "actions: %1$s" + "Doorstuurmodus" + "%1$sk" + "Threadindeling wijzigen" + "Geen TLS-eindpunt" + "OpenClaw-gateway" + "Handmatig instellen" + "Nadenken…" + "Gateway-toegang moet worden gecontroleerd" + "1 vastgehouden" + "%1$ss" + "Voorstel toepassen?" + "Niet nu" + "Niet goedgekeurd" + "Apps zoeken" + "1 geconfigureerd model" + "Goedkeuringsmelding sluiten" + "·" + "Offline" + "Spraakprovider" + "De goedkeuring van de Gateway wordt verwerkt. OpenClaw probeert het automatisch opnieuw." + "Max" + "Voor cronwijzigingen is operator.admin-toegang vereist." + "Denken" + "screen snapshot" + "Waargenomen nodes: %1$s" + "Geen acties gevonden" + "Opslaan en verbinden" + "list" + "Gateway heeft de goedkeuring vastgelegd en de keuze opgeslagen." + "Voer een geldig handmatig endpoint in om verbinding te maken." + "assistent" + "Verzenden naar chat..." + "Profiel opslaan" + "Vergrendeld" + "Automatisering bewerken" + "Gebruik hetzelfde netwerk of een beveiligde externe Gateway-URL." + "Anker" + "Taal" + "Deze app is ouder dan de Gateway. Werk OpenClaw op dit apparaat bij en probeer het opnieuw." + "Alle" + "Gateway-sessie wordt uitgevoerd" + "Wacht op beoordeling" + "Geen skills geïnstalleerd." + "Gateway controleren" + "Spreiding %1$s" + "Het resultaat voor %1$s is onbekend. Maak opnieuw verbinding, vernieuw Skills en probeer het opnieuw; de Gateway sluit veilig aan bij een overeenkomende installatie die nog wordt uitgevoerd." + "Vergeten" + "Geen gekoppelde gateways." + "%1$s · %2$s" + "<geheim geredigeerd>" + "%1$s problemen" + "OpenClaw" + "Luisteren · %1$s in wachtrij" + "Spraak van assistent gedempt" + "Node-acties worden alleen uitgevoerd wanneer de doel-app op de voorgrond staat (gevalideerd via het externe pad). Globale acties en acties binnen dezelfde app werken hier." + "Nog geen gateways gevonden. Gebruik handmatige configuratie als detectie wordt geblokkeerd." + "Thread openen" + "Bezig" + "Begin met spreken..." + "Telefoonknooppunt" + "Xhoog" + "Voer uit op de Gateway-host:" + "Voor wijzigingen aan skills is operator.admin vereist. Maak opnieuw verbinding met een Gateway-token met beheerdersrechten." + "Verbind de gateway om ClawHub-skills te bekijken." + "App-lijst blijft op deze telefoon." + "Inactief" + "Wordt getoond in de toegankelijkheidsinstellingen van Android." + "Slimme levering" + "Afwijzen" + "Gateway retourneerde status \'%1$s\' na %2$s." + "Gateway-token niet geconfigureerd" + "Not available to this agent" + "Bestanden" + "Machtigingen" + "Kan de camera niet starten. Kies een QR-afbeelding uit de galerij of voer de installatiecode handmatig in." + "Tik om te kopiëren" + "Wacht %1$sm" + "%1$s." + "Verbind de gateway om ClawHub-skills te installeren." + "Spraak zoeken" + " · Microfoon: Luistert" + "Maak opnieuw verbinding met operator.admin-toegang om Gateway-instellingen te bekijken en te wijzigen." + "Meer laden" + "Waarnemen over 3s" + "run" + "Spraak genereren…" + "← Terug" + "Verbinding verbreken" + "Voer de approve-opdracht uit op de Gateway-computer en controleer daarna opnieuw." + "Automatiseringen" + "%1$s min." + "Vertrouwen" + "Die QR-code is geen OpenClaw-installatie-QR. Genereer een nieuwe code met openclaw qr en probeer het opnieuw." + "Voorkeursmicrofoon niet beschikbaar; automatische routering wordt gebruikt." + "Afgewezen" + "Neem Android- en achtergrondpakketten op." + "Je Gateway is klaar." + "Geactiveerd" + "Structured Output" + "Dit duurt langer dan verwacht.\nControleer of de Gateway actief en bereikbaar is." + "Geen achtergrondtaken voor deze agent." + "Opnieuw verbinding maken" + "OpenClaw controleert de toegang tot de gateway en het knooppunt." + "Code Execution" + "Geen providergebruik" + "Beoordelen" + "Microfoontoestemming is vereist." + "%1$s d." + "%1$s beschikbaar" + "OpenClaw synchroniseert opnieuw" + "Gebeurtenisstream onderbroken; probeer te vernieuwen." + "Kan knooppunten en apparaten niet laden." + "Verbind de gateway om skills te laden." + "onbekend" + "Uitvoer" + "Talk mislukt: Realtime-provider is onverwacht gesloten." + "OpenClaw Tijdgevoelig" + "ban" + "Gateway-token vereist" + "Gekoppeld apparaat" + "Moet opnieuw worden goedgekeurd" + "Niet gepland" + "Contacten" + "Je telefoon blijft stil totdat deze nodig is" + "Luistert · spraak in wachtrij verzenden" + "Kon taakdetails niet laden" + "Agentbericht" + "Gateway vereist deze apparaatidentiteit. Verifieer opnieuw of stel deze gatewayverbinding opnieuw in." + "Volgende sessie" + "Verbindingsbeveiliging" + "Nu overslaan" + "Website" + "Verbind de Gateway om goedkeuringsverzoeken in de app te laden." + "%1$s gekopieerd" + "Geen apps geselecteerd. Er wordt niets doorgestuurd totdat je apps toevoegt." + "%1$s %2$s" + "Installatie vereist" + "Niet gekoppeld" + "Gateway heeft deze telefoon ontvangen" + "Geen geconfigureerde modellen" + "Uitschakelen" + "App-taal" + "Gateway koppelen" + "Opgeslagen authenticatie ongeldig" + "%1$s bereiken" + "Verbind de Gateway om recente logs te laden." + "Activeringswoorden opslaan" + "Beheer geïnstalleerde skills en voeg vertrouwde releases van ClawHub toe." + "Verzenden…" + "Nog geen agents geladen." + "Zoeken in ClawHub" + "Chat controleert de status van de Gateway." + "Koppeling vereist" + "Actieve uitvoeringen" + "Mislukt — %1$s" + "Verbinding tussen deze telefoon en OpenClaw." + "summarize" + "Widgetafbeelding opgeslagen in Downloads" + "Starten…" + "%1$s tokens" + "Clientfout" + "Verifieer dit verzoekende apparaat voordat u toegang verleent." + "Bluetooth LE-microfoon" + "%1$s %2$s" + "De automatisering is ingeschakeld." + "%1$s mln." + "Memory Get" + "%1$s · %2$s" + "Gearchiveerd" + "Opnieuw laden" + "Automatiseringen zoeken" + "Gekoppelde telefoons en nodehosts verschijnen hier na het koppelen." + "%1$s: %2$s" + "Deze automatisering is gewijzigd op de Gateway. Controleer de nieuwste versie voordat u opnieuw opslaat." + "Dicteren stoppen" + "Leesbaar" + "Bericht aan OpenClaw" + "Gateway-wachtwoord is ongeldig. Voer het opnieuw in of stel deze gatewayverbinding opnieuw in." + "Opnieuw verbinden" + "ISO-tijd, bijv. 2026-07-09T09:30:00Z" + "%1$s tools" + "Een eerdere reactie heeft deze goedkeuring al geweigerd." + "Gekoppeld" + "%1$s openen" + "%1$s/%2$s" + "De automatisering is voltooid met een onbekende status." + "De offlinewachtrij is vol (%1$s berichten); verwijder eerst items uit de wachtrij." + "Openbare gateways vereisen wss:// of Tailscale Serve. ws:// is toegestaan voor localhost, .local-hosts, de Android-emulator en privé-IP-adressen op het LAN." + "Verbind de gateway om skilldetails te laden." + "Volledige toegang vereist" + "Activeringswoorddetectie" + "Linkvoorvertoning uitklappen" + "Zoekopdracht voor threads wissen" + "NULL (MISLUKT)" + "Bijwerken" + "Beheerder" + "Aandacht vereist" + "Koppel dit apparaat aan je Gateway om het alleen voor echt werk te activeren, een liveoverzicht van agents bij de hand te houden en batterijverslindende achtergrondlussen te voorkomen." + "Rollen" + "Beantwoorden" + "Providercatalogus" + "Schakel toestemming in via Instellingen" + "A2UI push" + "Toegang controleren" + "Als de Gateway bereikbaar is, wordt het opnieuw verbinden zonder tussenkomst voltooid." + "Agentbeurt" + "in quarantaine plaatsen" + "Aandacht" + "Zoeken…" + "Waar vind ik een installatiecode?" + "Kan skill niet inschakelen." + "pdf" + "Verwijderen" + "%1$s%% online" + "Geen kanalen" + "Realtime spraak" + "Skill Workshop-acties voor afwijzen en in quarantaine plaatsen" + "Knooppunten en apparaten" + "Lokaal commandocentrum" + "emoji upload" + "Voorbeeld laden…" + "Hoog" + "focus" + "describe" + "%1$s-context" + "Luisteren naar antwoord..." + "voice" + "Verbonden met %1$s" + "role add" + "Chat vereist aandacht" + "Microfoon inschakelen" + "OpenClaw verzamelt en verzendt de namen, pakket-ID\'s en status van apps die zichtbaar zijn op deze telefoon wanneer je gekoppelde OpenClaw Gateway hierom vraagt. Zo kan je assistent vragen beantwoorden en acties uitvoeren met geïnstalleerde apps." + "Gateway niet verbonden" + "Beleid" + "Time-out tijdens het bevestigen van het verzonden bericht; vernieuw om de bezorging te controleren." + "Ondersteunende bestanden" + "Expressie" + "Achtergrondtaken" + "Dromen" + "Geen apps geblokkeerd. Apps kunnen doorsturen tenzij je blokkeringen toevoegt." + "Spraakherkenning niet beschikbaar" + "Platform" + "Gateway heeft de installatie van %1$s niet geretourneerd" + "Gateway vergeten?" + "Optionele beschrijving" + "%1$s openen" + "Startcanvas" + "Dromen" + "%1$s tot %2$s" + "Bestand delen" + "Realtime" + "API" + "OpenClaw is bezig…" + "Praten of dicteren met OpenClaw" + "Informatie over geïnstalleerde apps delen?" + "Automatisering laden…" + "Automatisering verwijderen" + "Standaardassistent" + "Kies een ondersteunde %1$s-provider op de Gateway" + "Niet beschikbaar" + "Lege map" + "Instellingen openen" + "Uit" + "Typografie" + "Stoppen" + "Nog geen overeenkomende threads." + "Het koppelen van de Gateway is gelukt.\nKeur de knooppuntmogelijkheden van deze telefoon goed via een operatorinterface." + "Deze skill is geïnstalleerd, maar kan momenteel niet worden uitgevoerd. Gebruik desktop of CLI voor configuratiewijzigingen." + "Spraakherkenning is bezig" + "Gateway thuis" + "Voer de goedkeuringsopdracht uit op de Gateway" + "Service uitgeschakeld" + "Kan voorstellen van Skill Workshop niet laden." + "Praat me bij over mijn recente OpenClaw-threads en stel vervolgstappen voor." + "Niet nu" + "openclaw qr" + "start" + "OpenClaw Node · Gesprek" + "Gebeurtenissen lezen en bijwerken" + "Talk mislukt: Realtime-provider gesloten: %1$s" + "Verbind de Gateway om door werkruimtebestanden te bladeren." + "%1$s via Gateway-relay" + "Kan Gateway-talkcatalogus niet laden" + "Monitoring · 1 geplande taak" + "Elke %1$s u" + "Schermoppervlak" + "OpenClaw-vertalingen · %1$s" + "Opdrachtverzoek" + "Up-to-date" + "Kanaal" + "Dempen opheffen" + "Nieuwe groep…" + "Audio voorbereiden…" + "Adaptief" + "Binnenkort" + "%1$s extra workers" + "Web Search" + "Probeer Chat, Spraak, Threads, Providers of Instellingen." + "OpenClaw Actief" + "navigate" + "aangevraagd %1$s" + "Verbind de Gateway om de uitvoeringsgeschiedenis van automatiseringen te bekijken." + "Apparaattoegang; aanmelding bij Gateway nog steeds vereist" + "Afgebroken" + "Voer een geldige installatiecode of gatewayadres in." + "Modellen" + "OpenClaw Passief" + "Gateway-wachtwoord ongeldig" + "De wijziging in de apparaatkoppeling kon niet worden geverifieerd. Vernieuw en probeer het opnieuw." + "Details bekijken" + "Bash" + "Token" + "De verbonden OpenClaw-agent kan apparaatmogelijkheden gebruiken die je inschakelt. Ga alleen door als je de Gateway en agent waarmee je verbinding maakt vertrouwt." + "Zeepokken" + "Geselecteerde of volledige fototoegang toegestaan." + "Toegankelijkheidsuitvoerder" + "%1$s ontbrekende items" + "Checklist van plan inklappen" + "Node-goedkeuring vereist" + "Gateway verbinden" + "... +%1$s meer" + "Checklist van plan uitklappen" + "Browser" + "screen record" + "Uitvoering in behandeling" + "Inschakelen laat OpenClaw de schermen van andere apps observeren en besturen wanneer geactiveerd. Toegang tot Android-toegankelijkheid is vereist." + "Herkomst" + "Persoonlijke AI op je apparaten" + "Attach" + "Automatisch" + "Overzicht" + "Herstel aanvragen mislukt. Tik om het opnieuw te proberen." + "Video" + "%1$s\n\n" + "Niet-versleuteld" + "Agenda" + "Gateway-status is niet OK; verzenden is niet mogelijk" + "📎 %1$s" + "Laatste status" + "Wacht tot het huidige antwoord is voltooid voordat je een nieuwe chat start." + "Profiel" + "Limieten van providers verschijnen hier wanneer je Gateway ze rapporteert." + "1 probleem" + "Threads in \"%1$s\" worden behouden en teruggezet naar Niet gegroepeerd." + "Aanbevolen" + "Aangemaakt" + "%1$s/%2$s actieve tokens" + "Geen actieresultaat" + "Knijpen" + "%1$s…" + "Skilldetails openen" + "Uitspreken mislukt: %1$s" + "Talk starten" + "Deze map kan niet worden geladen." + "De QR-code bevatte geen geldige setupcode." + "Toegang tot node controleren" + "Activeringszin toevoegen" + "Kan gateway niet bereiken" + "Automatisering" + "Verbinding nodig" + "Kan de goedkeuring niet afhandelen. Vernieuw en probeer het opnieuw." + "import" + "Hoe deze telefoon wordt weergegeven voor OpenClaw." + "Zoeken in threads activeren" + "Verbind de Gateway" + "Agenda lezen" + "Het overzicht wordt vernieuwd wanneer opnieuw verbinding wordt gemaakt en wanneer dit scherm wordt geopend." + "Kan skill niet uitschakelen." + "Nog steeds bezig met verbinden" + "Over %1$sm" + "SMS lezen" + "Verbind de Gateway om gebruik te laden." + "Waarmee kun je me op dit moment helpen vanaf deze telefoon?" + "Goedkeuring vereist" + "Nieuwe chat" + "Verbind de Gateway om Skill Workshop-voorstellen bij te werken." + "OpenClaw-verzoek mislukt." + "Toestemming vereist" + "Controleer de gereedheid van providers\nen geconfigureerde modellen." + "Laden" + "Foutmelding" + "Thema en vertaalde Android-tekst." + "Microfoon uit · verzenden…" + "Geen" + "Bekijken" + "Naam" + "Versie" + "Cron" + "Verbind deze telefoon met een Gateway voordat je OpenClaw opent." + "Activeringszin verwijderen" + "De installatiecode is niet geaccepteerd. Genereer een nieuwe code met openclaw qr." + "14 berichten · Android" + "Transcriptie mislukt: %1$s" + "Altijd" + "Kan dromen niet laden." + "De uitvoering van de automatisering is in de wachtrij geplaatst." + "Conversation Turn" + "De automatisering is gestart." + "Nieuwe groep" + "Serverfout" + "Video Generation" + "Goedkeuring van Gateway is in behandeling. Voer openclaw devices list uit op de Gateway-host, keur deze telefoon goed en probeer het opnieuw." + "Items verschijnen nadat een droomcyclus een verhalende samenvatting schrijft." + "%1$s ms" + "Geheugenopslag" + "Assistent aan het werk" + "OpenClaw kan apps weergeven die zichtbaar zijn in de launcher." + "Praten mislukt: %1$s" + "Geïnstalleerde skills zoeken" + "Inspecteren" + "Process" + "Recente threads" + "Terminal" + "Huidig" + "1 account" + "Gepauzeerd" + "Camera toestaan" + "Exec-goedkeuringsverzoeken verschijnen hier terwijl deze telefoon is verbonden." + " · Microfoon: In behandeling" + "Kopiëren" + "Details gekopieerd" + "Verwijderen" + "Vraag OpenClaw om Android-mogelijkheden te gebruiken." + "member" + "Controleren of deze Gateway de OpenClaw-instellingenassistent ondersteunt." + "Gebruik de herstelopties hieronder om opnieuw verbinding te maken." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Kan kanalen niet laden." + "Over %1$sd" + "Opeenvolgende fouten" + "Kan geen QR-code uit die afbeelding lezen. Kies een duidelijkere afbeelding of voer de installatiecode handmatig in." + "De Gateway is ouder dan deze app. Werk OpenClaw op de Gateway-host bij en probeer het opnieuw." + "Maak verbinding vóór chat, spraak en live-status." + "Gateway opnieuw verbinden" + "Derde partij" + "Gereedheid controleren" + "Beperkt" + "OpenClaw-logo" + "Model losmaken" + "Berichtenoppervlakken die met deze Gateway zijn verbonden." + "Verzenden" + "Gearchiveerde threads worden hier weergegeven." + "Commando gekopieerd" + "Geen voorvertoning beschikbaar" + "Keur deze telefoon goed op de gateway.\nProbeer daarna opnieuw verbinding te maken." + "QR-code scannen" + "Werkmap voor opdracht · kan niet worden gewist" + "Threadactiviteit" + "Beschikbaar" + "Automatisering verwijderen?" + "%1$s vandaag · %2$s totaal" + "Wachtwoord" + "Voorstel in quarantaine plaatsen?" + "Er zijn geen licentiekennisgevingen in deze build opgenomen." + "Kan widgetafbeelding niet opslaan" + "Wachten %1$s" + "Aan het spreken…" + "Providers en modellen" + "Knooppunt" + "%1$s " + "Prompt niet beschikbaar" + "Logboeken" + "Verbind de Gateway om voorstellen van Skill Workshop te bekijken." + "Tools" + "Gateway-schakelaar" + "SMS verzenden" + "OpenClaw is klaar om verder te gaan in je gewone chat." + "Geen opdrachten gevonden" + "Nog geen canvas-update. Tik om het opnieuw te proberen." + "Terminal heeft een verbonden Gateway nodig" + "Exec" + "App-filter" + "Hoofd" + "%1$sk" + "Gateway vereist" + "Toegang" + "Pakketten: snapshot=%1$s foreground=%2$s" + "Verbinding opnieuw proberen" + "Cron-planner is gestopt." + "Openen" + "Bericht gekopieerd" + "We konden je Gateway niet bereiken.\nLaten we dit oplossen." + "nu" + "Verwijderen na uitvoering" + "Geselecteerd op deze telefoon" + "unpin" + "Session History" + "Losmaken" + "Gebruik deze telefoon" + "Kan ClawHub-details voor %1$s niet laden." + "Tools worden uitgevoerd" + "Deel de exacte locatie zolang locatie is ingeschakeld." + "Mobile UI" + "Thema" + "De Gateway geeft deze goedkeuring nog steeds weer als in behandeling. Controleer deze voordat u het opnieuw probeert." + "Spraakbericht voltooien" + "Dicteren: %1$s" + "Niet toegestaan" + "Kies een andere afbeelding" + "Afbeeldingsvoorbeeld" + "OpenClaw luistert alleen wanneer je Gesprek of Dicteren start." + "Stappen en activiteit delen" + "Configuratie vereist" + "Werk deze Gateway bij om de OpenClaw-instellingenassistent te gebruiken." + "Deze gatewayverbinding heeft operator.admin nodig om ClawHub-skills te installeren." + "Voorstel toegepast." + "%1$s in behandeling" + "%1$su geleden" + "Oproeplog lezen" + "%1$s in wachtrij · wachten op Gateway" + "Verplaatsen naar groep" + "Scan QR-code om te koppelen" + "Goedkeuring geweigerd." + "Kan voorstel van Skill Workshop niet bekijken." + "Vastgezet" + "Profiel en apparaat" + "Selector voor denkniveau sluiten" + "Kan het bericht niet in de wachtrij plaatsen voor latere bezorging." + "In quarantaine" + "Planning · %1$s" + "Kon het denkniveau niet bijwerken." + "Selector voor denkniveau openen" + "Time-out bij spraakantwoord; beurt in wachtrij wordt opnieuw geprobeerd" + "Lay-out: Gedetailleerd" + "Deze afbeelding kan niet worden gedecodeerd." + "Gateway, spraak, meldingen, privacy" + "Werkruimtebestanden van agent" + "Dit apparaat verliest zijn vertrouwde toegang tot de Gateway." + "Gebruik de requestId uit de opdracht in behandeling in de approve-opdracht." + "Planning" + "Frequentielimiet" + "Niet afgeleverd" + "Payload · %1$s" + "Actief" + "Klauwen" + "Beëindigen" + "Systeemvertrouwen gebruiken" + "Geen providers gereed" + "Geeft prioriteit aan verbonden Bluetooth-microfoons." + "%1$s app is geblokkeerd voor doorsturen." + "Berichtacties" + "Soort" + "Uit archief halen" + "Transcripts" + "Activeringswoorden" + "Configureer %1$s op de Gateway" + "Scan een QR-code of gebruik de configuratiecode van je OpenClaw Gateway." + "Prototype designsysteem" + "Zeven" + " · Gesprek: Aan" + "Nog geen gebruiksgegevens." + "De chat is mislukt voordat de uitvoering begon; probeer het opnieuw." + "Verzenden" + "Sommige gedeelde afbeeldingen zijn weggelaten of konden niet worden toegevoegd." + "Agenda schrijven" + "timeout" + "Laag" + "Geblokkeerde lijst" + "act" + "Dismiss Task" + "Chat mislukt" + "OpenClaw · Live" + "Geïnstalleerd" + "Time-out tijdens het wachten op een antwoord; probeer het opnieuw of vernieuw." + "Eerdere gesprekken zoeken" + "Door threads bladeren" + "Vernieuwen" + "Parelduiken" + "Open de camera en richt op de code van openclaw qr." + "Geen apparaten" + "Meldingen doorsturen" + "Ik houd dit gesprek gescheiden van de gewone agentchat." + "De Gateway-sessie komt weer online. De agentsnelkoppelingen zouden zich zo automatisch moeten herstellen." + "Probeer een andere zoekopdracht of wis de huidige zoekopdracht." + "Locatie op de achtergrond toestaan?" + "Bovenkomen" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Spraakbericht annuleren" + "Terug scrollen" + "openclaw gateway" + "Gateway gekoppeld" + "Vervellen" + "Luisteren naar je volgende beurt." + "OpenClaw is bezig" + "Logvermelding" + "Mislukt: kon het beveiligde gateway-eindpunt voor deze host niet bereiken." + "Gateway is offline. Herstel hieronder de verbinding of kopieer de diagnostische gegevens." + "Stand-by" + "Test test 1 2 3" + "Kan ClawHub-skills niet zoeken." + "Geen prompt" + "Camera aan de voorkant" + "Logboekvermelding openen" + "Netwerktime-out" + "Nu" + "Groep hernoemen…" + "Meer agenten" + "openclaw nodes approve REQUEST_ID" + "Vastmaken" + "thread list" + "%1$s openen" + "upload" + "Gateway-wachtwoord niet geconfigureerd" + "Dicteerinstellingen" + "Providermodellen zijn geladen, maar de gereedheidsstatus is niet beschikbaar." + "Thread verwijderen?" + "OpenClaw maakt van deze telefoon een overzichtelijk mobiel bedieningspaneel voor threads, spraak, providers en Gateway." + "Nieuwste eerst" + "Volgende cyclus" + diff --git a/app/src/main/res/values-pl/assistant.xml b/app/src/main/res/values-pl/assistant.xml new file mode 100644 index 0000000..8ffd8fb --- /dev/null +++ b/app/src/main/res/values-pl/assistant.xml @@ -0,0 +1,7 @@ + + + "zapytaj OpenClaw %1$s" + "powiedz OpenClaw, aby %1$s" + "otwórz OpenClaw i zapytaj %1$s" + + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..fbc59ec --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Ufać tej bramie? + Zaufaj i kontynuuj + Anuluj + Nowy czat w worktree + Sprawdź odcisk certyfikatu, zanim zaufasz tej bramie.\n\n%1$s + Certyfikat bramy uległ zmianie. Kontynuuj tylko wtedy, gdy oczekujesz tej zmiany.\n\nStary SHA-256:\n%1$s\n\nNowy SHA-256:\n%2$s + Nieznane + WERSJA + COMMIT + ZBUDOWANO + Wersja %1$s + Commit Git %1$s + Zbudowano %1$s UTC, znacznik czasu %2$s + Data kompilacji %1$s + Skopiuj pełny hash commita Git + Skopiuj pełny znacznik czasu kompilacji + Commit Git OpenClaw + Znacznik czasu kompilacji OpenClaw + Skopiowano commit Git + Skopiowano znacznik czasu kompilacji + + "Nie udało się przygotować załącznika do wysłania." + "Mikrofon wyłączony" + "Wyświetlanie alertów OpenClaw" + "Aktywność wątku" + "Pełna" + "Zatwierdzenie dozwolone i zapisane." + "Wyświetlanie historii ostatnich połączeń" + "1 oczekujący" + "Nieobsługiwany załącznik" + "0 = dokładnie" + "Połącz Gateway, aby wyszukiwać wątki." + "%1$s kont" + "Zmiany Cron wymagają uprawnienia operator.admin. Kody konfiguracji celowo go nie przyznają. Połącz się ponownie przy użyciu współdzielonego tokenu lub hasła gatewaya, aby poprosić o dostęp administratora. Jeśli to urządzenie nadal go nie ma, zatwierdź oczekujące rozszerzenie zakresu z poziomu istniejącego klienta administratora." + "Apply Patch" + "Szczypanie" + "Włącz dźwięk głośnika" + "Kolejne pominięcia" + "Ten folder nie zawiera jeszcze żadnych plików." + "Brak połączenia" + "Sprawdzaj stan zainstalowanych umiejętności i zarządzaj nim." + "Niepowodzenie" + "Domyślny agent" + "Aparat" + "Usuń z grupy" + "Wyszukiwanie" + "Wstrzymano na czas odtwarzania głosu" + "Przed pobraniem Gateway zweryfikuje dokładnie tę wersję w ClawHub. Jeśli wersja wymaga wyraźnego potwierdzenia ryzyka, Android wyświetli ostrzeżenie Gateway przed ponowną próbą." + "Kod konfiguracji używa identyfikatora strefy IPv6. Użyj adresu IPv6 bez zakresu lub nazwy hosta w sieci LAN." + "Załącznik" + "Skonfiguruj słowa aktywujące, rozmowę i odtwarzanie." + "Nasłuchiwanie (PTT)" + "Propozycja odrzucona." + "Pokaż pasek boczny" + "użytkownik" + "%1$s · %2$s" + "Minimalny" + "Odmów" + "AKTYWNY AGENT" + "1 zaplanowane" + "Brak odpowiedzi" + "Wybrano %1$s" + "Tablica JSON argv polecenia" + "Nie udało się odczytać tego obrazu. Wybierz wyraźny zrzut ekranu lub obraz kodu QR z openclaw qr." + "Nie udało się wykonać działania %1$s dla propozycji Skill Workshop." + "Odpowiedziano gdzie indziej" + "Gateway zarejestrował zatwierdzenie jednorazowo." + "status" + "OpenClaw sprawdza lokalizację tylko wtedy, gdy zażąda tego sparowany Gateway. Na następnym ekranie systemu Android wybierz %1$s, aby zezwolić na sprawdzanie, gdy aplikacja działa w tle." + "odrzuć" + "Kontrast" + "Zastąpić konfigurację Gateway?" + "Nie udało się wczytać automatyzacji." + "Ty" + "Wbudowany mikrofon" + "Powierzchnia" + "Brak propozycji" + "Główny wątek" + "Otwórz czat" + "Akcje parowania urządzeń są niedostępne w tej sesji Gateway. Uruchom openclaw devices list na hoście Gateway i tam zarządzaj żądaniem. Zatwierdzanie uprawnień węzła odbywa się oddzielnie i nadal używa polecenia nodes approve <request id>." + "Żądanie działania" + "list pins" + "Połącz się z Gateway, aby wczytać propozycje Warsztatu Skills." + "Kod konfiguracji nie został zaakceptowany" + "Wyloguj się" + "Dostawca transkrypcji w czasie rzeczywistym nie jest skonfigurowany." + "Pokaż aplikacje systemowe" + "Zaktualizuj Gateway, aby wyświetlić konfigurację modelu dostawcy." + "Wysyłanie dyktowanego tekstu" + "Sprawdź tę propozycję, aby wczytać jej treść w formacie Markdown." + "otwórz OpenClaw i zapytaj %1$s" + "rozumowanie" + "Klient" + "Zastosowane" + "wideo" + "Promowane" + "Online" + "Zakresy" + "Dostawca głosu w czasie rzeczywistym nie jest skonfigurowany." + "%1$s · %2$s" + "kick" + "Gateway zwrócił nieprawidłową automatyzację." + "Identyfikator instancji" + "Wymagany jest token Gateway. Wprowadź go ponownie lub edytuj to połączenie." + "Źródło" + "Odśwież" + "W kolejce: %1$s" + "Rozpocznij czat" + "Oczekujące wywołania narzędzi czatu w aktywnym wątku pozostają widoczne tutaj." + "Wymagana weryfikacja certyfikatu" + "Otwórz bieżącą powierzchnię Canvas, aby ją sprawdzić lub z nią pracować." + "Automatyzacja zaktualizowana." + "Brak ostatnich sesji" + "Skrypt" + "Stan Gateway, gotowość węzła telefonu i ostatni strumień dziennika." + "Otwórz szczegóły automatyzacji" + "Środowisko uruchomieniowe" + "1 pracownik więcej" + "Agent %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Aby kontynuować, włącz %1$s w Ustawieniach Androida." + "Napraw" + "reactions" + "Gotowe" + "Wersja i aktualizacja" + "OpenClaw będzie pokazywać tutaj zatwierdzenia, nieudane zadania i problemy z kanałami." + "Mikrofon USB" + "Zbyt wiele udostępnień oczekuje na dodanie." + "Pominięto" + "Użyj adresu LAN komputera Gateway lub bezpiecznej zdalnej nazwy hosta." + "Wł." + "Wyszukiwanie wątków" + "Rozmowa w czasie rzeczywistym" + "· %1$s" + "OpenClaw przygotowuje odpowiedź." + "Zatwierdzenie dozwolone jednorazowo." + "Konfiguracja dostawcy" + "brak" + "Treść skryptu jest zachowywana bez zmian. Użyj CLI, aby edytować ten skrypt." + "Przewodnik konfiguracji Androida" + "Zablokowano przekazywanie dla %1$s aplikacji." + "Sparowane urządzenia" + ":%1$s" + "%1$s oczekujących" + "Nazwa urządzenia" + "Prześlij" + "Lokalizacja" + "np. America/New_York" + "Cel sesji" + "Sprawdź skill w ClawHub" + "snapshot" + "Odrzucić żądanie parowania z tego urządzenia?" + "Preferowany mikrofon" + "Host węzła" + "Poziom" + "Zamknij wybór aplikacji" + "Wklej udostępniony token Gateway lub token wydany przez operatora." + "Wszystkie systemy działają prawidłowo" + "Skopiowano diagnostykę Gateway" + "Błąd dźwięku" + "Zastąp konfigurację" + "Szybkie działania" + "Wysyłanie nie powiodło się: czat zakończył się niepowodzeniem przed rozpoczęciem działania; spróbuj ponownie." + "Mikrofon" + "Czat nadal sprawdza stan Gateway." + "Dokładna lokalizacja" + "Zezwól raz" + "+%1$s więcej" + "thread create" + "Zablokowana" + "Słowo lub wyrażenie aktywujące" + "Gateway wymaga zatwierdzenia urządzenia" + "Mikrofon zewnętrzny" + "%1$s/%2$s gotowych" + "Połączono (operator offline)" + "Uprawnienie niezatwierdzone" + "Spowoduje to trwałe usunięcie automatyzacji i jej harmonogramu z Gateway." + "Wczytywanie obrazu…" + "Połącz" + "Zatwierdź dostęp węzła" + "Dodaj Gateway" + "Transkrypcja niedostępna: %1$s" + "Obraz" + "Falowanie" + "Zamknij podgląd obrazu" + "eval" + "Ostatnie polecenie: %1$s" + "Otwórz terminal na urządzeniu, na którym działa OpenClaw." + "Brak brakujących elementów" + "Dane wyjściowe kanwy wymagają aktywnego połączenia z Gateway." + "%1$s · %2$s" + "Izolowany" + "© 2026 OpenClaw Foundation — licencja MIT." + "PDF" + "Conversations" + "Konsolidacja pamięci i dziennik snów." + "Create Goal" + "Ta automatyzacja została zmieniona podczas edycji. Przed zapisaniem przywróć najnowszą wersję z Gateway." + "Po połączeniu Gateway może wybudzać telefon za pomocą cichego powiadomienia push zamiast utrzymywać stale aktywną sesję." + "Tryb wybudzania" + "Usunąć sparowane urządzenie?" + "Tekst zdarzenia systemowego" + "Nie udało się skopiować obrazu widżetu" + "Nie" + "Opcjonalna ścieżka" + "Wysyłanie głosu z kolejki" + "Wbudowane" + "hide" + "runs" + "Wymagane jest hasło Gateway. Wprowadź je ponownie lub edytuj to połączenie." + "Tekst zdarzenia" + "Transkrypcja na żywo" + "Nie udało się wczytać konfiguracji modelu dostawcy." + "Zezwolono %1$s aplikacji na przekazywanie." + "Konfiguracja głosu" + "Dołącz wideo" + "Ukryte dodatkowe obrazy: %1$s" + "Odrzucić żądanie parowania?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Wydawca" + "Rozgałęź od tego miejsca" + " · Rozmowa: Mówienie" + "Opcjonalne nadpisanie" + "Domyślne" + "Zatwierdzanie poleceń" + "Aplikacja i Gateway używają niezgodnych wersji protokołu. Zaktualizuj OpenClaw w obu miejscach, a następnie spróbuj ponownie." + "Odśwież ekran" + "Odczytywanie ostatnich zdjęć i multimediów" + "Słuchaj" + "Połączono" + "Ukończono" + "Twój telefon jest sparowany z %1$s. Kontynuuj, aby dokończyć konfigurowanie dostępu do węzła." + "Bieżący wątek" + "Rozumowanie %1$s" + "Wyciszone" + "OpenClaw docenia swoich partnerów w społeczności open-source." + "otwarte" + "Łączenie z Gateway" + "Gateway może zmienić tę ścieżkę, ale nie może usunąć istniejącej ścieżki." + "TTS" + "Zapisywanie…" + "Punkt odniesienia %1$s" + "search" + "Aktywuj" + "Przeglądaj zaplanowane zadania Gateway i zarządzaj nimi." + "Wcześniejsza odpowiedź już zezwoliła na to polecenie jednorazowo." + "generate" + "Używaj tylko w zaufanej sieci prywatnej." + "Szukaj w ustawieniach" + "Rozmowa trwa" + "Uwierzytelnianie Gateway nie jest skonfigurowane. Edytuj to połączenie i spróbuj ponownie." + "Niepowodzenie: osiągnięto bezpieczny punkt końcowy, ale weryfikacja odcisku TLS przekroczyła limit czasu. Sprawdź Tailscale Serve lub TLS bramy i spróbuj ponownie." + "Krok 1" + "Dyktowanie" + "Otwórz wybór aplikacji" + "Brak oczekujących zatwierdzeń" + "edit" + "Połącz się z Gateway" + "Wprowadź kod konfiguracji z openclaw qr." + "Diagnostyka" + "Inne aplikacje pozostają nietknięte." + "Kruszenie" + "Spowoduje to trwałe usunięcie wątku i jego transkrypcji." + "Urządzenie zatwierdzone." + "Automatyczne ponawianie próby" + "Skopiowano obraz widżetu" + "%1$s ról" + "Brakuje 1 elementu" + "%1$s zaplanowanych" + "react" + "Agenci" + "Połącz się z Gateway, aby wczytać automatyzacje." + "Ponowne łączenie…" + "Wróć do konfiguracji" + "send" + "Nie udało się przetestować połączenia" + "Zweryfikuj i zainstaluj" + "Zmień to urządzenie w bezpieczny węzeł OpenClaw do czatu, głosu, kamery i narzędzi urządzenia." + "Konfiguracja ręczna" + "Otwórz czat, aby rozpocząć lub wznowić bieżący wątek." + "Wskazówka: zatrzymaj nasłuchiwanie, aby wysłać przechwyconą wypowiedź." + "Pomiń" + "Żądanie głosowe nie powiodło się" + "Spowoduje to odrzucenie propozycji „%1$s” i odświeżenie stanu Skill Workshop z gateway." + "Chowanie się w skorupie" + "update" + "Udostępnij" + "Kamera włączona" + "Telegram, WhatsApp, e-mail i inne kanały pojawią się tutaj po konfiguracji." + "Błąd sieci" + "Brodzenie w kałużach pływowych" + "Przywróć teraz obszar roboczy dla session=%1$s source=%2$s. Jeśli istnieje stan A2UI, natychmiast go odtwórz. Jeśli nie, utwórz i wyświetl w Canvas kompaktowy pulpit dostosowany do urządzeń mobilnych." + "Uruchomienie nie powiodło się: %1$s" + "Nie zażądano" + "Skonfiguruj dostawcę %1$s na Gateway" + "kill" + "Zatwierdzenia" + "Pliki niedostępne" + "Oznacz jako nieprzeczytane" + "Wyszukiwanie osób i danych kontaktowych" + "Wymagana tożsamość urządzenia" + "Wątek OpenClaw" + "Zezwól na dostęp do biblioteki zdjęć." + "Wcześniejsza odpowiedź już rozstrzygnęła to zatwierdzenie." + "Brak ostatnich wątków" + "Limit czasu: %1$s s" + "Brak wyników" + "Odczytywanie powiadomień z wybranych aplikacji" + "Dostępność nieznana" + "Skonfiguruj rozmowę" + "Dodatkowe" + "Gateway sparowany. Oczekiwanie na dostęp operatora." + "Dołącz obraz" + "Wybierz, co trafia do OpenClaw." + "Oczekiwanie na ponowne zatwierdzenie uprawnienia" + "Sprawdź wyróżnione elementy" + "Słuchanie..." + "Nadrób zaległości" + "Wiadomość" + "Odczyt kontaktów" + "Pamięć załączników offline jest pełna; najpierw usuń elementy z kolejki." + "Jednorazowo" + "Zmień nazwę" + "Nie znaleziono kanałów." + "Wyświetl wszystko" + "Nowe urządzenie" + "Session Status" + "Otwórz podgląd obrazu" + "Gałąź sesji uległa zmianie; przejrzyj i ponów tę wiadomość." + "close" + "To wygląda jak kod konfiguracji. Wróć i wybierz Skonfiguruj Gateway, a następnie Użyj kodu konfiguracji." + "✦" + "Agenci i automatyzacja" + "Zastosuj" + "Uruchomienie automatyzacji pominięte." + "Kontynuuj" + "Monitorowanie · %1$s zaplanowanych zadań" + "Przeglądaj" + "tabs" + "Oczekujące" + "Rozmowa: %1$s" + "read" + "Zaznacz tekst" + "Aktywność ruchowa" + "opis: %1$s" + "Odtwórz dźwięk" + "Czas" + "Niezweryfikowane" + "Yield" + "Kopiuj polecenie zatwierdzenia" + "Bieżące wyjście ekranu i interaktywna powierzchnia aplikacji." + "Usługa połączona" + "Wyświetlanie" + "Gotowe, gdy Ty będziesz" + "Nie udało się wczytać katalogu dostawców." + "Mówienie · oczekiwanie na odpowiedź" + "Nie przyznano" + "Zapisz zmiany" + "Gateway odrzucił uruchomienie automatyzacji." + "Session Send" + "Znajdź w ClawHub" + "Zawsze zezwala na żądane sprawdzanie lokalizacji, gdy OpenClaw działa w tle; Android pokazuje to w stałym powiadomieniu węzła." + "Zdarzenie systemowe" + "Połącz Gateway, aby wyświetlić dostawców" + "Następne bicie serca" + "Gateway sparowany. Oczekiwanie na zatwierdzenie uprawnień węzła." + "Marynowanie" + "Zamknij Canvas" + "Zapis kontaktów" + "Żadne zainstalowane umiejętności nie pasują do tego wyszukiwania." + "Konfiguracja dostawcy rozmów" + "Music Generation" + "Ustawienia Rozmowy" + "Monitorowanie · 1 wątek" + "Tekst ładunku" + "Ustaw tekst" + "Zatwierdzenie %1$s" + "Gateway nie zwrócił gotowości %1$s" + "Skonfigurowano %1$s modeli. Odśwież, aby ponownie sprawdzić dostępność." + "Conversation Send" + "Kanwa" + "1 dostawca" + "Nie udało się automatycznie odczytać certyfikatu Gateway. Wklej odcisk SHA-256 uzyskany na hoście Gateway." + "Wysyłanie nie powiodło się: %1$s" + "Most" + "Błąd dostarczenia" + "Używaj OpenClaw z telefonu" + "Wygląd" + "Warsztat Skills" + "Wymaga tokena" + "Podgląd · %1$s" + "Wymagane jest uprawnienie do mikrofonu" + "Połącz Gateway, aby wczytać propozycje Skill Workshop." + "Wszystkie systemy działają" + "Gateway jest nieosiągalny" + "OC" + "Zaktualizowano" + "Połączono (węzeł offline)" + "Strona główna" + "Dyktowanie nasłuchuje" + "Brak zarchiwizowanych wątków" + "Wybierz i sprawdź asystentów dostępnych na tym gateway." + "Tryb rozmowy aktywny" + "Praca · 1 aktywne uruchomienie" + "Zaakceptuj i włącz" + "Wymagana aktualizacja Gateway" + "Kopiuj obraz" + "Adres URL Gateway" + "main, isolated, current lub session:<id>" + "Multimedia niedostępne" + "Połącz się ze swoim Gateway, aby otworzyć powłokę w obszarze roboczym agenta." + "%1$s://%2$s:%3$s" + "Nie udało się wczytać szczegółów zatwierdzenia. Odśwież i spróbuj ponownie." + "Mogę sprawdzić stan Gateway, naprawić konfigurację, zmienić modele lub połączyć kanały." + "Tool Call" + "Wątki" + "Write" + "Zacznij od wpisania polecenia lub użyj głosu." + "D" + "Otwórz ustawienia" + "Obserwowanie…" + "Zakończ rozmowę" + "Ostatni błąd" + "Przejrzyj działania wymagające Twojej uwagi." + "Wyłączono dla wszystkich agentów." + "Uruchom tryb głosowy" + "Wróć do zadań w tle" + "Inna operacja cron jest nadal finalizowana." + "Czas odnowienia %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Rozpoznawanie mowy na urządzeniu jest niedostępne." + "Skrypt · tylko do odczytu" + "Załączniki są zbyt duże, aby dodać je do kolejki w jednej wiadomości; usuń część z nich i spróbuj ponownie." + "Skonfigurowano 1 model. Odśwież, aby ponownie sprawdzić dostępność." + "Widżet niedostępny" + "Ten host wymaga bezpiecznego połączenia." + "Ostatnie" + "Brak pasujących automatyzacji." + "Telefon może połączyć się z Gateway" + "Gateway" + "Wygasło" + "Zaplanowane zadania OpenClaw z Twojego gateway." + "Sub-agent" + "Oczekiwanie na zatwierdzenie urządzenia" + "Ładowanie wątku" + "Ten Gateway używa teraz certyfikatu zaufanego przez to urządzenie." + "Rozłożenie ms" + "event create" + "dokument" + "Skonfiguruj Gateway" + "Odtwórz wideo" + "Zapisane uwierzytelnienie jest nieprawidłowe. Uwierzytelnij ponownie lub zresetuj to połączenie Gateway." + "Podczas używania" + "screenshot" + "Przewiń do tego miejsca" + "Wyrażenie cron, np. 0 9 * * *" + "Powrót do głosu" + "Mów" + "Szczegóły" + "%1$s/%2$s online" + "Zezwolono %1$s aplikacjom na przekazywanie." + "Czat" + "Wymagany jest dostęp do mikrofonu." + "Dreptanie" + "Edytuj" + "Godziny ciszy" + "Kopiuj diagnostykę" + "Zaplanowane" + "Utwórz" + "Wygasa %1$s" + "Odrzuć" + "Odrzucić propozycję?" + "Błąd rozpoznawania mowy (%1$s)" + "Problem" + "Przeszukuj metadane rejestru. Gateway ponownie weryfikuje zaufanie przed każdym pobraniem." + "Do konfiguracji lokalnej użyj prywatnego adresu IP w sieci LAN albo włącz Tailscale Serve / udostępnij adres URL bramy Gateway z protokołem wss://, aby umożliwić dostęp zdalny." + "Przesyłanie…" + "Konto %1$s" + "Suggest Task" + "Szukaj" + "Nasłuchiwanie" + "Automatyzacja nie została wczytana." + "Dostępna jest aktualizacja Gateway. Gdy będziesz gotowy, uruchom aktualizację z poziomu Web UI lub CLI." + "wkrótce" + "Brak zatwierdzeń Gateway." + "Host" + "Dodaj w każdym polu jedno słowo lub wyrażenie aktywujące. Następnie wypowiedz je przed poleceniem." + "Transkrybuj, a następnie wyślij" + "Uruchom o" + "Wstrzymaj dźwięk" + "Dostęp do urządzenia Gateway" + "Brak podglądu" + "Urządzenia" + "OpenClaw dla Androida." + "Oczekiwanie na zatwierdzenie uprawnienia" + "Przed uruchomieniem, włączeniem, wyłączeniem, usunięciem lub odświeżeniem tej automatyzacji zapisz albo wycofaj zmiany." + "Nie ma jeszcze żadnych automatyzacji." + "Ta umiejętność wymaga skonfigurowania %1$s elementów. W systemie Android można sprawdzić, co jest zainstalowane; ustawienia i konfigurację można zmienić tylko na komputerze lub za pomocą CLI." + "%1$s ostatnie" + "Kanały" + "Bez fazy" + "Aktywny na tym telefonie" + "Sprawdzanie dostępu do węzła" + "Strefa czasowa" + "Akcje inspekcji i zastosowania w Skill Workshop" + "Zawsze zezwalaj" + "present" + "Skills zainstalowane na Gateway pojawią się tutaj." + "Kod mógł wygasnąć lub zostać wygenerowany dla innego Gateway." + "Wymagane uprawnienie" + "Automatyzacja ma nieprawidłową konfigurację." + "Lista dozwolonych" + "Konfiguracja, stan i naprawa" + "groups" + "Klucz publiczny" + "Informacje" + "Na tym obrazie nie znaleziono kodu QR konfiguracji. Wybierz kod QR wygenerowany przez openclaw qr lub wprowadź kod konfiguracji ręcznie." + "permissions" + "Połącz Gateway, aby wczytać węzły i sparowane urządzenia." + "Przełącz gałąź" + "Brak umiejętności" + "Odpowiedzi są odtwarzane na głos" + "Oznacz jako przeczytane" + "Oczekujące zatwierdzenie węzła" + "wake" + "%1$s propozycji" + "Uwierzytelnianie Gateway wymaga uwagi." + "Szczegóły połączenia" + "Milisekundy" + "Rozpoznawanie mowy" + "Opis" + "Ostatnie rozmowy" + "Twój telefon wysyła te informacje do Twojego Gateway, a nie na serwer prowadzony przez OpenClaw. Twój Gateway może uwzględnić je w żądaniach do wybranego przez Ciebie dostawcy AI." + "Dostarczenie" + "Wycisz głośnik" + "%1$s Uruchomione · %2$s Ukończone · %3$s Nieudane" + "Nawiązywanie połączenia z Gateway" + "Monitorowanie · %1$s wątków" + "Uruchomienie automatyzacji zakończone." + "Brak pasujących aplikacji." + "Wyślij do czatu" + "Automatyzacja usunięta." + "Włącz" + "Ostatnie uruchomienia" + "Wyrównaj kod QR wewnątrz kwadratu." + "Nie udało się wczytać próśb o zatwierdzenie." + "Zatwierdzono" + "Połącz swój Gateway, aby wczytać gotowość dostawcy." + "Niesparowano" + "To zatwierdzenie wygasło, zanim mogło zostać rozstrzygnięte." + "Obserwowanie za %1$ss — przełącz na docelową aplikację" + "Prompt agenta" + "emoji list" + "Cykliczne" + "Szukaj w OpenClaw" + "%1$s oczekujących" + "Rozpoznawanie mowy na urządzeniu jest niedostępne" + "Żadna aplikacja nie może udostępnić tej wiadomości" + "Zamknij wyszukiwanie" + "Polecenie do monitorowania" + "Kondycja" + "Dostęp do powiadomień" + "Głośnik wyciszony" + "Szukaj wątków" + "OK" + "Nie udało się otworzyć przewodnika konfiguracji." + "zapytaj OpenClaw %1$s" + "Wait for Agents" + "Adres" + "Zaplanowane zadania utworzone w Gateway pojawią się tutaj." + "Wyświetlany jest najnowszy fragment logu." + "Użyj kodu konfiguracji" + "sticker" + "Użyj bezpiecznego Gateway wss:// lub Tailscale Serve, wygeneruj kod konfiguracyjny z pełnym dostępem w Control UI lub za pomocą openclaw qr, następnie zeskanuj go lub wklej poniżej i połącz się ponownie, aby włączyć ustawienia i aktualizacje." + "steer" + "Wybrano" + "Android może zeskanować lub wkleić istniejący kod konfiguracji, ale ten gateway nie udostępnia jeszcze aplikacji generowania kodu konfiguracji. Wygeneruj QR/kod na hoście gateway za pomocą openclaw qr, a następnie zeskanuj go tutaj lub wklej kod konfiguracji poniżej." + "Stan obszaru roboczego" + "Napraw połączenie" + "Zapisz obraz" + "Węzeł %1$s" + "Wymagane hasło Gateway" + "Update Plan" + "Usuń załącznik" + "Uruchomienie automatyzacji nie powiodło się." + "Limity dostawcy i stan limitów użycia." + "Katalog rozmów Gateway nie został załadowany" + "ten Gateway" + "Brak ostatnich uruchomień." + "Model językowy na urządzeniu jest niedostępny" + "Panel wymaga połączenia z Gateway" + "Pasujące propozycje pojawią się tutaj po utworzeniu przez agentów wersji roboczych umiejętności wielokrotnego użytku." + "Session Search" + "OpenClaw mówi" + "Skanuj QR" + "Wybrane aplikacje" + "Cofnij zmiany" + "Polecenie zatwierdzenia skopiowane" + "Status dostarczenia" + "Kod QR nie został zaakceptowany" + "Twoje centrum poleceń głosowych." + "Testuj połączenie" + "OPENCLAW" + "Web Fetch" + "Polecenie" + "Zatwierdzić urządzenie?" + "Połącz się z Gateway, aby otworzyć panel tej sesji." + "Usunąć %1$s i zapisane dane logowania z tego telefonu?" + "Kod QR wskazuje na niezabezpieczony zdalny Gateway. %1$s %2$s" + "Powierzchnia ekranu gotowa" + "Sparuj Gateway" + "Połącz Gateway, aby wczytać kanały." + "Wstrzymuje działanie podczas innej aktywności głosowej." + "Model" + "Zdjęcia" + "Wklej kod konfiguracji" + "OpenClaw mówi" + "Łączenie..." + " · Lokalizacja: Zawsze" + "Wiadomości: %1$s" + "Rafowanie" + "Wczytaj z Gateway" + "tekst: %1$s" + "Wymaga" + "rename group" + "Gotowe" + "Dziennik czeka na pierwszy wpis." + "Zatwierdź" + "Strona na żywo" + "Automatyzacja jest już uruchomiona." + "Usuń tę automatyzację po pomyślnym jednorazowym uruchomieniu." + "Gotowe do czatu i obsługi głosowej" + "Połączono (operator: %1$s)" + "Parowanie Gateway zostało ukończone. Zatwierdź ten telefon jako węzeł, aby OpenClaw mógł używać włączonych przez Ciebie możliwości urządzenia." + "Odpowiedź została przerwana" + "obraz" + "%1$s wstrzymanych" + "Brak pasujących wątków" + "delete" + "Układ: kompaktowy" + "channels" + "Przyznano" + "Co %1$s min" + "1 token" + "%1$s %2$s" + "Zainstalowane aplikacje" + "oczekuje" + "Przygotowywanie notatki głosowej…" + "Nigdy" + "Podsystem" + "Po zakończeniu polecenia" + "Połączenie" + "Nie udało się wczytać historii uruchomień automatyzacji." + "Nazwa automatyzacji" + "Krok 2" + "Diagnozuj" + "Niektóre kontrole stanu kanałów nie zostały ukończone." + "pin" + "Kopiuj %1$s" + "Sparowano" + "Nie udało się zapisać słów aktywujących" + "Spowoduje to poddanie propozycji „%1$s” kwarantannie i odświeżenie stanu Skill Workshop z gateway." + "Nagraj notatkę głosową" + "W kolejce" + "Udzielono odpowiedzi" + "Zezwalaj na używanie narzędzi aparatu na żądanie." + "Problemy" + "Wybudzanie głosem" + "Żądanie parowania odrzucone." + "%1$s dni temu" + "roles" + "Skills" + "Archiwizuj" + "Węzeł offline. Połącz się ponownie i spróbuj jeszcze raz." + "System" + "Zdalny adres IP" + "Bez grupy" + "Szczegóły harmonogramu" + "Funkcje telefonu" + "Niedostępne" + "Panel" + "Wklej token" + "Brak dostawców" + "Odcisk SHA-256" + "Nie ma jeszcze żadnych wątków" + "Mikrofon Bluetooth" + "Ostatnie" + "Zmień nazwę wątku" + "Wynik rozstrzygnięcia jest nieznany. Działania pozostaną wyłączone do czasu zweryfikowania rekordu Gateway." + "dialog" + "Nasłuchuj słów aktywujących" + "camera snap" + "Przygotowywanie odtwarzania…" + "Gateway wybrał nieznanego dostawcę %1$s" + "delete group" + "Śledź Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Połącz Gateway, aby wczytać agentów." + "Wróć" + "Udostępnij wiadomość" + "Wygeneruj kod QR." + "Uruchom ponownie" + "Głośnik włączony" + "Usunąć grupę?" + "Brak" + "Szukaj propozycji" + "stop" + "Bezpieczne (TLS)" + "Brak węzłów lub sparowanych urządzeń." + "Pozostało %1$s%% %2$s" + "Kod konfiguracji wygasł" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DZIENNIK" + "notify" + "Ten telefon pozostaje uśpiony, dopóki Gateway go nie potrzebuje. Następnie wybudza się, synchronizuje i ponownie przechodzi w stan uśpienia." + "%1$s skonfigurowanych modeli" + "Licencje" + "Połącz Gateway, aby wyszukiwać Skills w ClawHub." + "Skill" + "Połączenie z Gateway uległo zmianie. Uruchom ponownie OpenClaw, aby ponownie się połączyć." + "Identyfikator urządzenia" + "Gateway nie zidentyfikował aktywnego dostawcy %1$s" + "Oczekiwanie" + "Słowa aktywujące zapisane" + "Najpierw najstarsze" + "Ekran" + "Działa od" + "Identyfikatory stref IPv6 nie są obsługiwane. Użyj adresu IPv6 bez zakresu lub nazwy hosta w sieci LAN." + "Wysłano — potwierdzanie dostarczenia…" + "dźwięk" + "This gateway connection needs operator.admin to update skills." + "Kod konfiguracji" + "Potwierdź ostrzeżenie Gateway i zainstaluj" + "Odśwież czat" + "Interwał" + "Działania dotyczące propozycji Skill Workshop wymagają zakresu operator.admin." + "Sesje" + "Zmień nazwę…" + "Połącz Gateway, aby wczytać dreaming." + "Konfiguracja" + "Otwórz Rozmowę" + "poll" + "Połącz, aby wczytać swoich agentów" + "role remove" + " · Rozmowa: Słuchanie" + "ClawHub nie zwrócił wersji możliwej do zainstalowania dla %1$s." + "Polecenie" + "To zatwierdzenie zostało anulowane, zanim mogło zostać rozstrzygnięte." + "Mikrofon włączony · oczekiwanie na Gateway" + "Tekst" + "Wyświetlanie %1$s z %2$s. Doprecyzuj wyszukiwanie, aby zobaczyć więcej." + "Dostępna wersja v%1$s" + "%1$s://%2$s" + "%1$s... (OK)" + "Dostawcy i skonfigurowane modele" + "Łączenie…" + "Połącz się z Gateway, aby zapisać słowa aktywujące" + "Otwórz profil" + "Uruchom Gateway." + "Pomóż mi przekształcić ten cel w praktyczną listę kontrolną: " + "Wyczyść wyszukiwanie sesji" + "Port" + "Wpisz kod konfiguracji" + "Nie udało się wczytać dzienników Gateway." + "%1$s dostawców gotowych" + "Twoi agenci są gotowi" + "Na Gateway nie skonfigurowano dostawcy %1$s" + "Nasłuchiwanie jednej wypowiedzi" + "Obserwuj" + "Milisekundy epoki (opcjonalnie)" + "Brak skonfigurowanych modeli. Odśwież, aby ponownie sprawdzić dostępność." + "Ustawienia" + "Tylny aparat" + "approve" + "Zanim zaczniesz" + "Nie udało się wczytać Skills." + "Wyłączona" + "Nadal oczekuje na zatwierdzenie" + "Nie udało się wczytać zadań w tle" + "Sprawdź, czy OpenClaw mówi wyraźnie na tym telefonie." + "Praca · %1$s aktywnych uruchomień" + "Katalog roboczy polecenia" + "Nazwa grupy" + "Wybierz z galerii" + "Version %1$s" + "Wstecz" + "Connect the gateway to update skills." + "Usuń po uruchomieniu" + "Kod konfiguracji wskazuje na niezabezpieczony zdalny Gateway. %1$s %2$s" + "Computer" + "Gateway rozłączony." + "Session Settings" + "Połącz Gateway, aby rozpocząć" + "Informacja o bezpieczeństwie" + "Inna odpowiedź" + "Odrzuć ostrzeżenie o udostępnionym obrazie" + "Gateway ocenił inne wydanie ClawHub. Sprawdź umiejętność ponownie przed instalacją." + "Otwórz dostęp systemowy" + "Ukończone" + "Obraz niedostępny" + "Powiadomienia" + "Zastosowanie, odrzucenie i poddanie kwarantannie wymagają zakresu operator.admin. Połącz ponownie ze wspólnym uwierzytelnianiem gatewaya lub zatwierdź uaktualnienie zakresu urządzenia operator.admin, aby włączyć akcje cyklu życia." + "sticker upload" + "Homarowanie" + "Messages to recover" + "openclaw devices approve %1$s" + "Czytelne szczegóły dziennika Gateway." + "Przejrzyj wygenerowane propozycje umiejętności, zanim staną się aktywnymi Skills." + "Dołączone" + "%1$s dostępnych" + "Oczekiwanie na zatwierdzenie węzła" + "Gateway oczekuje" + "Wymagane uwierzytelnienie" + "Węzły" + "Nie usypiaj" + "OpenClaw odpowiada" + "Dokumentacja" + "%1$s gotowych" + "Brak wyników" + "Język urządzenia nie jest obsługiwany" + "W kolejce — zostanie wysłane po ponownym połączeniu" + "%1$s min temu" + "Bieżąca gałąź" + "Sprawdzanie dostępu do parowania" + "Ograniczony dostęp do Gateway" + "Uruchamianie narzędzi..." + "Sprawdzanie zatwierdzenia…" + "Rób zdjęcia i nagrywaj klipy tym telefonem" + "Połączono i gotowe" + "Zamknij" + "Przekształć cel w praktyczną listę kontrolną." + "Kod konfiguracji zawiera nieprawidłowy URL Gateway." + "Włączaj tylko taki dostęp, na którego używanie przez OpenClaw podczas połączenia tego telefonu się zgadzasz. Możesz to później zmienić w Ustawieniach Androida." + "Konto" + "remove" + "Hasło opcjonalne" + "Uwierzytelnianie Gateway wymaga sprawdzenia. Sprawdź ustawienia Gateway, a następnie spróbuj ponownie." + "Kod QR używa identyfikatora strefy IPv6. Użyj adresu IPv6 bez zakresu lub nazwy hosta w sieci LAN." + "add" + "Krylowanie" + "Sprawny" + "Ukończono w %1$s" + "Argumenty" + "Opcje instalacji" + "Za %1$s godz." + "Oczekiwanie na zatwierdzenie Gateway. Uruchom to polecenie na hoście Gateway:" + "Wymagany dostęp administratora" + "set groups" + "Przypnij model" + "Wyczyść wyszukiwanie" + "Włączono dla kwalifikujących się agentów." + "Brak bieżącego wątku" + "granice: %1$s" + "Po %1$s" + "Zezwól harmonogramowi na uruchamianie tej automatyzacji." + "%1$s zastosowanych" + "Nie ma jeszcze dziennika snów." + "Odśwież zadania w tle" + "Podsumuj ostatnie wątki i kolejne kroki." + "Działa na urządzeniu, gdy OpenClaw jest widoczny." + "%1$s pracuje" + "%1$s %2$s" + "Surowe" + "Uruchomienia" + "Uruchom teraz" + "Gałąź bez nazwy" + "Skonfigurowano" + "camera list" + "1 zastosowany" + "camera clip" + "Tak" + "Test dźwięku" + "Wstrzymane" + "events" + "Katalog roboczy" + "Przejdź do najnowszych" + "Zezwalaj przez cały czas" + "Zeskanuj kod QR lub kod konfiguracji" + "Installing" + "Aktywne węzły, sparowane telefony i oczekujące prośby o urządzenia." + "Migawka: %1$s" + "Wcześniejsza odpowiedź już zezwoliła na to polecenie i zapisała wybór." + "Oczekujące żądania" + "Zatwierdzono" + "Obszar roboczy" + "Głos" + "Gotowe do rozmowy" + "Subagents" + "Niepowodzenie: nie wykryto bezpiecznego punktu końcowego Gateway. Włącz TLS Gateway lub Tailscale Serve, albo użyj zaufanego prywatnego adresu LAN z wybraną opcją Nieszyfrowane." + "Sygnały" + "Cel sesji" + "Gateway zarejestrował odrzucenie." + "Akceptuj" + "Zapytaj OpenClaw o cokolwiek" + "Połącz ponownie, aby kontynuować" + "%1$s sparowanych" + "Spowoduje to zastosowanie propozycji „%1$s” i odświeżenie stanu Skill Workshop z gateway." + "Gateway jest offline" + "openclaw devices list" + "Stan połączenia węzła OpenClaw" + "Alerty pozostają na tym telefonie." + "OpenClaw może odbierać wybrane alerty." + "Otwórz ekran" + "Działania czatu" + "Zezwolić na sterowanie innymi aplikacjami?" + "Sprawdzanie" + "Zeskanuj lub wklej kod konfiguracji, aby dodać kolejne Gateway." + "Rój" + "Przekroczono limit czasu TLS" + "Ostatnie sesje" + "Sparowane urządzenie usunięte." + "Gateway sparowany. Sprawdzanie zatwierdzenia uprawnień węzła." + "Ruch" + "Operacja cron nie powiodła się." + "Na komputerze Gateway uruchom:" + "Szukaj sesji" + "Odśwież logi" + "Obraz niedostępny · Dotknij, aby spróbować ponownie" + "openclaw nodes approve %1$s" + "Notatka głosowa · %1$s" + "Użycie" + "Nautilowanie" + "Kontekst %1$s%%" + "Transkrybuj polecenia głosowe" + "Wycisz" + "Rozpocznij nową rozmowę, a pojawi się tutaj." + "Problem z połączeniem" + "Średni" + "Rozgałęź" + "Włącz głośnik" + "Tekst zdarzenia systemowego" + "Sortowanie: %1$s" + "%1$s oczekujących" + "Image Generation" + "Notatka głosowa" + "Nic nie wymaga Twojej uwagi" + "OpenClaw potrzebuje uprawnień %1$s, aby kontynuować." + "Mikrofon przewodowego zestawu słuchawkowego" + "Strony" + "Dostarczono" + "Termin" + "Szczegóły skill nie są dostępne w bieżącym statusie skills." + "Wybierz, co ten telefon może udostępniać." + "Ta automatyzacja ma już uruchomienie w kolejce." + "Połącz się z Gateway, aby zarządzać automatyzacjami." + "Termin uruchomienia automatyzacji jeszcze nie nadszedł." + "Brak szczegółów" + "Trwa zatwierdzanie.\nOpenClaw automatycznie połączy się ponownie." + "Połącz Gateway, aby zobaczyć gotowość dostawców." + "Oczekiwanie na sparowanie" + "Rozpocznij lub kontynuuj rozmowę" + "Brak zaplanowanych zadań" + "Odpowiedz OpenClaw…" + "Stan" + "Węzeł OpenClaw · Połączono" + "Aktywne" + "Pokazuj stan debugowania udostępniania ekranu." + "Brak zgłoszonych limitów" + "Zamknij skaner" + "Co %1$s d" + "Włączone" + "Włącz i otwórz ustawienia" + "Online i gotowy" + "Ask User" + "Błąd czatu" + "Przewiń do przodu" + "%1$s z %2$s" + "Zaplanuj pracę" + "console" + "Ponów" + "Rozpocznij czat, a Twoje aktywne rozmowy OpenClaw pojawią się tutaj." + "Nie udało się wczytać automatyzacji." + "Powłoka w przestrzeni roboczej agenta" + "%1$s aktywne" + "Wybierz uprawnienia urządzenia" + "Czas ostatniego uruchomienia" + "Domyślny agent" + "%1$s godz." + "Rozmowa jest aktywna" + "Nie udało się zainstalować %1$s z ClawHub." + "Witamy w OpenClaw" + "Sterowanie innymi aplikacjami" + "Indeks sygnałów" + "Wprowadź sekret…" + "%1$s:%2$s" + "powiedz OpenClaw, aby %1$s" + "Wykryto" + "Ukryj pasek boczny" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Odtwarzanie dźwięku jest niedostępne" + "zastosuj" + "Nie udało się wczytać danych o użyciu." + "Utrzymuj dostępność węzła podczas aktywnej pracy." + "Następne wybudzenie" + "%1$s/%2$s" + "Brak ostatnich wpisów logu." + "Ręczna konfiguracja Gateway" + "Zmień nazwę grupy" + "Update Goal" + "Dostępność dostawcy nieznana" + "Dostawcy" + "Usuń grupę…" + "Ładunek" + "Rejestr połączeń" + "Memory Search" + "%1$s dostawców" + "Kontekst telefonu i prywatność" + "%1$s/%2$s połączonych" + "%1$s %2$s" + "Przywracanie po ponownym uruchomieniu Gateway nadal trwa." + "Zastąpienie kodu konfiguracji spowoduje usunięcie zapisanych na tym telefonie danych uwierzytelniających konfiguracji i tokenów urządzenia przed ponownym połączeniem. Może być konieczne ponowne zatwierdzenie uprawnień węzła dla tego telefonu; kontynuuj tylko wtedy, gdy chcesz sparować go przy użyciu nowego kodu konfiguracji Gateway." + "Otwórz automatyzację, aby wyświetlić jej konfigurację i historię uruchomień. Połączenia z uprawnieniami administratora umożliwiają również jej uruchamianie, edytowanie, włączanie, wyłączanie i usuwanie." + "Kontekst --" + "Propozycja została poddana kwarantannie." + "Automatyzacja wstrzymana." + "OpenClaw mobile" + "A2UI reset" + "Gateway jest niedostępny" + "Read" + "Ta umiejętność wymaga skonfigurowania 1 elementu. W systemie Android można sprawdzić, co jest zainstalowane; ustawienia i konfigurację można zmienić tylko na komputerze lub za pomocą CLI." + "Ostatnie uruchomienie" + "Dostęp do aparatu jest potrzebny do zeskanowania kodu QR konfiguracji." + "Nie udało się zaktualizować modelu." + "Bulgotanie" + "thread reply" + "Usuń…" + "Połącz się z Gateway, aby przeglądać automatyzacje." + "OSTATNIE LOGI" + "Ładowanie ostatnich uruchomień…" + "Nie można wyświetlić podglądu tego pliku. Może to być plik binarny lub zbyt duży." + "Sprawdź" + "Odczytuj lokalizację tego telefonu" + "Klucz Skill" + "Zainstalowano %1$s." + "Gateway" + "akcje: %1$s" + "Tryb przekazywania" + "%1$sk" + "Przełącz układ wątków" + "Brak punktu końcowego TLS" + "OpenClaw Gateway" + "Skonfiguruj ręcznie" + "Myślenie…" + "Dostęp do Gateway wymaga sprawdzenia" + "1 wstrzymany" + "%1$ss" + "Zastosować propozycję?" + "Nie teraz" + "Niezatwierdzone" + "Szukaj aplikacji" + "1 skonfigurowany model" + "Odrzuć powiadomienie o zatwierdzeniu" + "·" + "Offline" + "Dostawca rozpoznawania mowy" + "Trwa zatwierdzanie Gateway. OpenClaw ponowi próbę automatycznie." + "Maks." + "Zmiany w cron wymagają dostępu operator.admin." + "Myślenie" + "screen snapshot" + "Zaobserwowane węzły: %1$s" + "Nie znaleziono działań" + "Zapisz i połącz" + "list" + "Gateway zarejestrował zatwierdzenie i zapisał wybór." + "Wprowadź prawidłowy ręczny punkt końcowy, aby się połączyć." + "asystent" + "Wysyłanie do czatu..." + "Zapisz profil" + "Zablokowane" + "Edytuj automatyzację" + "Użyj tej samej sieci albo bezpiecznego zdalnego adresu URL Gateway." + "Punkt odniesienia" + "Język" + "Ta aplikacja jest starsza niż Gateway. Zaktualizuj OpenClaw na tym urządzeniu, a następnie spróbuj ponownie." + "Wszystkie" + "Trwa sesja Gateway" + "Oczekuje na sprawdzenie" + "Nie zainstalowano skills." + "Sprawdzanie Gateway" + "Rozłożenie %1$s" + "Wynik dla %1$s jest nieznany. Połącz się ponownie, odśwież Skills, a następnie spróbuj ponownie; Gateway bezpiecznie dołączy do zgodnej instalacji, która nadal trwa." + "Zapomnij" + "Brak sparowanych Gateway." + "%1$s · %2$s" + "<sekret ukryty>" + "%1$s problemów" + "OpenClaw" + "Nasłuchiwanie · %1$s w kolejce" + "Mowa asystenta wyciszona" + "Akcje węzłów działają tylko wtedy, gdy docelowa aplikacja jest na pierwszym planie (weryfikowane przez ścieżkę zdalną). Akcje globalne i akcje w tej samej aplikacji działają tutaj." + "Nie znaleziono jeszcze żadnych Gateway. Jeśli wykrywanie jest zablokowane, użyj konfiguracji ręcznej." + "Otwórz wątek" + "Praca w toku" + "Zacznij mówić..." + "Węzeł telefonu" + "Bardzo wysoki" + "Uruchom na hoście Gateway:" + "Zmiany umiejętności wymagają uprawnienia operator.admin. Połącz się ponownie, używając tokenu Gateway z uprawnieniami administratora." + "Połącz Gateway, aby przeglądać Skills w ClawHub." + "Lista aplikacji pozostaje na tym telefonie." + "Bezczynny" + "Wyświetlane w ustawieniach ułatwień dostępu Androida." + "Inteligentne dostarczanie" + "Odrzuć" + "Gateway zwrócił status „%1$s” po wykonaniu działania %2$s." + "Token Gateway nie jest skonfigurowany" + "Not available to this agent" + "Pliki" + "Uprawnienia" + "Nie udało się uruchomić aparatu. Wybierz obraz kodu QR z galerii lub wprowadź kod konfiguracji ręcznie." + "Stuknij, aby skopiować" + "Oczekiwanie %1$sm" + "%1$s." + "Połącz Gateway, aby instalować Skills z ClawHub." + "Wyszukaj głos" + " · Mikrofon: nasłuchuje" + "Połącz ponownie z dostępem operator.admin, aby przejrzeć i zmienić ustawienia Gateway." + "Załaduj więcej" + "Obserwuj za 3s" + "run" + "Generowanie głosu…" + "← Wstecz" + "Rozłącz" + "Uruchom polecenie zatwierdzania na komputerze Gateway, a następnie sprawdź ponownie." + "Automatyzacje" + "%1$s min" + "Zaufaj" + "Ten kod QR nie jest kodem konfiguracji OpenClaw. Wygeneruj nowy kod za pomocą openclaw qr, a następnie spróbuj ponownie." + "Preferowany mikrofon jest niedostępny; używane jest automatyczne przekierowanie." + "Odrzucone" + "Uwzględnij pakiety Androida i działające w tle." + "Gateway jest gotowy." + "Aktywowano" + "Structured Output" + "Trwa to dłużej niż oczekiwano.\nSprawdź, czy Gateway jest uruchomiony i osiągalny." + "Brak zadań w tle dla tego agenta." + "Ponowne łączenie" + "OpenClaw sprawdza dostęp do Gateway i węzła." + "Code Execution" + "Brak użycia dostawcy" + "Przejrzyj" + "Wymagane jest uprawnienie do korzystania z mikrofonu." + "%1$s d" + "Dostępne: %1$s" + "OpenClaw ponownie się synchronizuje" + "Strumień zdarzeń został przerwany; spróbuj odświeżyć." + "Nie udało się wczytać węzłów i urządzeń." + "Połącz Gateway, aby wczytać skills." + "nieznane" + "Dane wyjściowe" + "Rozmowa nie powiodła się: dostawca w czasie rzeczywistym nieoczekiwanie zamknął połączenie." + "OpenClaw — pilne" + "ban" + "Wymagany token Gateway" + "Sparowane urządzenie" + "Wymaga ponownego zatwierdzenia" + "Nie zaplanowano" + "Kontakty" + "Telefon pozostaje bezczynny, dopóki nie jest potrzebny" + "Słuchanie · wysyłanie głosu z kolejki" + "Nie udało się wczytać szczegółów zadania" + "Wiadomość agenta" + "Gateway wymaga tożsamości tego urządzenia. Uwierzytelnij ponownie lub zresetuj to połączenie Gateway." + "Następna sesja" + "Bezpieczeństwo połączenia" + "Pomiń na razie" + "Witryna" + "Połącz Gateway, aby wczytać prośby o zatwierdzenie w aplikacji." + "Skopiowano %1$s" + "Nie wybrano żadnych aplikacji. Nic nie będzie przekazywane, dopóki nie dodasz aplikacji." + "%1$s %2$s" + "Wymaga konfiguracji" + "Niesparowano" + "Gateway odebrał dane tego telefonu" + "Brak skonfigurowanych modeli" + "Wyłącz" + "Język aplikacji" + "Parowanie z Gateway" + "Zapisane uwierzytelnienie jest nieprawidłowe" + "%1$s zakresów" + "Połącz Gateway, aby wczytać ostatnie logi." + "Zapisz słowa aktywujące" + "Zarządzaj zainstalowanymi umiejętnościami i dodawaj zaufane wydania z ClawHub." + "Wysyłanie…" + "Nie wczytano jeszcze żadnych agentów." + "Przeszukaj ClawHub" + "Czat sprawdza stan Gateway." + "Wymagane parowanie" + "Aktywne uruchomienia" + "Niepowodzenie — %1$s" + "Połączenie między tym telefonem a OpenClaw." + "summarize" + "Obraz widżetu zapisano w folderze Pobrane" + "Uruchamianie…" + "%1$s tokenów" + "Błąd klienta" + "Zweryfikuj urządzenie wysyłające żądanie przed przyznaniem dostępu." + "Mikrofon Bluetooth LE" + "%1$s %2$s" + "Automatyzacja włączona." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Zarchiwizowane" + "Odśwież" + "Szukaj automatyzacji" + "Połączone telefony i hosty węzłów pojawią się tutaj po sparowaniu." + "%1$s: %2$s" + "Ta automatyzacja została zmieniona w Gateway. Przejrzyj najnowszą wersję przed ponownym zapisaniem." + "Zatrzymaj dyktowanie" + "Czytelna" + "Napisz do OpenClaw" + "Hasło Gateway jest nieprawidłowe. Wprowadź je ponownie lub zresetuj to połączenie Gateway." + "Połącz ponownie" + "Czas ISO, np. 2026-07-09T09:30:00Z" + "Narzędzia: %1$s" + "Wcześniejsza odpowiedź już odrzuciła to zatwierdzenie." + "Połączono" + "Otwórz %1$s" + "%1$s/%2$s" + "Uruchomienie automatyzacji zakończyło się z nieznanym stanem." + "Kolejka offline jest pełna (%1$s wiadomości); najpierw usuń elementy z kolejki." + "Publiczne bramy Gateway wymagają wss:// lub Tailscale Serve. Protokół ws:// jest dozwolony dla localhost, hostów .local, emulatora Androida i prywatnych adresów IP w sieci LAN." + "Połącz Gateway, aby wczytać szczegóły skill." + "Wymagany pełny dostęp" + "Nasłuchiwanie aktywacji" + "Rozwiń podgląd linku" + "Wyczyść wyszukiwanie wątków" + "NULL (NIEPOWODZENIE)" + "Aktualizuj" + "Administrator" + "Wymaga uwagi" + "Sparuj to urządzenie ze swoim Gateway, aby wybudzać je tylko do rzeczywistych zadań, mieć pod ręką bieżący przegląd agentów i unikać wyczerpujących baterię pętli w tle." + "Role" + "Odpowiedz" + "Katalog dostawców" + "Włącz uprawnienie w Ustawieniach" + "A2UI push" + "Sprawdź dostęp" + "Jeśli Gateway jest osiągalny, ponowne połączenie powinno zakończyć się bez interwencji." + "Tura agenta" + "poddaj kwarantannie" + "Uwaga" + "Wyszukiwanie…" + "Skąd wziąć kod konfiguracji?" + "Nie udało się włączyć umiejętności." + "pdf" + "Usuń" + "%1$s%% online" + "Brak kanałów" + "Głos w czasie rzeczywistym" + "Akcje odrzucenia i kwarantanny w Skill Workshop" + "Węzły i urządzenia" + "Lokalne centrum poleceń" + "emoji upload" + "Wczytywanie podglądu…" + "Wysoki" + "focus" + "describe" + "kontekst %1$s" + "Nasłuchiwanie odpowiedzi..." + "voice" + "Połączono z %1$s" + "role add" + "Czat wymaga uwagi" + "Włącz mikrofon" + "OpenClaw zbiera i wysyła nazwy, identyfikatory pakietów oraz status aplikacji widocznych na tym telefonie, gdy poprosi o to sparowany Gateway OpenClaw. Pozwala to asystentowi odpowiadać na pytania i wykonywać działania z użyciem zainstalowanych aplikacji." + "Gateway nie jest połączony" + "Zasada" + "Upłynął limit czasu potwierdzania wysłanej wiadomości; odśwież, aby sprawdzić dostarczenie." + "Pliki pomocnicze" + "Wyrażenie" + "Zadania w tle" + "Sen" + "Nie zablokowano żadnych aplikacji. Aplikacje mogą przekazywać dane, dopóki nie dodasz blokad." + "Rozpoznawanie mowy jest niedostępne" + "Platforma" + "Gateway nie zwrócił konfiguracji %1$s" + "Zapomnieć Gateway?" + "Opcjonalny opis" + "Otwórz %1$s" + "Kanwa główna" + "Śnienie" + "%1$s–%2$s" + "Udostępnij plik" + "W czasie rzeczywistym" + "API" + "OpenClaw pracuje…" + "Rozmawiaj lub dyktuj z OpenClaw" + "Udostępnić informacje o zainstalowanych aplikacjach?" + "Wczytywanie automatyzacji…" + "Usuń automatyzację" + "Domyślny asystent" + "Wybierz obsługiwanego dostawcę %1$s na Gateway" + "Niedostępne" + "Pusty folder" + "Otwórz ustawienia" + "Wyłączone" + "Typografia" + "Zatrzymaj" + "Nie ma jeszcze pasujących wątków." + "Parowanie z Gateway powiodło się.\nZatwierdź uprawnienia węzła tego telefonu w interfejsie operatora." + "Ta umiejętność jest zainstalowana, ale obecnie nie może zostać uruchomiona. Aby zmienić konfigurację, użyj komputera lub CLI." + "Mechanizm rozpoznawania jest zajęty" + "Domowy Gateway" + "Uruchom polecenie zatwierdzania w Gateway" + "Usługa wyłączona" + "Nie udało się wczytać propozycji Skill Workshop." + "Przedstaw mi podsumowanie ostatnich wątków OpenClaw i zaproponuj kolejne kroki." + "Nie teraz" + "openclaw qr" + "start" + "Węzeł OpenClaw · Rozmowa" + "Odczytywanie i aktualizowanie wydarzeń" + "Rozmowa nie powiodła się: dostawca w czasie rzeczywistym zamknął połączenie: %1$s" + "Połącz Gateway, aby przeglądać pliki obszaru roboczego." + "%1$s przez przekaźnik Gateway" + "Nie udało się załadować katalogu rozmów Gateway" + "Monitorowanie · 1 zaplanowane zadanie" + "Co %1$s godz." + "Powierzchnia ekranu" + "Tłumaczenia OpenClaw · %1$s" + "Żądanie polecenia" + "Aktualne" + "Kanał" + "Wyłącz wyciszenie" + "Nowa grupa…" + "Przygotowywanie dźwięku…" + "Adaptacyjny" + "Wkrótce" + "%1$s więcej pracowników" + "Web Search" + "Wypróbuj czat, głos, wątki, dostawców lub ustawienia." + "OpenClaw aktywny" + "navigate" + "zażądano %1$s" + "Połącz się z Gateway, aby wyświetlić historię uruchomień automatyzacji." + "Dostęp do urządzenia; nadal wymagana zgoda w Gateway" + "Przerwano" + "Wprowadź prawidłowy kod konfiguracji lub adres Gateway." + "Modele" + "OpenClaw — pasywne" + "Nieprawidłowe hasło Gateway" + "Nie udało się zweryfikować zmiany parowania urządzenia. Odśwież i spróbuj ponownie." + "Wyświetl szczegóły" + "Bash" + "Token" + "Połączony agent OpenClaw może korzystać z włączonych przez Ciebie funkcji urządzenia. Kontynuuj tylko wtedy, gdy ufasz Gateway i agentowi, z którym się łączysz." + "Obrastanie pąklami" + "Przyznano dostęp do wybranych zdjęć lub pełny dostęp do zdjęć." + "Wykonawca ułatwień dostępu" + "Brakuje elementów: %1$s" + "Zwiń listę kontrolną planu" + "Wymagane zatwierdzenie węzła" + "Połącz Gateway" + "... +%1$s więcej" + "Rozwiń listę kontrolną planu" + "Przeglądarka" + "screen record" + "Oczekujące uruchomienie" + "Włączenie pozwala OpenClaw obserwować i sterować ekranami innych aplikacji, gdy jest aktywne. Wymagany jest dostęp do ułatwień dostępu Androida." + "Pochodzenie" + "Osobista AI na Twoich urządzeniach" + "Attach" + "Automatycznie" + "Przegląd" + "Nie udało się wysłać żądania przywrócenia. Dotknij, aby spróbować ponownie." + "Wideo" + "%1$s\n\n" + "Nieszyfrowane" + "Kalendarz" + "Stan Gateway nie jest prawidłowy; nie można wysłać" + "📎 %1$s" + "Ostatni status" + "Przed rozpoczęciem nowego czatu poczekaj na zakończenie bieżącej odpowiedzi." + "Profil" + "Limity dostawcy pojawią się tutaj, gdy Twój Gateway je zgłosi." + "1 problem" + "Wątki w grupie „%1$s” zostaną zachowane i przeniesione z powrotem do sekcji Bez grupy." + "Zalecane" + "Utworzono" + "%1$s/%2$s aktywnych tokenów" + "Brak wyniku akcji" + "Pstrykanie" + "%1$s…" + "Otwórz szczegóły Skill" + "Odtwarzanie mowy nie powiodło się: %1$s" + "Rozpocznij rozmowę" + "Nie można załadować tego folderu." + "Kod QR nie zawierał prawidłowego kodu konfiguracji." + "Sprawdź dostęp do węzła" + "Dodaj wyrażenie aktywujące" + "Nie można połączyć się z gateway" + "Automatyzacja" + "Wymaga połączenia" + "Nie udało się rozstrzygnąć prośby o zatwierdzenie. Odśwież i spróbuj ponownie." + "import" + "Jak ten telefon jest widoczny dla OpenClaw." + "Ustaw fokus na wyszukiwaniu wątków" + "Połącz Gateway" + "Odczyt kalendarza" + "Przegląd jest odświeżany po ponownym połączeniu i po otwarciu tego ekranu." + "Nie udało się wyłączyć umiejętności." + "Nadal trwa łączenie" + "Za %1$s min" + "Odczyt SMS" + "Połącz Gateway, aby wczytać użycie." + "W czym możesz mi teraz pomóc, korzystając z tego telefonu?" + "Wymaga zatwierdzenia" + "Nowy czat" + "Połącz Gateway, aby zaktualizować propozycje Skill Workshop." + "Żądanie OpenClaw nie powiodło się." + "Wymagane uprawnienie" + "Sprawdź gotowość dostawców\ni skonfigurowane modele." + "Ładowanie" + "Alert o niepowodzeniu" + "Motyw i przetłumaczony tekst Androida." + "Mikrofon wyłączony · wysyłanie…" + "Brak" + "Wyświetl" + "Nazwa" + "Wersja" + "Cron" + "Połącz ten telefon z Gateway przed otwarciem OpenClaw." + "Usuń wyrażenie aktywujące" + "Kod konfiguracji nie został zaakceptowany. Wygeneruj nowy kod za pomocą openclaw qr." + "14 wiadomości · Android" + "Transkrypcja nie powiodła się: %1$s" + "Zawsze" + "Nie udało się wczytać snów." + "Uruchomienie automatyzacji dodane do kolejki." + "Conversation Turn" + "Automatyzacja uruchomiona." + "Nowa grupa" + "Błąd serwera" + "Video Generation" + "Oczekiwanie na zatwierdzenie Gateway. Uruchom openclaw devices list na hoście Gateway, zatwierdź ten telefon, a następnie spróbuj ponownie." + "Wpisy pojawią się po tym, jak cykl dreaming zapisze podsumowanie narracyjne." + "%1$s ms" + "Magazyn pamięci" + "Asystent pracuje" + "OpenClaw może wyświetlać listę aplikacji widocznych w launcherze." + "Rozmowa nie powiodła się: %1$s" + "Szukaj w zainstalowanych Skills" + "Sprawdź" + "Process" + "Ostatnie wątki" + "Terminal" + "Bieżąca" + "1 konto" + "Wstrzymano" + "Zezwól na aparat" + "Prośby o zatwierdzenie wykonania pojawią się tutaj, gdy ten telefon będzie połączony." + " · Mikrofon: oczekuje" + "Kopiuj" + "Szczegóły skopiowane" + "Usuń" + "Poproś OpenClaw o użycie funkcji Androida." + "member" + "Sprawdzanie, czy ten Gateway obsługuje asystenta ustawień OpenClaw." + "Użyj poniższych opcji odzyskiwania, aby ponownie nawiązać połączenie." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Nie udało się wczytać kanałów." + "Za %1$s d" + "Kolejne błędy" + "Nie udało się odczytać kodu QR z tego obrazu. Wybierz wyraźniejszy obraz lub wprowadź kod konfiguracji ręcznie." + "Gateway jest starszy niż ta aplikacja. Zaktualizuj OpenClaw na hoście Gateway, a następnie spróbuj ponownie." + "Połącz się przed rozpoczęciem czatu, rozmowy głosowej i wyświetlaniem statusu na żywo." + "Połącz ponownie z Gateway" + "Innej firmy" + "Sprawdź gotowość" + "Ograniczony" + "Logo OpenClaw" + "Odepnij model" + "Powierzchnie komunikacji połączone z tym Gateway." + "Wysyłanie" + "Zarchiwizowane wątki pojawią się tutaj." + "Polecenie skopiowane" + "Podgląd niedostępny" + "Zatwierdź ten telefon w Gateway.\nNastępnie ponów próbę połączenia." + "Zeskanuj kod QR" + "Katalog roboczy polecenia · nie można wyczyścić" + "Aktywność wątku" + "Dostępne" + "Usunąć automatyzację?" + "%1$s dzisiaj · %2$s łącznie" + "Hasło" + "Poddaj propozycję kwarantannie?" + "W tej kompilacji nie dołączono informacji o licencjach." + "Nie udało się zapisać obrazu widżetu" + "Oczekiwanie %1$s" + "Mówienie…" + "Dostawcy i modele" + "Węzeł" + "%1$s " + "Polecenie niedostępne" + "Dzienniki" + "Połącz Gateway, aby sprawdzić propozycje Skill Workshop." + "Narzędzia" + "Przełącznik Gateway" + "Wyślij SMS" + "OpenClaw jest gotowy do kontynuowania w Twoim zwykłym czacie." + "Nie znaleziono poleceń" + "Brak aktualizacji obszaru roboczego. Dotknij, aby spróbować ponownie." + "Terminal wymaga połączonego Gateway" + "Exec" + "Filtr aplikacji" + "Główne" + "%1$sk" + "Wymagany Gateway" + "Dostęp" + "Pakiety: snapshot=%1$s foreground=%2$s" + "Ponów próbę połączenia" + "Harmonogram Cron jest zatrzymany." + "Otwórz" + "Wiadomość skopiowana" + "Nie udało się połączyć z Gateway.\nSpróbujmy to naprawić." + "teraz" + "Usuń po uruchomieniu" + "Wybrano na tym telefonie" + "unpin" + "Session History" + "Odepnij" + "Użyj tego telefonu" + "Nie udało się wczytać szczegółów z ClawHub dla %1$s." + "Narzędzia uruchomione" + "Udostępniaj dokładną lokalizację, gdy lokalizacja jest włączona." + "Mobile UI" + "Motyw" + "Gateway nadal pokazuje to zatwierdzenie jako oczekujące. Sprawdź je przed ponowną próbą." + "Zakończ notatkę głosową" + "Dyktowanie: %1$s" + "Niedozwolone" + "Wybierz inny obraz" + "Podgląd obrazu" + "OpenClaw nasłuchuje tylko wtedy, gdy rozpoczniesz rozmowę lub dyktowanie." + "Udostępnianie liczby kroków i aktywności" + "Wymaga konfiguracji" + "Zaktualizuj ten Gateway, aby korzystać z asystenta ustawień OpenClaw." + "To połączenie z Gateway wymaga operator.admin, aby instalować Skills z ClawHub." + "Propozycja zastosowana." + "%1$s oczekujących" + "%1$s godz. temu" + "Odczyt rejestru połączeń" + "%1$s w kolejce · oczekiwanie na Gateway" + "Przenieś do grupy" + "Zeskanuj kod QR, aby sparować" + "Zatwierdzenie odrzucone." + "Nie udało się sprawdzić propozycji Skill Workshop." + "Przypięte" + "Profil i urządzenie" + "Zamknij selektor poziomu myślenia" + "Nie udało się dodać wiadomości do kolejki w celu późniejszego dostarczenia." + "Kwarantanna" + "Harmonogram · %1$s" + "Nie udało się zaktualizować poziomu rozumowania." + "Otwórz selektor poziomu myślenia" + "Upłynął limit czasu odpowiedzi głosowej; ponawianie oczekującej tury" + "Układ: szczegółowy" + "Nie udało się zdekodować tego obrazu." + "Gateway, głos, powiadomienia, prywatność" + "Pliki obszaru roboczego agenta" + "To urządzenie utraci zaufany dostęp do Gateway." + "Użyj requestId z oczekującego polecenia w poleceniu zatwierdzania." + "Harmonogram" + "Limit częstotliwości" + "Nie dostarczono" + "Ładunek · %1$s" + "Uruchomione" + "Chwytanie szczypcami" + "Zakończ" + "Użyj zaufania systemowego" + "Brak gotowych dostawców" + "Nadaje priorytet podłączonym mikrofonom Bluetooth." + "Zablokowano przekazywanie dla %1$s aplikacji." + "Działania dotyczące wiadomości" + "Rodzaj" + "Przywróć z archiwum" + "Transcripts" + "Słowa aktywujące" + "Skonfiguruj %1$s na Gateway" + "Zeskanuj kod QR lub użyj kodu konfiguracji z OpenClaw Gateway." + "Prototyp systemu projektowego" + "Przesiewanie" + " · Rozmowa: Włączona" + "Nie ma jeszcze danych użycia." + "Czat nie powiódł się przed rozpoczęciem działania; spróbuj ponownie." + "Wyślij" + "Niektóre udostępnione obrazy zostały pominięte lub nie można było ich dodać." + "Zapis kalendarza" + "timeout" + "Niskie" + "Lista blokowanych" + "act" + "Dismiss Task" + "Czat nie powiódł się" + "OpenClaw · Na żywo" + "Zainstalowane" + "Upłynął limit czasu oczekiwania na odpowiedź; spróbuj ponownie lub odśwież." + "Znajdź poprzednie rozmowy" + "Przeglądaj wątki" + "Odświeżanie" + "Poławianie pereł" + "Otwórz aparat i wykadruj kod z openclaw qr." + "Brak urządzeń" + "Przekazuj powiadomienia" + "Będę utrzymywać tę rozmowę oddzielnie od zwykłego czatu z agentem." + "Sesja Gateway wraca do trybu online. Skróty agentów powinny za chwilę automatycznie zacząć działać." + "Spróbuj wyszukać inaczej lub wyczyść bieżące zapytanie." + "Zezwolić na lokalizację w tle?" + "Wynurzanie" + "Inicjalizacja" + "%1$s · %2$s · %3$s" + "Anuluj notatkę głosową" + "Przewiń do tyłu" + "openclaw gateway" + "Gateway sparowany" + "Linienie" + "Nasłuchiwanie Twojej następnej wypowiedzi." + "OpenClaw działa" + "Wpis dziennika" + "Niepowodzenie: nie udało się połączyć z bezpiecznym punktem końcowym bramy dla tego hosta." + "Gateway jest offline. Napraw połączenie poniżej lub skopiuj dane diagnostyczne." + "Tryb gotowości" + "Test test 1 2 3" + "Nie udało się wyszukać Skills w ClawHub." + "Brak monitu" + "Przedni aparat" + "Otwórz wpis dziennika" + "Przekroczono limit czasu sieci" + "Teraz" + "Zmień nazwę grupy…" + "Więcej agentów" + "openclaw nodes approve REQUEST_ID" + "Przypnij" + "thread list" + "Otwórz %1$s" + "upload" + "Hasło Gateway nie jest skonfigurowane" + "Ustawienia dyktowania" + "Modele dostawców zostały wczytane, ale informacje o gotowości są niedostępne." + "Usunąć wątek?" + "OpenClaw zmienia ten telefon w przejrzysty mobilny interfejs do obsługi wątków, głosu, dostawców i Gateway." + "Najpierw najnowsze" + "Następny cykl" + diff --git a/app/src/main/res/values-pt-rBR/assistant.xml b/app/src/main/res/values-pt-rBR/assistant.xml new file mode 100644 index 0000000..e394858 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/assistant.xml @@ -0,0 +1,7 @@ + + + "perguntar ao OpenClaw %1$s" + "dizer ao OpenClaw para %1$s" + "abrir o OpenClaw e perguntar %1$s" + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..bb8749c --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Confiar neste gateway? + Confiar e continuar + Cancelar + Novo chat no worktree + Verifique a impressão digital do certificado antes de confiar neste gateway.\n\n%1$s + O certificado do gateway foi alterado. Continue somente se você esperava essa alteração.\n\nSHA-256 antigo:\n%1$s\n\nSHA-256 novo:\n%2$s + Desconhecido + VERSÃO + COMMIT + COMPILADO + Versão %1$s + Commit do Git %1$s + Compilado em %1$s UTC, carimbo de data/hora %2$s + Data da compilação %1$s + Copiar o hash completo do commit do Git + Copiar o carimbo de data/hora completo da compilação + Commit do Git do OpenClaw + Carimbo de data/hora da compilação do OpenClaw + Commit do Git copiado + Carimbo de data/hora da compilação copiado + + "Não foi possível preparar um anexo para envio." + "Microfone desligado" + "Mostrar alertas do OpenClaw" + "Atividade da conversa" + "Completo" + "Aprovação permitida e salva." + "Mostrar histórico de chamadas recentes" + "1 pendente" + "Anexo não compatível" + "0 = exato" + "Conecte o Gateway para pesquisar conversas." + "%1$s contas" + "Alterações no Cron exigem operator.admin. Os códigos de configuração não concedem essa permissão intencionalmente. Reconecte-se usando o token compartilhado ou a senha do Gateway para solicitar acesso de administrador. Se este dispositivo ainda não tiver esse acesso, aprove a atualização de escopo pendente em um cliente administrador existente." + "Apply Patch" + "Beliscando" + "Ativar som do alto-falante" + "Execuções ignoradas consecutivas" + "Esta pasta ainda não tem arquivos." + "Não conectado" + "Inspecione e gerencie o estado das skills instaladas." + "Falhou" + "Agente padrão" + "Câmera" + "Remover do grupo" + "Pesquisando" + "Pausado para reprodução de voz" + "O Gateway verificará esta versão exata com o ClawHub antes do download. Se a versão exigir o reconhecimento explícito do risco, o Android exibirá o aviso do Gateway antes de tentar novamente." + "O código de configuração usa um ID de zona IPv6. Use um endereço IPv6 sem escopo ou um nome de host da LAN." + "Anexo" + "Configure palavras de ativação, fala e reprodução." + "Ouvindo (PTT)" + "Proposta rejeitada." + "Mostrar barra lateral" + "usuário" + "%1$s · %2$s" + "Mínimo" + "Negar" + "AGENTE ATIVO" + "1 agendada" + "Sem resposta" + "Selecionado %1$s" + "Array JSON argv do comando" + "Não foi possível ler essa imagem. Escolha uma captura de tela nítida ou uma imagem do QR gerado por openclaw qr." + "Não foi possível %1$s a proposta do Skill Workshop." + "Respondido em outro lugar" + "O Gateway registrou a aprovação uma vez." + "status" + "O OpenClaw só verifica a localização quando o Gateway pareado solicita. Na próxima tela do Android, escolha %1$s para permitir verificações enquanto o app estiver em segundo plano." + "rejeitar" + "Contraste" + "Substituir configuração do gateway?" + "Não foi possível carregar as automações." + "Você" + "Microfone integrado" + "Superfície" + "Nenhuma proposta" + "Conversa principal" + "Abrir chat" + "As ações de pareamento de dispositivos não estão disponíveis nesta sessão do Gateway. Execute openclaw devices list no host do Gateway e gerencie a solicitação por lá. A aprovação de recursos do nó é separada e ainda usa nodes approve <request id>." + "Solicitação de ação" + "list pins" + "Conecte-se a um Gateway para carregar as propostas da Oficina de Skills." + "O código de configuração não foi aceito" + "Sair" + "O provedor de transcrição em tempo real não está configurado." + "Mostrar apps do sistema" + "Atualize seu Gateway para visualizar a configuração de modelos do provedor." + "Enviando ditado" + "Inspecione esta proposta para carregar seu markdown." + "abrir o OpenClaw e perguntar %1$s" + "raciocínio" + "Cliente" + "Aplicado" + "vídeo" + "Promovido" + "On-line" + "Escopos" + "O provedor de voz em tempo real não está configurado." + "%1$s · %2$s" + "kick" + "O Gateway retornou uma automação inválida." + "ID da instância" + "O token do Gateway é obrigatório. Insira-o novamente ou edite esta conexão." + "Origem" + "Atualizar" + "%1$s na fila" + "Iniciar chat" + "As chamadas de ferramentas do Chat que aguardam na conversa ativa permanecem visíveis aqui." + "Revisão de certificado necessária" + "Abra a superfície atual do Canvas para inspecioná-la ou interagir com ela." + "A automação foi atualizada." + "Nenhuma sessão recente" + "Script" + "Status do Gateway, prontidão do nó do telefone e fluxo de logs recente." + "Abrir detalhes da automação" + "Ambiente de execução" + "Mais 1 worker" + "Agente %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Ative %1$s nas Configurações do Android para continuar." + "Reparar" + "reactions" + "Concluído" + "Versão e atualização" + "O OpenClaw exibirá aprovações, trabalhos com falha e problemas de canais aqui." + "Microfone USB" + "Há compartilhamentos demais aguardando para serem adicionados." + "Ignorado" + "Use o endereço LAN do computador do Gateway ou o nome de host remoto seguro." + "Ativado" + "Pesquisando conversas" + "Conversa em tempo real" + "· %1$s" + "O OpenClaw está preparando uma resposta." + "Aprovação permitida uma vez." + "Configuração do provedor" + "nenhuma" + "Os payloads do script são preservados sem alterações. Use a CLI para editar este script." + "Guia de configuração do Android" + "%1$s apps impedidos de encaminhar." + "Dispositivos pareados" + ":%1$s" + "%1$s pendentes" + "Nome do dispositivo" + "Enviar" + "Localização" + "ex.: America/New_York" + "Destino da sessão" + "Revisar skill do ClawHub" + "snapshot" + "Rejeitar a solicitação de pareamento deste dispositivo?" + "Microfone preferido" + "Host do nó" + "Nível" + "Fechar seletor de apps" + "Cole um token compartilhado do Gateway ou um token emitido pelo operador." + "Todos os sistemas estão operando normalmente" + "Diagnósticos do gateway copiados" + "Erro de áudio" + "Substituir configuração" + "Ações rápidas" + "Falha no envio: o chat falhou antes do início da execução; tente novamente." + "Microfone" + "O chat ainda está verificando o status do Gateway." + "Localização precisa" + "Permitir uma vez" + "+%1$s mais" + "thread create" + "Bloqueada" + "Palavra ou frase de ativação" + "O Gateway precisa da aprovação do dispositivo" + "Microfone externo" + "%1$s/%2$s prontas" + "Conectado (operador offline)" + "Recurso não aprovado" + "Isso remove permanentemente a automação e seu agendamento do Gateway." + "Carregando imagem…" + "Conectar" + "Aprovar acesso do nó" + "Adicionar Gateway" + "Transcrição indisponível: %1$s" + "Imagem" + "Mareando" + "Fechar pré-visualização da imagem" + "eval" + "Último comando: %1$s" + "Tenha um terminal aberto no dispositivo que está executando OpenClaw." + "Nenhum item ausente" + "A saída do Canvas precisa de uma conexão ativa com o gateway." + "%1$s · %2$s" + "Isolado" + "© 2026 OpenClaw Foundation — Licença MIT." + "PDF" + "Conversations" + "Consolidação de memória e diário de sonhos." + "Create Goal" + "Esta automação foi alterada enquanto você a editava. Reverta para a versão mais recente do Gateway antes de salvar." + "Quando conectado, o Gateway pode ativar o telefone com uma notificação push silenciosa em vez de manter uma sessão sempre ativa." + "Modo de ativação" + "Remover dispositivo pareado?" + "Texto do evento do sistema" + "Não foi possível copiar a imagem do widget" + "Não" + "Caminho opcional" + "Enviando voz na fila" + "Integrado" + "hide" + "runs" + "A senha do Gateway é obrigatória. Insira-a novamente ou edite esta conexão." + "Texto do evento" + "Transcrição ao vivo" + "Não foi possível carregar a configuração de modelos do provedor." + "%1$s app autorizado a encaminhar." + "Configuração de voz" + "Anexar vídeo" + "Imagens adicionais ocultas: %1$s" + "Rejeitar solicitação de pareamento?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Publicador" + "Bifurcar a partir daqui" + " · Conversa: Falando" + "Substituição opcional" + "Padrão" + "Aprovação de comando" + "O aplicativo e o Gateway usam versões de protocolo incompatíveis. Atualize o OpenClaw em ambos e tente novamente." + "Atualizar tela" + "Ler fotos e mídias recentes" + "Ouvir" + "Conectado" + "Concluída" + "Seu telefone está pareado com %1$s. Continue para concluir o acesso ao nó." + "Conversa atual" + "Raciocínio %1$s" + "Silenciado" + "A OpenClaw agradece a seus parceiros na comunidade de código aberto." + "aberto" + "Conectando ao Gateway" + "O gateway pode alterar este caminho, mas não pode limpar um caminho existente." + "TTS" + "Salvando…" + "Âncora %1$s" + "search" + "Ativar" + "Inspecione e gerencie tarefas agendadas do Gateway." + "Uma resposta anterior já permitiu este comando uma vez." + "generate" + "Use somente em uma rede privada confiável." + "Pesquisar configurações" + "Conversa ao vivo" + "A autenticação do Gateway não está configurada. Edite esta conexão e tente novamente." + "Falha: endpoint seguro alcançado, mas a verificação da impressão digital TLS expirou. Verifique o Tailscale Serve ou o TLS do gateway e tente novamente." + "Etapa 1" + "Ditado" + "Abrir seletor de apps" + "Nenhuma aprovação pendente" + "edit" + "Conecte-se ao seu Gateway" + "Insira o código de configuração gerado por openclaw qr." + "Diagnóstico" + "Outros apps permanecem intocados." + "Quebrando" + "Isso exclui permanentemente a conversa e sua transcrição." + "Dispositivo aprovado." + "Tentando novamente de forma automática" + "Imagem do widget copiada" + "%1$s funções" + "1 item ausente" + "%1$s agendadas" + "react" + "Agentes" + "Conecte o Gateway para carregar as automações." + "Reconectando…" + "Voltar à configuração" + "send" + "Não foi possível testar a conexão" + "Verificar e instalar" + "Transforme este dispositivo em um nó OpenClaw seguro para chat, voz, câmera e ferramentas do dispositivo." + "Configuração manual" + "Abra o Chat para iniciar ou retomar a conversa atual." + "Dica: pare de ouvir para enviar o turno capturado." + "Pular" + "Falha na solicitação de voz" + "Isso rejeitará \"%1$s\" e atualizará o estado do Skill Workshop com base no gateway." + "Criando concha" + "update" + "Compartilhar" + "Câmera ativada" + "Telegram, WhatsApp, email e outros canais aparecem aqui após a configuração." + "Erro de rede" + "Explorando poças de maré" + "Restaure o canvas agora para session=%1$s source=%2$s. Se já houver um estado A2UI, reproduza-o imediatamente. Caso contrário, crie e renderize um painel compacto e otimizado para dispositivos móveis no Canvas." + "Falha ao iniciar: %1$s" + "Não solicitado" + "Configure um provedor de %1$s no Gateway" + "kill" + "Aprovações" + "Arquivos indisponíveis" + "Marcar como não lida" + "Encontrar pessoas e dados de contato" + "Identidade do dispositivo necessária" + "Conversa do OpenClaw" + "Permita acesso à biblioteca de fotos." + "Uma resposta anterior já resolveu esta aprovação." + "Nenhuma conversa recente" + "Tempo limite de %1$ss" + "Nenhum resultado" + "Ler notificações de apps selecionados" + "Disponibilidade desconhecida" + "Configurar conversa" + "Extra" + "Gateway pareado. Aguardando acesso do operador." + "Anexar imagem" + "Escolha o que chega ao OpenClaw." + "Reaprovação de recurso pendente" + "Revise os itens destacados" + "Ouvindo..." + "Atualize-me" + "Mensagem" + "Ler contatos" + "O armazenamento de anexos offline está cheio; exclua primeiro os itens na fila." + "Uma vez" + "Renomear" + "Nenhum canal encontrado." + "Ver tudo" + "Novo dispositivo" + "Session Status" + "Abrir visualização da imagem" + "O ramo da sessão mudou; revise e tente enviar esta mensagem novamente." + "close" + "Isso parece ser um código de configuração. Volte e escolha Configurar Gateway e depois Usar código de configuração." + "✦" + "Agentes e automação" + "Aplicar" + "A execução da automação foi ignorada." + "Continuar" + "Monitorando · %1$s tarefas agendadas" + "Explorar" + "tabs" + "Pendente" + "Conversa: %1$s" + "read" + "Selecionar texto" + "Atividade de movimento" + "descrição: %1$s" + "Reproduzir áudio" + "Horário" + "Não verificado" + "Yield" + "Copiar comando de aprovação" + "Saída da tela atual e superfície interativa do app." + "Serviço conectado" + "Exibição" + "Pronto quando você estiver" + "Não foi possível carregar o catálogo de provedores." + "Falando · aguardando resposta" + "Não concedida" + "Salvar alterações" + "O Gateway rejeitou a execução da automação." + "Session Send" + "Encontrar no ClawHub" + "Sempre permite as verificações de localização solicitadas enquanto o OpenClaw está em segundo plano; o Android mostra isso na notificação persistente do nó." + "Evento do sistema" + "Conecte o Gateway para visualizar provedores" + "Próximo heartbeat" + "Gateway pareado. Aguardando aprovação da funcionalidade do nó." + "Colocando em salmoura" + "Fechar Canvas" + "Gravar contatos" + "Nenhuma skill instalada corresponde a esta pesquisa." + "Configuração do provedor de fala" + "Music Generation" + "Configurações da conversa" + "Monitorando · 1 conversa" + "Texto do payload" + "Definir texto" + "Aprovação %1$s" + "O Gateway não retornou a prontidão de %1$s" + "%1$s modelos configurados. Atualize para verificar novamente a disponibilidade." + "Conversation Send" + "Canvas" + "1 provedor" + "Não foi possível ler automaticamente o certificado do Gateway. Cole a impressão digital SHA-256 obtida no host do Gateway." + "Falha no envio: %1$s" + "Ponte" + "Erro de entrega" + "Use o OpenClaw pelo seu telefone" + "Aparência" + "Oficina de Skills" + "Requer token" + "Pré-visualização · %1$s" + "Permissão para usar o microfone necessária" + "Conecte o Gateway para carregar as propostas do Skill Workshop." + "Todos os sistemas operacionais" + "Gateway inacessível" + "OC" + "Atualizado" + "Conectado (nó offline)" + "Início" + "O ditado está ouvindo" + "Nenhuma conversa arquivada" + "Escolha e inspecione os assistentes disponíveis neste gateway." + "Modo de conversa ativo" + "Em execução · 1 execução ativa" + "Concordar e ativar" + "Atualização do Gateway Necessária" + "Copiar imagem" + "URL do Gateway" + "main, isolated, current ou session:<id>" + "Mídia indisponível" + "Conecte-se ao seu Gateway para abrir um shell no workspace do agente." + "%1$s://%2$s:%3$s" + "Não foi possível carregar os detalhes da aprovação. Atualize e tente novamente." + "Posso verificar o status do Gateway, reparar a configuração, alterar modelos ou conectar canais." + "Tool Call" + "Conversas" + "Write" + "Comece com um prompt ou use a voz." + "D" + "Abrir Ajustes" + "Observando…" + "Encerrar conversa" + "Último erro" + "Revise as ações que precisam da sua atenção." + "Desativada para todos os agentes." + "Iniciar voz" + "Voltar para tarefas em segundo plano" + "Outra ação cron ainda está sendo concluída." + "Tempo de espera %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "O reconhecimento de fala no dispositivo não está disponível." + "Script · somente leitura" + "Os anexos são grandes demais para serem colocados na fila em uma única mensagem; remova alguns e tente novamente." + "1 modelo configurado. Atualize para verificar novamente a disponibilidade." + "Widget indisponível" + "É necessária uma conexão segura para este host." + "Recentes" + "Nenhuma automação correspondente." + "O telefone pode acessar o Gateway" + "Gateway" + "Expirado" + "Trabalho agendado do OpenClaw a partir do seu gateway." + "Sub-agent" + "Aguardando aprovação do dispositivo" + "Carregando conversa" + "Este Gateway agora apresenta um certificado confiável para este dispositivo." + "Escalonamento ms" + "event create" + "documento" + "Configurar Gateway" + "Reproduzir vídeo" + "A autenticação salva é inválida. Autentique-se novamente ou redefina esta conexão de gateway." + "Durante o uso" + "screenshot" + "Retroceder até aqui" + "Expressão cron, ex.: 0 9 * * *" + "Voltar para voz" + "Falar" + "Detalhes" + "%1$s/%2$s online" + "%1$s apps com permissão para encaminhar." + "Chat" + "É necessário acesso ao microfone." + "Andando de lado" + "Editar" + "Horário de silêncio" + "Copiar diagnóstico" + "Agendado" + "Criar" + "Expira em %1$s" + "Dispensar" + "Rejeitar proposta?" + "Erro de fala (%1$s)" + "Problema" + "Pesquise os metadados do registro. O Gateway verifica novamente a confiabilidade antes de qualquer download." + "Use um IP de LAN privada para a configuração local ou ative o Tailscale Serve / exponha uma URL de gateway wss:// para acesso remoto." + "Enviando…" + "Conta %1$s" + "Suggest Task" + "Pesquisar" + "Ouvindo" + "Automação não carregada." + "Uma atualização do Gateway está disponível. Execute a atualização pela Web UI ou CLI quando estiver pronto." + "em breve" + "Nenhuma aprovação do Gateway." + "Host" + "Adicione uma palavra ou frase de ativação por campo. Depois, diga uma delas antes do comando." + "Transcrever e enviar" + "Executar em" + "Pausar áudio" + "Acesso ao dispositivo Gateway" + "Sem pré-visualização" + "Dispositivos" + "OpenClaw para Android." + "Aprovação de recurso pendente" + "Salve ou reverta suas edições antes de executar, ativar, desativar, excluir ou atualizar esta automação." + "Ainda não há automações." + "Esta skill requer %1$s itens de configuração. O Android mostra o que está instalado; as alterações de instalação/configuração devem ser feitas pelo desktop ou pela CLI." + "%1$s recente(s)" + "Canais" + "Sem fase" + "Ativo neste telefone" + "Verificando acesso ao nó" + "Fuso horário" + "Ações de inspeção e aplicação do Skill Workshop" + "Permitir sempre" + "present" + "As Skills instaladas no Gateway aparecerão aqui." + "O código pode ter expirado ou ter sido gerado para outro Gateway." + "Permissão necessária" + "A automação tem uma configuração inválida." + "Lista de permissões" + "Configuração, status e reparo" + "groups" + "Chave pública" + "Sobre" + "Nenhum código QR de configuração foi encontrado nessa imagem. Escolha o QR gerado por openclaw qr ou insira o código de configuração manualmente." + "permissions" + "Conecte o Gateway para carregar nós e dispositivos pareados." + "Trocar de ramo" + "Nenhuma skill" + "As respostas são reproduzidas em voz alta" + "Marcar como lida" + "Aprovação do nó pendente" + "wake" + "%1$s propostas" + "A autenticação do Gateway requer atenção." + "Detalhes da conexão" + "Milissegundos" + "Reconhecimento de fala" + "Descrição" + "Conversas recentes" + "Seu telefone envia essas informações para o seu Gateway, e não para um servidor operado pelo OpenClaw. Seu Gateway pode incluí-las em solicitações ao provedor de IA que você escolheu." + "Entrega" + "Silenciar alto-falante" + "%1$s Em execução · %2$s Concluído · %3$s Falhou" + "Abrindo conexão com o Gateway" + "Monitorando · %1$s conversas" + "A execução da automação foi concluída." + "Nenhum app correspondente." + "Enviar para o chat" + "A automação foi excluída." + "Ativar" + "Execuções recentes" + "Alinhe o código QR dentro do quadrado." + "Não foi possível carregar as aprovações." + "Já aprovei" + "Conecte seu Gateway para carregar a prontidão do provedor." + "Não pareado" + "Esta aprovação expirou antes de poder ser resolvida." + "Observando em %1$ss — alterne para o app de destino" + "Prompt do agente" + "emoji list" + "Recorrente" + "Pesquisar no OpenClaw" + "%1$s pendentes" + "Reconhecimento de fala no dispositivo indisponível" + "Nenhum app pode compartilhar esta mensagem" + "Fechar pesquisa" + "Comando a monitorar" + "Integridade" + "Leitor de notificações" + "Alto-falante silenciado" + "Buscar conversas" + "OK" + "Não foi possível abrir o guia de configuração." + "perguntar ao OpenClaw %1$s" + "Wait for Agents" + "Endereço" + "As tarefas agendadas criadas no Gateway aparecerão aqui." + "Mostrando o bloco de log mais recente." + "Usar código de configuração" + "sticker" + "Use um Gateway seguro wss:// ou Tailscale Serve, gere um código de configuração de acesso completo na Control UI ou com openclaw qr, depois escaneie ou cole-o abaixo e reconecte para ativar configurações e atualizações." + "steer" + "Selecionado" + "O Android pode escanear ou colar um código de configuração existente, mas este Gateway ainda não expõe a geração de códigos de configuração para o app. Gere o QR/código no host do Gateway com openclaw qr e, em seguida, escaneie-o aqui ou cole o código de configuração abaixo." + "Status do Canvas" + "Corrigir conexão" + "Salvar imagem" + "Nó %1$s" + "Senha do Gateway necessária" + "Update Plan" + "Remover anexo" + "Falha na execução da automação." + "Limites do provedor e integridade da cota." + "Catálogo de conversa do Gateway não carregado" + "este gateway" + "Ainda não há execuções recentes." + "Modelo de linguagem no dispositivo indisponível" + "O painel precisa de um Gateway conectado" + "Propostas correspondentes aparecerão aqui depois que os agentes criarem rascunhos de skills reutilizáveis." + "Session Search" + "OpenClaw está falando" + "Escanear QR" + "Aplicativos selecionados" + "Reverter alterações" + "Comando de aprovação copiado" + "Status da entrega" + "Código QR não aceito" + "Sua central de comandos de voz." + "Testar conexão" + "OPENCLAW" + "Web Fetch" + "Prompt" + "Aprovar dispositivo?" + "Conecte-se ao seu Gateway para abrir o painel desta sessão." + "Remover %1$s e suas credenciais salvas deste telefone?" + "O código QR aponta para um gateway remoto inseguro. %1$s %2$s" + "Superfície da tela pronta" + "Parear Gateway" + "Conecte o gateway para carregar canais." + "Pausa durante outras atividades de voz." + "Modelo" + "Fotos" + "Colar código de configuração" + "OpenClaw falando" + "Conectando..." + " · Localização: Sempre" + "Mensagens: %1$s" + "Explorando o recife" + "Carregar do gateway" + "texto: %1$s" + "Necessários" + "rename group" + "Pronto" + "O diário está aguardando sua primeira entrada." + "Aprovar" + "Página ao vivo" + "A automação já está em execução." + "Remova esta automação após uma execução única bem-sucedida." + "Pronto para chat e voz" + "Conectado (operador: %1$s)" + "O pareamento do Gateway foi concluído. Aprove este telefone como um nó para que o OpenClaw possa usar os recursos do dispositivo que você habilitar." + "Resposta interrompida" + "imagem" + "%1$s retidos" + "Nenhuma conversa correspondente" + "delete" + "Layout: Compacto" + "channels" + "Concedido" + "A cada %1$smin" + "1 token" + "%1$s %2$s" + "Apps instalados" + "pendente" + "Preparando nota de voz…" + "Nunca" + "Subsistema" + "Ao encerrar comando" + "Conexão" + "Não foi possível carregar o histórico de execuções da automação." + "Nome da automação" + "Etapa 2" + "Diagnosticar" + "Algumas verificações de status de canais não foram concluídas." + "pin" + "Copiar %1$s" + "Pareado" + "Não foi possível salvar as palavras de ativação" + "Isso colocará \"%1$s\" em quarentena e atualizará o estado do Skill Workshop com base no gateway." + "Gravar nota de voz" + "Na fila" + "Respondido" + "Permitir ferramentas de câmera quando solicitado." + "Problemas" + "Ativação por voz" + "Solicitação de pareamento rejeitada." + "há %1$s d" + "roles" + "Skills" + "Arquivar" + "Nó offline. Reconecte e tente novamente." + "Sistema" + "IP remoto" + "Sem grupo" + "Detalhes do agendamento" + "Recursos do telefone" + "Não disponível" + "Painel" + "Colar token" + "Nenhum provedor" + "Impressão digital SHA-256" + "Ainda não há conversas" + "Microfone Bluetooth" + "Recentes" + "Renomear conversa" + "Resultado da resolução desconhecido. As ações permanecem desativadas até que o registro do Gateway seja verificado." + "dialog" + "Ouvir palavras de ativação" + "camera snap" + "Preparando reprodução…" + "O Gateway selecionou o provedor desconhecido %1$s" + "delete group" + "Seguir Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Conecte o Gateway para carregar agentes." + "Voltar" + "Compartilhar mensagem" + "Gere um código QR." + "Reiniciar" + "Alto-falante ligado" + "Excluir grupo?" + "Ausente" + "Buscar propostas" + "stop" + "Seguro (TLS)" + "Nenhum nó ou dispositivo pareado." + "%1$s%% restante %2$s" + "Código de configuração expirado" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DIÁRIO" + "notify" + "Este telefone permanece inativo até que o Gateway precise dele; então, ele desperta, sincroniza e volta ao modo de espera." + "%1$s modelos configurados" + "Licenças" + "Conecte o Gateway para pesquisar Skills do ClawHub." + "Skill" + "A conexão com o Gateway mudou. Reinicie o OpenClaw para reconectar." + "ID do dispositivo" + "O Gateway não identificou o provedor ativo de %1$s" + "Aguardando" + "Palavras de ativação salvas" + "Mais antigos primeiro" + "Tela" + "Em execução desde" + "IDs de zona IPv6 não são compatíveis. Use um endereço IPv6 sem escopo ou um nome de host da LAN." + "Enviado — confirmando a entrega…" + "áudio" + "This gateway connection needs operator.admin to update skills." + "Código de configuração" + "Reconhecer o aviso do Gateway e instalar" + "Atualizar chat" + "Intervalo" + "As ações de propostas do Skill Workshop exigem o escopo operator.admin." + "Sessões" + "Renomear…" + "Conecte o gateway para carregar os sonhos." + "Configuração" + "Abrir Talk" + "poll" + "Conecte-se para carregar seus agentes" + "role remove" + " · Conversa: Ouvindo" + "O ClawHub não retornou uma versão instalável para %1$s." + "Comando" + "Esta aprovação foi cancelada antes de poder ser resolvida." + "Microfone ligado · aguardando Gateway" + "Texto" + "Mostrando %1$s de %2$s. Refine a pesquisa para ver mais." + "v%1$s disponível" + "%1$s://%2$s" + "%1$s... (OK)" + "Provedores e modelos configurados" + "Conectando…" + "Conecte-se a um Gateway para salvar as palavras de ativação" + "Abrir perfil" + "Inicie seu Gateway." + "Ajude-me a transformar este objetivo em uma lista de tarefas prática: " + "Limpar pesquisa de sessões" + "Porta" + "Inserir código de configuração" + "Não foi possível carregar os logs do Gateway." + "%1$s provedores prontos" + "Seus agentes estão prontos" + "Nenhum provedor de %1$s está configurado no Gateway" + "Ouvindo por um turno" + "Observar" + "Milissegundos epoch (opcional)" + "Nenhum modelo configurado. Atualize para verificar novamente a disponibilidade." + "Configurações" + "Câmera traseira" + "approve" + "Antes de começar" + "Não foi possível carregar as Skills." + "Desabilitado" + "Ainda aguardando aprovação" + "Não foi possível carregar as tarefas em segundo plano" + "Verifique se o OpenClaw consegue falar claramente neste telefone." + "Em execução · %1$s execuções ativas" + "Diretório de trabalho do comando" + "Nome do grupo" + "Escolher da galeria" + "Version %1$s" + "Voltar" + "Connect the gateway to update skills." + "Excluir após a execução" + "O código de configuração aponta para um gateway remoto inseguro. %1$s %2$s" + "Computer" + "Gateway desconectado." + "Session Settings" + "Conecte o Gateway para começar" + "Aviso de segurança" + "Outra resposta" + "Dispensar aviso de imagem compartilhada" + "O Gateway avaliou uma versão diferente do ClawHub. Revise a skill novamente antes de instalar." + "Abrir acesso do sistema" + "Concluídas" + "Imagem indisponível" + "Notificações" + "Aplicar, rejeitar e colocar em quarentena requerem o escopo operator.admin. Reconecte com a autenticação compartilhada do gateway ou aprove uma atualização de escopo de dispositivo operator.admin para habilitar as ações de ciclo de vida." + "sticker upload" + "Pescando lagostas" + "Messages to recover" + "openclaw devices approve %1$s" + "Detalhes legíveis do log do gateway." + "Revise propostas de skills geradas antes que se tornem skills ativos." + "Incluída" + "%1$s disponíveis" + "Aprovação do nó pendente" + "Gateway pendente" + "Autenticação necessária" + "Nós" + "Manter ativo" + "O OpenClaw está respondendo" + "Documentação" + "%1$s prontos" + "Ainda não há saída" + "Idioma do dispositivo não compatível" + "Na fila — será enviado quando a conexão for restabelecida" + "há %1$s min" + "Ramo atual" + "Verificando acesso de pareamento" + "Acesso limitado ao Gateway" + "Executando ferramentas..." + "Verificando aprovação…" + "Capturar fotos e clipes deste telefone" + "Conectado e pronto" + "Fechar" + "Transforme um objetivo em uma lista de tarefas práticas." + "O código de configuração tem uma URL de gateway inválida." + "Habilite apenas os acessos que você se sente confortável em permitir que o OpenClaw use enquanto este telefone estiver conectado. Você pode alterá-los depois nas Configurações do Android." + "Conta" + "remove" + "Senha opcional" + "A autenticação do Gateway precisa ser revisada. Verifique as configurações do gateway e tente novamente." + "O código QR usa um ID de zona IPv6. Use um endereço IPv6 sem escopo ou um nome de host da LAN." + "add" + "Krilando" + "Íntegro" + "Concluído em %1$s" + "Argumentos" + "Opções de instalação" + "Em %1$sh" + "A aprovação do Gateway está pendente. Execute isto no host do Gateway:" + "Acesso de administrador necessário" + "set groups" + "Fixar modelo" + "Limpar busca" + "Ativada para agentes qualificados." + "Nenhuma conversa atual" + "limites: %1$s" + "Após %1$s" + "Permita que o agendador execute esta automação." + "%1$s aplicados" + "Ainda não há diário de sonhos." + "Atualizar tarefas em segundo plano" + "Resuma as conversas recentes e os próximos passos." + "É executado no dispositivo enquanto o OpenClaw está visível." + "%1$s está trabalhando" + "%1$s %2$s" + "Bruto" + "Execuções" + "Executar agora" + "Ramificação sem título" + "Configurado" + "camera list" + "1 aplicado" + "camera clip" + "Sim" + "Teste de áudio" + "Retido" + "events" + "Diretório de trabalho" + "Ir para a mais recente" + "Permitir o tempo todo" + "Escanear QR ou código de configuração" + "Installing" + "Nós ativos, telefones pareados e solicitações de dispositivos pendentes." + "Captura: %1$s" + "Uma resposta anterior já permitiu este comando e salvou a escolha." + "Solicitações pendentes" + "Aprovado" + "Espaço de trabalho" + "Voz" + "Pronto para conversar" + "Subagents" + "Falhou: nenhum endpoint de gateway seguro foi detectado. Ative o TLS do gateway ou o Tailscale Serve, ou use um endereço de LAN privada confiável com a opção Não criptografado selecionada." + "Sinais" + "Destino da sessão" + "O Gateway registrou uma negação." + "Aceitar" + "Pergunte qualquer coisa ao OpenClaw" + "Reconecte-se para continuar" + "%1$s pareados" + "Isso aplicará \"%1$s\" e atualizará o estado do Skill Workshop com base no gateway." + "Gateway offline" + "openclaw devices list" + "Status da conexão do OpenClaw Node" + "Os alertas permanecem neste telefone." + "O OpenClaw pode receber alertas selecionados." + "Abrir Tela" + "Ações do chat" + "Permitir o controle de outros apps?" + "Inspecionando" + "Escaneie ou cole um código de configuração para adicionar outro Gateway." + "Swarm" + "Tempo limite de TLS esgotado" + "Sessões recentes" + "Dispositivo pareado removido." + "Gateway pareado. Verificando a aprovação de recursos do nó." + "Movimento" + "Falha na ação cron." + "No computador do Gateway, execute:" + "Pesquisar sessões" + "Atualizar logs" + "Imagem indisponível · Toque para tentar novamente" + "openclaw nodes approve %1$s" + "Mensagem de voz · %1$s" + "Uso" + "Nautilando" + "Contexto %1$s%%" + "Transcrever comandos de voz" + "Silenciar" + "Inicie uma nova conversa e ela aparecerá aqui." + "Problema de conexão" + "Média" + "Bifurcar" + "Ativar alto-falante" + "Texto de evento do sistema" + "Ordenação: %1$s" + "%1$s aguardando" + "Image Generation" + "Nota de voz" + "Nada precisa da sua atenção" + "O OpenClaw precisa das permissões %1$s para continuar." + "Microfone do headset com fio" + "Páginas" + "Entregue" + "Prazo" + "Os detalhes da Skill não estão disponíveis no status atual das Skills." + "Escolha o que este telefone pode compartilhar." + "Esta automação já tem uma execução na fila." + "Conecte o Gateway para gerenciar automações." + "A automação ainda não está programada para ser executada." + "Sem detalhes" + "A aprovação está em andamento.\nO OpenClaw se reconectará automaticamente." + "Conecte seu Gateway para ver a prontidão dos provedores." + "Aguardando pareamento" + "Inicie ou continue uma conversa" + "Nenhuma tarefa agendada" + "Responder ao OpenClaw…" + "Status" + "OpenClaw Node · Conectado" + "Ativo" + "Mostrar o estado de depuração do compartilhamento de tela." + "Nenhum limite informado" + "Fechar scanner" + "A cada %1$sd" + "Ativado" + "Ativar e abrir configurações" + "Online e pronto" + "Ask User" + "Erro no chat" + "Rolar para frente" + "%1$s de %2$s" + "Planeje o trabalho" + "console" + "Tentar novamente" + "Inicie uma conversa e suas conversas ativas do OpenClaw aparecerão aqui." + "Não foi possível carregar a automação." + "Shell no espaço de trabalho do agente" + "%1$s ativo(s)" + "Escolha as permissões do dispositivo" + "Duração da última execução" + "Agente padrão" + "%1$s h" + "A conversa está ativa" + "Não foi possível instalar %1$s pelo ClawHub." + "Boas-vindas ao OpenClaw" + "Controlar outros apps" + "Índice de sinais" + "Digite o segredo…" + "%1$s:%2$s" + "dizer ao OpenClaw para %1$s" + "Descoberto" + "Ocultar barra lateral" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "A reprodução de áudio está indisponível" + "aplicar" + "Não foi possível carregar o uso." + "Manter o nó disponível durante o trabalho ativo." + "Próxima ativação" + "%1$s/%2$s" + "Nenhuma entrada de log recente." + "Gateway manual" + "Renomear grupo" + "Update Goal" + "Disponibilidade do provedor desconhecida" + "Provedores" + "Excluir grupo…" + "Payload" + "Registro de chamadas" + "Memory Search" + "%1$s provedores" + "Contexto e privacidade do telefone" + "%1$s/%2$s conectados" + "%1$s %2$s" + "A recuperação da reinicialização do Gateway ainda está em andamento." + "Substituir o código de configuração apaga as credenciais de configuração e os tokens de dispositivo salvos neste telefone antes de reconectar. Este telefone pode precisar de uma nova aprovação de recursos do nó; prossiga somente se quiser pareá-lo com um novo código de configuração do Gateway." + "Abra uma automação para consultar sua configuração e seu histórico de execuções. Conexões com escopo de administrador também podem executá-la, editá-la, ativá-la, desativá-la ou excluí-la." + "Contexto --" + "Proposta colocada em quarentena." + "A automação foi pausada." + "OpenClaw para dispositivos móveis" + "A2UI reset" + "Gateway indisponível" + "Read" + "Esta skill requer 1 item de configuração. O Android mostra o que está instalado; as alterações de instalação/configuração devem ser feitas pelo desktop ou pela CLI." + "Última execução" + "O acesso à câmera é necessário para escanear o QR de configuração." + "Não foi possível atualizar o modelo." + "Borbulhando" + "thread reply" + "Excluir…" + "Conecte o Gateway para inspecionar as automações." + "LOGS RECENTES" + "Carregando execuções recentes…" + "Não é possível visualizar este arquivo. Ele pode ser binário ou muito grande." + "Verificar" + "Ler a localização deste telefone" + "Chave da Skill" + "%1$s foi instalada." + "Gateways" + "ações: %1$s" + "Modo de encaminhamento" + "%1$sk" + "Alternar layout das conversas" + "Nenhum endpoint TLS" + "Gateway do OpenClaw" + "Configurar manualmente" + "Pensando…" + "Acesso ao Gateway precisa de revisão" + "1 retido" + "%1$ss" + "Aplicar proposta?" + "Agora não" + "Não aprovado" + "Pesquisar apps" + "1 modelo configurado" + "Dispensar aviso de aprovação" + "·" + "Off-line" + "Provedor de fala" + "A aprovação do Gateway está em andamento. O OpenClaw tentará novamente automaticamente." + "Máximo" + "Alterações de cron exigem acesso operator.admin." + "Pensando" + "screen snapshot" + "Nós observados: %1$s" + "Nenhuma ação encontrada" + "Salvar e conectar" + "list" + "O Gateway registrou a aprovação e salvou a escolha." + "Insira um endpoint manual válido para conectar." + "assistente" + "Enviando para o chat..." + "Salvar perfil" + "Bloqueado" + "Editar automação" + "Use a mesma rede ou uma URL remota segura do Gateway." + "Âncora" + "Idioma" + "Este aplicativo é mais antigo que o Gateway. Atualize o OpenClaw neste dispositivo e tente novamente." + "Todos" + "Sessão do Gateway em andamento" + "Aguardando revisão" + "Nenhuma Skill instalada." + "Verificando Gateway" + "Escalonamento %1$s" + "O resultado para %1$s é desconhecido. Reconecte, atualize Skills e tente novamente; o Gateway ingressará com segurança em uma instalação correspondente que ainda esteja em andamento." + "Esquecer" + "Nenhum Gateway emparelhado." + "%1$s · %2$s" + "<segredo ocultado>" + "%1$s problemas" + "OpenClaw" + "Ouvindo · %1$s na fila" + "Fala do assistente silenciada" + "As ações de nós são executadas somente quando o app de destino está em primeiro plano (validado pelo caminho remoto). Ações globais e ações no mesmo app funcionam aqui." + "Nenhum Gateway encontrado ainda. Use a configuração manual se a descoberta estiver bloqueada." + "Abrir conversa" + "Trabalhando" + "Comece a falar..." + "Nó do telefone" + "Muito alto" + "Execute no host do Gateway:" + "Alterações em skills exigem operator.admin. Reconecte usando um token de Gateway com privilégios de administrador." + "Conecte o Gateway para inspecionar Skills do ClawHub." + "A lista de apps permanece neste telefone." + "Ocioso" + "Exibido nas configurações de Acessibilidade do Android." + "Entrega inteligente" + "Rejeitar" + "O Gateway retornou o status \'%1$s\' após %2$s." + "Token do Gateway não configurado" + "Not available to this agent" + "Arquivos" + "Permissões" + "Não foi possível iniciar a câmera. Escolha uma imagem de QR na galeria ou insira o código de configuração manualmente." + "Toque para copiar" + "Aguardando %1$s min" + "%1$s." + "Conecte o Gateway para instalar Skills do ClawHub." + "Pesquisar voz" + " · Mic: Ouvindo" + "Reconecte com acesso operator.admin para revisar e alterar as configurações do Gateway." + "Carregar mais" + "Observar em 3s" + "run" + "Gerando voz…" + "← Voltar" + "Desconectar" + "Execute o comando de aprovação no computador do Gateway e verifique novamente." + "Automações" + "%1$smin" + "Confiar" + "Esse código QR não é um QR de configuração do OpenClaw. Gere um novo código com openclaw qr e tente novamente." + "O microfone preferido não está disponível; usando o roteamento automático." + "Rejeitado" + "Incluir pacotes do Android e em segundo plano." + "Seu Gateway está pronto." + "Acionado" + "Structured Output" + "Isso está demorando mais do que o esperado.\nVerifique se o Gateway está em execução e acessível." + "Não há tarefas em segundo plano para este agente." + "Reconectando" + "O OpenClaw está verificando o acesso ao Gateway e ao nó." + "Code Execution" + "Nenhum uso de provedor" + "Revisar" + "É necessário conceder permissão para usar o microfone." + "%1$s d" + "%1$s disponíveis" + "O OpenClaw está sincronizando novamente" + "O fluxo de eventos foi interrompido; tente atualizar." + "Não foi possível carregar os nós e dispositivos." + "Conecte o Gateway para carregar as Skills." + "desconhecido" + "Saída" + "Falha na conversa: o provedor em tempo real fechou inesperadamente." + "OpenClaw Urgente" + "ban" + "Token do Gateway necessário" + "Dispositivo pareado" + "Precisa de nova aprovação" + "Não agendado" + "Contatos" + "Seu telefone permanece silencioso até que seja necessário" + "Ouvindo · enviando voz na fila" + "Não foi possível carregar os detalhes da tarefa" + "Mensagem do agente" + "O Gateway exige a identidade deste dispositivo. Autentique-se novamente ou redefina esta conexão de gateway." + "Próxima sessão" + "Segurança da conexão" + "Ignorar por enquanto" + "Site" + "Conecte o Gateway para carregar solicitações de aprovação no app." + "%1$s copiado" + "Nenhum app selecionado. Nada será encaminhado até que você adicione apps." + "%1$s %2$s" + "Requer configuração" + "Não pareado" + "O Gateway recebeu este telefone" + "Nenhum modelo configurado" + "Desativar" + "Idioma do app" + "Pareando Gateway" + "Autenticação salva inválida" + "%1$s escopos" + "Conecte o Gateway para carregar os logs recentes." + "Salvar palavras de ativação" + "Gerencie as skills instaladas e adicione versões confiáveis do ClawHub." + "Enviando…" + "Nenhum agente carregado ainda." + "Pesquisar no ClawHub" + "O chat está verificando a integridade do Gateway." + "Pareamento necessário" + "Execuções ativas" + "Falha — %1$s" + "Conexão entre este telefone e o OpenClaw." + "summarize" + "Imagem do widget salva em Downloads" + "Iniciando…" + "%1$s tokens" + "Erro do cliente" + "Verifique este dispositivo solicitante antes de conceder acesso." + "Microfone Bluetooth LE" + "%1$s %2$s" + "A automação foi ativada." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Arquivada" + "Recarregar" + "Pesquisar automações" + "Telefones vinculados e hosts de nós aparecerão aqui após o pareamento." + "%1$s: %2$s" + "Esta automação foi alterada no Gateway. Revise a versão mais recente antes de salvar novamente." + "Parar ditado" + "Legível" + "Enviar mensagem para OpenClaw" + "A senha do Gateway é inválida. Insira-a novamente ou redefina esta conexão de gateway." + "Reconectar" + "Horário ISO, ex.: 2026-07-09T09:30:00Z" + "%1$s ferramentas" + "Uma resposta anterior já negou esta aprovação." + "Vinculado" + "Abrir %1$s" + "%1$s/%2$s" + "A execução da automação foi concluída com um status desconhecido." + "A fila offline está cheia (%1$s mensagens); exclua primeiro os itens na fila." + "Gateways públicos exigem wss:// ou Tailscale Serve. ws:// é permitido para localhost, hosts .local, o emulador do Android e IPs de LAN privada." + "Conecte o Gateway para carregar os detalhes da Skill." + "Acesso Total Necessário" + "Detector de ativação" + "Expandir pré-visualização do link" + "Limpar pesquisa de conversas" + "NULL (FALHOU)" + "Atualizar" + "Administrador" + "Precisa de atenção" + "Pareie este dispositivo com seu Gateway para ativá-lo apenas para tarefas reais, manter uma visão geral dos agentes ativos à mão e evitar ciclos em segundo plano que consomem bateria." + "Funções" + "Responder" + "Catálogo de provedores" + "Ative a permissão nas Configurações" + "A2UI push" + "Verificar acesso" + "Se o Gateway estiver acessível, a reconexão deverá ser concluída sem intervenção." + "Turno do agente" + "colocar em quarentena" + "Atenção" + "Pesquisando…" + "Onde obtenho um código de configuração?" + "Não foi possível ativar a skill." + "pdf" + "Remover" + "%1$s%% online" + "Nenhum canal" + "Voz em tempo real" + "Ações de rejeição e quarentena do Skill Workshop" + "Nós e dispositivos" + "Central de comando local" + "emoji upload" + "Carregando pré-visualização…" + "Alto" + "focus" + "describe" + "contexto de %1$s" + "Ouvindo resposta..." + "voice" + "Conectado a %1$s" + "role add" + "O chat precisa de atenção" + "Ativar microfone" + "O OpenClaw coleta e envia os nomes, IDs de pacote e status dos aplicativos visíveis neste telefone quando o seu Gateway do OpenClaw pareado os solicita. Isso permite que o seu assistente responda perguntas e execute ações usando aplicativos instalados." + "Gateway não conectado" + "Política" + "O tempo de espera pela confirmação da mensagem enviada esgotou; atualize para verificar a entrega." + "Arquivos de suporte" + "Expressão" + "Tarefas em segundo plano" + "Sonhar" + "Nenhum app bloqueado. Os apps podem encaminhar até que você adicione bloqueios." + "Reconhecimento de fala indisponível" + "Plataforma" + "O Gateway não retornou a configuração de %1$s" + "Esquecer Gateway?" + "Descrição opcional" + "Abrir %1$s" + "Tela inicial" + "Sonhando" + "%1$s a %2$s" + "Compartilhar arquivo" + "Tempo real" + "API" + "O OpenClaw está trabalhando…" + "Fale ou dite com o OpenClaw" + "Compartilhar informações dos aplicativos instalados?" + "Carregando automação…" + "Excluir automação" + "Assistente padrão" + "Escolha um provedor de %1$s compatível no Gateway" + "Indisponível" + "Pasta vazia" + "Abrir configurações" + "Desativado" + "Tipografia" + "Parar" + "Ainda não há conversas correspondentes." + "O pareamento do Gateway foi bem-sucedido.\nAprove os recursos de nó deste telefone em uma interface de operador." + "Esta skill está instalada, mas não está qualificada para execução no momento. Use o desktop ou a CLI para alterar a configuração." + "Reconhecedor ocupado" + "Gateway doméstico" + "Execute o comando de aprovação no Gateway" + "Serviço desativado" + "Não foi possível carregar as propostas do Skill Workshop." + "Atualize-me sobre minhas conversas recentes no OpenClaw e sugira os próximos passos." + "Agora não" + "openclaw qr" + "start" + "OpenClaw Node · Conversa" + "Ler e atualizar eventos" + "Falha na conversa: provedor em tempo real fechado: %1$s" + "Conecte o Gateway para navegar pelos arquivos do espaço de trabalho." + "%1$s via relay do Gateway" + "Não foi possível carregar o catálogo de conversa do Gateway" + "Monitorando · 1 tarefa agendada" + "A cada %1$sh" + "Superfície da tela" + "Traduções do OpenClaw · %1$s" + "Solicitação de comando" + "Atualizado" + "Canal" + "Ativar som" + "Novo grupo…" + "Preparando áudio…" + "Adaptável" + "Em breve" + "Mais %1$s workers" + "Web Search" + "Experimente Chat, Voz, Conversas, Provedores ou Configurações." + "OpenClaw Ativo" + "navigate" + "solicitado %1$s" + "Conecte o Gateway para consultar o histórico de execuções da automação." + "Acesso ao dispositivo; a adesão no Gateway ainda é necessária" + "Cancelado" + "Insira um código de configuração ou endereço de gateway válido." + "Modelos" + "OpenClaw Passivo" + "Senha do Gateway inválida" + "Não foi possível verificar a alteração no pareamento do dispositivo. Atualize e tente novamente." + "Ver detalhes" + "Bash" + "Token" + "O agente OpenClaw conectado pode usar os recursos do dispositivo que você ativar. Continue somente se você confiar no Gateway e no agente ao qual se conectar." + "Coletando cracas" + "Acesso selecionado ou completo às fotos concedido." + "Executor de acessibilidade" + "%1$s itens ausentes" + "Recolher lista de verificação do plano" + "Aprovação do nó necessária" + "Conectar Gateway" + "... +%1$s mais" + "Expandir lista de verificação do plano" + "Navegador" + "screen record" + "Execução pendente" + "Ativar permite que o OpenClaw observe e controle as telas de outros apps quando armado. É necessário o acesso à acessibilidade do Android." + "Origem" + "IA pessoal nos seus dispositivos" + "Attach" + "Automático" + "Visão geral" + "Falha ao solicitar a restauração. Toque para tentar novamente." + "Vídeo" + "%1$s\n\n" + "Não criptografado" + "Calendário" + "A integridade do Gateway não está OK; não é possível enviar" + "📎 %1$s" + "Último status" + "Aguarde a conclusão da resposta atual antes de iniciar um novo chat." + "Perfil" + "Os limites do provedor aparecerão aqui quando seu Gateway os informar." + "1 problema" + "As conversas em \"%1$s\" são mantidas e movidas de volta para Sem grupo." + "Recomendado" + "Criado" + "%1$s/%2$s tokens ativos" + "Nenhum resultado de ação" + "Estalando" + "%1$s…" + "Abrir detalhes da Skill" + "Falha na reprodução da fala: %1$s" + "Iniciar conversa" + "Não foi possível carregar esta pasta." + "O código QR não continha um código de configuração válido." + "Revise o acesso ao nó" + "Adicionar frase de ativação" + "Não é possível acessar o gateway" + "Automação" + "Precisa de conexão" + "Não foi possível concluir a aprovação. Atualize e tente novamente." + "import" + "Como este telefone aparece para o OpenClaw." + "Focar na pesquisa de conversas" + "Conectar o gateway" + "Ler calendário" + "A visão geral é atualizada ao reconectar e quando esta tela é aberta." + "Não foi possível desativar a skill." + "Ainda conectando" + "Em %1$smin" + "Ler SMS" + "Conecte o Gateway para carregar o uso." + "Com o que você pode me ajudar neste telefone agora?" + "Precisa de aprovação" + "Novo chat" + "Conecte o Gateway para atualizar as propostas do Skill Workshop." + "A solicitação do OpenClaw falhou." + "Permissão necessária" + "Revise a prontidão dos provedores\ne os modelos configurados." + "Carregando" + "Alerta de falha" + "Tema e texto do Android traduzido." + "Microfone desligado · enviando…" + "Nenhum" + "Visualizar" + "Nome" + "Versão" + "Cron" + "Conecte este telefone a um Gateway antes de abrir o OpenClaw." + "Remover frase de ativação" + "O código de configuração não foi aceito. Gere um novo código com openclaw qr." + "14 mensagens · Android" + "Falha na transcrição: %1$s" + "Sempre" + "Não foi possível carregar o modo de sonho." + "Execução da automação adicionada à fila." + "Conversation Turn" + "A automação foi iniciada." + "Novo grupo" + "Erro do servidor" + "Video Generation" + "A aprovação do Gateway está pendente. Execute openclaw devices list no host do Gateway, aprove este telefone e tente novamente." + "As entradas aparecem depois que um ciclo de sonhos grava um resumo narrativo." + "%1$s ms" + "Armazenamento de memória" + "Assistente trabalhando" + "O OpenClaw pode listar apps visíveis no launcher." + "Falha ao falar: %1$s" + "Pesquisar skills instaladas" + "Inspecionar" + "Process" + "Conversas recentes" + "Terminal" + "Atual" + "1 conta" + "Pausado" + "Permitir câmera" + "As solicitações de aprovação de execução aparecerão aqui enquanto este telefone estiver conectado." + " · Mic: Pendente" + "Copiar" + "Detalhes copiados" + "Excluir" + "Peça ao OpenClaw para usar os recursos do Android." + "member" + "Verificando se este Gateway é compatível com o assistente de configurações do OpenClaw." + "Use as opções de recuperação abaixo para se reconectar." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Não foi possível carregar os canais." + "Em %1$sd" + "Erros consecutivos" + "Não foi possível ler um código QR nessa imagem. Escolha uma imagem mais nítida ou insira o código de configuração manualmente." + "O Gateway é mais antigo que este aplicativo. Atualize o OpenClaw no host do Gateway e tente novamente." + "Conecte-se antes de usar o chat, a voz e o status ao vivo." + "Reconectar Gateway" + "Terceiros" + "Revisar prontidão" + "Limitado" + "Logotipo do OpenClaw" + "Desafixar modelo" + "Superfícies de mensagens conectadas a este gateway." + "Enviando" + "As conversas arquivadas aparecerão aqui." + "Comando copiado" + "Nenhuma pré-visualização disponível" + "Aprove este telefone no Gateway.\nDepois, tente se conectar novamente." + "Escanear código QR" + "Diretório de trabalho do comando · não é possível limpar" + "Atividade da conversa" + "Disponível" + "Excluir automação?" + "%1$s hoje · %2$s no total" + "Senha" + "Colocar proposta em quarentena?" + "Nenhum aviso de licença está incluído neste build." + "Não foi possível salvar a imagem do widget" + "Aguardando %1$s" + "Falando…" + "Provedores e modelos" + "Nó" + "%1$s " + "Prompt indisponível" + "Logs" + "Conecte o Gateway para inspecionar as propostas do Skill Workshop." + "Ferramentas" + "Controle do Gateway" + "Enviar SMS" + "O OpenClaw está pronto para continuar no seu chat comum." + "Nenhum comando encontrado" + "Ainda não há atualização da tela. Toque para tentar novamente." + "O Terminal precisa de um Gateway conectado" + "Exec" + "Filtro de apps" + "Principal" + "%1$sk" + "Gateway Necessário" + "Acesso" + "Pacotes: snapshot=%1$s foreground=%2$s" + "Tentar conexão novamente" + "O agendador cron está parado." + "Abrir" + "Mensagem copiada" + "Não foi possível acessar seu Gateway.\nVamos corrigir isso." + "agora" + "Excluir após execução" + "Selecionado neste telefone" + "unpin" + "Session History" + "Desafixar" + "Usar este telefone" + "Não foi possível carregar os detalhes do ClawHub para %1$s." + "Ferramentas em execução" + "Compartilhar a localização precisa enquanto a localização estiver ativada." + "Mobile UI" + "Tema" + "O Gateway ainda mostra esta aprovação como pendente. Revise antes de tentar novamente." + "Concluir nota de voz" + "Ditado: %1$s" + "Não permitido" + "Escolher outra imagem" + "Prévia da imagem" + "O OpenClaw só escuta quando você inicia a Conversa ou o Ditado." + "Compartilhar passos e atividade" + "Requer configuração" + "Atualize este Gateway para usar o assistente de configurações do OpenClaw." + "Esta conexão com o Gateway precisa de operator.admin para instalar Skills do ClawHub." + "Proposta aplicada." + "%1$s pendentes" + "há %1$s h" + "Ler registro de chamadas" + "%1$s na fila · aguardando o gateway" + "Mover para o grupo" + "Escanear QR para emparelhar" + "Aprovação negada." + "Não foi possível inspecionar a proposta do Skill Workshop." + "Fixado" + "Perfil e dispositivo" + "Fechar seletor de nível de raciocínio" + "Não foi possível colocar a mensagem na fila para entrega posterior." + "Quarentena" + "Agenda · %1$s" + "Não foi possível atualizar o nível de raciocínio." + "Abrir seletor de nível de raciocínio" + "A resposta por voz atingiu o tempo limite; tentando novamente o turno na fila" + "Layout: Detalhado" + "Não foi possível decodificar esta imagem." + "Gateway, voz, notificações, privacidade" + "Arquivos do espaço de trabalho do agente" + "Este dispositivo perderá o acesso confiável ao Gateway." + "Use o requestId do comando pendente no comando approve." + "Agendamento" + "Limite de taxa" + "Não entregue" + "Payload · %1$s" + "Em execução" + "Agarrando" + "Encerrar" + "Usar confiança do sistema" + "Nenhum provedor pronto" + "Prioriza microfones Bluetooth conectados." + "%1$s app impedido de encaminhar." + "Ações da mensagem" + "Tipo" + "Desarquivar" + "Transcripts" + "Palavras de ativação" + "Configure %1$s no Gateway" + "Escaneie um código QR ou use o código de configuração do seu OpenClaw Gateway." + "Protótipo do design system" + "Peneirando" + " · Conversa: Ativa" + "Ainda não há dados de uso." + "O chat falhou antes do início da execução; tente novamente." + "Enviar" + "Algumas imagens compartilhadas foram omitidas ou não puderam ser adicionadas." + "Gravar calendário" + "timeout" + "Baixo" + "Lista de bloqueio" + "act" + "Dismiss Task" + "Falha no chat" + "OpenClaw · Ao vivo" + "Instaladas" + "O tempo de espera por uma resposta esgotou; tente novamente ou atualize." + "Encontre conversas anteriores" + "Navegar pelas conversas" + "Atualizando" + "Coletando pérolas" + "Abra a câmera e enquadre o código do openclaw qr." + "Nenhum dispositivo" + "Encaminhar notificações" + "Vou manter esta conversa separada do chat comum do agente." + "A sessão do Gateway está voltando a ficar online. Os atalhos dos agentes devem se estabilizar automaticamente em instantes." + "Tente uma busca diferente ou limpe a consulta atual." + "Permitir localização em segundo plano?" + "Emergindo" + "Inicialização" + "%1$s · %2$s · %3$s" + "Cancelar nota de voz" + "Rolar para trás" + "openclaw gateway" + "Gateway pareado" + "Trocando a carapaça" + "Aguardando sua próxima fala." + "OpenClaw está trabalhando" + "Entrada de log" + "Falha: não foi possível acessar o endpoint seguro do gateway para este host." + "O Gateway está offline. Corrija a conexão abaixo ou copie os diagnósticos." + "Em espera" + "Teste teste 1 2 3" + "Não foi possível pesquisar Skills do ClawHub." + "Sem prompt" + "Câmera frontal" + "Abrir entrada de log" + "Tempo limite da rede excedido" + "Agora" + "Renomear grupo…" + "Mais Agentes" + "openclaw nodes approve REQUEST_ID" + "Fixar" + "thread list" + "Abrir %1$s" + "upload" + "Senha do Gateway não configurada" + "Configurações de ditado" + "Os modelos do provedor foram carregados, mas a disponibilidade não está acessível." + "Excluir conversa?" + "O OpenClaw transforma este celular em uma interface móvel simples para conversas, voz, provedores e Gateway." + "Mais recentes primeiro" + "Próximo ciclo" + diff --git a/app/src/main/res/values-ru/assistant.xml b/app/src/main/res/values-ru/assistant.xml new file mode 100644 index 0000000..8fabda2 --- /dev/null +++ b/app/src/main/res/values-ru/assistant.xml @@ -0,0 +1,7 @@ + + + "спросить OpenClaw %1$s" + "сказать OpenClaw %1$s" + "открыть OpenClaw и спросить %1$s" + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..2415f67 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Доверять этому шлюзу? + Доверять и продолжить + Отмена + Новый чат в worktree + Проверьте отпечаток сертификата, прежде чем доверять этому шлюзу.\n\n%1$s + Сертификат шлюза изменился. Продолжайте, только если вы ожидали это изменение.\n\nСтарый SHA-256:\n%1$s\n\nНовый SHA-256:\n%2$s + Неизвестно + ВЕРСИЯ + КОММИТ + СОБРАНО + Версия %1$s + Git-коммит %1$s + Собрано %1$s UTC, метка времени %2$s + Дата сборки %1$s + Скопировать полный хеш Git-коммита + Скопировать полную метку времени сборки + Git-коммит OpenClaw + Метка времени сборки OpenClaw + Git-коммит скопирован + Метка времени сборки скопирована + + "Не удалось подготовить вложение к отправке." + "Микрофон выключен" + "Показывать оповещения OpenClaw" + "Активность ветки" + "Полная" + "Одобрение разрешено и сохранено." + "Показывать историю недавних вызовов" + "1 в ожидании" + "Неподдерживаемое вложение" + "0 = точно" + "Подключите Gateway для поиска веток." + "%1$s аккаунтов" + "Для изменений Cron требуется operator.admin. Коды настройки намеренно не предоставляют это разрешение. Повторно подключитесь с помощью общего токена или пароля Gateway, чтобы запросить административный доступ. Если у этого устройства по-прежнему нет такого доступа, подтвердите ожидающее расширение области разрешений в существующем клиенте администратора." + "Apply Patch" + "Щипаю" + "Включить динамик" + "Последовательные пропуски" + "В этой папке пока нет файлов." + "Не подключено" + "Просматривайте состояние установленных навыков и управляйте им." + "Ошибка" + "Агент по умолчанию" + "Камера" + "Удалить из группы" + "Поиск" + "Приостановлено для воспроизведения голоса" + "Gateway проверит именно этот выпуск через ClawHub перед загрузкой. Если для выпуска требуется явное подтверждение риска, Android покажет предупреждение Gateway перед повторной попыткой." + "Код настройки использует идентификатор зоны IPv6. Используйте IPv6-адрес без области действия или имя хоста в LAN." + "Вложение" + "Настройте фразы активации, голосовое управление и воспроизведение." + "Прослушивание (PTT)" + "Предложение отклонено." + "Показать боковую панель" + "пользователь" + "%1$s · %2$s" + "Минимальный" + "Запретить" + "АКТИВНЫЙ АГЕНТ" + "Запланировано: 1" + "Нет ответа" + "Выбрано %1$s" + "JSON-массив argv команды" + "Не удалось прочитать это изображение. Выберите чёткий снимок экрана или изображение QR-кода из openclaw qr." + "Не удалось выполнить действие %1$s с предложением Skill Workshop." + "Отвечено в другом месте" + "Gateway зафиксировал одобрение однократно." + "status" + "OpenClaw проверяет местоположение, только когда его запрашивает сопряжённый Gateway. На следующем экране Android выберите %1$s, чтобы разрешить проверки, когда приложение работает в фоновом режиме." + "отклонить" + "Контрастность" + "Заменить настройку Gateway?" + "Не удалось загрузить автоматизации." + "Вы" + "Встроенный микрофон" + "Интерфейс" + "Нет предложений" + "Основная ветка" + "Открыть чат" + "Действия по сопряжению устройств недоступны в этом сеансе Gateway. Выполните openclaw devices list на хосте Gateway и обработайте запрос там. Одобрение возможностей узла выполняется отдельно с помощью nodes approve <request id>." + "Запрос на действие" + "list pins" + "Подключитесь к Gateway, чтобы загрузить предложения Мастерской Skills." + "Код настройки не принят" + "Выйти" + "Провайдер транскрипции в реальном времени не настроен." + "Показывать системные приложения" + "Обновите Gateway, чтобы просмотреть конфигурацию моделей провайдера." + "Отправка диктовки" + "Откройте это предложение, чтобы загрузить его разметку Markdown." + "открыть OpenClaw и спросить %1$s" + "рассуждение" + "Клиент" + "Применено" + "видео" + "Продвигаемые" + "В сети" + "Области доступа" + "Провайдер голосовой связи в реальном времени не настроен." + "%1$s · %2$s" + "kick" + "Gateway вернул недействительную автоматизацию." + "ID экземпляра" + "Требуется токен Gateway. Введите его снова или измените это подключение." + "Источник" + "Обновить" + "В очереди: %1$s" + "Начать чат" + "Здесь отображаются ожидающие вызовы инструментов чата в активной ветке." + "Требуется проверка сертификата" + "Откройте текущую поверхность Canvas для просмотра или взаимодействия с ней." + "Автоматизация обновлена." + "Нет недавних сеансов" + "Скрипт" + "Статус Gateway, готовность телефонного узла и поток последних журналов." + "Открыть сведения об автоматизации" + "Среда выполнения" + "Ещё 1 воркер" + "Агент %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Включите %1$s в настройках Android, чтобы продолжить." + "Восстановить" + "reactions" + "Готово" + "Версия и обновление" + "OpenClaw будет показывать здесь подтверждения, неудачные задания и проблемы с каналами." + "USB-микрофон" + "Слишком много общих ресурсов ожидают добавления." + "Пропущено" + "Используйте LAN-адрес компьютера Gateway или защищенное удаленное имя хоста." + "Вкл." + "Поиск веток" + "Разговор в реальном времени" + "· %1$s" + "OpenClaw готовит ответ." + "Одобрение разрешено однократно." + "Настройка провайдера" + "нет" + "Содержимое скрипта сохраняется без изменений. Используйте CLI для редактирования этого скрипта." + "Руководство по настройке Android" + "Переадресация заблокирована для %1$s приложений." + "Сопряженные устройства" + ":%1$s" + "%1$s ожидают подтверждения" + "Имя устройства" + "Отправить" + "Местоположение" + "например America/New_York" + "Целевая сессия" + "Проверить навык ClawHub" + "snapshot" + "Отклонить запрос на сопряжение от этого устройства?" + "Предпочитаемый микрофон" + "Хост узла" + "Уровень" + "Закрыть выбор приложений" + "Вставьте общий токен Gateway или токен, выданный оператором." + "Все системы работают штатно" + "Диагностика gateway скопирована" + "Ошибка аудио" + "Заменить настройку" + "Быстрые действия" + "Не удалось отправить: сбой чата до начала выполнения; попробуйте ещё раз." + "Микрофон" + "Чат всё ещё проверяет состояние Gateway." + "Точная геопозиция" + "Разрешить один раз" + "+%1$s еще" + "thread create" + "Заблокировано" + "Слово или фраза активации" + "Gateway требуется подтверждение устройства" + "Внешний микрофон" + "Готово: %1$s/%2$s" + "Подключено (оператор не в сети)" + "Возможность не одобрена" + "Автоматизация и её расписание будут безвозвратно удалены из Gateway." + "Загрузка изображения…" + "Подключиться" + "Одобрить доступ узла" + "Добавить Gateway" + "Транскрибация недоступна: %1$s" + "Изображение" + "Качаемся на волнах" + "Закрыть предпросмотр изображения" + "eval" + "Последняя команда: %1$s" + "Откройте терминал на устройстве, на котором запущен OpenClaw." + "Ничего не требуется" + "Для вывода Canvas требуется активное подключение к Gateway." + "%1$s · %2$s" + "Изолированный" + "© 2026 OpenClaw Foundation — лицензия MIT." + "PDF" + "Conversations" + "Консолидация памяти и дневник снов." + "Create Goal" + "Эта автоматизация была изменена во время редактирования. Перед сохранением вернитесь к последней версии из Gateway." + "После подключения Gateway сможет активировать телефон с помощью беззвучного push-уведомления вместо поддержания постоянно активного сеанса." + "Режим пробуждения" + "Удалить сопряжённое устройство?" + "Текст системного события" + "Не удалось скопировать изображение виджета" + "Нет" + "Необязательный путь" + "Отправка голоса из очереди" + "Встроенный" + "hide" + "runs" + "Требуется пароль Gateway. Введите его снова или измените это подключение." + "Текст события" + "Расшифровка в реальном времени" + "Не удалось загрузить конфигурацию моделей провайдера." + "Приложений с разрешённой переадресацией: %1$s." + "Настройка голоса" + "Прикрепить видео" + "Скрыто дополнительных изображений: %1$s" + "Отклонить запрос на сопряжение?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Издатель" + "Ответвить отсюда" + " · Разговор: говорит" + "Необязательное переопределение" + "По умолчанию" + "Подтверждение команды" + "Приложение и Gateway используют несовместимые версии протокола. Обновите OpenClaw на обоих устройствах и повторите попытку." + "Обновить экран" + "Доступ к недавним фото и медиафайлам" + "Слушать" + "Подключено" + "Выполнено" + "Ваш телефон сопряжён с %1$s. Продолжите, чтобы завершить настройку доступа к узлу." + "Текущая ветка" + "Размышление %1$s" + "Без звука" + "OpenClaw благодарит своих партнеров в сообществе open-source." + "открыто" + "Подключение к Gateway" + "Gateway может изменить этот путь, но не может удалить существующий путь." + "TTS" + "Сохранение…" + "Привязка %1$s" + "search" + "Активировать" + "Просматривайте запланированные задачи Gateway и управляйте ими." + "Предыдущий ответ уже разрешил эту команду однократно." + "generate" + "Используйте только в доверенной частной сети." + "Поиск в настройках" + "Разговор в реальном времени" + "Аутентификация Gateway не настроена. Измените это подключение и попробуйте снова." + "Ошибка: защищенный эндпоинт доступен, но время ожидания проверки TLS-отпечатка истекло. Проверьте Tailscale Serve или TLS Gateway и повторите попытку." + "Шаг 1" + "Диктовка" + "Открыть выбор приложений" + "Нет ожидающих подтверждений" + "edit" + "Подключитесь к вашему Gateway" + "Введите код настройки из openclaw qr." + "Диагностика" + "Другие приложения остаются нетронутыми." + "Раскалываем" + "Это навсегда удалит ветку и её историю." + "Устройство одобрено." + "Автоматическая повторная попытка" + "Изображение виджета скопировано" + "Ролей: %1$s" + "Не хватает 1 элемента" + "Запланировано: %1$s" + "react" + "Агенты" + "Подключите Gateway, чтобы загрузить автоматизации." + "Повторное подключение…" + "Вернуться к настройке" + "send" + "Не удалось проверить подключение" + "Проверить и установить" + "Превратите это устройство в безопасный узел OpenClaw для чата, голоса, камеры и инструментов устройства." + "Ручная настройка" + "Откройте чат, чтобы начать или продолжить текущую ветку." + "Совет: остановите прослушивание, чтобы отправить записанную реплику." + "Пропустить" + "Не удалось выполнить голосовой запрос" + "Предложение \"%1$s\" будет отклонено, а состояние Skill Workshop будет обновлено из Gateway." + "Обрастаю панцирем" + "update" + "Поделиться" + "Камера включена" + "Telegram, WhatsApp, электронная почта и другие каналы появятся здесь после настройки." + "Ошибка сети" + "Исследование приливных луж" + "Восстановите холст сейчас для session=%1$s source=%2$s. Если состояние A2UI уже существует, немедленно воспроизведите его. В противном случае создайте и отобразите на холсте компактную панель управления, удобную для мобильных устройств." + "Не удалось запустить: %1$s" + "Не запрошено" + "Настройте поставщика %1$s на Gateway" + "kill" + "Одобрения" + "Файлы недоступны" + "Отметить как непрочитанное" + "Поиск людей и контактных данных" + "Требуется идентификация устройства" + "Ветка OpenClaw" + "Разрешите доступ к медиатеке." + "Предыдущий ответ уже разрешил это одобрение." + "Нет недавних веток" + "Тайм-аут: %1$s с" + "Совпадений нет" + "Чтение уведомлений выбранных приложений" + "Доступность неизвестна" + "Настроить разговор" + "Дополнительно" + "Сопряжение с Gateway выполнено. Ожидание доступа оператора." + "Прикрепить изображение" + "Выберите, что поступает в OpenClaw." + "Ожидается повторное одобрение возможности" + "Проверьте выделенные элементы" + "Прослушивание..." + "Введи меня в курс дела" + "Сообщение" + "Чтение контактов" + "Хранилище офлайн-вложений заполнено; сначала удалите элементы из очереди." + "Однократно" + "Переименовать" + "Каналы не найдены." + "Показать все" + "Новое устройство" + "Session Status" + "Открыть предпросмотр изображения" + "Ветка сессии изменилась; проверьте и повторите это сообщение." + "close" + "Похоже, это код настройки. Вернитесь назад, выберите «Настроить Gateway», а затем — «Использовать код настройки»." + "✦" + "Агенты и автоматизация" + "Применить" + "Выполнение автоматизации пропущено." + "Продолжить" + "Мониторинг · %1$s запланированных задач" + "Обзор" + "tabs" + "Ожидание" + "Разговор: %1$s" + "read" + "Выбрать текст" + "Физическая активность" + "description: %1$s" + "Воспроизвести аудио" + "Время" + "Не проверено" + "Yield" + "Скопировать команду подтверждения" + "Текущий вывод экрана и интерактивная поверхность приложения." + "Служба подключена" + "Отображение" + "Готовы, когда будете готовы" + "Не удалось загрузить каталог провайдеров." + "Говорит · ожидание ответа" + "Не предоставлено" + "Сохранить изменения" + "Gateway отклонил запуск автоматизации." + "Session Send" + "Найти в ClawHub" + "Всегда разрешает запрошенные проверки местоположения, пока OpenClaw работает в фоновом режиме; Android показывает это в постоянном уведомлении узла." + "Системное событие" + "Подключите Gateway, чтобы просмотреть провайдеров" + "Следующий сигнал активности" + "Сопряжение с Gateway выполнено. Ожидание одобрения возможностей узла." + "Засаливаем" + "Закрыть Canvas" + "Запись контактов" + "Нет установленных навыков, соответствующих этому запросу." + "Настройка провайдера разговоров" + "Music Generation" + "Настройки Разговора" + "Мониторинг · 1 ветка" + "Текст полезной нагрузки" + "Задать текст" + "Подтверждение %1$s" + "Gateway не вернул готовность %1$s" + "Настроено моделей: %1$s. Обновите, чтобы повторно проверить доступность." + "Conversation Send" + "Холст" + "1 провайдер" + "Не удалось автоматически прочитать сертификат Gateway. Вставьте отпечаток SHA-256, полученный на хосте Gateway." + "Не удалось отправить: %1$s" + "Мост" + "Ошибка доставки" + "Используйте OpenClaw с телефона" + "Внешний вид" + "Мастерская Skills" + "Требуется токен" + "Предпросмотр · %1$s" + "Требуется разрешение на доступ к микрофону" + "Подключите Gateway, чтобы загрузить предложения Skill Workshop." + "Все системы работают" + "Gateway недоступен" + "OC" + "Обновлено" + "Подключено (узел не в сети)" + "Главная" + "Идёт прослушивание диктовки" + "Нет архивных веток" + "Выберите и просмотрите ассистентов, доступных на этом gateway." + "Режим разговора активен" + "Выполняется · 1 активный запуск" + "Согласиться и включить" + "Требуется обновление Gateway" + "Копировать изображение" + "URL Gateway" + "main, isolated, current или session:<id>" + "Медиа недоступно" + "Подключитесь к своему Gateway, чтобы открыть оболочку в рабочей области агента." + "%1$s://%2$s:%3$s" + "Не удалось загрузить сведения о подтверждении. Обновите данные и повторите попытку." + "Я могу проверить состояние Gateway, восстановить конфигурацию, сменить модели или подключить каналы." + "Tool Call" + "Ветки" + "Write" + "Начните с запроса или используйте голосовой ввод." + "D" + "Открыть настройки" + "Наблюдение…" + "Завершить разговор" + "Последняя ошибка" + "Проверьте действия, требующие вашего внимания." + "Отключено для всех агентов." + "Начать голосовой разговор" + "Назад к фоновым задачам" + "Другое действие cron ещё не завершено." + "Период ожидания %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Распознавание речи на устройстве недоступно." + "Скрипт · только для чтения" + "Вложения слишком велики, чтобы поставить их в очередь в одном сообщении; удалите некоторые из них и попробуйте ещё раз." + "Настроена 1 модель. Обновите, чтобы повторно проверить доступность." + "Виджет недоступен" + "Для этого хоста требуется защищённое соединение." + "Недавние" + "Подходящие автоматизации не найдены." + "Телефон может подключиться к Gateway" + "Gateway" + "Срок истёк" + "Запланированная работа OpenClaw с вашего gateway." + "Sub-agent" + "Ожидание подтверждения устройства" + "Загрузка ветки" + "Теперь этот Gateway предоставляет сертификат, которому доверяет это устройство." + "Интервал разброса, мс" + "event create" + "документ" + "Настроить Gateway" + "Воспроизвести видео" + "Сохранённые данные аутентификации недействительны. Выполните повторную аутентификацию или сбросьте это подключение к gateway." + "При использовании" + "screenshot" + "Перемотать сюда" + "Cron-выражение, например 0 9 * * *" + "Вернуться к голосу" + "Говорить" + "Сведения" + "В сети: %1$s/%2$s" + "Разрешена переадресация для %1$s приложений." + "Чат" + "Требуется доступ к микрофону." + "Семеним" + "Изменить" + "Часы тишины" + "Копировать диагностику" + "Запланировано" + "Создать" + "Истекает через %1$s" + "Закрыть" + "Отклонить предложение?" + "Ошибка распознавания речи (%1$s)" + "Проблема" + "Ищите в метаданных реестра. Gateway повторно проверяет надёжность перед каждой загрузкой." + "Для локальной настройки используйте частный IP-адрес локальной сети, а для удалённого доступа включите Tailscale Serve или откройте доступ к URL-адресу Gateway с wss://." + "Отправка…" + "Учётная запись %1$s" + "Suggest Task" + "Поиск" + "Прослушивание" + "Автоматизация не загружена." + "Доступно обновление Gateway. Когда будете готовы, запустите обновление через Web UI или CLI." + "скоро" + "Нет подтверждений Gateway." + "Хост" + "Добавьте в каждое поле одно слово или фразу активации. Затем произнесите её перед командой." + "Расшифровать и отправить" + "Запустить в" + "Приостановить аудио" + "Доступ к устройству Gateway" + "Нет предпросмотра" + "Устройства" + "OpenClaw для Android." + "Ожидается одобрение возможности" + "Сохраните или отмените изменения, прежде чем запускать, включать, отключать, удалять или обновлять эту автоматизацию." + "Автоматизаций пока нет." + "Для этого навыка требуется настроить %1$s элементов. На Android можно посмотреть, что установлено; настройка выполняется с компьютера или через CLI." + "%1$s недавних" + "Каналы" + "Без фазы" + "Активен на этом телефоне" + "Проверка доступа к узлу" + "Часовой пояс" + "Действия проверки и применения Skill Workshop" + "Разрешать всегда" + "present" + "Skills, установленные на Gateway, появятся здесь." + "Возможно, срок действия кода истек или он был создан для другого Gateway." + "Требуется разрешение" + "Конфигурация автоматизации недействительна." + "Список разрешений" + "Настройка, статус и восстановление" + "groups" + "Открытый ключ" + "О приложении" + "На этом изображении не найден QR-код настройки. Выберите QR-код, созданный с помощью openclaw qr, или введите код настройки вручную." + "permissions" + "Подключите Gateway, чтобы загрузить узлы и сопряженные устройства." + "Переключить ветку" + "Нет навыков" + "Ответы воспроизводятся вслух" + "Отметить как прочитанное" + "Ожидается подтверждение узла" + "wake" + "Предложений: %1$s" + "Требуется внимание к аутентификации Gateway." + "Сведения о подключении" + "Миллисекунды" + "Распознавание речи" + "Описание" + "Недавние разговоры" + "Ваш телефон отправляет эту информацию на ваш Gateway, а не на сервер, управляемый OpenClaw. Ваш Gateway может включать её в запросы к выбранному вами провайдеру ИИ." + "Доставка" + "Отключить динамик" + "%1$s Выполняется · %2$s Готово · %3$s Ошибок" + "Установка соединения с Gateway" + "Мониторинг · %1$s веток" + "Выполнение автоматизации завершено." + "Подходящих приложений нет." + "Отправить в чат" + "Автоматизация удалена." + "Включить" + "Недавние запуски" + "Выровняйте QR-код внутри квадрата." + "Не удалось загрузить запросы на подтверждение." + "Я одобрил" + "Подключите Gateway, чтобы загрузить готовность провайдеров." + "Не сопряжено" + "Срок действия этого одобрения истёк до его разрешения." + "Наблюдение через %1$s с — переключитесь на целевое приложение" + "Промпт агента" + "emoji list" + "Повторяющееся" + "Поиск в OpenClaw" + "Ожидают: %1$s" + "Распознавание речи на устройстве недоступно" + "Нет приложения, которое может поделиться этим сообщением" + "Закрыть поиск" + "Команда для наблюдения" + "Состояние" + "Прослушивание уведомлений" + "Динамик выключен" + "Поиск веток" + "ОК" + "Не удалось открыть руководство по настройке." + "спросить OpenClaw %1$s" + "Wait for Agents" + "Адрес" + "Запланированные задачи, созданные на Gateway, появятся здесь." + "Показан последний фрагмент журнала." + "Использовать код настройки" + "sticker" + "Используйте защищённый wss:// или Tailscale Serve Gateway, создайте код настройки с полным доступом в Control UI или с помощью openclaw qr, затем отсканируйте или вставьте его ниже и переподключитесь, чтобы включить настройки и обновления." + "steer" + "Выбрано" + "Android может отсканировать или вставить существующий код настройки, но этот gateway пока не предоставляет приложению возможность создавать коды настройки. Создайте QR-код/код на хосте gateway с помощью openclaw qr, затем отсканируйте его здесь или вставьте код настройки ниже." + "Статус Canvas" + "Исправить подключение" + "Сохранить изображение" + "Узел %1$s" + "Требуется пароль Gateway" + "Update Plan" + "Удалить вложение" + "Не удалось выполнить автоматизацию." + "Лимиты провайдера и состояние квоты." + "Каталог разговоров Gateway не загружен" + "этот Gateway" + "Пока нет недавних запусков." + "Языковая модель на устройстве недоступна" + "Для панели управления требуется подключение к Gateway" + "Подходящие предложения появятся здесь после того, как агенты создадут черновики многоразовых Skills." + "Session Search" + "OpenClaw говорит" + "Сканировать QR-код" + "Выбранные приложения" + "Отменить изменения" + "Команда одобрения скопирована" + "Статус доставки" + "QR-код не принят" + "Ваш центр голосового управления." + "Проверить подключение" + "OPENCLAW" + "Web Fetch" + "Запрос" + "Одобрить устройство?" + "Подключитесь к Gateway, чтобы открыть панель управления этого сеанса." + "Удалить %1$s и сохранённые учётные данные с этого телефона?" + "QR-код указывает на небезопасный удаленный Gateway. %1$s %2$s" + "Поверхность экрана готова" + "Сопрячь Gateway" + "Подключите Gateway, чтобы загрузить каналы." + "Приостанавливается во время другой голосовой активности." + "Модель" + "Фото" + "Вставить код настройки" + "OpenClaw говорит" + "Подключение..." + " · Геопозиция: всегда" + "Сообщений: %1$s" + "Исследую риф" + "Загрузить из Gateway" + "text: %1$s" + "Требуется" + "rename group" + "Готово" + "Дневник ожидает первой записи." + "Одобрить" + "Страница в реальном времени" + "Автоматизация уже выполняется." + "Удалить эту автоматизацию после успешного однократного запуска." + "Готово к чату и голосовому общению" + "Подключено (оператор: %1$s)" + "Сопряжение с Gateway завершено. Одобрите этот телефон как узел, чтобы OpenClaw мог использовать включенные вами возможности устройства." + "Ответ прерван" + "изображение" + "%1$s удержано" + "Подходящих веток нет" + "delete" + "Макет: компактный" + "channels" + "Предоставлено" + "Каждые %1$s мин." + "1 токен" + "%1$s %2$s" + "Установленные приложения" + "ожидание" + "Подготовка голосового сообщения…" + "Никогда" + "Подсистема" + "При завершении команды" + "Подключение" + "Не удалось загрузить историю запусков автоматизаций." + "Название автоматизации" + "Шаг 2" + "Диагностика" + "Некоторые проверки состояния каналов не были завершены." + "pin" + "Копировать %1$s" + "Сопряжено" + "Не удалось сохранить фразы активации" + "Предложение \"%1$s\" будет помещено в карантин, а состояние Skill Workshop будет обновлено из Gateway." + "Записать голосовое сообщение" + "В очереди" + "Отвечено" + "Разрешать доступ к камере по запросу." + "Проблемы" + "Голосовая активация" + "Запрос на сопряжение отклонён." + "%1$s дн. назад" + "roles" + "Skills" + "Архивировать" + "Узел не в сети. Переподключитесь и повторите попытку." + "Система" + "Удалённый IP-адрес" + "Без группы" + "Сведения о расписании" + "Возможности телефона" + "Недоступно" + "Панель управления" + "Вставить токен" + "Нет провайдеров" + "Отпечаток SHA-256" + "Веток пока нет" + "Bluetooth-микрофон" + "Недавние" + "Переименовать ветку" + "Результат обработки неизвестен. Действия будут недоступны, пока запись Gateway не будет проверена." + "dialog" + "Распознавать фразы активации" + "camera snap" + "Подготовка воспроизведения…" + "Gateway выбрал неизвестного поставщика %1$s" + "delete group" + "Следовать Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Подключите Gateway, чтобы загрузить агентов." + "Назад" + "Поделиться сообщением" + "Создайте QR-код." + "Перезапустить" + "Динамик включен" + "Удалить группу?" + "Отсутствует" + "Поиск предложений" + "stop" + "Защищённое (TLS)" + "Нет узлов или сопряженных устройств." + "Осталось %1$s%% %2$s" + "Срок действия кода настройки истек" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "ДНЕВНИК" + "notify" + "Этот телефон остаётся в спящем режиме, пока не понадобится Gateway, затем пробуждается, синхронизируется и снова переходит в спящий режим." + "%1$s настроенных моделей" + "Лицензии" + "Подключите Gateway для поиска навыков ClawHub." + "Навык" + "Подключение к Gateway изменилось. Перезапустите OpenClaw для повторного подключения." + "ID устройства" + "Gateway не определил активного поставщика %1$s" + "Ожидание" + "Фразы активации сохранены" + "Сначала старые" + "Экран" + "Выполняется с" + "Идентификаторы зон IPv6 не поддерживаются. Используйте IPv6-адрес без области действия или имя хоста в LAN." + "Отправлено — подтверждение доставки…" + "аудио" + "This gateway connection needs operator.admin to update skills." + "Код настройки" + "Принять предупреждение Gateway и установить" + "Обновить чат" + "Интервал" + "Для действий с предложениями Skill Workshop требуется область доступа operator.admin." + "Сеансы" + "Переименовать…" + "Подключите Gateway, чтобы загрузить сновидения." + "Настройка" + "Открыть Разговор" + "poll" + "Подключитесь, чтобы загрузить агентов" + "role remove" + " · Разговор: слушает" + "ClawHub не вернул устанавливаемую версию для %1$s." + "Команда" + "Это одобрение было отменено до его разрешения." + "Микрофон включен · ожидание gateway" + "Текст" + "Показано %1$s из %2$s. Уточните поиск, чтобы увидеть больше." + "Доступна версия v%1$s" + "%1$s://%2$s" + "%1$s... (ОК)" + "Провайдеры и настроенные модели" + "Подключение…" + "Подключитесь к Gateway, чтобы сохранить фразы активации" + "Открыть профиль" + "Запустите ваш Gateway." + "Помогите мне преобразовать эту цель в практический контрольный список: " + "Очистить поиск сессий" + "Порт" + "Введите код настройки" + "Не удалось загрузить журналы Gateway." + "%1$s провайдеров готово" + "Ваши агенты готовы" + "На Gateway не настроен поставщик %1$s" + "Ожидание одной реплики" + "Наблюдать" + "Миллисекунды эпохи (необязательно)" + "Нет настроенных моделей. Обновите, чтобы повторно проверить доступность." + "Настройки" + "Задняя камера" + "approve" + "Перед началом" + "Не удалось загрузить Skills." + "Отключено" + "Все еще ожидается одобрение" + "Не удалось загрузить фоновые задачи" + "Проверьте, что OpenClaw может четко говорить на этом телефоне." + "Выполняется · %1$s активных запусков" + "Рабочий каталог команды" + "Название группы" + "Выбрать из галереи" + "Version %1$s" + "Назад" + "Connect the gateway to update skills." + "Удалить после запуска" + "Код настройки указывает на небезопасный удаленный Gateway. %1$s %2$s" + "Computer" + "Gateway отключен." + "Session Settings" + "Подключите Gateway, чтобы начать" + "Уведомление о безопасности" + "Другой ответ" + "Закрыть предупреждение об общем изображении" + "Gateway проверил другой выпуск ClawHub. Ещё раз просмотрите навык перед установкой." + "Открыть системный доступ" + "Завершено" + "Изображение недоступно" + "Уведомления" + "Действия «Применить», «Отклонить» и «Карантин» требуют области operator.admin. Переподключитесь с общей авторизацией gateway или подтвердите повышение области устройства до operator.admin, чтобы включить действия жизненного цикла." + "sticker upload" + "Ловим омаров" + "Messages to recover" + "openclaw devices approve %1$s" + "Подробные данные журнала Gateway в удобочитаемом виде." + "Просматривайте сгенерированные предложения Skills, прежде чем они станут активными." + "В комплекте" + "Доступно: %1$s" + "Ожидание подтверждения узла" + "Ожидание Gateway" + "Требуется аутентификация" + "Узлы" + "Не выключать экран" + "OpenClaw отвечает" + "Документация" + "Готово: %1$s" + "Результатов пока нет" + "Язык устройства не поддерживается" + "В очереди — будет отправлено после переподключения" + "%1$s мин назад" + "Текущая ветка" + "Проверка доступа для сопряжения" + "Ограниченный доступ к Gateway" + "Выполняются инструменты..." + "Проверка одобрения…" + "Снимать фото и видео на этом телефоне" + "Подключено и готово" + "Закрыть" + "Преобразуйте цель в практический контрольный список." + "Код настройки содержит недействительный URL Gateway." + "Включайте только тот доступ, который вы готовы предоставить OpenClaw, пока этот телефон подключен. Позже это можно изменить в настройках Android." + "Аккаунт" + "remove" + "Пароль необязателен" + "Аутентификация Gateway требует проверки. Проверьте настройки gateway, затем повторите попытку." + "QR-код использует идентификатор зоны IPv6. Используйте IPv6-адрес без области действия или имя хоста в LAN." + "add" + "Криллим" + "Исправно" + "Готово за %1$s" + "Аргументы" + "Параметры установки" + "Через %1$s ч." + "Ожидается одобрение Gateway. Выполните следующую команду на хосте Gateway:" + "Требуется доступ администратора" + "set groups" + "Закрепить модель" + "Очистить поиск" + "Включено для подходящих агентов." + "Нет текущей ветки" + "bounds: %1$s" + "После %1$s" + "Разрешить планировщику запускать эту автоматизацию." + "%1$s применено" + "Дневника снов пока нет." + "Обновить фоновые задачи" + "Подведите итоги недавних веток и предложите дальнейшие шаги." + "Работает на устройстве, пока OpenClaw отображается на экране." + "%1$s работает" + "%1$s %2$s" + "Необработанные данные" + "Запуски" + "Запустить сейчас" + "Ветка без названия" + "Настроено" + "camera list" + "1 применено" + "camera clip" + "Да" + "Тест аудио" + "Удержано" + "events" + "Рабочий каталог" + "Перейти к последнему" + "Разрешать всегда" + "Отсканируйте QR-код или код настройки" + "Installing" + "Активные узлы, сопряженные телефоны и ожидающие запросы устройств." + "Снимок: %1$s" + "Предыдущий ответ уже разрешил эту команду и сохранил выбор." + "Ожидающие запросы" + "Одобрено" + "Рабочая область" + "Голос" + "Готово к разговору" + "Subagents" + "Не удалось: не обнаружена защищённая конечная точка Gateway. Включите TLS для Gateway или Tailscale Serve либо используйте доверенный частный LAN-адрес с выбранным вариантом «Без шифрования»." + "Сигналы" + "Целевая сессия" + "Gateway зафиксировал отклонение." + "Принять" + "Спросите OpenClaw о чем угодно" + "Переподключитесь, чтобы продолжить" + "Сопряжено устройств: %1$s" + "Будет применено предложение \"%1$s\", а состояние Skill Workshop будет обновлено из Gateway." + "Gateway не в сети" + "openclaw devices list" + "Статус подключения узла OpenClaw" + "Оповещения остаются на этом телефоне." + "OpenClaw может получать выбранные оповещения." + "Открыть экран" + "Действия с чатом" + "Разрешить управление другими приложениями?" + "Проверка" + "Отсканируйте или вставьте код настройки, чтобы добавить еще один gateway." + "Рой" + "Время ожидания TLS истекло" + "Недавние сеансы" + "Сопряжённое устройство удалено." + "Gateway сопряжён. Проверяется разрешение возможностей узла." + "Движение" + "Не удалось выполнить действие cron." + "На компьютере Gateway выполните:" + "Поиск сессий" + "Обновить журналы" + "Изображение недоступно · Нажмите, чтобы повторить" + "openclaw nodes approve %1$s" + "Голосовое сообщение · %1$s" + "Использование" + "Наутилусим" + "Контекст %1$s%%" + "Расшифровывать голосовые запросы" + "Отключить микрофон" + "Начните новый разговор, и он появится здесь." + "Проблема с подключением" + "Средний" + "Ответвить" + "Включить динамик" + "Текст системного события" + "Сортировка: %1$s" + "Ожидают: %1$s" + "Image Generation" + "Голосовое сообщение" + "Ничто не требует вашего внимания" + "OpenClaw требуются разрешения %1$s, чтобы продолжить." + "Микрофон проводной гарнитуры" + "Страницы" + "Доставлено" + "Срок" + "Сведения о Skill недоступны в текущем статусе Skills." + "Выберите, чем может делиться этот телефон." + "Запуск этой автоматизации уже добавлен в очередь." + "Подключите Gateway для управления автоматизациями." + "Время запуска автоматизации ещё не наступило." + "Нет сведений" + "Выполняется подтверждение.\nOpenClaw переподключится автоматически." + "Подключите Gateway, чтобы просмотреть готовность провайдеров." + "Ожидание сопряжения" + "Начать или продолжить разговор" + "Нет запланированных заданий" + "Ответить OpenClaw…" + "Статус" + "Узел OpenClaw · Подключён" + "Активно" + "Показывать состояние отладки демонстрации экрана." + "Ограничения не указаны" + "Закрыть сканер" + "Каждые %1$s дн." + "Включено" + "Включить и открыть настройки" + "В сети и готово" + "Ask User" + "Ошибка чата" + "Прокрутить вперёд" + "%1$s из %2$s" + "Спланировать работу" + "console" + "Повторить" + "Начните чат, и ваши активные беседы OpenClaw появятся здесь." + "Не удалось загрузить автоматизацию." + "Оболочка в рабочем пространстве агента" + "%1$s активных" + "Выберите разрешения устройства" + "Последняя длительность" + "Агент по умолчанию" + "%1$s ч" + "Разговор идёт" + "Не удалось установить %1$s из ClawHub." + "Добро пожаловать в OpenClaw" + "Управлять другими приложениями" + "Индекс сигналов" + "Введите секрет…" + "%1$s:%2$s" + "сказать OpenClaw %1$s" + "Обнаружено" + "Скрыть боковую панель" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Воспроизведение аудио недоступно" + "применить" + "Не удалось загрузить данные об использовании." + "Поддерживать доступность узла во время активной работы." + "Следующее пробуждение" + "%1$s/%2$s" + "Нет недавних записей журнала." + "Настройка Gateway вручную" + "Переименовать группу" + "Update Goal" + "Доступность провайдера неизвестна" + "Провайдеры" + "Удалить группу…" + "Полезная нагрузка" + "Журнал вызовов" + "Memory Search" + "%1$s провайдеров" + "Контекст телефона и конфиденциальность" + "Подключено: %1$s/%2$s" + "%1$s %2$s" + "Восстановление после перезапуска Gateway всё ещё выполняется." + "При замене кода настройки сохранённые на этом телефоне учётные данные настройки и токены устройства будут удалены перед повторным подключением. Возможно, для этого телефона потребуется снова одобрить возможности узла. Продолжайте, только если действительно хотите выполнить сопряжение с помощью нового кода настройки Gateway." + "Откройте автоматизацию, чтобы просмотреть её конфигурацию и историю запусков. Подключения с правами администратора также позволяют запускать, изменять, включать, отключать и удалять её." + "Контекст --" + "Предложение помещено в карантин." + "Автоматизация приостановлена." + "OpenClaw для мобильных устройств" + "A2UI reset" + "Gateway недоступен" + "Read" + "Для этого навыка требуется настроить 1 элемент. На Android можно посмотреть, что установлено; настройка выполняется с компьютера или через CLI." + "Последний запуск" + "Для сканирования QR-кода настройки нужен доступ к камере." + "Не удалось обновить модель." + "Пускаем пузыри" + "thread reply" + "Удалить…" + "Подключите Gateway, чтобы просмотреть автоматизации." + "НЕДАВНИЕ ЖУРНАЛЫ" + "Загрузка недавних запусков…" + "Предварительный просмотр этого файла невозможен. Возможно, это двоичный файл или он слишком большой." + "Проверить" + "Получать местоположение этого телефона" + "Ключ Skill" + "Установлен %1$s." + "Gateway" + "actions: %1$s" + "Режим пересылки" + "%1$sk" + "Переключить вид веток" + "Конечная точка TLS отсутствует" + "Gateway OpenClaw" + "Настроить вручную" + "Обработка…" + "Требуется проверка доступа к Gateway" + "1 удержано" + "%1$sс" + "Применить предложение?" + "Не сейчас" + "Не одобрено" + "Поиск приложений" + "1 настроенная модель" + "Закрыть уведомление о подтверждении" + "·" + "Не в сети" + "Поставщик распознавания речи" + "Выполняется одобрение Gateway. OpenClaw повторит попытку автоматически." + "Максимальный" + "Для изменения cron требуется доступ operator.admin." + "Размышление" + "screen snapshot" + "Обнаружено узлов: %1$s" + "Действия не найдены" + "Сохранить и подключиться" + "list" + "Gateway зафиксировал одобрение и сохранил выбор." + "Введите корректный ручной endpoint для подключения." + "ассистент" + "Отправка в чат..." + "Сохранить профиль" + "Заблокировано" + "Изменить автоматизацию" + "Используйте ту же сеть или защищенный удаленный URL Gateway." + "Якорь" + "Язык" + "Это приложение старее, чем Gateway. Обновите OpenClaw на этом устройстве и повторите попытку." + "Все" + "Выполняется сеанс Gateway" + "Ожидает проверки" + "Skills не установлены." + "Проверка Gateway" + "Разброс %1$s" + "Результат для %1$s неизвестен. Переподключитесь, обновите Skills и повторите попытку; Gateway безопасно присоединится к соответствующей установке, если она всё ещё выполняется." + "Забыть" + "Нет сопряженных gateways." + "%1$s · %2$s" + "<секрет скрыт>" + "Проблем: %1$s" + "OpenClaw" + "Идёт прослушивание · %1$s в очереди" + "Речь ассистента отключена" + "Действия с узлами выполняются только когда целевое приложение на переднем плане (проверяется через удалённый путь). Глобальные действия и действия в том же приложении работают здесь." + "Шлюзы пока не найдены. Если обнаружение заблокировано, настройте подключение вручную." + "Открыть ветку" + "В работе" + "Начните говорить..." + "Узел телефона" + "Сверхвысокий" + "Выполните на хосте Gateway:" + "Для изменения навыков требуется operator.admin. Переподключитесь с токеном Gateway, имеющим права администратора." + "Подключите Gateway для просмотра навыков ClawHub." + "Список приложений остается на этом телефоне." + "Ожидание" + "Отображается в настройках специальных возможностей Android." + "Умная доставка" + "Отклонить" + "Gateway вернул статус \'%1$s\' после выполнения действия %2$s." + "Токен Gateway не настроен" + "Not available to this agent" + "Файлы" + "Разрешения" + "Не удалось запустить камеру. Выберите изображение QR-кода из галереи или введите код настройки вручную." + "Нажмите, чтобы скопировать" + "Ожидание %1$s мин" + "%1$s." + "Подключите Gateway для установки навыков ClawHub." + "Поиск голоса" + " · Микрофон: слушает" + "Переподключитесь с доступом operator.admin, чтобы просматривать и изменять настройки Gateway." + "Загрузить еще" + "Наблюдать через 3 с" + "run" + "Создание голоса…" + "← Назад" + "Отключиться" + "Выполните команду approve на компьютере Gateway, затем проверьте еще раз." + "Автоматизации" + "%1$s мин" + "Доверять" + "Этот QR-код не является QR-кодом настройки OpenClaw. Создайте новый код с помощью openclaw qr и повторите попытку." + "Предпочитаемый микрофон недоступен; используется автоматическая маршрутизация." + "Отклонено" + "Включить Android и фоновые пакеты." + "Ваш Gateway готов." + "Активировано" + "Structured Output" + "Это занимает больше времени, чем ожидалось.\nУбедитесь, что Gateway запущен и доступен." + "У этого агента нет фоновых задач." + "Повторное подключение" + "OpenClaw проверяет доступ к Gateway и узлу." + "Code Execution" + "Нет использования провайдеров" + "Проверить" + "Требуется разрешение на доступ к микрофону." + "%1$s дн." + "Доступно: %1$s" + "OpenClaw восстанавливает синхронизацию" + "Поток событий прерван; попробуйте обновить." + "Не удалось загрузить узлы и устройства." + "Подключите Gateway, чтобы загрузить Skills." + "неизвестно" + "Результат" + "Разговор не удался: поставщик Realtime неожиданно закрыл соединение." + "OpenClaw: срочные" + "ban" + "Требуется токен Gateway" + "Сопряжённое устройство" + "Требуется повторное одобрение" + "Не запланировано" + "Контакты" + "Ваш телефон не будет вас беспокоить, пока не понадобится" + "Слушает · отправка голоса из очереди" + "Не удалось загрузить сведения о задаче" + "Сообщение агента" + "Gateway требует идентификацию этого устройства. Выполните повторную аутентификацию или сбросьте это подключение к gateway." + "Следующий сеанс" + "Безопасность подключения" + "Пропустить пока" + "Веб-сайт" + "Подключите Gateway, чтобы загрузить запросы на подтверждение в приложении." + "%1$s скопировано" + "Приложения не выбраны. Пересылка не начнётся, пока вы не добавите приложения." + "%1$s %2$s" + "Требуется настройка" + "Не сопряжено" + "Gateway получил данные этого телефона" + "Нет настроенных моделей" + "Отключить" + "Язык приложения" + "Сопряжение с Gateway" + "Сохраненные данные аутентификации недействительны" + "Областей доступа: %1$s" + "Подключите Gateway, чтобы загрузить недавние журналы." + "Сохранить фразы активации" + "Управляйте установленными навыками и добавляйте доверенные выпуски из ClawHub." + "Отправка…" + "Агенты пока не загружены." + "Поиск в ClawHub" + "Чат проверяет состояние Gateway." + "Требуется сопряжение" + "Активные запуски" + "Ошибка — %1$s" + "Соединение между этим телефоном и OpenClaw." + "summarize" + "Изображение виджета сохранено в папку «Загрузки»" + "Запуск…" + "%1$s токенов" + "Ошибка клиента" + "Проверьте запрашивающее устройство, прежде чем предоставлять доступ." + "Bluetooth LE-микрофон" + "%1$s %2$s" + "Автоматизация включена." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "В архиве" + "Перезагрузить" + "Поиск автоматизаций" + "Связанные телефоны и хосты узлов появятся здесь после сопряжения." + "%1$s: %2$s" + "Эта автоматизация была изменена в Gateway. Перед повторным сохранением проверьте последнюю версию." + "Остановить диктовку" + "Удобочитаемый" + "Написать в OpenClaw" + "Пароль Gateway недействителен. Введите его повторно или сбросьте это подключение к gateway." + "Подключиться повторно" + "Время в формате ISO, например 2026-07-09T09:30:00Z" + "%1$s инструментов" + "Предыдущий ответ уже отклонил это одобрение." + "Привязано" + "Открыть %1$s" + "%1$s/%2$s" + "Выполнение автоматизации завершено с неизвестным статусом." + "Офлайн-очередь заполнена (%1$s сообщений); сначала удалите элементы из очереди." + "Для общедоступных шлюзов требуется wss:// или Tailscale Serve. ws:// разрешён для localhost, хостов .local, эмулятора Android и частных IP-адресов локальной сети." + "Подключите Gateway, чтобы загрузить сведения о Skill." + "Требуется полный доступ" + "Распознавание фраз активации" + "Развернуть предпросмотр ссылки" + "Очистить поиск веток" + "NULL (ОШИБКА)" + "Обновить" + "Администратор" + "Требует внимания" + "Подключите это устройство к своему Gateway, чтобы активировать его только для реальной работы, всегда иметь под рукой актуальный обзор агентов и избежать фоновых циклов, расходующих заряд батареи." + "Роли" + "Ответить" + "Каталог провайдеров" + "Включите разрешение в настройках" + "A2UI push" + "Проверить доступ" + "Если Gateway доступен, повторное подключение завершится без вмешательства." + "Ход агента" + "поместить в карантин" + "Требует внимания" + "Поиск…" + "Где получить код настройки?" + "Не удалось включить навык." + "pdf" + "Удалить" + "%1$s%% в сети" + "Нет каналов" + "Голос в реальном времени" + "Действия отклонения и карантина Skill Workshop" + "Узлы и устройства" + "Локальный центр управления" + "emoji upload" + "Загрузка предпросмотра…" + "Высокий" + "focus" + "describe" + "контекст %1$s" + "Ожидание ответа..." + "voice" + "Подключено к %1$s" + "role add" + "Чат требует внимания" + "Включить микрофон" + "OpenClaw собирает и отправляет названия, идентификаторы пакетов и статус приложений, видимых на этом телефоне, когда сопряжённый OpenClaw Gateway запрашивает их. Это позволяет вашему ассистенту отвечать на вопросы и выполнять действия с помощью установленных приложений." + "Gateway не подключён" + "Политика" + "Время ожидания подтверждения отправленного сообщения истекло; обновите, чтобы проверить доставку." + "Вспомогательные файлы" + "Выражение" + "Фоновые задачи" + "Сон" + "Заблокированных приложений нет. Приложения могут выполнять пересылку, пока вы не добавите блокировки." + "Распознавание речи недоступно" + "Платформа" + "Gateway не вернул настройку %1$s" + "Забыть gateway?" + "Необязательное описание" + "Открыть %1$s" + "Домашний холст" + "Сновидение" + "с %1$s до %2$s" + "Поделиться файлом" + "В реальном времени" + "API" + "OpenClaw работает…" + "Говорите или диктуйте с помощью OpenClaw" + "Поделиться информацией об установленных приложениях?" + "Загрузка автоматизации…" + "Удалить автоматизацию" + "Ассистент по умолчанию" + "Выберите поддерживаемого поставщика %1$s на Gateway" + "Недоступно" + "Пустая папка" + "Открыть настройки" + "Выкл." + "Типографика" + "Остановить" + "Подходящих веток пока нет." + "Сопряжение с Gateway выполнено.\nПодтвердите возможности узла этого телефона в интерфейсе оператора." + "Этот навык установлен, но сейчас не может быть запущен. Для изменения настроек используйте компьютер или CLI." + "Распознаватель занят" + "Домашний Gateway" + "Выполните команду подтверждения на Gateway" + "Служба отключена" + "Не удалось загрузить предложения Skill Workshop." + "Расскажите о последних событиях в моих недавних ветках OpenClaw и предложите дальнейшие шаги." + "Не сейчас" + "openclaw qr" + "start" + "Узел OpenClaw · Разговор" + "Просмотр и изменение событий" + "Разговор не удался: поставщик Realtime закрыл соединение: %1$s" + "Подключите Gateway, чтобы просматривать файлы рабочего пространства." + "%1$s через ретранслятор Gateway" + "Не удалось загрузить каталог разговоров Gateway" + "Мониторинг · 1 запланированная задача" + "Каждые %1$s ч." + "Поверхность экрана" + "Переводы OpenClaw · %1$s" + "Запрос команды" + "Актуальная версия" + "Канал" + "Включить микрофон" + "Новая группа…" + "Подготовка аудио…" + "Адаптивный" + "Скоро" + "Ещё %1$s воркеров" + "Web Search" + "Попробуйте Чат, Голос, Ветки, Провайдеры или Настройки." + "OpenClaw активен" + "navigate" + "запрошено %1$s" + "Подключите Gateway, чтобы просмотреть историю запусков автоматизаций." + "Доступ к устройству; также требуется согласие в Gateway" + "Прервано" + "Введите действительный код настройки или адрес Gateway." + "Модели" + "OpenClaw: пассивные" + "Недействительный пароль Gateway" + "Не удалось проверить изменение сопряжения устройства. Обновите данные и повторите попытку." + "Посмотреть подробности" + "Bash" + "Токен" + "Подключенный агент OpenClaw может использовать включенные вами возможности устройства. Продолжайте, только если вы доверяете Gateway и агенту, к которому подключаетесь." + "Обрастаем ракушками" + "Предоставлен доступ к выбранным фотографиям или ко всей медиатеке." + "Исполнитель специальных возможностей" + "Не хватает элементов: %1$s" + "Свернуть контрольный список плана" + "Требуется одобрение узла" + "Подключить Gateway" + "... +%1$s еще" + "Развернуть контрольный список плана" + "Браузер" + "screen record" + "Запуск ожидается" + "Включение позволяет OpenClaw наблюдать и управлять экранами других приложений в активном режиме. Требуется доступ к специальным возможностям Android." + "Источник" + "Персональный ИИ на ваших устройствах" + "Attach" + "Автоматически" + "Обзор" + "Не удалось запросить восстановление. Нажмите, чтобы повторить попытку." + "Видео" + "%1$s\n\n" + "Без шифрования" + "Календарь" + "Состояние Gateway неудовлетворительное; отправка невозможна" + "📎 %1$s" + "Последний статус" + "Дождитесь завершения текущего ответа, прежде чем начинать новый чат." + "Профиль" + "Лимиты провайдера появятся здесь, когда ваш Gateway сообщит о них." + "1 проблема" + "Ветки в группе \"%1$s\" сохранятся и вернутся в раздел «Без группы»." + "Рекомендуется" + "Создано" + "%1$s/%2$s активных токенов" + "Нет результата действия" + "Щёлкаем" + "%1$s…" + "Открыть сведения о Skill" + "Не удалось воспроизвести речь: %1$s" + "Начать разговор" + "Не удалось загрузить эту папку." + "QR-код не содержал действительный код настройки." + "Проверьте доступ к узлу" + "Добавить фразу активации" + "Не удаётся связаться с Gateway" + "Автоматизация" + "Требуется подключение" + "Не удалось обработать запрос на подтверждение. Обновите данные и повторите попытку." + "import" + "Как этот телефон отображается в OpenClaw." + "Перейти к поиску веток" + "Подключить Gateway" + "Чтение календаря" + "Обзор обновляется при повторном подключении и открытии этого экрана." + "Не удалось отключить навык." + "Подключение всё ещё выполняется" + "Через %1$s мин." + "Чтение SMS" + "Подключите Gateway, чтобы загрузить данные об использовании." + "Что ты можешь помочь мне сделать с этого телефона прямо сейчас?" + "Требуется одобрение" + "Новый чат" + "Подключите Gateway, чтобы обновить предложения Skill Workshop." + "Запрос OpenClaw не удался." + "Требуется разрешение" + "Проверьте готовность провайдеров\nи настроенные модели." + "Загрузка" + "Оповещение о сбое" + "Тема и переведённый текст Android." + "Микрофон выключен · отправка…" + "Нет" + "Просмотр" + "Название" + "Версия" + "Cron" + "Подключите этот телефон к Gateway перед открытием OpenClaw." + "Удалить фразу активации" + "Код настройки не принят. Создайте новый код с помощью openclaw qr." + "14 сообщений · Android" + "Не удалось выполнить транскрибацию: %1$s" + "Всегда" + "Не удалось загрузить сновидения." + "Запуск автоматизации добавлен в очередь." + "Conversation Turn" + "Автоматизация запущена." + "Новая группа" + "Ошибка сервера" + "Video Generation" + "Ожидается одобрение Gateway. Выполните openclaw devices list на хосте Gateway, одобрите этот телефон и повторите попытку." + "Записи появятся после того, как цикл сновидения запишет краткое повествовательное резюме." + "%1$s мс" + "Хранилище памяти" + "Ассистент работает" + "OpenClaw может показывать список приложений, видимых в лаунчере." + "Не удалось начать разговор: %1$s" + "Поиск установленных навыков" + "Проверить" + "Process" + "Недавние ветки" + "Терминал" + "Текущая" + "1 аккаунт" + "Приостановлено" + "Разрешить доступ к камере" + "Запросы на подтверждение выполнения будут отображаться здесь, пока этот телефон подключен." + " · Микрофон: ожидает" + "Копировать" + "Сведения скопированы" + "Удалить" + "Попросите OpenClaw использовать возможности Android." + "member" + "Проверка того, поддерживает ли этот Gateway ассистент настроек OpenClaw." + "Используйте варианты восстановления ниже, чтобы подключиться повторно." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Не удалось загрузить каналы." + "Через %1$s д." + "Последовательные ошибки" + "Не удалось прочитать QR-код с этого изображения. Выберите более чёткое изображение или введите код настройки вручную." + "Gateway старее, чем это приложение. Обновите OpenClaw на хосте Gateway и повторите попытку." + "Подключитесь, чтобы использовать чат, голосовую связь и статус в реальном времени." + "Повторно подключить Gateway" + "Стороннее" + "Проверить готовность" + "Ограниченный" + "Логотип OpenClaw" + "Открепить модель" + "Поверхности обмена сообщениями, подключенные к этому Gateway." + "Отправка" + "Здесь появятся архивные ветки." + "Команда скопирована" + "Предпросмотр недоступен" + "Подтвердите этот телефон на Gateway.\nЗатем повторите подключение." + "Сканировать QR-код" + "Рабочий каталог команды · нельзя очистить" + "Активность ветки" + "Доступно" + "Удалить автоматизацию?" + "%1$s сегодня · %2$s всего" + "Пароль" + "Поместить предложение в карантин?" + "В этой сборке нет включенных уведомлений о лицензиях." + "Не удалось сохранить изображение виджета" + "Ожидание %1$s" + "Говорит…" + "Провайдеры и модели" + "Узел" + "%1$s " + "Запрос недоступен" + "Журналы" + "Подключите Gateway, чтобы просмотреть предложения Skill Workshop." + "Инструменты" + "Переключатель Gateway" + "Отправка SMS" + "OpenClaw готов продолжить в вашем обычном чате." + "Команды не найдены" + "Обновлений холста пока нет. Нажмите, чтобы повторить попытку." + "Для терминала требуется подключенный Gateway" + "Exec" + "Фильтр приложений" + "Главная" + "%1$sk" + "Требуется Gateway" + "Доступ" + "Пакеты: snapshot=%1$s foreground=%2$s" + "Повторить подключение" + "Планировщик Cron остановлен." + "Открыть" + "Сообщение скопировано" + "Не удалось подключиться к вашему Gateway.\nДавайте это исправим." + "сейчас" + "Удалить после выполнения" + "Выбрано на этом телефоне" + "unpin" + "Session History" + "Открепить" + "Использовать этот телефон" + "Не удалось загрузить сведения ClawHub для %1$s." + "Выполняются инструменты" + "Передавать точное местоположение, пока геолокация включена." + "Mobile UI" + "Тема" + "Gateway по-прежнему показывает это одобрение как ожидающее. Проверьте его перед повторной попыткой." + "Завершить голосовое сообщение" + "Диктовка: %1$s" + "Не разрешено" + "Выбрать другое изображение" + "Предварительный просмотр изображения" + "OpenClaw слушает только после запуска разговора или диктовки." + "Передача данных о шагах и активности" + "Требуется настройка" + "Обновите этот Gateway, чтобы использовать ассистент настроек OpenClaw." + "Для установки навыков ClawHub этому подключению к Gateway требуется operator.admin." + "Предложение применено." + "%1$s в ожидании" + "%1$s ч назад" + "Чтение журнала вызовов" + "%1$s в очереди · ожидание Gateway" + "Переместить в группу" + "Отсканируйте QR-код для сопряжения" + "Одобрение отклонено." + "Не удалось просмотреть предложение Skill Workshop." + "Закреплено" + "Профиль и устройство" + "Закрыть выбор уровня размышления" + "Не удалось поставить сообщение в очередь для последующей доставки." + "Карантин" + "Расписание · %1$s" + "Не удалось обновить уровень рассуждений." + "Открыть выбор уровня размышления" + "Время ожидания голосового ответа истекло; повторная попытка для хода в очереди" + "Макет: подробный" + "Не удалось декодировать это изображение." + "Gateway, голос, уведомления, конфиденциальность" + "Файлы рабочего пространства агента" + "Это устройство потеряет доверенный доступ к Gateway." + "Используйте requestId из ожидающей команды в команде approve." + "Расписание" + "Ограничение частоты" + "Не доставлено" + "Полезная нагрузка · %1$s" + "Выполняется" + "Орудую клешнями" + "Завершить" + "Использовать системное доверие" + "Нет готовых провайдеров" + "Приоритет отдаётся подключённым Bluetooth-микрофонам." + "Переадресация заблокирована для %1$s приложения." + "Действия с сообщением" + "Тип" + "Разархивировать" + "Transcripts" + "Фразы активации" + "Настройте %1$s на Gateway" + "Отсканируйте QR-код или используйте код настройки из вашего OpenClaw Gateway." + "Прототип дизайн-системы" + "Просеиваем" + " · Разговор: включён" + "Данных об использовании пока нет." + "Сбой чата до начала выполнения; попробуйте ещё раз." + "Отправить" + "Некоторые изображения, которыми поделились, были пропущены или не удалось добавить." + "Запись календаря" + "timeout" + "Низкий" + "Список блокировки" + "act" + "Dismiss Task" + "Ошибка чата" + "OpenClaw · В эфире" + "Установлено" + "Время ожидания ответа истекло; повторите попытку или обновите." + "Найти предыдущие разговоры" + "Просмотреть ветки" + "Обновление" + "Добыча жемчуга" + "Откройте камеру и наведите ее на код из openclaw qr." + "Нет устройств" + "Пересылка уведомлений" + "Я буду хранить этот разговор отдельно от обычного чата с агентом." + "Сеанс Gateway восстанавливает подключение. Ярлыки агентов вскоре должны автоматически стабилизироваться." + "Попробуйте изменить поисковый запрос или очистить его." + "Разрешить фоновое определение местоположения?" + "Всплываем" + "Начальная настройка" + "%1$s · %2$s · %3$s" + "Отменить голосовое сообщение" + "Прокрутить назад" + "openclaw gateway" + "Gateway сопряжён" + "Линяем" + "Ожидание вашей следующей реплики." + "OpenClaw работает" + "Запись журнала" + "Ошибка: не удалось подключиться к защищенному эндпоинту Gateway для этого хоста." + "Gateway не в сети. Исправьте подключение ниже или скопируйте диагностические данные." + "Ожидание" + "Проверка, проверка, раз, два, три" + "Не удалось выполнить поиск навыков ClawHub." + "Без запроса" + "Фронтальная камера" + "Открыть запись журнала" + "Время ожидания сети истекло" + "Сейчас" + "Переименовать группу…" + "Больше агентов" + "openclaw nodes approve REQUEST_ID" + "Закрепить" + "thread list" + "Открыть %1$s" + "upload" + "Пароль Gateway не настроен" + "Настройки диктовки" + "Модели провайдеров загружены, но сведения о готовности недоступны." + "Удалить ветку?" + "OpenClaw превращает этот телефон в удобный мобильный интерфейс для управления ветками, голосом, провайдерами и Gateway." + "Сначала новые" + "Следующий цикл" + diff --git a/app/src/main/res/values-sv/assistant.xml b/app/src/main/res/values-sv/assistant.xml new file mode 100644 index 0000000..5e18185 --- /dev/null +++ b/app/src/main/res/values-sv/assistant.xml @@ -0,0 +1,7 @@ + + + "fråga OpenClaw %1$s" + "be OpenClaw att %1$s" + "öppna OpenClaw och fråga %1$s" + + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..eab904b --- /dev/null +++ b/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw-nod + Lita på denna gateway? + Lita på och fortsätt + Avbryt + Ny chatt i worktree + Verifiera certifikatets fingeravtryck innan du litar på denna gateway.\n\n%1$s + Gateway-certifikatet har ändrats. Fortsätt bara om du förväntade dig denna ändring.\n\nTidigare SHA-256:\n%1$s\n\nNy SHA-256:\n%2$s + Okänt + VERSION + COMMIT + BYGGD + Version %1$s + Git-commit %1$s + Byggd den %1$s UTC, tidsstämpel %2$s + Byggdatum %1$s + Kopiera fullständig Git-commit-hash + Kopiera hela tidsstämpeln för bygget + Git-commit för OpenClaw + Byggtidsstämpel för OpenClaw + Git-commit kopierad + Byggtidsstämpel kopierad + + "Det gick inte att förbereda en bilaga för att skickas." + "Mikrofon av" + "Visa aviseringar från OpenClaw" + "Trådaktivitet" + "Fullständig" + "Godkännande tillåtet och sparat." + "Visa senaste samtalshistoriken" + "1 väntande" + "Bilaga stöds inte" + "0 = exakt" + "Anslut Gateway för att söka efter trådar." + "%1$s konton" + "Cron-ändringar kräver operator.admin. Konfigurationskoder ger avsiktligt inte den behörigheten. Återanslut med Gateway-enhetens delade token eller lösenord för att begära administratörsåtkomst. Om den här enheten fortfarande saknar behörigheten måste du godkänna den väntande behörighetsuppgraderingen från en befintlig administratörsklient." + "Apply Patch" + "Nyper" + "Slå på högtalarljud" + "Överhoppningar i följd" + "Den här mappen har inga filer än." + "Inte ansluten" + "Granska och hantera status för installerade skills." + "Misslyckades" + "Standardagent" + "Kamera" + "Ta bort från grupp" + "Söker" + "Pausad för röstuppspelning" + "Gateway verifierar exakt den här versionen med ClawHub före nedladdningen. Om versionen kräver ett uttryckligt godkännande av risken visar Android Gateway-varningen innan ett nytt försök görs." + "Installationskoden använder ett IPv6-zon-ID. Använd en IPv6-adress utan omfång eller ett LAN-värdnamn." + "Bilaga" + "Konfigurera aktiveringsord, tal och uppspelning." + "Lyssnar (PTT)" + "Förslaget avvisades." + "Visa sidofält" + "användare" + "%1$s · %2$s" + "Minimal" + "Neka" + "AKTIV AGENT" + "1 schemalagt" + "Inget svar" + "Vald %1$s" + "Kommando argv JSON-array" + "Det gick inte att läsa bilden. Välj en tydlig skärmbild eller bild av QR-koden från openclaw qr." + "Det gick inte att %1$s förslaget från Skill Workshop." + "Besvarad på annat håll" + "Gateway registrerade godkännandet en gång." + "status" + "OpenClaw kontrollerar bara platsen när din parkopplade Gateway begär det. På nästa Android-skärm väljer du %1$s för att tillåta kontroller medan appen körs i bakgrunden." + "avvisa" + "Kontrast" + "Ersätt gateway-konfiguration?" + "Det gick inte att läsa in automatiseringar." + "Du" + "Inbyggd mikrofon" + "Gränssnitt" + "Inga förslag" + "Huvudtråd" + "Öppna chatt" + "Åtgärder för enhetsparkoppling är inte tillgängliga i den här Gateway-sessionen. Kör openclaw devices list på Gateway-värden och hantera begäran där. Godkännande av nodfunktioner är separat och använder fortfarande nodes approve <request id>." + "Åtgärdsbegäran" + "list pins" + "Anslut till en Gateway för att läsa in Skill Workshop-förslag." + "Konfigurationskoden accepterades inte" + "Logga ut" + "Leverantör för realtidstranskribering är inte konfigurerad." + "Visa systemappar" + "Uppdatera din Gateway för att visa leverantörens modellkonfiguration." + "Skickar diktering" + "Granska förslaget för att läsa in dess markdown." + "öppna OpenClaw och fråga %1$s" + "resonemang" + "Klient" + "Tillämpad" + "video" + "Framhävd" + "Online" + "Omfattningar" + "Leverantör för realtidsröst är inte konfigurerad." + "%1$s · %2$s" + "kick" + "Gateway returnerade en ogiltig automatisering." + "Instans-ID" + "Gateway-token krävs. Ange den igen eller redigera den här anslutningen." + "Källa" + "Uppdatera" + "%1$s i kö" + "Starta chatt" + "Anrop till chattverktyg som väntar i den aktiva tråden visas fortfarande här." + "Certifikatet behöver granskas" + "Öppna den aktuella Canvas-ytan för att inspektera eller interagera med den." + "Automatiseringen har uppdaterats." + "Inga senaste sessioner" + "Skript" + "Gateway-status, telefonnodens beredskap och senaste loggström." + "Öppna information om automatiseringen" + "Körmiljö" + "1 arbetare till" + "Agent %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Aktivera %1$s i Android-inställningarna för att fortsätta." + "Reparera" + "reactions" + "Klar" + "Version och uppdatering" + "OpenClaw kommer att visa godkännanden, misslyckade jobb och kanalproblem här." + "USB-mikrofon" + "För många delningar väntar på att läggas till." + "Överhoppad" + "Använd Gateway-datorns LAN-adress eller säkra fjärrvärdnamn." + "På" + "Söker efter trådar" + "Realtidssamtal" + "· %1$s" + "OpenClaw förbereder ett svar." + "Godkännande tillåtet en gång." + "Leverantörskonfiguration" + "ingen" + "Skriptnyttolaster bevaras oförändrade. Använd CLI för att redigera det här skriptet." + "Android-konfigurationsguide" + "%1$s appar blockerade från att vidarebefordra." + "Parkopplade enheter" + ":%1$s" + "%1$s väntande" + "Enhetsnamn" + "Skicka" + "Plats" + "t.ex. America/New_York" + "Sessionsmål" + "Granska ClawHub-skill" + "snapshot" + "Avvisa parkopplingsbegäran från den här enheten?" + "Föredragen mikrofon" + "Nodvärd" + "Nivå" + "Stäng appväljare" + "Klistra in en delad Gateway-token eller en operatörsutfärdad token." + "Alla system fungerar normalt" + "Gateway-diagnostik kopierad" + "Ljudfel" + "Ersätt konfiguration" + "Snabbåtgärder" + "Det gick inte att skicka: Chatten misslyckades innan körningen startade. Försök igen." + "Mikrofon" + "Chatten kontrollerar fortfarande statusen för Gateway." + "Exakt plats" + "Tillåt en gång" + "+%1$s till" + "thread create" + "Blockerad" + "Aktiveringsord eller fras" + "Gateway behöver enhetsgodkännande" + "Extern mikrofon" + "%1$s/%2$s redo" + "Ansluten (operatör offline)" + "Funktionen är inte godkänd" + "Detta tar permanent bort automatiseringen och dess schema från Gateway." + "Läser in bild…" + "Anslut" + "Godkänn nodåtkomst" + "Lägg till Gateway" + "Transkribering är inte tillgänglig: %1$s" + "Bild" + "Svallar" + "Stäng bildförhandsvisning" + "eval" + "Senaste kommando: %1$s" + "Ha en terminal öppen på enheten som kör OpenClaw." + "Inget saknas" + "Canvas-utdata kräver en aktiv Gateway-anslutning." + "%1$s · %2$s" + "Isolerad" + "© 2026 OpenClaw Foundation — MIT-licens." + "PDF" + "Conversations" + "Minneskonsolidering och drömdagbok." + "Create Goal" + "Den här automatiseringen ändrades medan du redigerade. Återställ till den senaste versionen från Gateway innan du sparar." + "När telefonen är ansluten kan Gateway väcka den med en tyst pushnotis i stället för att upprätthålla en session som alltid är aktiv." + "Väckningsläge" + "Ta bort den parkopplade enheten?" + "Systemhändelsetext" + "Det gick inte att kopiera widgetbilden" + "Nej" + "Valfri sökväg" + "Skickar köad röst" + "Inbyggd" + "hide" + "runs" + "Gateway-lösenord krävs. Ange det igen eller redigera den här anslutningen." + "Händelsetext" + "Livediktat" + "Det gick inte att läsa in leverantörens modellkonfiguration." + "%1$s app får vidarebefordra." + "Röstkonfiguration" + "Bifoga video" + "Ytterligare bilder är dolda: %1$s" + "Avvisa parkopplingsbegäran?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Utgivare" + "Förgrena härifrån" + " · Samtal: Talar" + "Valfri åsidosättning" + "Standard" + "Kommandogodkännande" + "Appen och Gateway använder inkompatibla protokollversioner. Uppdatera OpenClaw på båda och försök sedan igen." + "Uppdatera skärm" + "Läs senaste foton och medier" + "Lyssna" + "Ansluten" + "Slutförd" + "Din telefon är parkopplad med %1$s. Fortsätt för att slutföra nodåtkomsten." + "Aktuell tråd" + "Tänkande %1$s" + "Avstängd" + "OpenClaw uppskattar sina partner i open source-communityn." + "öppen" + "Ansluter till Gateway" + "Gatewayen kan ändra denna sökväg men kan inte rensa en befintlig sökväg." + "TTS" + "Sparar…" + "Ankare %1$s" + "search" + "Aktivera" + "Granska och hantera schemalagt Gateway-arbete." + "Ett tidigare svar tillät redan detta kommando en gång." + "generate" + "Använd endast i ett betrott privat nätverk." + "Sök i inställningar" + "Talk är aktivt" + "Gateway-autentisering är inte konfigurerad. Redigera den här anslutningen och försök igen." + "Misslyckades: säker slutpunkt nåddes, men verifieringen av TLS-fingeravtrycket nådde tidsgränsen. Kontrollera Tailscale Serve eller Gateway TLS och försök igen." + "Steg 1" + "Diktering" + "Öppna appväljare" + "Inga väntande godkännanden" + "edit" + "Anslut till din Gateway" + "Ange konfigurationskoden från openclaw qr." + "Diagnostik" + "Andra appar förblir orörda." + "Knäcker" + "Detta tar permanent bort tråden och dess transkription." + "Enheten har godkänts." + "Försöker igen automatiskt" + "Widgetbilden har kopierats" + "%1$s roller" + "1 objekt saknas" + "%1$s schemalagda" + "react" + "Agenter" + "Anslut Gateway för att läsa in automatiseringar." + "Återansluter…" + "Återgå till konfigurationen" + "send" + "Kunde inte testa anslutningen" + "Verifiera och installera" + "Gör den här enheten till en säker OpenClaw-nod för chatt, röst, kamera och enhetsverktyg." + "Manuell konfiguration" + "Öppna Chat för att starta eller återuppta den aktuella tråden." + "Tips: sluta lyssna för att skicka den fångade turen." + "Hoppa över" + "Röstbegäran misslyckades" + "Detta avvisar \"%1$s\" och uppdaterar statusen för Skill Workshop från Gateway." + "Skalar" + "update" + "Dela" + "Kamera aktiverad" + "Telegram, WhatsApp, e-post och andra kanaler visas här efter konfiguration." + "Nätverksfel" + "Utforskar tidvattenpölar" + "Återställ Canvas nu för session=%1$s source=%2$s. Om ett befintligt A2UI-tillstånd finns ska det spelas upp igen omedelbart. Annars ska du skapa och rendera en kompakt mobilanpassad instrumentpanel i Canvas." + "Det gick inte att starta: %1$s" + "Inte begärt" + "Konfigurera en %1$s-leverantör på Gateway" + "kill" + "Godkännanden" + "Filer är inte tillgängliga" + "Markera som oläst" + "Hitta personer och kontaktuppgifter" + "Enhetsidentitet krävs" + "OpenClaw-tråd" + "Tillåt åtkomst till fotobiblioteket." + "Ett tidigare svar behandlade redan detta godkännande." + "Inga senaste trådar" + "Tidsgräns %1$ss" + "Inga träffar" + "Läs aviseringar från valda appar" + "Tillgänglighet okänd" + "Konfigurera Talk" + "Extra" + "Gateway parkopplad. Väntar på operatörsåtkomst." + "Bifoga bild" + "Välj vad som når OpenClaw." + "Förnyat godkännande av funktion väntar" + "Granska markerade objekt" + "Lyssnar..." + "Uppdatera mig" + "Meddelande" + "Läs kontakter" + "Offlinelagringen för bilagor är full; ta först bort köade objekt." + "En gång" + "Byt namn" + "Inga kanaler hittades." + "Visa alla" + "Ny enhet" + "Session Status" + "Öppna bildförhandsvisning" + "Sessionsgrenen ändrades; granska och försök skicka meddelandet igen." + "close" + "Det ser ut som en konfigurationskod. Gå tillbaka och välj Konfigurera Gateway och sedan Använd konfigurationskod." + "✦" + "Agenter och automatisering" + "Tillämpa" + "Automatiseringskörningen hoppades över." + "Fortsätt" + "Övervakar · %1$s schemalagda jobb" + "Bläddra" + "tabs" + "Väntande" + "Samtal: %1$s" + "read" + "Markera text" + "Rörelseaktivitet" + "beskrivning: %1$s" + "Spela upp ljud" + "Tid" + "Overifierad" + "Yield" + "Kopiera godkännandekommando" + "Aktuell skärmutdata och interaktiv appyta." + "Tjänsten ansluten" + "Visning" + "Redo när du är det" + "Det gick inte att läsa in leverantörskatalogen." + "Talar · väntar på svar" + "Inte beviljad" + "Spara ändringar" + "Gateway avvisade automatiseringskörningen." + "Session Send" + "Hitta på ClawHub" + "Tillåter alltid begärda platskontroller medan OpenClaw körs i bakgrunden; Android visar detta i den permanenta nodaviseringen." + "Systemhändelse" + "Anslut Gateway för att visa leverantörer" + "Nästa heartbeat" + "Gateway parkopplad. Väntar på godkännande av nodfunktioner." + "Saltar" + "Stäng Canvas" + "Skriv kontakter" + "Inga installerade skills matchar sökningen." + "Konfiguration av Talk Provider" + "Music Generation" + "Samtalsinställningar" + "Övervakning · 1 tråd" + "Nyttolasttext" + "Ange text" + "Godkännande %1$s" + "Gateway returnerade inte beredskap för %1$s" + "%1$s konfigurerade modeller. Uppdatera för att kontrollera tillgängligheten igen." + "Conversation Send" + "Arbetsyta" + "1 leverantör" + "Gateway-certifikatet kunde inte läsas automatiskt. Klistra in SHA-256-fingeravtrycket som hämtats på Gateway-värden." + "Det gick inte att skicka: %1$s" + "Brygga" + "Leveransfel" + "Använd OpenClaw från din telefon" + "Utseende" + "Skill-workshop" + "Token krävs" + "Förhandsgranskning · %1$s" + "Mikrofonbehörighet krävs" + "Anslut Gateway för att läsa in förslag från Skill Workshop." + "Alla system fungerar" + "Gateway kan inte nås" + "OC" + "Uppdaterad" + "Ansluten (nod offline)" + "Hem" + "Dikteringen lyssnar" + "Inga arkiverade trådar" + "Välj och granska assistenterna som är tillgängliga på denna gateway." + "Pratläge aktivt" + "Arbetar · 1 aktiv körning" + "Godkänn och aktivera" + "Gateway-uppdatering krävs" + "Kopiera bild" + "Gateway-URL" + "main, isolated, current eller session:<id>" + "Media otillgängligt" + "Anslut till din Gateway för att öppna ett skal i agentens arbetsyta." + "%1$s://%2$s:%3$s" + "Det gick inte att läsa in information om godkännandet. Uppdatera och försök igen." + "Jag kan kontrollera Gateway-status, reparera konfiguration, byta modeller eller ansluta kanaler." + "Tool Call" + "Trådar" + "Write" + "Börja med en prompt eller använd rösten." + "D" + "Öppna inställningar" + "Observerar…" + "Avsluta samtal" + "Senaste fel" + "Granska åtgärder som kräver din uppmärksamhet." + "Inaktiverad för alla agenter." + "Starta röstläge" + "Tillbaka till bakgrundsuppgifter" + "En annan cron-åtgärd håller fortfarande på att slutföras." + "Väntetid %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Taligenkänning på enheten är inte tillgänglig." + "Skript · skrivskyddat" + "Bilagorna är för stora för att köas i ett enda meddelande. Ta bort några och försök igen." + "1 konfigurerad modell. Uppdatera för att kontrollera tillgängligheten igen." + "Widgeten är inte tillgänglig" + "En säker anslutning krävs för den här värden." + "Senaste" + "Inga matchande automatiseringar." + "Telefonen kan nå Gateway" + "Gateway" + "Utgången" + "Schemalagt OpenClaw-arbete från din gateway." + "Sub-agent" + "Väntar på enhetsgodkännande" + "Läser in tråd" + "Denna Gateway använder nu ett certifikat som den här enheten litar på." + "Förskjutning ms" + "event create" + "dokument" + "Konfigurera Gateway" + "Spela upp video" + "Sparad autentisering är ogiltig. Autentisera igen eller återställ den här Gateway-anslutningen." + "Vid användning" + "screenshot" + "Spola tillbaka hit" + "Cron-uttryck, t.ex. 0 9 * * *" + "Tillbaka till röst" + "Prata" + "Detaljer" + "%1$s/%2$s online" + "%1$s appar får vidarebefordra." + "Chatt" + "Mikrofonåtkomst krävs." + "Kilar" + "Redigera" + "Tysta timmar" + "Kopiera diagnostik" + "Schemalagd" + "Skapa" + "Upphör om %1$s" + "Avfärda" + "Avvisa förslaget?" + "Talfel (%1$s)" + "Problem" + "Sök i registrets metadata. Gateway verifierar tilliten igen före varje nedladdning." + "Använd en privat LAN-IP-adress för lokal konfiguration eller aktivera Tailscale Serve / exponera en Gateway-URL med wss:// för fjärråtkomst." + "Skickar…" + "Konto %1$s" + "Suggest Task" + "Sök" + "Lyssnar" + "Automatiseringen har inte lästs in." + "En uppdatering för Gateway är tillgänglig. Kör uppdateringen från webbgränssnittet eller CLI när du är redo." + "snart" + "Inga Gateway-godkännanden." + "Värd" + "Lägg till ett aktiveringsord eller en fras per fält. Säg sedan ett av dem före ditt kommando." + "Transkribera och skicka sedan" + "Kör vid" + "Pausa ljud" + "Åtkomst till Gateway-enheten" + "Ingen förhandsvisning" + "Enheter" + "OpenClaw för Android." + "Godkännande av funktion väntar" + "Spara eller återställ dina ändringar innan du kör, aktiverar, inaktiverar, tar bort eller uppdaterar den här automatiseringen." + "Inga automatiseringar ännu." + "Denna skill kräver %1$s konfigurationsåtgärder. Android visar vad som är installerat. Konfigurationsändringar görs via datorn eller CLI." + "%1$s senaste" + "Kanaler" + "Ofasad" + "Aktiv på den här telefonen" + "Kontrollerar nodåtkomst" + "Tidszon" + "Skill Workshop – granska och tillämpa åtgärder" + "Tillåt alltid" + "present" + "Skills som är installerade på gatewayen visas här." + "Koden kan ha gått ut eller genererats för en annan Gateway." + "Behörighet krävs" + "Automatiseringen har en ogiltig konfiguration." + "Tillåtelselista" + "Installation, status och reparation" + "groups" + "Offentlig nyckel" + "Om" + "Ingen QR-kod för konfiguration hittades i bilden. Välj QR-koden som genererades av openclaw qr eller ange konfigurationskoden manuellt." + "permissions" + "Anslut Gateway för att läsa in noder och parkopplade enheter." + "Byt gren" + "Inga Skills" + "Svar spelas upp högt" + "Markera som läst" + "Nodgodkännande väntar" + "wake" + "%1$s förslag" + "Autentiseringen för Gateway behöver åtgärdas." + "Anslutningsdetaljer" + "Millisekunder" + "Taligenkänning" + "Beskrivning" + "Senaste konversationer" + "Din telefon skickar denna information till din Gateway, inte till en server som drivs av OpenClaw. Din Gateway kan inkludera den i förfrågningar till den AI-leverantör du valt." + "Leverans" + "Stäng av högtalare" + "%1$s Körs · %2$s Klart · %3$s Misslyckades" + "Öppnar anslutningen till Gateway" + "Övervakning · %1$s trådar" + "Automatiseringskörningen är slutförd." + "Inga matchande appar." + "Skicka till chatt" + "Automatiseringen har tagits bort." + "Aktivera" + "Senaste körningar" + "Placera QR-koden inom rutan." + "Det gick inte att läsa in godkännanden." + "Jag har godkänt" + "Anslut din Gateway för att läsa in provider-beredskap." + "Inte parkopplad" + "Detta godkännande upphörde att gälla innan det kunde behandlas." + "Observerar om %1$ss — växla till målappen" + "Agentprompt" + "emoji list" + "Återkommande" + "Sök i OpenClaw" + "%1$s väntande" + "Taligenkänning på enheten är inte tillgänglig" + "Ingen app kan dela det här meddelandet" + "Stäng sökning" + "Kommando att bevaka" + "Hälsa" + "Aviseringslyssnare" + "Högtalare avstängd" + "Sök efter trådar" + "OK" + "Det gick inte att öppna konfigurationsguiden." + "fråga OpenClaw %1$s" + "Wait for Agents" + "Adress" + "Schemalagda uppgifter som skapas på Gateway visas här." + "Visar det senaste loggavsnittet." + "Använd konfigurationskod" + "sticker" + "Använd en säker wss:// eller Tailscale Serve Gateway, generera en konfigurationskod med fullständig åtkomst i Control UI eller med openclaw qr, skanna eller klistra sedan in den nedan och återanslut för att aktivera inställningar och uppgraderingar." + "steer" + "Vald" + "Android kan skanna eller klistra in en befintlig konfigurationskod, men denna gateway exponerar ännu inte generering av konfigurationskoder för appen. Generera QR-koden/koden på gateway-värden med openclaw qr och skanna den sedan här eller klistra in konfigurationskoden nedan." + "Canvas-status" + "Åtgärda anslutning" + "Spara bild" + "Nod %1$s" + "Gateway-lösenord krävs" + "Update Plan" + "Ta bort bilaga" + "Automatiseringskörningen misslyckades." + "Leverantörsgränser och kvothälsa." + "Gateway-samtalskatalogen har inte lästs in" + "denna Gateway" + "Inga senaste körningar än." + "Språkmodellen på enheten är inte tillgänglig" + "Instrumentpanelen behöver en ansluten Gateway" + "Matchande förslag visas här efter att agenter skapat återanvändbara skill-utkast." + "Session Search" + "OpenClaw talar" + "Skanna QR" + "Valda appar" + "Återställ ändringar" + "Godkännandekommando kopierat" + "Leveransstatus" + "QR-koden accepterades inte" + "Din central för röstkommandon." + "Testa anslutning" + "OPENCLAW" + "Web Fetch" + "Prompt" + "Godkänn enheten?" + "Anslut till din Gateway för att öppna instrumentpanelen för den här sessionen." + "Ta bort %1$s och dess sparade inloggningsuppgifter från den här telefonen?" + "QR-koden pekar på en osäker fjärr-Gateway. %1$s %2$s" + "Skärmytan är klar" + "Parkoppla Gateway" + "Anslut Gateway för att läsa in kanaler." + "Pausas under annan röstaktivitet." + "Modell" + "Foton" + "Klistra in installationskod" + "OpenClaw talar" + "Ansluter..." + " · Plats: Alltid" + "Meddelanden: %1$s" + "Revar" + "Läs in från Gateway" + "text: %1$s" + "Behöver" + "rename group" + "Klar" + "Dagboken väntar på sin första post." + "Godkänn" + "Livesida" + "Automatiseringen körs redan." + "Ta bort den här automatiseringen efter en lyckad engångskörning." + "Redo för chatt och röst" + "Ansluten (operatör: %1$s)" + "Gateway-parkopplingen är klar. Godkänn den här telefonen som en nod så att OpenClaw kan använda de enhetsfunktioner du aktiverar." + "Svaret avbröts" + "bild" + "%1$s pausade" + "Inga matchande trådar" + "delete" + "Layout: Kompakt" + "channels" + "Beviljad" + "Var %1$s minut" + "1 token" + "%1$s %2$s" + "Installerade appar" + "väntande" + "Förbereder röstmeddelande…" + "Aldrig" + "Delsystem" + "När kommandot avslutas" + "Anslutning" + "Det gick inte att läsa in historiken över automatiseringskörningar." + "Automatiseringens namn" + "Steg 2" + "Diagnostisera" + "Vissa kanalstatuskontroller slutfördes inte." + "pin" + "Kopiera %1$s" + "Parkopplad" + "Det gick inte att spara aktiveringsorden" + "Detta sätter \"%1$s\" i karantän och uppdaterar statusen för Skill Workshop från Gateway." + "Spela in röstmeddelande" + "I kö" + "Besvarad" + "Tillåt kameraverktyg på begäran." + "Problem" + "Röstaktivering" + "Parkopplingsbegäran avvisades." + "%1$s d sedan" + "roles" + "Skills" + "Arkivera" + "Noden är offline. Anslut igen och försök på nytt." + "System" + "Fjärr-IP" + "Ogrupperade" + "Schemadetaljer" + "Telefonfunktioner" + "Inte tillgänglig" + "Instrumentpanel" + "Klistra in token" + "Inga leverantörer" + "SHA-256-fingeravtryck" + "Inga trådar ännu" + "Bluetooth-mikrofon" + "Senaste" + "Byt namn på tråd" + "Behandlingsresultat okänt. Åtgärder förblir inaktiverade tills Gateway-posten är verifierad." + "dialog" + "Lyssna efter aktiveringsord" + "camera snap" + "Förbereder uppspelning…" + "Gateway valde okänd leverantör %1$s" + "delete group" + "Följ Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Anslut Gateway för att läsa in agenter." + "Gå tillbaka" + "Dela meddelande" + "Generera en QR-kod." + "Starta om" + "Högtalare på" + "Ta bort grupp?" + "Saknas" + "Sök förslag" + "stop" + "Säker (TLS)" + "Inga noder eller parkopplade enheter." + "%1$s%% kvar %2$s" + "Konfigurationskoden har gått ut" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DAGBOK" + "notify" + "Den här telefonen förblir vilande tills Gateway behöver den. Då vaknar den, synkroniserar och går tillbaka till viloläge." + "%1$s konfigurerade modeller" + "Licenser" + "Anslut Gateway för att söka efter Skills på ClawHub." + "Skill" + "Gateway-anslutningen ändrades. Starta om OpenClaw för att återansluta." + "Enhets-ID" + "Gateway identifierade inte den aktiva %1$s-leverantören" + "Väntar" + "Aktiveringsorden har sparats" + "Äldsta först" + "Skärm" + "Körs sedan" + "IPv6-zon-ID:n stöds inte. Använd en IPv6-adress utan omfång eller ett LAN-värdnamn." + "Skickat — bekräftar leverans…" + "ljud" + "This gateway connection needs operator.admin to update skills." + "Installationskod" + "Bekräfta Gateway-varningen och installera" + "Uppdatera chatt" + "Intervall" + "Förslagsåtgärder i Skill Workshop kräver behörigheten operator.admin." + "Sessioner" + "Byt namn…" + "Anslut Gateway för att läsa in drömmar." + "Konfiguration" + "Öppna Talk" + "poll" + "Anslut för att läsa in dina agenter" + "role remove" + " · Samtal: Lyssnar" + "ClawHub returnerade ingen installerbar version för %1$s." + "Kommando" + "Detta godkännande avbröts innan det kunde behandlas." + "Mikrofon på · väntar på Gateway" + "Text" + "Visar %1$s av %2$s. Förfina sökningen för att se fler." + "v%1$s tillgänglig" + "%1$s://%2$s" + "%1$s... (OK)" + "Leverantörer och konfigurerade modeller" + "Ansluter…" + "Anslut till en Gateway för att spara aktiveringsord" + "Öppna profil" + "Starta din Gateway." + "Hjälp mig att omvandla det här målet till en praktisk checklista: " + "Rensa sessionssökning" + "Port" + "Ange installationskod" + "Det gick inte att läsa in Gateway-loggar." + "%1$s leverantörer redo" + "Dina agenter är redo" + "Ingen %1$s-leverantör är konfigurerad på Gateway" + "Lyssnar efter ett inlägg" + "Observera" + "Epok-millisekunder (valfritt)" + "Inga konfigurerade modeller. Uppdatera för att kontrollera tillgängligheten igen." + "Inställningar" + "Bakre kamera" + "approve" + "Innan du börjar" + "Det gick inte att läsa in Skills." + "Inaktiverad" + "Väntar fortfarande på godkännande" + "Det gick inte att läsa in bakgrundsuppgifter" + "Kontrollera att OpenClaw kan tala tydligt på den här telefonen." + "Arbetar · %1$s aktiva körningar" + "Arbetskatalog för kommando" + "Gruppnamn" + "Välj från galleri" + "Version %1$s" + "Tillbaka" + "Connect the gateway to update skills." + "Ta bort efter körning" + "Installationskoden pekar på en osäker fjärr-Gateway. %1$s %2$s" + "Computer" + "Gateway frånkopplad." + "Session Settings" + "Anslut Gateway för att börja" + "Säkerhetsmeddelande" + "Annat svar" + "Avfärda varning om delad bild" + "Gateway utvärderade en annan ClawHub-version. Granska denna skill igen innan du installerar." + "Öppna systemåtkomst" + "Slutförd" + "Bilden är inte tillgänglig" + "Aviseringar" + "Tillämpa, avvisa och sätt i karantän kräver scope operator.admin. Återanslut med delad gateway-autentisering eller godkänn en scope-uppgradering till operator.admin för enheten för att aktivera livscykelåtgärder." + "sticker upload" + "Fiskar hummer" + "Messages to recover" + "openclaw devices approve %1$s" + "Läsbar detaljerad Gateway-logg." + "Granska genererade Skills-förslag innan de blir aktiva Skills." + "Medföljande" + "%1$s tillgängliga" + "Väntar på nodgodkännande" + "Gateway väntar" + "Autentisering krävs" + "Noder" + "Håll aktiv" + "OpenClaw svarar" + "Dokumentation" + "%1$s redo" + "Ingen utdata ännu" + "Enhetens språk stöds inte" + "I kö — skickas när anslutningen återupprättas" + "%1$s min sedan" + "Aktuell gren" + "Kontrollerar parkopplingsåtkomst" + "Begränsad Gateway-åtkomst" + "Kör verktyg..." + "Kontrollerar godkännande…" + "Ta foton och spela in klipp med den här telefonen" + "Ansluten och redo" + "Stäng" + "Omvandla ett mål till en konkret checklista." + "Installationskoden har en ogiltig Gateway-URL." + "Aktivera endast åtkomst som du är bekväm med att låta OpenClaw använda medan den här telefonen är ansluten. Du kan ändra detta senare i Android-inställningarna." + "Konto" + "remove" + "Lösenord valfritt" + "Gateway-autentisering behöver granskas. Kontrollera Gateway-inställningarna och försök igen." + "QR-koden använder ett IPv6-zon-ID. Använd en IPv6-adress utan omfång eller ett LAN-värdnamn." + "add" + "Fiskar krill" + "Fungerar" + "Klart på %1$s" + "Argument" + "Installationsalternativ" + "Om %1$s tim" + "Godkännande av Gateway väntar. Kör detta på Gateway-värden:" + "Administratörsåtkomst krävs" + "set groups" + "Fäst modell" + "Rensa sökning" + "Aktiverad för behöriga agenter." + "Ingen aktuell tråd" + "gränser: %1$s" + "Efter %1$s" + "Tillåt schemaläggaren att köra den här automatiseringen." + "%1$s tillämpade" + "Ingen drömdagbok ännu." + "Uppdatera bakgrundsuppgifter" + "Sammanfatta de senaste trådarna och nästa steg." + "Körs på enheten medan OpenClaw är synligt." + "%1$s arbetar" + "%1$s %2$s" + "Rå" + "Körningar" + "Kör nu" + "Namnlös gren" + "Konfigurerad" + "camera list" + "1 tillämpad" + "camera clip" + "Ja" + "Ljudtest" + "Pausad" + "events" + "Arbetskatalog" + "Hoppa till senaste" + "Tillåt alltid" + "Skanna QR- eller konfigurationskod" + "Installing" + "Aktiva noder, parkopplade telefoner och väntande enhetsförfrågningar." + "Ögonblicksbild: %1$s" + "Ett tidigare svar tillät redan detta kommando och sparade valet." + "Väntande förfrågningar" + "Godkänd" + "Arbetsyta" + "Röst" + "Redo att prata" + "Subagents" + "Misslyckades: ingen säker gateway-slutpunkt upptäcktes. Aktivera gateway-TLS eller Tailscale Serve, eller använd en betrodd privat LAN-adress med Okrypterad vald." + "Signaler" + "Sessionsmål" + "Gateway registrerade ett nekande." + "Acceptera" + "Fråga OpenClaw vad som helst" + "Anslut igen för att fortsätta" + "%1$s parkopplade" + "Detta tillämpar \"%1$s\" och uppdaterar statusen för Skill Workshop från Gateway." + "Gateway är offline" + "openclaw devices list" + "Anslutningsstatus för OpenClaw-nod" + "Varningar stannar på den här telefonen." + "OpenClaw kan ta emot valda varningar." + "Öppna skärm" + "Chattåtgärder" + "Tillåt styrning av andra appar?" + "Granskar" + "Skanna eller klistra in en konfigurationskod för att lägga till en till Gateway." + "Svärm" + "TLS tog för lång tid" + "Senaste sessioner" + "Den parkopplade enheten har tagits bort." + "Gateway har parkopplats. Kontrollerar godkännande av nodfunktioner." + "Rörelse" + "Cron-åtgärden misslyckades." + "Kör följande på Gateway-datorn:" + "Sök sessioner" + "Uppdatera loggar" + "Bilden är inte tillgänglig · Tryck för att försöka igen" + "openclaw nodes approve %1$s" + "Röstmeddelande · %1$s" + "Användning" + "Nautilar" + "Kontext %1$s%%" + "Transkribera röstkommandon" + "Stäng av ljud" + "Starta en ny konversation så visas den här." + "Anslutningsproblem" + "Medel" + "Förgrena" + "Aktivera högtalare" + "Systemhändelsetext" + "Sortering: %1$s" + "%1$s väntar" + "Image Generation" + "Röstmeddelande" + "Inget behöver din uppmärksamhet" + "OpenClaw behöver behörigheter för %1$s för att fortsätta." + "Mikrofon på trådbundet headset" + "Sidor" + "Levererat" + "Förfaller" + "Skill-information är inte tillgänglig i aktuell skills-status." + "Välj vad den här telefonen kan dela." + "Den här automatiseringen har redan en köad körning." + "Anslut Gateway för att hantera automatiseringar." + "Det är inte dags för automatiseringen ännu." + "Inga detaljer" + "Godkännandet pågår.\nOpenClaw återansluter automatiskt." + "Anslut din Gateway för att visa leverantörernas beredskap." + "Väntar på parkoppling" + "Starta eller fortsätt en konversation" + "Inga schemalagda jobb" + "Svara OpenClaw…" + "Status" + "OpenClaw-nod · Ansluten" + "Aktiv" + "Visa felsökningsstatus för skärmdelning." + "Inga gränser rapporterade" + "Stäng skanner" + "Var %1$s dag" + "Aktiverad" + "Aktivera och öppna inställningar" + "Online och redo" + "Ask User" + "Chattfel" + "Bläddra framåt" + "%1$s av %2$s" + "Planera arbetet" + "console" + "Försök igen" + "Starta en chatt så visas dina aktiva OpenClaw-konversationer här." + "Det gick inte att läsa in automatiseringen." + "Skal i agentens arbetsyta" + "%1$s aktiva" + "Välj enhetsbehörigheter" + "Senaste varaktighet" + "Standardagent" + "%1$s tim" + "Konversationen är aktiv" + "Det gick inte att installera %1$s från ClawHub." + "Välkommen till OpenClaw" + "Styr andra appar" + "Signalindex" + "Ange hemlighet…" + "%1$s:%2$s" + "be OpenClaw att %1$s" + "Upptäckt" + "Dölj sidofält" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Ljuduppspelning är otillgänglig" + "tillämpa" + "Det gick inte att läsa in användningen." + "Håll noden tillgänglig under aktivt arbete." + "Nästa aktivering" + "%1$s/%2$s" + "Inga senaste loggposter." + "Manuell Gateway" + "Byt namn på grupp" + "Update Goal" + "Leverantörstillgänglighet okänd" + "Leverantörer" + "Ta bort grupp…" + "Nyttolast" + "Samtalslogg" + "Memory Search" + "%1$s leverantörer" + "Telefonkontext och integritet" + "%1$s/%2$s anslutna" + "%1$s %2$s" + "Återställning efter omstart av Gateway pågår fortfarande." + "Om du ersätter konfigurationskoden rensas telefonens sparade konfigurationsuppgifter och enhetstoken innan återanslutning. Telefonens nodfunktioner kan behöva godkännas igen. Fortsätt bara om du avser att parkoppla med en ny konfigurationskod för Gateway." + "Öppna en automatisering för att granska dess konfiguration och körningshistorik. Anslutningar med administratörsbehörighet kan även köra, redigera, aktivera, inaktivera eller ta bort den." + "Kontext --" + "Förslaget sattes i karantän." + "Automatiseringen har pausats." + "OpenClaw för mobilen" + "A2UI reset" + "Gateway är inte tillgänglig" + "Read" + "Denna skill kräver 1 konfigurationsåtgärd. Android visar vad som är installerat. Konfigurationsändringar görs via datorn eller CLI." + "Senaste körning" + "Kameraåtkomst behövs för att skanna QR-koden för konfiguration." + "Det gick inte att uppdatera modellen." + "Bubblar" + "thread reply" + "Radera…" + "Anslut Gateway för att granska automatiseringar." + "SENASTE LOGGAR" + "Läser in senaste körningar…" + "Den här filen kan inte förhandsgranskas. Den kan vara binär eller för stor." + "Kontrollera" + "Läs av den här telefonens plats" + "Skill-nyckel" + "Installerade %1$s." + "Gateways" + "åtgärder: %1$s" + "Vidarebefordringsläge" + "%1$sk" + "Växla trådlayout" + "Ingen TLS-slutpunkt" + "OpenClaw Gateway" + "Konfigurera manuellt" + "Tänker…" + "Gateway-åtkomst behöver granskas" + "1 pausad" + "%1$ss" + "Tillämpa förslaget?" + "Inte nu" + "Ej godkänd" + "Sök appar" + "1 konfigurerad modell" + "Avfärda godkännandemeddelande" + "·" + "Offline" + "Talprovider" + "Godkännande av Gateway pågår. OpenClaw försöker igen automatiskt." + "Max" + "Cron-ändringar kräver åtkomsten operator.admin." + "Tänker" + "screen snapshot" + "Observerade noder: %1$s" + "Inga åtgärder hittades" + "Spara och anslut" + "list" + "Gateway registrerade godkännandet och sparade valet." + "Ange en giltig manuell slutpunkt för att ansluta." + "assistent" + "Skickar till chatten..." + "Spara profil" + "Låst" + "Redigera automatisering" + "Använd samma nätverk eller en säker fjärr-Gateway-URL." + "Ankare" + "Språk" + "Den här appen är äldre än Gateway. Uppdatera OpenClaw på den här enheten och försök sedan igen." + "Alla" + "Gateway-session pågår" + "Väntar på granskning" + "Inga skills installerade." + "Kontrollerar Gateway" + "Förskjutning %1$s" + "Resultatet för %1$s är okänt. Anslut igen, uppdatera Skills och försök sedan igen. Gateway ansluter säkert till en matchande installation som fortfarande pågår." + "Glöm" + "Inga parkopplade Gateways." + "%1$s · %2$s" + "<dold hemlighet>" + "%1$s problem" + "OpenClaw" + "Lyssnar · %1$s i kö" + "Assistentens tal är avstängt" + "Nodåtgärder körs endast när målappen är i förgrunden (validerat via fjärrsökvägen). Globala åtgärder och åtgärder i samma app fungerar här." + "Inga gateways har hittats ännu. Använd manuell konfiguration om identifieringen är blockerad." + "Öppna tråd" + "Arbetar" + "Börja prata..." + "Telefonnod" + "Xhigh" + "Kör på Gateway-värden:" + "Ändringar av skills kräver operator.admin. Anslut igen med en gateway-token som har administratörsbehörighet." + "Anslut Gateway för att granska Skills på ClawHub." + "Applistan stannar på den här telefonen." + "Inaktiv" + "Visas i Androids tillgänglighetsinställningar." + "Smart leverans" + "Avvisa" + "Gateway returnerade statusen \'%1$s\' efter %2$s." + "Gateway-token är inte konfigurerad" + "Not available to this agent" + "Filer" + "Behörigheter" + "Det gick inte att starta kameran. Välj en QR-bild från galleriet eller ange konfigurationskoden manuellt." + "Tryck för att kopiera" + "Väntar %1$sm" + "%1$s." + "Anslut Gateway för att installera Skills från ClawHub." + "Sök röst" + " · Mikrofon: Lyssnar" + "Återanslut med operator.admin-åtkomst för att granska och ändra Gateway-inställningar." + "Läs in fler" + "Observera om 3s" + "run" + "Genererar röst…" + "← Tillbaka" + "Koppla från" + "Kör godkännandekommandot på Gateway-datorn och kontrollera sedan igen." + "Automatiseringar" + "%1$s min" + "Lita på" + "QR-koden är inte en konfigurationskod för OpenClaw. Generera en ny kod med openclaw qr och försök igen." + "Den föredragna mikrofonen är inte tillgänglig. Automatisk dirigering används." + "Avvisad" + "Inkludera Android och bakgrundspaket." + "Din Gateway är redo." + "Aktiverad" + "Structured Output" + "Det här tar längre tid än väntat.\nKontrollera att Gateway körs och går att nå." + "Inga bakgrundsuppgifter för den här agenten." + "Återansluter" + "OpenClaw kontrollerar åtkomst till gateway och nod." + "Code Execution" + "Ingen provider-användning" + "Granska" + "Mikrofonbehörighet krävs." + "%1$s d" + "%1$s tillgängliga" + "OpenClaw synkroniserar igen" + "Händelseströmmen avbröts; försök att uppdatera." + "Det gick inte att läsa in noder och enheter." + "Anslut gatewayen för att läsa in skills." + "okänd" + "Utdata" + "Samtal misslyckades: realtidsleverantören stängdes oväntat." + "OpenClaw Tidskänslig" + "ban" + "Gateway-token krävs" + "Parkopplad enhet" + "Behöver godkännas igen" + "Inte schemalagd" + "Kontakter" + "Din telefon förblir tyst tills den behövs" + "Lyssnar · skickar köad röst" + "Det gick inte att läsa in uppgiftsdetaljer" + "Agentmeddelande" + "Gateway kräver den här enhetsidentiteten. Autentisera igen eller återställ den här Gateway-anslutningen." + "Nästa session" + "Anslutningssäkerhet" + "Hoppa över för nu" + "Webbplats" + "Anslut Gateway för att läsa in godkännandeförfrågningar i appen." + "%1$s kopierad" + "Inga appar har valts. Ingenting vidarebefordras förrän du lägger till appar." + "%1$s %2$s" + "Kräver konfiguration" + "Ej parkopplad" + "Gateway tog emot den här telefonen" + "Inga konfigurerade modeller" + "Inaktivera" + "Appspråk" + "Parkopplar Gateway" + "Sparad autentisering är ogiltig" + "%1$s behörighetsområden" + "Anslut Gateway för att läsa in senaste loggar." + "Spara aktiveringsord" + "Hantera installerade skills och lägg till betrodda versioner från ClawHub." + "Skickar…" + "Inga agenter inlästa ännu." + "Sök i ClawHub" + "Chatten kontrollerar Gateway-statusen." + "Parkoppling krävs" + "Aktiva körningar" + "Misslyckades — %1$s" + "Anslutning mellan den här telefonen och OpenClaw." + "summarize" + "Widgetbilden har sparats i Hämtade filer" + "Startar…" + "%1$s token" + "Klientfel" + "Verifiera enheten som skickar begäran innan du beviljar åtkomst." + "Bluetooth LE-mikrofon" + "%1$s %2$s" + "Automatiseringen har aktiverats." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Arkiverad" + "Läs in igen" + "Sök efter automationer" + "Länkade telefoner och nodvärdar visas här efter parkoppling." + "%1$s: %2$s" + "Den här automatiseringen har ändrats på Gateway. Granska den senaste versionen innan du sparar igen." + "Stoppa diktering" + "Läsvänlig" + "Meddela OpenClaw" + "Gateway-lösenordet är ogiltigt. Ange det igen eller återställ den här Gateway-anslutningen." + "Anslut igen" + "ISO-tid, t.ex. 2026-07-09T09:30:00Z" + "%1$s verktyg" + "Ett tidigare svar nekade redan detta godkännande." + "Länkat" + "Öppna %1$s" + "%1$s/%2$s" + "Automatiseringskörningen slutfördes med okänd status." + "Offlinekön är full (%1$s meddelanden). Ta först bort köade objekt." + "Offentliga Gateway-adresser kräver wss:// eller Tailscale Serve. ws:// tillåts för localhost, .local-värdar, Android-emulatorn och privata LAN-IP-adresser." + "Anslut gatewayen för att läsa in skill-information." + "Fullständig åtkomst krävs" + "Aktiveringslyssnare" + "Utöka länkförhandsgranskning" + "Rensa trådsökningen" + "NULL (MISSLYCKADES)" + "Uppdatera" + "Administratör" + "Kräver åtgärd" + "Parkoppla den här enheten med din Gateway för att endast väcka den vid faktiskt arbete, ha en aktuell agentöversikt nära till hands och undvika batterikrävande bakgrundsloopar." + "Roller" + "Svara" + "Leverantörskatalog" + "Aktivera behörighet i Inställningar" + "A2UI push" + "Kontrollera åtkomst" + "Om Gateway kan nås bör återanslutningen slutföras utan åtgärd." + "Agenttur" + "sätt i karantän" + "Åtgärd krävs" + "Söker…" + "Var hittar jag en konfigurationskod?" + "Det gick inte att aktivera skill." + "pdf" + "Ta bort" + "%1$s%% online" + "Inga kanaler" + "Röst i realtid" + "Skill Workshop – avvisa och sätt i karantän" + "Noder och enheter" + "Lokal kommandocentral" + "emoji upload" + "Läser in förhandsgranskning…" + "Hög" + "focus" + "describe" + "%1$s kontext" + "Lyssnar efter svar..." + "voice" + "Ansluten till %1$s" + "role add" + "Chatten behöver uppmärksamhet" + "Aktivera mikrofon" + "OpenClaw samlar in och skickar namn, paket-ID:n och status för appar som är synliga på den här telefonen när din parkopplade OpenClaw Gateway begär det. Detta gör att din assistent kan svara på frågor och utföra åtgärder med installerade appar." + "Gateway är inte ansluten" + "Policy" + "Tidsgränsen för att bekräfta det skickade meddelandet överskreds; uppdatera för att kontrollera leveransen." + "Stödfiler" + "Uttryck" + "Bakgrundsuppgifter" + "Dröm" + "Inga appar har blockerats. Appar kan vidarebefordra tills du lägger till blockeringar." + "Taligenkänning är inte tillgänglig" + "Plattform" + "Gateway returnerade inte konfiguration för %1$s" + "Glöm Gateway?" + "Valfri beskrivning" + "Öppna %1$s" + "Hemarbetsyta" + "Drömmer" + "%1$s till %2$s" + "Dela fil" + "Realtid" + "API" + "OpenClaw arbetar…" + "Prata eller diktera med OpenClaw" + "Dela information om installerade appar?" + "Läser in automatisering…" + "Ta bort automatisering" + "Standardassistent" + "Välj en %1$s-leverantör som stöds på Gateway" + "Inte tillgänglig" + "Tom mapp" + "Öppna inställningar" + "Av" + "Typografi" + "Stoppa" + "Inga matchande trådar än." + "Parkopplingen med Gateway lyckades.\nGodkänn den här telefonens nodfunktioner via ett operatörsgränssnitt." + "Denna skill är installerad men kan för närvarande inte köras. Använd datorn eller CLI för konfigurationsändringar." + "Igenkännaren är upptagen" + "Gateway för hemmet" + "Kör godkännandekommandot på Gateway" + "Tjänsten inaktiverad" + "Det gick inte att läsa in förslag från Skill Workshop." + "Uppdatera mig om mina senaste OpenClaw-trådar och föreslå nästa steg." + "Inte nu" + "openclaw qr" + "start" + "OpenClaw-nod · Samtal" + "Läs och uppdatera händelser" + "Samtal misslyckades: realtidsleverantören stängdes: %1$s" + "Anslut Gateway för att bläddra bland filerna i arbetsytan." + "%1$s via Gateway-relä" + "Kunde inte läsa in Gateway-samtalskatalogen" + "Övervakar · 1 schemalagt jobb" + "Var %1$s timme" + "Skärmyta" + "OpenClaw-översättningar · %1$s" + "Kommandobegäran" + "Uppdaterad" + "Kanal" + "Slå på ljud" + "Ny grupp…" + "Förbereder ljud…" + "Adaptiv" + "Snart" + "%1$s arbetare till" + "Web Search" + "Prova Chatt, Röst, Trådar, Leverantörer eller Inställningar." + "OpenClaw Aktiv" + "navigate" + "begärdes %1$s" + "Anslut Gateway för att granska historiken över automatiseringskörningar." + "Enhetsåtkomst; aktivt godkännande krävs fortfarande i Gateway" + "Avbruten" + "Ange en giltig installationskod eller gateway-adress." + "Modeller" + "OpenClaw Passiv" + "Gateway-lösenordet är ogiltigt" + "Det gick inte att verifiera ändringen av enhetsparkopplingen. Uppdatera och försök igen." + "Visa detaljer" + "Bash" + "Token" + "Den anslutna OpenClaw-agenten kan använda enhetsfunktioner som du aktiverar. Fortsätt bara om du litar på den Gateway och agent som du ansluter till." + "Havstulpaninsamling" + "Vald eller fullständig fotoåtkomst har beviljats." + "Tillgänglighetsexekverare" + "%1$s objekt saknas" + "Dölj planens checklista" + "Nodgodkännande krävs" + "Anslut Gateway" + "... +%1$s till" + "Visa planens checklista" + "Webbläsare" + "screen record" + "Körning väntar" + "Att aktivera låter OpenClaw observera och styra andra appars skärmar när det är aktiverat. Androids tillgänglighetsåtkomst krävs." + "Ursprung" + "Personlig AI på dina enheter" + "Attach" + "Automatiskt" + "Översikt" + "Det gick inte att begära återställning. Tryck för att försöka igen." + "Video" + "%1$s\n\n" + "Okrypterad" + "Kalender" + "Gateway-statusen är inte OK; det går inte att skicka" + "📎 %1$s" + "Senaste status" + "Vänta tills det aktuella svaret är klart innan du startar en ny chatt." + "Profil" + "Leverantörsgränser visas här när din Gateway rapporterar dem." + "1 problem" + "Trådar i \"%1$s\" behålls och flyttas tillbaka till Ogrupperade." + "Rekommenderas" + "Skapad" + "%1$s/%2$s aktiva token" + "Inget åtgärdsresultat" + "Knäppande" + "%1$s…" + "Öppna Skill-information" + "Det gick inte att spela upp tal: %1$s" + "Starta Talk" + "Det gick inte att läsa in den här mappen." + "QR-koden innehöll ingen giltig installationskod." + "Granska nodåtkomst" + "Lägg till aktiveringsfras" + "Kan inte nå gateway" + "Automatisering" + "Behöver anslutning" + "Det gick inte att behandla godkännandet. Uppdatera och försök igen." + "import" + "Hur den här telefonen visas för OpenClaw." + "Fokusera trådsökningen" + "Anslut Gateway" + "Läs kalender" + "Översikten uppdateras vid återanslutning och när den här skärmen öppnas." + "Det gick inte att inaktivera skill." + "Ansluter fortfarande" + "Om %1$s min" + "Läs SMS" + "Anslut Gateway för att läsa in användning." + "Vad kan du hjälpa mig att göra från den här telefonen just nu?" + "Behöver godkännande" + "Ny chatt" + "Anslut Gateway för att uppdatera förslag i Skill Workshop." + "OpenClaw-begäran misslyckades." + "Behörighet krävs" + "Granska leverantörernas beredskap\noch konfigurerade modeller." + "Läser in" + "Felavisering" + "Tema och översatt Android-text." + "Mikrofon av · skickar…" + "Ingen" + "Visa" + "Namn" + "Version" + "Cron" + "Anslut den här telefonen till en Gateway innan du öppnar OpenClaw." + "Ta bort aktiveringsfras" + "Konfigurationskoden accepterades inte. Generera en ny kod med openclaw qr." + "14 meddelanden · Android" + "Transkriberingen misslyckades: %1$s" + "Alltid" + "Det gick inte att läsa in drömmar." + "Automatiseringskörningen har köats." + "Conversation Turn" + "Automatiseringen har startats." + "Ny grupp" + "Serverfel" + "Video Generation" + "Godkännande av Gateway väntar. Kör openclaw devices list på Gateway-värden, godkänn den här telefonen och försök sedan igen." + "Poster visas efter att en drömcykel har skrivit en berättande sammanfattning." + "%1$s ms" + "Minneslager" + "Assistenten arbetar" + "OpenClaw kan lista appar som visas i startprogrammet." + "Det gick inte att prata: %1$s" + "Sök bland installerade skills" + "Granska" + "Process" + "Senaste trådarna" + "Terminal" + "Aktuell" + "1 konto" + "Pausad" + "Tillåt kamera" + "Begäranden om Exec-godkännande visas här medan den här telefonen är ansluten." + " · Mikrofon: Väntar" + "Kopiera" + "Detaljer kopierade" + "Ta bort" + "Be OpenClaw att använda Android-funktioner." + "member" + "Kontrollerar om den här Gateway stöder OpenClaw-inställningsassistenten." + "Använd återställningsalternativen nedan för att återansluta." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Det gick inte att läsa in kanaler." + "Om %1$s d" + "Fel i följd" + "Det gick inte att läsa någon QR-kod från bilden. Välj en tydligare bild eller ange konfigurationskoden manuellt." + "Gateway är äldre än den här appen. Uppdatera OpenClaw på Gateway-värden och försök sedan igen." + "Anslut före chatt, röst och livestatus." + "Återanslut Gateway" + "Tredjepart" + "Granska beredskap" + "Begränsad" + "OpenClaw-logotyp" + "Lossa modell" + "Meddelandeytor anslutna till denna Gateway." + "Skickar" + "Arkiverade trådar visas här." + "Kommando kopierat" + "Ingen förhandsgranskning tillgänglig" + "Godkänn den här telefonen på Gateway.\nFörsök sedan ansluta igen." + "Skanna QR-kod" + "Arbetskatalog för kommando · kan inte rensas" + "Trådaktivitet" + "Tillgänglig" + "Ta bort automatiseringen?" + "%1$s idag · %2$s totalt" + "Lösenord" + "Sätta förslaget i karantän?" + "Inga licensmeddelanden ingår i denna build." + "Det gick inte att spara widgetbilden" + "Väntar %1$s" + "Talar…" + "Leverantörer och modeller" + "Nod" + "%1$s " + "Prompten är inte tillgänglig" + "Loggar" + "Anslut Gateway för att granska förslag från Skill Workshop." + "Verktyg" + "Gateway-reglage" + "Skicka SMS" + "OpenClaw är redo att fortsätta i din vanliga chatt." + "Inga kommandon hittades" + "Ingen canvasuppdatering ännu. Tryck för att försöka igen." + "Terminalen behöver en ansluten Gateway" + "Exec" + "Appfilter" + "Huvudagent" + "%1$sk" + "Gateway krävs" + "Åtkomst" + "Paket: snapshot=%1$s foreground=%2$s" + "Försök ansluta igen" + "Cron-schemaläggaren har stoppats." + "Öppna" + "Meddelandet kopierat" + "Det gick inte att nå din Gateway.\nVi löser det här." + "nu" + "Ta bort efter körning" + "Vald på den här telefonen" + "unpin" + "Session History" + "Lossa" + "Använd den här telefonen" + "Det gick inte att läsa in ClawHub-information för %1$s." + "Verktyg körs" + "Dela exakt plats när platsåtkomst är aktiverad." + "Mobile UI" + "Tema" + "Gateway visar fortfarande detta godkännande som väntande. Granska det innan du försöker igen." + "Slutför röstmeddelande" + "Diktering: %1$s" + "Inte tillåtet" + "Välj en annan bild" + "Förhandsgranskning av bild" + "OpenClaw lyssnar bara när du startar Samtal eller Diktering." + "Dela steg och aktivitet" + "Kräver konfiguration" + "Uppdatera den här Gateway för att använda OpenClaw-inställningsassistenten." + "Den här Gateway-anslutningen behöver operator.admin för att installera Skills från ClawHub." + "Förslaget har tillämpats." + "%1$s väntande" + "%1$s tim sedan" + "Läs samtalshistorik" + "%1$s i kö · väntar på gateway" + "Flytta till grupp" + "Skanna QR-kod för att parkoppla" + "Godkännande nekat." + "Det gick inte att granska förslaget från Skill Workshop." + "Fäst" + "Profil och enhet" + "Stäng väljaren för tänkenivå" + "Det gick inte att köa meddelandet för senare leverans." + "Karantän" + "Schema · %1$s" + "Det gick inte att uppdatera tankenivån." + "Öppna väljaren för tänkenivå" + "Tidsgränsen för röstsvar överskreds; försöker igen med den köade turen" + "Layout: Detaljerad" + "Den här bilden kunde inte avkodas." + "Gateway, röst, aviseringar, integritet" + "Filer i agentens arbetsyta" + "Den här enheten förlorar sin betrodda åtkomst till Gateway." + "Använd requestId från det väntande kommandot i godkännandekommandot." + "Schema" + "Hastighetsgräns" + "Inte levererat" + "Nyttolast · %1$s" + "Körs" + "Klor" + "Avsluta" + "Använd systemets förtroende" + "Inga leverantörer redo" + "Prioriterar anslutna Bluetooth-mikrofoner." + "%1$s app blockerad från att vidarebefordra." + "Meddelandeåtgärder" + "Typ" + "Avarkivera" + "Transcripts" + "Aktiveringsord" + "Konfigurera %1$s på Gateway" + "Skanna en QR-kod eller använd installationskoden från din OpenClaw Gateway." + "Prototyp för designsystem" + "Sållar" + " · Samtal: På" + "Inga användningsdata ännu." + "Chatten misslyckades innan körningen startade. Försök igen." + "Skicka" + "Vissa delade bilder utelämnades eller kunde inte läggas till." + "Skriv kalender" + "timeout" + "Låg" + "Blockeringslista" + "act" + "Dismiss Task" + "Chatten misslyckades" + "OpenClaw · Live" + "Installerad" + "Tidsgränsen för att vänta på ett svar överskreds; försök igen eller uppdatera." + "Hitta tidigare konversationer" + "Bläddra bland trådar" + "Uppdaterar" + "Fiskar pärlor" + "Öppna kameran och rama in koden från openclaw qr." + "Inga enheter" + "Vidarebefordra aviseringar" + "Jag håller den här konversationen skild från vanlig agentchatt." + "Gateway-sessionen ansluter igen. Agentgenvägarna bör stabiliseras automatiskt om en stund." + "Prova en annan sökning eller rensa den aktuella sökfrågan." + "Tillåt platsåtkomst i bakgrunden?" + "Går upp till ytan" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Avbryt röstmeddelande" + "Bläddra bakåt" + "openclaw gateway" + "Gateway parkopplad" + "Ömsar" + "Lyssnar efter din nästa tur." + "OpenClaw arbetar" + "Loggpost" + "Misslyckades: kunde inte nå den säkra Gateway-slutpunkten för den här värden." + "Gateway är offline. Åtgärda anslutningen nedan eller kopiera diagnostiken." + "Vänteläge" + "Testar testar 1 2 3" + "Det gick inte att söka efter Skills på ClawHub." + "Ingen prompt" + "Främre kamera" + "Öppna loggpost" + "Tidsgränsen för nätverket överskreds" + "Nu" + "Byt namn på grupp…" + "Fler agenter" + "openclaw nodes approve REQUEST_ID" + "Fäst" + "thread list" + "Öppna %1$s" + "upload" + "Gateway-lösenord är inte konfigurerat" + "Dikteringsinställningar" + "Leverantörsmodellerna har lästs in, men beredskapsstatusen är inte tillgänglig." + "Ta bort tråden?" + "OpenClaw förvandlar den här telefonen till en ren mobil kommandoyta för trådar, röst, leverantörer och Gateway." + "Nyaste först" + "Nästa cykel" + diff --git a/app/src/main/res/values-th/assistant.xml b/app/src/main/res/values-th/assistant.xml new file mode 100644 index 0000000..254ac34 --- /dev/null +++ b/app/src/main/res/values-th/assistant.xml @@ -0,0 +1,7 @@ + + + "ถาม OpenClaw %1$s" + "บอก OpenClaw ให้ %1$s" + "เปิด OpenClaw แล้วถาม %1$s" + + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..c285541 --- /dev/null +++ b/app/src/main/res/values-th/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + เชื่อถือเกตเวย์นี้หรือไม่? + เชื่อถือและดำเนินการต่อ + ยกเลิก + แชทใหม่ใน worktree + ตรวจสอบลายนิ้วมือของใบรับรองก่อนเชื่อถือเกตเวย์นี้\n\n%1$s + ใบรับรองของเกตเวย์มีการเปลี่ยนแปลง ดำเนินการต่อเมื่อคุณคาดว่าจะมีการเปลี่ยนแปลงนี้เท่านั้น\n\nSHA-256 เดิม:\n%1$s\n\nSHA-256 ใหม่:\n%2$s + ไม่ทราบ + เวอร์ชัน + คอมมิต + สร้างเมื่อ + เวอร์ชัน %1$s + คอมมิต Git %1$s + สร้างเมื่อ %1$s UTC, การประทับเวลา %2$s + วันที่สร้าง %1$s + คัดลอกแฮชแบบเต็มของคอมมิต Git + คัดลอกการประทับเวลาแบบเต็มของบิลด์ + คอมมิต Git ของ OpenClaw + การประทับเวลาของบิลด์ OpenClaw + คัดลอกคอมมิต Git แล้ว + คัดลอกการประทับเวลาของบิลด์แล้ว + + "ไม่สามารถเตรียมไฟล์แนบสำหรับส่งได้" + "ปิดไมค์" + "แสดงการแจ้งเตือนจาก OpenClaw" + "กิจกรรมของเธรด" + "เต็มรูปแบบ" + "อนุมัติและบันทึกแล้ว" + "แสดงประวัติการโทรล่าสุด" + "รอดำเนินการ 1 รายการ" + "ไฟล์แนบที่ไม่รองรับ" + "0 = แม่นยำ" + "เชื่อมต่อ Gateway เพื่อค้นหาเธรด" + "%1$s บัญชี" + "การเปลี่ยนแปลง Cron ต้องใช้ operator.admin โดยรหัสตั้งใจจะไม่ให้สิทธิ์นี้ โปรดเชื่อมต่อกับโทเค็นที่ใช้ร่วมกันหรือรหัสผ่านของ Gateway อีกครั้งเพื่อขอสิทธิ์ผู้ดูแลระบบ หากอุปกรณ์นี้ยังไม่มีสิทธิ์ดังกล่าว ให้อนุมัติการอัปเกรดขอบเขตที่รอดำเนินการจากไคลเอนต์ผู้ดูแลระบบที่มีอยู่" + "Apply Patch" + "กำลังหนีบ" + "เปิดเสียงลำโพง" + "การข้ามต่อเนื่อง" + "โฟลเดอร์นี้ยังไม่มีไฟล์" + "ไม่ได้เชื่อมต่อ" + "ตรวจสอบและจัดการสถานะของ Skill ที่ติดตั้งแล้ว" + "ล้มเหลว" + "Agent เริ่มต้น" + "กล้อง" + "นำออกจากกลุ่ม" + "กำลังค้นหา" + "หยุดชั่วคราวเพื่อเล่นเสียง" + "Gateway จะตรวจสอบรีลีสนี้กับ ClawHub ก่อนดาวน์โหลด หากรีลีสต้องมีการยอมรับความเสี่ยงอย่างชัดแจ้ง Android จะแสดงคำเตือนจาก Gateway ก่อนลองอีกครั้ง" + "รหัสการตั้งค่าใช้ IPv6 zone ID โปรดใช้ที่อยู่ IPv6 แบบไม่มีขอบเขตหรือชื่อโฮสต์ LAN" + "ไฟล์แนบ" + "กำหนดค่าคำปลุก การพูด และการเล่นเสียง" + "กำลังฟัง (PTT)" + "ปฏิเสธข้อเสนอแล้ว" + "แสดงแถบด้านข้าง" + "ผู้ใช้" + "%1$s · %2$s" + "ต่ำสุด" + "ปฏิเสธ" + "เอเจนต์ที่ใช้งานอยู่" + "กำหนดเวลาไว้ 1 งาน" + "ไม่มีการตอบกลับ" + "เลือก %1$s แล้ว" + "อาร์เรย์ JSON ของ argv คำสั่ง" + "ไม่สามารถอ่านรูปภาพนั้นได้ โปรดเลือกภาพหน้าจอหรือรูปภาพคิวอาร์จาก openclaw qr ที่ชัดเจน" + "ไม่สามารถ %1$s ข้อเสนอ Skill Workshop ได้" + "ตอบจากที่อื่นแล้ว" + "Gateway บันทึกการอนุมัติหนึ่งครั้ง" + "status" + "OpenClaw จะตรวจสอบตำแหน่งเมื่อ Gateway ที่จับคู่ไว้ร้องขอเท่านั้น ในหน้าจอ Android ถัดไป ให้เลือก %1$s เพื่ออนุญาตให้ตรวจสอบขณะที่แอปทำงานอยู่เบื้องหลัง" + "ปฏิเสธ" + "คอนทราสต์" + "แทนที่การตั้งค่า Gateway หรือไม่?" + "ไม่สามารถโหลดระบบอัตโนมัติได้" + "คุณ" + "ไมโครโฟนในตัว" + "พื้นผิว" + "ไม่มีข้อเสนอ" + "เธรดหลัก" + "เปิดแชท" + "การดำเนินการจับคู่อุปกรณ์ไม่พร้อมใช้งานในเซสชัน Gateway นี้ ให้เรียกใช้ openclaw devices list บนโฮสต์ Gateway และจัดการคำขอที่นั่น การอนุมัติความสามารถของโหนดเป็นคนละส่วนกันและยังคงใช้ nodes approve <request id>" + "คำขอดำเนินการ" + "list pins" + "เชื่อมต่อกับ Gateway เพื่อโหลดข้อเสนอ Skill Workshop" + "ไม่ยอมรับรหัสตั้งค่า" + "ออกจากระบบ" + "ยังไม่ได้กำหนดค่าผู้ให้บริการถอดเสียงแบบเรียลไทม์" + "แสดงแอประบบ" + "อัปเดต Gateway เพื่อดูการกำหนดค่าโมเดลของผู้ให้บริการ" + "กำลังส่งคำป้อนตามคำบอก" + "ตรวจสอบข้อเสนอนี้เพื่อโหลด Markdown" + "เปิด OpenClaw แล้วถาม %1$s" + "การให้เหตุผล" + "ไคลเอนต์" + "ใช้งานแล้ว" + "วิดีโอ" + "เลื่อนระดับแล้ว" + "ออนไลน์" + "ขอบเขต" + "ยังไม่ได้กำหนดค่าผู้ให้บริการเสียงแบบเรียลไทม์" + "%1$s · %2$s" + "kick" + "Gateway ส่งคืนระบบอัตโนมัติที่ไม่ถูกต้อง" + "รหัสอินสแตนซ์" + "จำเป็นต้องใช้โทเค็น Gateway ป้อนอีกครั้งหรือแก้ไขการเชื่อมต่อนี้" + "แหล่งที่มา" + "รีเฟรช" + "อยู่ในคิว %1$s รายการ" + "เริ่มแชท" + "การเรียกใช้เครื่องมือแชทที่รอดำเนินการในเธรดที่ใช้งานอยู่จะยังคงแสดงที่นี่" + "ต้องตรวจสอบใบรับรอง" + "เปิดพื้นผิว Canvas ปัจจุบันเพื่อตรวจสอบหรือโต้ตอบกับมัน" + "อัปเดตระบบอัตโนมัติแล้ว" + "ไม่มีเซสชันล่าสุด" + "สคริปต์" + "สถานะ Gateway, ความพร้อมของโหนดโทรศัพท์ และสตรีมบันทึกล่าสุด" + "เปิดรายละเอียดการทำงานอัตโนมัติ" + "รันไทม์" + "worker อีก 1 รายการ" + "เอเจนต์ %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "โปรดเปิดใช้ %1$s ในการตั้งค่า Android เพื่อดำเนินการต่อ" + "ซ่อมแซม" + "reactions" + "เสร็จสิ้น" + "เวอร์ชันและการอัปเดต" + "OpenClaw จะแสดงการอนุมัติ งานที่ล้มเหลว และปัญหาของช่องทางไว้ที่นี่" + "ไมโครโฟน USB" + "มีการแชร์รอเพิ่มมากเกินไป" + "ข้ามแล้ว" + "ใช้ที่อยู่ LAN ของคอมพิวเตอร์ Gateway หรือชื่อโฮสต์ระยะไกลที่ปลอดภัย" + "เปิด" + "กำลังค้นหาเธรด" + "การพูดคุยแบบเรียลไทม์" + "· %1$s" + "OpenClaw กำลังเตรียมคำตอบ" + "อนุมัติหนึ่งครั้ง" + "ตั้งค่าผู้ให้บริการ" + "ไม่มี" + "เพย์โหลดของสคริปต์จะถูกเก็บไว้โดยไม่มีการเปลี่ยนแปลง ใช้ CLI เพื่อแก้ไขสคริปต์นี้" + "คู่มือการตั้งค่า Android" + "บล็อกไม่ให้ส่งต่อ %1$s แอป" + "อุปกรณ์ที่จับคู่แล้ว" + ":%1$s" + "รอดำเนินการ %1$s รายการ" + "ชื่ออุปกรณ์" + "ส่ง" + "ตำแหน่งที่ตั้ง" + "เช่น America/New_York" + "เป้าหมายเซสชัน" + "ตรวจสอบ Skill จาก ClawHub" + "snapshot" + "ปฏิเสธคำขอจับคู่จากอุปกรณ์นี้หรือไม่?" + "ไมโครโฟนที่ต้องการ" + "โฮสต์โหนด" + "ระดับ" + "ปิดตัวเลือกแอป" + "วางโทเค็น Gateway ที่แชร์หรือโทเค็นที่ออกโดยผู้ดำเนินการ" + "ทุกระบบทำงานปกติ" + "คัดลอกการวินิจฉัย Gateway แล้ว" + "ข้อผิดพลาดเกี่ยวกับเสียง" + "แทนที่การตั้งค่า" + "การดำเนินการด่วน" + "ส่งไม่สำเร็จ: การแชทล้มเหลวก่อนเริ่มทำงาน โปรดลองอีกครั้ง" + "ไมโครโฟน" + "แชตยังคงตรวจสอบสถานะของ Gateway" + "ตำแหน่งที่ตั้งที่แม่นยำ" + "อนุญาตครั้งเดียว" + "+%1$s เพิ่มเติม" + "thread create" + "ถูกบล็อก" + "คำหรือวลีปลุก" + "Gateway ต้องได้รับการอนุมัติอุปกรณ์" + "ไมโครโฟนภายนอก" + "พร้อมใช้งาน %1$s/%2$s" + "เชื่อมต่อแล้ว (ผู้ควบคุมออฟไลน์)" + "ความสามารถยังไม่ได้รับการอนุมัติ" + "การดำเนินการนี้จะลบระบบอัตโนมัติและกำหนดการของระบบออกจาก Gateway อย่างถาวร" + "กำลังโหลดรูปภาพ…" + "เชื่อมต่อ" + "อนุมัติการเข้าถึงโหนด" + "เพิ่ม Gateway" + "ไม่สามารถถอดเสียงได้: %1$s" + "รูปภาพ" + "กำลังขึ้นลงตามน้ำ" + "ปิดตัวอย่างรูปภาพ" + "eval" + "คำสั่งล่าสุด: %1$s" + "เปิดเทอร์มินัลไว้บนอุปกรณ์ที่กำลังรัน OpenClaw" + "ไม่มีรายการที่ขาดหาย" + "เอาต์พุตแคนวาสต้องใช้การเชื่อมต่อ Gateway ที่ใช้งานอยู่" + "%1$s · %2$s" + "แยก" + "© 2026 OpenClaw Foundation — ใบอนุญาต MIT" + "PDF" + "Conversations" + "การรวบรวมหน่วยความจำและไดอารีความฝัน" + "Create Goal" + "ระบบอัตโนมัตินี้มีการเปลี่ยนแปลงขณะที่คุณกำลังแก้ไข โปรดย้อนกลับเป็นเวอร์ชันล่าสุดบน Gateway ก่อนบันทึก" + "เมื่อเชื่อมต่อแล้ว Gateway สามารถปลุกโทรศัพท์ด้วยการแจ้งเตือนแบบเงียบแทนการคงเซสชันให้ทำงานตลอดเวลา" + "โหมดปลุก" + "นำอุปกรณ์ที่จับคู่ออกหรือไม่?" + "ข้อความเหตุการณ์ระบบ" + "ไม่สามารถคัดลอกรูปภาพวิดเจ็ตได้" + "ไม่" + "เส้นทาง (ไม่บังคับ)" + "กำลังส่งเสียงที่อยู่ในคิว" + "มีมาในตัว" + "hide" + "runs" + "จำเป็นต้องใช้รหัสผ่าน Gateway ป้อนอีกครั้งหรือแก้ไขการเชื่อมต่อนี้" + "ข้อความเหตุการณ์" + "ข้อความถอดเสียงสด" + "ไม่สามารถโหลดการกำหนดค่าโมเดลของผู้ให้บริการได้" + "อนุญาตให้ %1$s แอปส่งต่อได้" + "การตั้งค่าเสียง" + "แนบวิดีโอ" + "ซ่อนรูปภาพเพิ่มเติม: %1$s" + "ปฏิเสธคำขอจับคู่หรือไม่?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "ผู้เผยแพร่" + "แยกสาขาจากที่นี่" + " · สนทนา: กำลังพูด" + "การแทนที่ (ไม่บังคับ)" + "ค่าเริ่มต้น" + "การอนุมัติคำสั่ง" + "แอปและ Gateway ใช้เวอร์ชันโปรโตคอลที่เข้ากันไม่ได้ โปรดอัปเดต OpenClaw ทั้งสองฝั่ง แล้วลองอีกครั้ง" + "รีเฟรชหน้าจอ" + "อ่านรูปภาพและสื่อล่าสุด" + "ฟัง" + "เชื่อมต่อแล้ว" + "เสร็จสมบูรณ์" + "โทรศัพท์ของคุณจับคู่กับ %1$s แล้ว ดำเนินการต่อเพื่อเปิดสิทธิ์เข้าถึงโหนดให้เสร็จสมบูรณ์" + "เธรดปัจจุบัน" + "การคิด %1$s" + "ปิดเสียง" + "OpenClaw ขอขอบคุณพันธมิตรในชุมชนโอเพนซอร์ส" + "เปิด" + "กำลังเชื่อมต่อ Gateway" + "gateway สามารถเปลี่ยนเส้นทางนี้ได้ แต่ไม่สามารถล้างเส้นทางที่มีอยู่ได้" + "TTS" + "กำลังบันทึก…" + "จุดยึด %1$s" + "search" + "เปิดใช้งาน" + "ตรวจสอบและจัดการงาน Gateway ที่กำหนดเวลาไว้" + "การตอบกลับก่อนหน้านี้อนุญาตคำสั่งนี้หนึ่งครั้งแล้ว" + "generate" + "ใช้เฉพาะบนเครือข่ายส่วนตัวที่เชื่อถือได้เท่านั้น" + "ค้นหาการตั้งค่า" + "การสนทนากำลังดำเนินอยู่" + "ยังไม่ได้กำหนดค่าการยืนยันตัวตนของ Gateway แก้ไขการเชื่อมต่อนี้แล้วลองอีกครั้ง" + "ล้มเหลว: เข้าถึงปลายทางที่ปลอดภัยแล้ว แต่การยืนยันลายนิ้วมือ TLS หมดเวลา ตรวจสอบ Tailscale Serve หรือ gateway TLS แล้วลองอีกครั้ง" + "ขั้นตอนที่ 1" + "การป้อนตามคำบอก" + "เปิดตัวเลือกแอป" + "ไม่มีรายการรออนุมัติ" + "edit" + "เชื่อมต่อกับ Gateway ของคุณ" + "ป้อนรหัสตั้งค่าจาก openclaw qr" + "การวินิจฉัย" + "แอปอื่นจะไม่ถูกแตะต้อง" + "กำลังแตกร้าว" + "การดำเนินการนี้จะลบเธรดและบันทึกการสนทนาอย่างถาวร" + "อนุมัติอุปกรณ์แล้ว" + "กำลังลองใหม่โดยอัตโนมัติ" + "คัดลอกรูปภาพวิดเจ็ตแล้ว" + "%1$s บทบาท" + "ขาด 1 รายการ" + "กำหนดเวลาไว้ %1$s งาน" + "react" + "เอเจนต์" + "เชื่อมต่อ Gateway เพื่อโหลดระบบอัตโนมัติ" + "กำลังเชื่อมต่อใหม่…" + "กลับไปยังการตั้งค่า" + "send" + "ไม่สามารถทดสอบการเชื่อมต่อได้" + "ตรวจสอบและติดตั้ง" + "เปลี่ยนอุปกรณ์นี้ให้เป็นโหนด OpenClaw ที่ปลอดภัยสำหรับแชท, เสียง, กล้อง และเครื่องมืออุปกรณ์" + "ตั้งค่าด้วยตนเอง" + "เปิดแชทเพื่อเริ่มหรือดำเนินการต่อในเธรดปัจจุบัน" + "เคล็ดลับ: หยุดฟังเพื่อส่งช่วงที่บันทึกไว้" + "ข้าม" + "คำขอด้วยเสียงล้มเหลว" + "การดำเนินการนี้จะปฏิเสธ \"%1$s\" และรีเฟรชสถานะ Skill Workshop จาก Gateway" + "กำลังสร้างเปลือก" + "update" + "แชร์" + "เปิดใช้งานกล้องแล้ว" + "Telegram, WhatsApp, อีเมล และช่องทางอื่นๆ จะปรากฏที่นี่หลังจากตั้งค่า" + "ข้อผิดพลาดของเครือข่าย" + "กำลังสำรวจแอ่งน้ำทะเล" + "กู้คืน Canvas ตอนนี้สำหรับ session=%1$s source=%2$s หากมีสถานะ A2UI อยู่แล้ว ให้เล่นซ้ำทันที หากไม่มี ให้สร้างและแสดงผลแดชบอร์ดขนาดกะทัดรัดที่เหมาะกับอุปกรณ์เคลื่อนที่ใน Canvas" + "เริ่มไม่สำเร็จ: %1$s" + "ไม่ได้ร้องขอ" + "กำหนดค่าผู้ให้บริการ %1$s บน Gateway" + "kill" + "การอนุมัติ" + "ไฟล์ไม่พร้อมใช้งาน" + "ทำเครื่องหมายว่ายังไม่ได้อ่าน" + "ค้นหาบุคคลและรายละเอียดการติดต่อ" + "ต้องระบุตัวตนอุปกรณ์" + "เธรด OpenClaw" + "อนุญาตการเข้าถึงคลังรูปภาพ" + "การตอบกลับก่อนหน้านี้ดำเนินการอนุมัตินี้แล้ว" + "ไม่มีเธรดล่าสุด" + "หมดเวลา %1$s วินาที" + "ไม่พบรายการที่ตรงกัน" + "อ่านการแจ้งเตือนจากแอปที่เลือก" + "ไม่ทราบสถานะความพร้อมใช้งาน" + "ตั้งค่าการสนทนา" + "เพิ่มเติม" + "จับคู่ Gateway แล้ว กำลังรอสิทธิ์เข้าถึงของผู้ควบคุม" + "แนบรูปภาพ" + "เลือกสิ่งที่จะส่งถึง OpenClaw" + "รอการอนุมัติความสามารถอีกครั้ง" + "ตรวจสอบรายการที่ไฮไลต์" + "กำลังฟัง..." + "สรุปความคืบหน้าให้ฉัน" + "ข้อความ" + "อ่านรายชื่อติดต่อ" + "พื้นที่จัดเก็บไฟล์แนบแบบออฟไลน์เต็มแล้ว โปรดลบรายการที่อยู่ในคิวก่อน" + "ครั้งเดียว" + "เปลี่ยนชื่อ" + "ไม่พบช่องทาง" + "ดูทั้งหมด" + "อุปกรณ์ใหม่" + "Session Status" + "เปิดตัวอย่างรูปภาพ" + "สาขาของเซสชันเปลี่ยนแปลง โปรดตรวจสอบและลองส่งข้อความนี้อีกครั้ง" + "close" + "ข้อมูลนี้ดูเหมือนรหัสตั้งค่า โปรดย้อนกลับแล้วเลือกตั้งค่า Gateway จากนั้นเลือกใช้รหัสตั้งค่า" + "✦" + "เอเจนต์และระบบอัตโนมัติ" + "ใช้" + "ข้ามการทำงานของระบบอัตโนมัติแล้ว" + "ดำเนินการต่อ" + "กำลังตรวจสอบ · งานที่กำหนดเวลาไว้ %1$s รายการ" + "เรียกดู" + "tabs" + "รอดำเนินการ" + "การสนทนา: %1$s" + "read" + "เลือกข้อความ" + "กิจกรรมการเคลื่อนไหว" + "description: %1$s" + "เล่นเสียง" + "เวลา" + "ยังไม่ได้ยืนยัน" + "Yield" + "คัดลอกคำสั่งอนุมัติ" + "เอาต์พุตหน้าจอปัจจุบันและพื้นผิวแอปแบบโต้ตอบ" + "เชื่อมต่อบริการแล้ว" + "การแสดงผล" + "พร้อมเมื่อคุณพร้อม" + "ไม่สามารถโหลดแค็ตตาล็อกผู้ให้บริการได้" + "กำลังพูด · กำลังรอการตอบกลับ" + "ยังไม่ได้อนุญาต" + "บันทึกการเปลี่ยนแปลง" + "Gateway ปฏิเสธการทำงานของระบบอัตโนมัติ" + "Session Send" + "ค้นหาบน ClawHub" + "อนุญาตการตรวจสอบตำแหน่งที่ร้องขอเสมอขณะที่ OpenClaw ทำงานอยู่เบื้องหลัง; Android จะแสดงสิ่งนี้ในการแจ้งเตือนโหนดแบบถาวร" + "เหตุการณ์ของระบบ" + "เชื่อมต่อ Gateway เพื่อดูผู้ให้บริการ" + "ฮาร์ตบีตครั้งถัดไป" + "จับคู่ Gateway แล้ว กำลังรอการอนุมัติความสามารถของโหนด" + "กำลังแช่น้ำเกลือ" + "ปิด Canvas" + "เขียนรายชื่อติดต่อ" + "ไม่มี Skills ที่ติดตั้งแล้วตรงกับการค้นหานี้" + "การตั้งค่าผู้ให้บริการ Talk" + "Music Generation" + "การตั้งค่าการคุย" + "กำลังตรวจสอบ · 1 เธรด" + "ข้อความ Payload" + "ตั้งค่าข้อความ" + "การอนุมัติ %1$s" + "Gateway ไม่ได้ส่งคืนความพร้อมของ %1$s" + "มีโมเดลที่กำหนดค่าไว้ %1$s รายการ รีเฟรชเพื่อตรวจสอบความพร้อมใช้งานอีกครั้ง" + "Conversation Send" + "แคนวาส" + "ผู้ให้บริการ 1 ราย" + "ไม่สามารถอ่านใบรับรองของ Gateway โดยอัตโนมัติได้ ให้วางลายนิ้วมือ SHA-256 ที่ได้รับจากโฮสต์ Gateway" + "ส่งไม่สำเร็จ: %1$s" + "บริดจ์" + "ข้อผิดพลาดในการส่ง" + "ใช้ OpenClaw จากโทรศัพท์ของคุณ" + "รูปลักษณ์" + "เวิร์กช็อป Skills" + "ต้องใช้โทเค็น" + "ตัวอย่าง · %1$s" + "ต้องได้รับสิทธิ์เข้าถึงไมโครโฟน" + "เชื่อมต่อ Gateway เพื่อโหลดข้อเสนอ Skill Workshop" + "ระบบทั้งหมดทำงานตามปกติ" + "ไม่สามารถเข้าถึง Gateway ได้" + "OC" + "อัปเดตแล้ว" + "เชื่อมต่อแล้ว (โหนดออฟไลน์)" + "หน้าหลัก" + "การป้อนตามคำบอกกำลังฟังอยู่" + "ไม่มีเธรดที่เก็บถาวร" + "เลือกและตรวจสอบผู้ช่วยที่พร้อมใช้งานบน gateway นี้" + "โหมดพูดคุยทำงานอยู่" + "กำลังทำงาน · มีงานที่ใช้งานอยู่ 1 รายการ" + "ยอมรับและเปิดใช้งาน" + "ต้องอัปเดต Gateway" + "คัดลอกรูปภาพ" + "URL ของ Gateway" + "main, isolated, current หรือ session:<id>" + "สื่อไม่พร้อมใช้งาน" + "เชื่อมต่อกับ Gateway ของคุณเพื่อเปิดเชลล์ในพื้นที่ทำงานของเอเจนต์" + "%1$s://%2$s:%3$s" + "ไม่สามารถโหลดรายละเอียดการอนุมัติได้ รีเฟรชแล้วลองอีกครั้ง" + "ฉันสามารถตรวจสอบสถานะ Gateway ซ่อมการกำหนดค่า เปลี่ยนโมเดล หรือเชื่อมต่อช่องทางได้" + "Tool Call" + "เธรด" + "Write" + "เริ่มต้นด้วยพรอมต์ หรือใช้เสียง" + "D" + "เปิดการตั้งค่า" + "กำลังสังเกต…" + "จบการสนทนา" + "ข้อผิดพลาดล่าสุด" + "ตรวจสอบการดำเนินการที่ต้องการความสนใจจากคุณ" + "ปิดใช้งานสำหรับเอเจนต์ทั้งหมด" + "เริ่มใช้เสียง" + "กลับไปยังงานเบื้องหลัง" + "การดำเนินการ cron อื่นยังดำเนินการไม่เสร็จ" + "ช่วงพัก %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "การรู้จำเสียงพูดบนอุปกรณ์ไม่พร้อมใช้งาน" + "สคริปต์ · อ่านอย่างเดียว" + "ไฟล์แนบมีขนาดใหญ่เกินกว่าจะเพิ่มลงในคิวสำหรับข้อความเดียวได้ โปรดนำบางไฟล์ออกแล้วลองอีกครั้ง" + "มีโมเดลที่กำหนดค่าไว้ 1 รายการ รีเฟรชเพื่อตรวจสอบความพร้อมใช้งานอีกครั้ง" + "วิดเจ็ตไม่พร้อมใช้งาน" + "โฮสต์นี้ต้องใช้การเชื่อมต่อที่ปลอดภัย" + "รายการล่าสุด" + "ไม่พบระบบอัตโนมัติที่ตรงกัน" + "โทรศัพท์สามารถเข้าถึง Gateway ได้" + "Gateway" + "หมดอายุแล้ว" + "งาน OpenClaw ที่กำหนดเวลาไว้จาก gateway ของคุณ" + "Sub-agent" + "กำลังรอการอนุมัติอุปกรณ์" + "กำลังโหลดเธรด" + "ขณะนี้ Gateway นี้แสดงใบรับรองที่อุปกรณ์นี้เชื่อถือ" + "การหน่วงเหลื่อม ms" + "event create" + "เอกสาร" + "ตั้งค่า Gateway" + "เล่นวิดีโอ" + "การยืนยันตัวตนที่บันทึกไว้ไม่ถูกต้อง ยืนยันตัวตนใหม่หรือรีเซ็ตการเชื่อมต่อ Gateway นี้" + "ขณะใช้งาน" + "screenshot" + "ย้อนกลับมาที่นี่" + "นิพจน์ cron เช่น 0 9 * * *" + "กลับไปที่เสียง" + "พูด" + "รายละเอียด" + "ออนไลน์ %1$s/%2$s" + "อนุญาตให้ส่งต่อ %1$s แอป" + "แชท" + "จำเป็นต้องเข้าถึงไมโครโฟน" + "กำลังไต่" + "แก้ไข" + "ช่วงเวลาห้ามรบกวน" + "คัดลอกข้อมูลวินิจฉัย" + "กำหนดเวลาแล้ว" + "สร้าง" + "หมดอายุใน %1$s" + "ปิด" + "ปฏิเสธข้อเสนอหรือไม่?" + "ข้อผิดพลาดในการรู้จำเสียง (%1$s)" + "ปัญหา" + "ค้นหาข้อมูลเมตาของรีจิสทรี โดย Gateway จะตรวจสอบความน่าเชื่อถืออีกครั้งก่อนดาวน์โหลด" + "ใช้ IP ของ LAN ส่วนตัวสำหรับการตั้งค่าภายในเครื่อง หรือเปิดใช้งาน Tailscale Serve / เปิดเผย URL ของ Gateway แบบ wss:// สำหรับการเข้าถึงจากระยะไกล" + "กำลังส่ง…" + "บัญชี %1$s" + "Suggest Task" + "ค้นหา" + "กำลังฟัง" + "ยังไม่ได้โหลดการทำงานอัตโนมัติ" + "มีอัปเดต Gateway พร้อมใช้งาน เรียกใช้การอัปเดตจาก Web UI หรือ CLI เมื่อคุณพร้อม" + "เร็ว ๆ นี้" + "ไม่มีการอนุมัติของ Gateway" + "โฮสต์" + "เพิ่มคำหรือวลีปลุกหนึ่งรายการต่อช่อง จากนั้นพูดคำหรือวลีดังกล่าวก่อนออกคำสั่ง" + "ถอดเสียงแล้วส่ง" + "เรียกใช้เมื่อ" + "หยุดเสียงชั่วคราว" + "การเข้าถึงอุปกรณ์ Gateway" + "ไม่มีตัวอย่าง" + "อุปกรณ์" + "OpenClaw สำหรับ Android" + "รอการอนุมัติความสามารถ" + "บันทึกหรือย้อนกลับการแก้ไขของคุณก่อนเรียกใช้ เปิดใช้งาน ปิดใช้งาน ลบ หรือรีเฟรชระบบอัตโนมัตินี้" + "ยังไม่มีระบบอัตโนมัติ" + "Skills นี้ต้องตั้งค่า %1$s รายการ โดย Android จะแสดงสิ่งที่ติดตั้งไว้ ส่วนการตั้งค่า/การเปลี่ยนแปลงการกำหนดค่าต้องดำเนินการบนเดสก์ท็อปหรือ CLI" + "%1$s ล่าสุด" + "ช่องทาง" + "Unphased" + "ใช้งานอยู่บนโทรศัพท์เครื่องนี้" + "กำลังตรวจสอบสิทธิ์เข้าถึงโหนด" + "เขตเวลา" + "การดำเนินการตรวจสอบและใช้งานของ Skill Workshop" + "อนุญาตเสมอ" + "present" + "Skills ที่ติดตั้งบน Gateway จะแสดงที่นี่" + "รหัสอาจหมดอายุแล้วหรือถูกสร้างขึ้นสำหรับ Gateway อื่น" + "ต้องได้รับอนุญาต" + "ระบบอัตโนมัติมีการกำหนดค่าที่ไม่ถูกต้อง" + "รายการที่อนุญาต" + "การตั้งค่า สถานะ และการซ่อม" + "groups" + "คีย์สาธารณะ" + "เกี่ยวกับ" + "ไม่พบคิวอาร์โค้ดสำหรับตั้งค่าในรูปภาพนั้น โปรดเลือกคิวอาร์ที่สร้างโดย openclaw qr หรือป้อนรหัสตั้งค่าด้วยตนเอง" + "permissions" + "เชื่อมต่อ Gateway เพื่อโหลดโหนดและอุปกรณ์ที่จับคู่แล้ว" + "สลับสาขา" + "ไม่มี Skills" + "เล่นคำตอบออกเสียง" + "ทำเครื่องหมายว่าอ่านแล้ว" + "โหนดกำลังรอการอนุมัติ" + "wake" + "ข้อเสนอ %1$s รายการ" + "การตรวจสอบสิทธิ์ Gateway ต้องได้รับการตรวจสอบ" + "รายละเอียดการเชื่อมต่อ" + "มิลลิวินาที" + "การรู้จำเสียงพูด" + "คำอธิบาย" + "การสนทนาล่าสุด" + "โทรศัพท์ของคุณจะส่งข้อมูลนี้ไปยัง Gateway ของคุณ ไม่ใช่ไปยังเซิร์ฟเวอร์ที่ดำเนินการโดย OpenClaw Gateway ของคุณอาจรวมข้อมูลนี้ในคำขอไปยังผู้ให้บริการ AI ที่คุณเลือก" + "การส่ง" + "ปิดเสียงลำโพง" + "%1$s กำลังทำงาน · %2$s เสร็จสิ้น · %3$s ล้มเหลว" + "กำลังเปิดการเชื่อมต่อ Gateway" + "กำลังตรวจสอบ · %1$s เธรด" + "การทำงานของระบบอัตโนมัติเสร็จสิ้นแล้ว" + "ไม่พบแอปที่ตรงกัน" + "ส่งไปยังแชท" + "ลบระบบอัตโนมัติแล้ว" + "เปิดใช้งาน" + "การรันล่าสุด" + "จัดรหัส QR ให้อยู่ภายในสี่เหลี่ยม" + "ไม่สามารถโหลดการอนุมัติได้" + "ฉันอนุมัติแล้ว" + "เชื่อมต่อ Gateway ของคุณเพื่อโหลดความพร้อมของผู้ให้บริการ" + "ยังไม่ได้จับคู่" + "การอนุมัตินี้หมดอายุก่อนที่จะสามารถดำเนินการได้" + "กำลังสังเกตในอีก %1$s วินาที — สลับไปยังแอปเป้าหมาย" + "พรอมต์ของ Agent" + "emoji list" + "ทำซ้ำ" + "ค้นหา OpenClaw" + "รอดำเนินการ %1$s รายการ" + "ไม่สามารถใช้การรู้จำเสียงบนอุปกรณ์ได้" + "ไม่มีแอปที่สามารถแชร์ข้อความนี้ได้" + "ปิดการค้นหา" + "คำสั่งที่จะเฝ้าดู" + "สถานภาพ" + "ตัวรับฟังการแจ้งเตือน" + "ปิดเสียงลำโพงแล้ว" + "ค้นหาเธรด" + "ตกลง" + "ไม่สามารถเปิดคู่มือการตั้งค่าได้" + "ถาม OpenClaw %1$s" + "Wait for Agents" + "ที่อยู่" + "งานตามกำหนดการที่สร้างบน Gateway จะปรากฏที่นี่" + "กำลังแสดงส่วนบันทึกล่าสุด" + "ใช้รหัสตั้งค่า" + "sticker" + "ใช้ Gateway แบบ wss:// ที่ปลอดภัยหรือ Tailscale Serve สร้างรหัสตั้งค่าแบบเข้าถึงเต็มรูปแบบใน Control UI หรือด้วย openclaw qr จากนั้นสแกนหรือวางรหัสด้านล่างแล้วเชื่อมต่อใหม่เพื่อเปิดใช้การตั้งค่าและการอัปเกรด" + "steer" + "เลือกแล้ว" + "Android สามารถสแกนหรือวางรหัสตั้งค่าที่มีอยู่ได้ แต่ Gateway นี้ยังไม่เปิดให้แอปสร้างรหัสตั้งค่า สร้าง QR/รหัสบนโฮสต์ Gateway ด้วย openclaw qr จากนั้นสแกนที่นี่หรือวางรหัสตั้งค่าด้านล่าง" + "สถานะ Canvas" + "แก้ไขการเชื่อมต่อ" + "บันทึกรูปภาพ" + "โหนด %1$s" + "ต้องใช้รหัสผ่าน Gateway" + "Update Plan" + "ลบไฟล์แนบ" + "การทำงานของระบบอัตโนมัติล้มเหลว" + "ขีดจำกัดของผู้ให้บริการและสถานะโควตา" + "ยังไม่ได้โหลดแค็ตตาล็อกการพูดของ Gateway" + "Gateway นี้" + "ยังไม่มีการรันล่าสุด" + "ไม่สามารถใช้โมเดลภาษาบนอุปกรณ์ได้" + "แดชบอร์ดต้องเชื่อมต่อกับ Gateway" + "ข้อเสนอที่ตรงกันจะปรากฏที่นี่หลังจากที่ agent สร้างแบบร่าง skill ที่นำกลับมาใช้ใหม่ได้" + "Session Search" + "OpenClaw กำลังพูด" + "สแกน QR" + "แอปที่เลือก" + "ย้อนกลับการเปลี่ยนแปลง" + "คัดลอกคำสั่งอนุมัติแล้ว" + "สถานะการส่ง" + "ไม่ยอมรับ QR code" + "ศูนย์บัญชาการด้วยเสียงของคุณ" + "ทดสอบการเชื่อมต่อ" + "OPENCLAW" + "Web Fetch" + "พรอมต์" + "อนุมัติอุปกรณ์หรือไม่?" + "เชื่อมต่อกับ Gateway ของคุณเพื่อเปิดแดชบอร์ดของเซสชันนี้" + "นำ %1$s และข้อมูลประจำตัวที่บันทึกไว้ออกจากโทรศัพท์เครื่องนี้หรือไม่?" + "คิวอาร์โค้ดชี้ไปยัง Gateway ระยะไกลที่ไม่ปลอดภัย %1$s %2$s" + "พื้นผิวหน้าจอพร้อมแล้ว" + "จับคู่ Gateway" + "เชื่อมต่อ Gateway เพื่อโหลดช่องทาง" + "หยุดชั่วคราวเมื่อมีกิจกรรมเสียงอื่น" + "โมเดล" + "รูปภาพ" + "วางรหัสตั้งค่า" + "OpenClaw กำลังพูด" + "กำลังเชื่อมต่อ..." + " · ตำแหน่งที่ตั้ง: ตลอดเวลา" + "ข้อความ: %1$s" + "กำลังสร้างแนวปะการัง" + "โหลดจาก Gateway" + "text: %1$s" + "ต้องการ" + "rename group" + "พร้อม" + "ไดอารีกำลังรอรายการแรก" + "อนุมัติ" + "หน้าไลฟ์" + "ระบบอัตโนมัติกำลังทำงานอยู่แล้ว" + "ลบระบบอัตโนมัตินี้หลังจากเรียกใช้แบบครั้งเดียวสำเร็จ" + "พร้อมสำหรับการแชตและเสียง" + "เชื่อมต่อแล้ว (ผู้ควบคุม: %1$s)" + "การจับคู่กับ Gateway เสร็จสมบูรณ์แล้ว อนุมัติโทรศัพท์เครื่องนี้เป็นโหนดเพื่อให้ OpenClaw สามารถใช้ความสามารถของอุปกรณ์ที่คุณเปิดใช้งานได้" + "ยกเลิกการตอบกลับแล้ว" + "รูปภาพ" + "พักไว้ %1$s รายการ" + "ไม่มีเธรดที่ตรงกัน" + "delete" + "เลย์เอาต์: กะทัดรัด" + "channels" + "อนุญาตแล้ว" + "ทุก %1$s นาที" + "1 โทเค็น" + "%1$s %2$s" + "แอปที่ติดตั้ง" + "รอดำเนินการ" + "กำลังเตรียมข้อความเสียง…" + "ไม่เลย" + "ระบบย่อย" + "เมื่อคำสั่งสิ้นสุด" + "การเชื่อมต่อ" + "ไม่สามารถโหลดประวัติการทำงานของระบบอัตโนมัติได้" + "ชื่อการทำงานอัตโนมัติ" + "ขั้นตอนที่ 2" + "วินิจฉัย" + "การตรวจสอบสถานะช่องทางบางรายการยังไม่เสร็จสมบูรณ์" + "pin" + "คัดลอก %1$s" + "จับคู่แล้ว" + "ไม่สามารถบันทึกคำปลุกได้" + "การดำเนินการนี้จะกักกัน \"%1$s\" และรีเฟรชสถานะ Skill Workshop จาก Gateway" + "บันทึกข้อความเสียง" + "อยู่ในคิว" + "ตอบแล้ว" + "อนุญาตให้ใช้เครื่องมือกล้องเมื่อมีการร้องขอ" + "ปัญหา" + "การปลุกด้วยเสียง" + "ปฏิเสธคำขอจับคู่แล้ว" + "%1$s วันที่แล้ว" + "roles" + "Skills" + "เก็บถาวร" + "โหนดออฟไลน์ โปรดเชื่อมต่อใหม่แล้วลองอีกครั้ง" + "ระบบ" + "IP ระยะไกล" + "ไม่ได้จัดกลุ่ม" + "รายละเอียดกำหนดการ" + "ความสามารถของโทรศัพท์" + "ไม่พร้อมใช้งาน" + "แดชบอร์ด" + "วางโทเค็น" + "ไม่มีผู้ให้บริการ" + "ลายนิ้วมือ SHA-256" + "ยังไม่มีเธรด" + "ไมโครโฟน Bluetooth" + "ล่าสุด" + "เปลี่ยนชื่อเธรด" + "ไม่ทราบผลการดำเนินการ การดำเนินการจะยังคงถูกปิดใช้งานจนกว่าจะยืนยันบันทึกของ Gateway" + "dialog" + "ฟังคำปลุก" + "camera snap" + "กำลังเตรียมการเล่น…" + "Gateway เลือกผู้ให้บริการที่ไม่รู้จัก %1$s" + "delete group" + "ติดตาม Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "เชื่อมต่อ Gateway เพื่อโหลด Agents" + "ย้อนกลับ" + "แชร์ข้อความ" + "สร้าง QR code" + "รีสตาร์ท" + "เปิดลำโพง" + "ลบกลุ่มหรือไม่?" + "ไม่มี" + "ค้นหาข้อเสนอ" + "stop" + "ปลอดภัย (TLS)" + "ไม่มีโหนดหรืออุปกรณ์ที่จับคู่แล้ว" + "เหลือ %1$s%% %2$s" + "รหัสตั้งค่าหมดอายุแล้ว" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "ไดอารี" + "notify" + "โทรศัพท์เครื่องนี้จะอยู่ในโหมดพักจนกว่า Gateway จะต้องการใช้งาน จากนั้นจะตื่นขึ้น ซิงค์ และกลับเข้าสู่โหมดพัก" + "โมเดลที่กำหนดค่าแล้ว %1$s รายการ" + "ใบอนุญาต" + "เชื่อมต่อ Gateway เพื่อค้นหา Skills ใน ClawHub" + "ทักษะ" + "การเชื่อมต่อ Gateway มีการเปลี่ยนแปลง เริ่ม OpenClaw ใหม่เพื่อเชื่อมต่ออีกครั้ง" + "รหัสอุปกรณ์" + "Gateway ไม่ได้ระบุผู้ให้บริการ %1$s ที่ใช้งานอยู่" + "กำลังรอ" + "บันทึกคำปลุกแล้ว" + "เก่าสุดก่อน" + "หน้าจอ" + "กำลังทำงานตั้งแต่" + "ไม่รองรับ IPv6 zone ID โปรดใช้ที่อยู่ IPv6 แบบไม่มีขอบเขตหรือชื่อโฮสต์ LAN" + "ส่งแล้ว — กำลังยืนยันการส่งถึง…" + "เสียง" + "This gateway connection needs operator.admin to update skills." + "รหัสตั้งค่า" + "รับทราบคำเตือนจาก Gateway และติดตั้ง" + "รีเฟรชแชท" + "ช่วงเวลา" + "การดำเนินการกับข้อเสนอ Skill Workshop ต้องใช้ขอบเขต operator.admin" + "เซสชัน" + "เปลี่ยนชื่อ…" + "เชื่อมต่อ Gateway เพื่อโหลดการฝัน" + "ตั้งค่า" + "เปิด พูดคุย" + "poll" + "เชื่อมต่อเพื่อโหลดเอเจนต์ของคุณ" + "role remove" + " · สนทนา: กำลังฟัง" + "ClawHub ไม่ได้ส่งคืนเวอร์ชันที่ติดตั้งได้สำหรับ %1$s" + "คำสั่ง" + "การอนุมัตินี้ถูกยกเลิกก่อนที่จะสามารถดำเนินการได้" + "ไมค์เปิด · กำลังรอ Gateway" + "ข้อความ" + "กำลังแสดง %1$s จาก %2$s รายการ ปรับการค้นหาเพื่อดูเพิ่มเติม" + "มี v%1$s พร้อมใช้งาน" + "%1$s://%2$s" + "%1$s... (ตกลง)" + "ผู้ให้บริการและโมเดลที่กำหนดค่าไว้" + "กำลังเชื่อมต่อ…" + "เชื่อมต่อกับ Gateway เพื่อบันทึกคำปลุก" + "เปิดโปรไฟล์" + "เริ่ม Gateway ของคุณ" + "ช่วยเปลี่ยนเป้าหมายนี้ให้เป็นรายการตรวจสอบที่นำไปใช้ได้จริง: " + "ล้างการค้นหาเซสชัน" + "พอร์ต" + "ป้อนรหัสตั้งค่า" + "ไม่สามารถโหลดบันทึกของ Gateway ได้" + "ผู้ให้บริการ %1$s รายพร้อมใช้งาน" + "เอเจนต์ของคุณพร้อมแล้ว" + "ไม่มีการกำหนดค่าผู้ให้บริการ %1$s บน Gateway" + "กำลังฟังหนึ่งรอบ" + "สังเกต" + "Epoch มิลลิวินาที (ไม่บังคับ)" + "ไม่มีโมเดลที่กำหนดค่าไว้ รีเฟรชเพื่อตรวจสอบความพร้อมใช้งานอีกครั้ง" + "การตั้งค่า" + "กล้องหลัง" + "approve" + "ก่อนเริ่มต้น" + "ไม่สามารถโหลด Skills ได้" + "ปิดใช้งาน" + "ยังรอการอนุมัติอยู่" + "ไม่สามารถโหลดงานเบื้องหลังได้" + "ตรวจสอบว่า OpenClaw สามารถพูดได้ชัดเจนบนโทรศัพท์เครื่องนี้" + "กำลังทำงาน · มีงานที่ใช้งานอยู่ %1$s รายการ" + "ไดเรกทอรีทำงานของคำสั่ง" + "ชื่อกลุ่ม" + "เลือกจากแกลเลอรี" + "Version %1$s" + "ย้อนกลับ" + "Connect the gateway to update skills." + "ลบหลังเรียกใช้" + "รหัสตั้งค่าชี้ไปยัง Gateway ระยะไกลที่ไม่ปลอดภัย %1$s %2$s" + "Computer" + "Gateway ถูกตัดการเชื่อมต่อ" + "Session Settings" + "เชื่อมต่อ Gateway เพื่อเริ่มต้น" + "ประกาศด้านความปลอดภัย" + "คำตอบอื่น" + "ปิดคำเตือนรูปภาพที่แชร์" + "Gateway ประเมิน ClawHub รุ่นอื่น โปรดตรวจสอบ Skill อีกครั้งก่อนติดตั้ง" + "เปิดการเข้าถึงระบบ" + "เสร็จสิ้น" + "รูปภาพไม่พร้อมใช้งาน" + "การแจ้งเตือน" + "การใช้งาน การปฏิเสธ และการกักกันต้องใช้ขอบเขต operator.admin เชื่อมต่อใหม่ด้วยการยืนยันตัวตน gateway ที่ใช้ร่วมกัน หรืออนุมัติการอัปเกรดขอบเขตอุปกรณ์ operator.admin เพื่อเปิดใช้งานการดำเนินการวงจรชีวิต" + "sticker upload" + "กำลังจับกุ้งมังกร" + "Messages to recover" + "openclaw devices approve %1$s" + "รายละเอียดบันทึก Gateway ที่อ่านได้" + "ตรวจสอบข้อเสนอ skill ที่สร้างขึ้นก่อนที่จะกลายเป็น skills ที่ใช้งานได้จริง" + "มาพร้อมแพ็กเกจ" + "พร้อมใช้งาน %1$s รายการ" + "กำลังรอการอนุมัติโหนด" + "Gateway กำลังรอดำเนินการ" + "ต้องมีการยืนยันตัวตน" + "โหนด" + "เปิดหน้าจอไว้เสมอ" + "OpenClaw กำลังตอบกลับ" + "เอกสาร" + "พร้อม %1$s รายการ" + "ยังไม่มีผลลัพธ์" + "ไม่รองรับภาษาของอุปกรณ์" + "อยู่ในคิว — จะส่งเมื่อเชื่อมต่ออีกครั้ง" + "%1$s นาทีที่แล้ว" + "สาขาปัจจุบัน" + "กำลังตรวจสอบสิทธิ์การจับคู่" + "การเข้าถึง Gateway แบบจำกัด" + "กำลังเรียกใช้เครื่องมือ..." + "กำลังตรวจสอบการอนุมัติ…" + "ถ่ายภาพและคลิปจากโทรศัพท์เครื่องนี้" + "เชื่อมต่อแล้วและพร้อมใช้งาน" + "ปิด" + "เปลี่ยนเป้าหมายให้เป็นรายการตรวจสอบที่นำไปปฏิบัติได้" + "รหัสตั้งค่ามี URL ของ Gateway ไม่ถูกต้อง" + "เปิดใช้งานเฉพาะการเข้าถึงที่คุณสบายใจให้ OpenClaw ใช้ขณะที่โทรศัพท์เครื่องนี้เชื่อมต่ออยู่ คุณสามารถเปลี่ยนแปลงได้ภายหลังในการตั้งค่า Android" + "บัญชี" + "remove" + "รหัสผ่าน ไม่บังคับ" + "การยืนยันตัวตน Gateway ต้องได้รับการตรวจสอบ ตรวจสอบการตั้งค่า Gateway แล้วลองอีกครั้ง" + "รหัส QR ใช้ IPv6 zone ID โปรดใช้ที่อยู่ IPv6 แบบไม่มีขอบเขตหรือชื่อโฮสต์ LAN" + "add" + "กำลังจับเคย" + "ปกติ" + "เสร็จใน %1$s" + "อาร์กิวเมนต์" + "ตัวเลือกการติดตั้ง" + "ในอีก %1$s ชม." + "การอนุมัติ Gateway อยู่ระหว่างรอดำเนินการ เรียกใช้คำสั่งนี้บนโฮสต์ Gateway:" + "ต้องมีสิทธิ์ผู้ดูแลระบบ" + "set groups" + "ปักหมุดโมเดล" + "ล้างการค้นหา" + "เปิดใช้งานสำหรับเอเจนต์ที่มีสิทธิ์" + "ไม่มีเธรดปัจจุบัน" + "bounds: %1$s" + "หลังจาก %1$s" + "อนุญาตให้ตัวจัดกำหนดการเรียกใช้ระบบอัตโนมัตินี้" + "นำไปใช้แล้ว %1$s รายการ" + "ยังไม่มีไดอารีความฝัน" + "รีเฟรชงานเบื้องหลัง" + "สรุปเธรดล่าสุดและขั้นตอนถัดไป" + "ทำงานบนอุปกรณ์ขณะที่ OpenClaw แสดงอยู่" + "%1$s กำลังทำงาน" + "%1$s %2$s" + "ข้อมูลดิบ" + "การทำงาน" + "เรียกใช้ตอนนี้" + "บรานช์ไม่มีชื่อ" + "กำหนดค่าแล้ว" + "camera list" + "นำไปใช้แล้ว 1 รายการ" + "camera clip" + "ใช่" + "ทดสอบเสียง" + "ระงับไว้" + "events" + "ไดเรกทอรีทำงาน" + "ข้ามไปยังล่าสุด" + "อนุญาตตลอดเวลา" + "สแกน QR หรือรหัสตั้งค่า" + "Installing" + "โหนดที่ใช้งานอยู่, โทรศัพท์ที่จับคู่แล้ว และคำขออุปกรณ์ที่รอดำเนินการ" + "สแนปช็อต: %1$s" + "การตอบกลับก่อนหน้านี้อนุญาตคำสั่งนี้และบันทึกตัวเลือกไว้แล้ว" + "คำขอที่รอดำเนินการ" + "อนุมัติแล้ว" + "พื้นที่ทำงาน" + "เสียง" + "พร้อมสนทนา" + "Subagents" + "ล้มเหลว: ไม่พบจุดเชื่อมต่อ gateway ที่ปลอดภัย เปิดใช้งาน gateway TLS หรือ Tailscale Serve หรือใช้ที่อยู่ LAN ส่วนตัวที่เชื่อถือได้โดยเลือก Unencrypted" + "สัญญาณ" + "เป้าหมายเซสชัน" + "Gateway บันทึกการปฏิเสธ" + "ยอมรับ" + "ถาม OpenClaw ได้ทุกอย่าง" + "เชื่อมต่อใหม่เพื่อดำเนินการต่อ" + "จับคู่แล้ว %1$s เครื่อง" + "การดำเนินการนี้จะนำ \"%1$s\" ไปใช้และรีเฟรชสถานะ Skill Workshop จาก Gateway" + "Gateway ออฟไลน์" + "openclaw devices list" + "สถานะการเชื่อมต่อของ OpenClaw node" + "การเตือนจะอยู่บนโทรศัพท์เครื่องนี้" + "OpenClaw สามารถรับการเตือนที่เลือกได้" + "เปิดหน้าจอ" + "การดำเนินการของแชท" + "อนุญาตให้ควบคุมแอปอื่นหรือไม่?" + "กำลังตรวจสอบ" + "สแกนหรือวางรหัสการตั้งค่าเพื่อเพิ่ม Gateway อื่น" + "Swarm" + "TLS หมดเวลา" + "เซสชันล่าสุด" + "นำอุปกรณ์ที่จับคู่ออกแล้ว" + "จับคู่ Gateway แล้ว กำลังตรวจสอบการอนุมัติความสามารถของโหนด" + "การเคลื่อนไหว" + "การดำเนินการ cron ล้มเหลว" + "บนคอมพิวเตอร์ Gateway ให้รัน:" + "ค้นหาเซสชัน" + "รีเฟรชบันทึก" + "รูปภาพไม่พร้อมใช้งาน · แตะเพื่อลองอีกครั้ง" + "openclaw nodes approve %1$s" + "ข้อความเสียง · %1$s" + "การใช้งาน" + "กำลังล่องทะเลแบบนอติลุส" + "บริบท %1$s%%" + "ถอดเสียงคำสั่งเสียง" + "ปิดเสียง" + "เริ่มการสนทนาใหม่ แล้วจะแสดงที่นี่" + "ปัญหาการเชื่อมต่อ" + "ปานกลาง" + "แยกเซสชัน" + "เปิดใช้งานลำโพง" + "ข้อความเหตุการณ์ระบบ" + "จัดเรียง: %1$s" + "กำลังรอ %1$s รายการ" + "Image Generation" + "ข้อความเสียง" + "ไม่มีสิ่งใดต้องให้คุณดำเนินการ" + "OpenClaw ต้องการสิทธิ์อนุญาต %1$s เพื่อดำเนินการต่อ" + "ไมโครโฟนชุดหูฟังแบบมีสาย" + "หน้า" + "ส่งแล้ว" + "ครบกำหนด" + "รายละเอียด Skill ไม่พร้อมใช้งานในสถานะ Skills ปัจจุบัน" + "เลือกสิ่งที่โทรศัพท์เครื่องนี้สามารถแชร์ได้" + "ระบบอัตโนมัตินี้มีงานอยู่ในคิวแล้ว" + "เชื่อมต่อ Gateway เพื่อจัดการระบบอัตโนมัติ" + "ระบบอัตโนมัติยังไม่ถึงกำหนดทำงาน" + "ไม่มีรายละเอียด" + "กำลังดำเนินการอนุมัติ\nOpenClaw จะเชื่อมต่ออีกครั้งโดยอัตโนมัติ" + "เชื่อมต่อ Gateway ของคุณเพื่อดูความพร้อมของผู้ให้บริการ" + "กำลังรอการจับคู่" + "เริ่มหรือสนทนาต่อ" + "ไม่มีงานที่กำหนดเวลาไว้" + "ตอบกลับ OpenClaw…" + "สถานะ" + "OpenClaw Node · เชื่อมต่อแล้ว" + "ใช้งานอยู่" + "แสดงสถานะการแก้ไขข้อบกพร่องของการแชร์หน้าจอ" + "ไม่มีรายงานขีดจำกัด" + "ปิดเครื่องสแกน" + "ทุก %1$s วัน" + "เปิดใช้งานแล้ว" + "เปิดใช้งานและเปิดการตั้งค่า" + "ออนไลน์และพร้อมใช้งาน" + "Ask User" + "ข้อผิดพลาดของแชท" + "เลื่อนไปข้างหน้า" + "%1$s จาก %2$s" + "วางแผนงาน" + "console" + "ลองอีกครั้ง" + "เริ่มแชท แล้วการสนทนา OpenClaw ที่ใช้งานอยู่ของคุณจะแสดงที่นี่" + "ไม่สามารถโหลดระบบอัตโนมัติได้" + "เชลล์ในพื้นที่ทำงานของเอเจนต์" + "%1$s กำลังใช้งานอยู่" + "เลือกสิทธิ์ของอุปกรณ์" + "ระยะเวลาล่าสุด" + "เอเจนต์เริ่มต้น" + "%1$s ชม." + "การสนทนากำลังดำเนินอยู่" + "ไม่สามารถติดตั้ง %1$s จาก ClawHub ได้" + "ยินดีต้อนรับสู่ OpenClaw" + "ควบคุมแอปอื่น" + "ดัชนี Signal" + "ป้อน secret…" + "%1$s:%2$s" + "บอก OpenClaw ให้ %1$s" + "ค้นพบแล้ว" + "ซ่อนแถบด้านข้าง" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "ไม่สามารถเล่นเสียงได้" + "นำไปใช้" + "ไม่สามารถโหลดข้อมูลการใช้งานได้" + "ให้โหนดพร้อมใช้งานระหว่างที่กำลังทำงาน" + "การปลุกครั้งถัดไป" + "%1$s/%2$s" + "ไม่มีรายการบันทึกล่าสุด" + "Gateway แบบกำหนดเอง" + "เปลี่ยนชื่อกลุ่ม" + "Update Goal" + "ไม่ทราบความพร้อมใช้งานของผู้ให้บริการ" + "ผู้ให้บริการ" + "ลบกลุ่ม…" + "เพย์โหลด" + "บันทึกการโทร" + "Memory Search" + "ผู้ให้บริการ %1$s ราย" + "บริบทของโทรศัพท์และความเป็นส่วนตัว" + "เชื่อมต่อแล้ว %1$s/%2$s" + "%1$s %2$s" + "การกู้คืนหลังจากรีสตาร์ท Gateway ยังคงดำเนินการอยู่" + "การแทนที่รหัสตั้งค่าจะล้างข้อมูลประจำตัวสำหรับการตั้งค่าและโทเค็นอุปกรณ์ที่บันทึกไว้ในโทรศัพท์เครื่องนี้ก่อนเชื่อมต่อใหม่ โทรศัพท์เครื่องนี้อาจต้องได้รับการอนุมัติความสามารถของโหนดอีกครั้ง โปรดดำเนินการต่อเฉพาะเมื่อคุณตั้งใจจะจับคู่กับรหัสตั้งค่า Gateway ใหม่" + "เปิดระบบอัตโนมัติเพื่อตรวจสอบการกำหนดค่าและประวัติการทำงาน การเชื่อมต่อที่มีสิทธิ์ระดับผู้ดูแลระบบยังสามารถเรียกใช้ แก้ไข เปิดใช้งาน ปิดใช้งาน หรือลบระบบได้" + "บริบท --" + "กักกันข้อเสนอแล้ว" + "หยุดระบบอัตโนมัติชั่วคราวแล้ว" + "OpenClaw สำหรับอุปกรณ์เคลื่อนที่" + "A2UI reset" + "Gateway ไม่พร้อมใช้งาน" + "Read" + "Skills นี้ต้องตั้งค่า 1 รายการ โดย Android จะแสดงสิ่งที่ติดตั้งไว้ ส่วนการตั้งค่า/การเปลี่ยนแปลงการกำหนดค่าต้องดำเนินการบนเดสก์ท็อปหรือ CLI" + "การเรียกใช้ล่าสุด" + "จำเป็นต้องมีสิทธิ์เข้าถึงกล้องเพื่อสแกน QR สำหรับการตั้งค่า" + "ไม่สามารถอัปเดตโมเดลได้" + "กำลังผุดฟอง" + "thread reply" + "ลบ…" + "เชื่อมต่อ Gateway เพื่อตรวจสอบการทำงานอัตโนมัติ" + "บันทึกล่าสุด" + "กำลังโหลดการรันล่าสุด…" + "ไม่สามารถแสดงตัวอย่างไฟล์นี้ได้ ไฟล์อาจเป็นไฟล์ไบนารีหรือมีขนาดใหญ่เกินไป" + "ตรวจสอบ" + "อ่านตำแหน่งของโทรศัพท์เครื่องนี้" + "คีย์ของ Skill" + "ติดตั้ง %1$s แล้ว" + "Gateway" + "actions: %1$s" + "โหมดการส่งต่อ" + "%1$sพัน" + "สลับเค้าโครงเธรด" + "ไม่มีปลายทาง TLS" + "Gateway ของ OpenClaw" + "ตั้งค่าด้วยตนเอง" + "กำลังคิด…" + "จำเป็นต้องตรวจสอบการเข้าถึง Gateway" + "พักไว้ 1 รายการ" + "%1$sวินาที" + "ใช้ข้อเสนอหรือไม่?" + "ยังไม่ใช่ตอนนี้" + "ยังไม่ได้อนุมัติ" + "ค้นหาแอป" + "โมเดลที่กำหนดค่าแล้ว 1 รายการ" + "ปิดการแจ้งเตือนการอนุมัติ" + "·" + "ออฟไลน์" + "ผู้ให้บริการเสียงพูด" + "กำลังดำเนินการอนุมัติ Gateway OpenClaw จะลองใหม่โดยอัตโนมัติ" + "สูงสุด" + "การเปลี่ยนแปลง cron ต้องมีสิทธิ์เข้าถึง operator.admin" + "กำลังคิด" + "screen snapshot" + "โหนดที่สังเกต: %1$s" + "ไม่พบการดำเนินการ" + "บันทึกและเชื่อมต่อ" + "list" + "Gateway บันทึกการอนุมัติและบันทึกตัวเลือกไว้แล้ว" + "ป้อน endpoint ด้วยตนเองที่ถูกต้องเพื่อเชื่อมต่อ" + "ผู้ช่วย" + "กำลังส่งไปยังแชท..." + "บันทึกโปรไฟล์" + "ล็อกแล้ว" + "แก้ไขระบบอัตโนมัติ" + "ใช้เครือข่ายเดียวกัน หรือ URL ของ Gateway ระยะไกลที่ปลอดภัย" + "จุดยึด" + "ภาษา" + "แอปนี้เก่ากว่า Gateway โปรดอัปเดต OpenClaw บนอุปกรณ์นี้ แล้วลองอีกครั้ง" + "ทั้งหมด" + "เซสชัน Gateway กำลังดำเนินการ" + "รอการตรวจสอบ" + "ยังไม่ได้ติดตั้ง Skills" + "กำลังตรวจสอบ Gateway" + "เหลื่อมเวลา %1$s" + "ไม่ทราบผลลัพธ์สำหรับ %1$s โปรดเชื่อมต่อใหม่ รีเฟรช Skills แล้วลองอีกครั้ง โดย Gateway จะเข้าร่วมการติดตั้งที่ตรงกันและยังทำงานอยู่อย่างปลอดภัย" + "ลืม" + "ไม่มี Gateway ที่จับคู่แล้ว" + "%1$s · %2$s" + "<ข้อมูลลับถูกปกปิด>" + "ปัญหา %1$s รายการ" + "OpenClaw" + "กำลังฟัง · อยู่ในคิว %1$s รายการ" + "ปิดเสียงพูดของผู้ช่วยแล้ว" + "การดำเนินการกับโหนดจะทำงานเฉพาะเมื่อแอปเป้าหมายอยู่เบื้องหน้า (ตรวจสอบผ่านเส้นทางระยะไกล) การดำเนินการระดับส่วนกลางและการดำเนินการในแอปเดียวกันสามารถทำงานได้ที่นี่" + "ยังไม่พบ Gateway ใช้การตั้งค่าด้วยตนเองหากการค้นหาถูกบล็อก" + "เปิดเธรด" + "กำลังทำงาน" + "เริ่มพูด..." + "โหนดโทรศัพท์" + "สูงเป็นพิเศษ" + "เรียกใช้บนโฮสต์ Gateway:" + "การเปลี่ยนแปลง Skill ต้องใช้ operator.admin โปรดเชื่อมต่อใหม่ด้วยโทเค็น Gateway ที่มีสิทธิ์ผู้ดูแลระบบ" + "เชื่อมต่อ Gateway เพื่อตรวจสอบ Skills ใน ClawHub" + "รายการแอปจะอยู่บนโทรศัพท์เครื่องนี้" + "ไม่ได้ใช้งาน" + "แสดงในการตั้งค่าการช่วยการเข้าถึงของ Android" + "การส่งอัจฉริยะ" + "ปฏิเสธ" + "Gateway ส่งคืนสถานะ \'%1$s\' หลังจาก %2$s" + "ยังไม่ได้กำหนดค่าโทเค็น Gateway" + "Not available to this agent" + "ไฟล์" + "สิทธิ์" + "ไม่สามารถเปิดกล้องได้ โปรดเลือกรูปภาพคิวอาร์จากแกลเลอรีหรือป้อนรหัสตั้งค่าด้วยตนเอง" + "แตะเพื่อคัดลอก" + "รอ %1$s นาที" + "%1$s." + "เชื่อมต่อ Gateway เพื่อติดตั้ง Skills จาก ClawHub" + "ค้นหาเสียง" + " · ไมค์: กำลังฟัง" + "เชื่อมต่อใหม่ด้วยสิทธิ์เข้าถึง operator.admin เพื่อตรวจสอบและเปลี่ยนการตั้งค่า Gateway" + "โหลดเพิ่มเติม" + "สังเกตในอีก 3 วินาที" + "run" + "กำลังสร้างเสียง…" + "← กลับ" + "ตัดการเชื่อมต่อ" + "รันคำสั่ง approve บนคอมพิวเตอร์ Gateway แล้วตรวจสอบอีกครั้ง" + "การทำงานอัตโนมัติ" + "%1$s นาที" + "เชื่อถือ" + "คิวอาร์โค้ดนั้นไม่ใช่คิวอาร์สำหรับตั้งค่า OpenClaw โปรดสร้างรหัสใหม่ด้วย openclaw qr แล้วลองอีกครั้ง" + "ไมโครโฟนที่ต้องการไม่พร้อมใช้งาน กำลังใช้การกำหนดเส้นทางอัตโนมัติ" + "ปฏิเสธแล้ว" + "รวมแพ็กเกจของ Android และแพ็กเกจเบื้องหลัง" + "Gateway ของคุณพร้อมใช้งานแล้ว" + "ทริกเกอร์แล้ว" + "Structured Output" + "ใช้เวลานานกว่าที่คาดไว้\nตรวจสอบว่า Gateway กำลังทำงานและสามารถเข้าถึงได้" + "ไม่มีงานเบื้องหลังสำหรับเอเจนต์นี้" + "กำลังเชื่อมต่อใหม่" + "OpenClaw กำลังตรวจสอบ Gateway และการเข้าถึงโหนด" + "Code Execution" + "ไม่มีการใช้งานผู้ให้บริการ" + "ตรวจสอบ" + "จำเป็นต้องได้รับสิทธิ์ใช้ไมโครโฟน" + "%1$s วัน" + "พร้อมใช้งาน %1$s รายการ" + "OpenClaw กำลังซิงค์ข้อมูลอีกครั้ง" + "สตรีมเหตุการณ์ถูกขัดจังหวะ โปรดลองรีเฟรช" + "ไม่สามารถโหลดโหนดและอุปกรณ์ได้" + "เชื่อมต่อ Gateway เพื่อโหลด Skills" + "ไม่ทราบ" + "ผลลัพธ์" + "การพูดคุยล้มเหลว: ผู้ให้บริการเรียลไทม์ปิดโดยไม่คาดคิด" + "OpenClaw ไวต่อเวลา" + "ban" + "ต้องใช้โทเค็น Gateway" + "อุปกรณ์ที่จับคู่แล้ว" + "ต้องอนุมัติอีกครั้ง" + "ไม่ได้กำหนดเวลา" + "รายชื่อ" + "โทรศัพท์ของคุณจะไม่ส่งเสียงจนกว่าจะจำเป็น" + "กำลังฟัง · กำลังส่งเสียงที่อยู่ในคิว" + "ไม่สามารถโหลดรายละเอียดงานได้" + "ข้อความจากเอเจนต์" + "Gateway ต้องใช้อัตลักษณ์ของอุปกรณ์นี้ ยืนยันตัวตนใหม่หรือรีเซ็ตการเชื่อมต่อ Gateway นี้" + "เซสชันถัดไป" + "ความปลอดภัยของการเชื่อมต่อ" + "ข้ามไปก่อน" + "เว็บไซต์" + "เชื่อมต่อ Gateway เพื่อโหลดคำขออนุมัติในแอป" + "คัดลอก %1$s แล้ว" + "ยังไม่ได้เลือกแอป จะไม่มีการส่งต่อจนกว่าคุณจะเพิ่มแอป" + "%1$s %2$s" + "ต้องตั้งค่า" + "ไม่ได้จับคู่" + "Gateway ได้รับข้อมูลจากโทรศัพท์เครื่องนี้แล้ว" + "ไม่มีโมเดลที่กำหนดค่าไว้" + "ปิดใช้งาน" + "ภาษาของแอป" + "กำลังจับคู่ Gateway" + "ข้อมูลยืนยันตัวตนที่บันทึกไว้ไม่ถูกต้อง" + "%1$s ขอบเขต" + "เชื่อมต่อ Gateway เพื่อโหลดบันทึกล่าสุด" + "บันทึกคำปลุก" + "จัดการ Skills ที่ติดตั้งแล้วและเพิ่มรุ่นที่เชื่อถือได้จาก ClawHub" + "กำลังส่ง…" + "ยังไม่ได้โหลด Agents" + "ค้นหา ClawHub" + "แชตกำลังตรวจสอบสถานะของ Gateway" + "ต้องจับคู่" + "การทำงานที่ใช้งานอยู่" + "ล้มเหลว — %1$s" + "การเชื่อมต่อระหว่างโทรศัพท์เครื่องนี้กับ OpenClaw" + "summarize" + "บันทึกรูปภาพวิดเจ็ตไปยัง Downloads แล้ว" + "กำลังเริ่มต้น…" + "%1$s โทเค็น" + "ข้อผิดพลาดของไคลเอนต์" + "ตรวจสอบอุปกรณ์ที่ส่งคำขอนี้ก่อนอนุญาตให้เข้าถึง" + "ไมโครโฟน Bluetooth LE" + "%1$s %2$s" + "เปิดใช้งานระบบอัตโนมัติแล้ว" + "%1$sล้าน" + "Memory Get" + "%1$s · %2$s" + "เก็บถาวรแล้ว" + "โหลดใหม่" + "ค้นหาระบบอัตโนมัติ" + "โทรศัพท์ที่เชื่อมโยงและโฮสต์โหนดจะแสดงที่นี่หลังจากการจับคู่" + "%1$s: %2$s" + "ระบบอัตโนมัตินี้มีการเปลี่ยนแปลงบน Gateway โปรดตรวจสอบเวอร์ชันล่าสุดก่อนบันทึกอีกครั้ง" + "หยุดการป้อนตามคำบอก" + "อ่านง่าย" + "ส่งข้อความถึง OpenClaw" + "รหัสผ่าน Gateway ไม่ถูกต้อง ป้อนใหม่หรือรีเซ็ตการเชื่อมต่อ Gateway นี้" + "เชื่อมต่อใหม่" + "เวลาแบบ ISO เช่น 2026-07-09T09:30:00Z" + "เครื่องมือ %1$s รายการ" + "การตอบกลับก่อนหน้านี้ปฏิเสธการอนุมัตินี้แล้ว" + "เชื่อมโยงแล้ว" + "เปิด %1$s" + "%1$s/%2$s" + "การทำงานของระบบอัตโนมัติเสร็จสิ้นโดยมีสถานะที่ไม่ทราบ" + "คิวออฟไลน์เต็มแล้ว (%1$s ข้อความ) โปรดลบรายการที่อยู่ในคิวก่อน" + "Gateway สาธารณะต้องใช้ wss:// หรือ Tailscale Serve ส่วน ws:// ใช้ได้กับ localhost, โฮสต์ .local, โปรแกรมจำลอง Android และ IP ของ LAN ส่วนตัว" + "เชื่อมต่อ Gateway เพื่อโหลดรายละเอียด Skill" + "ต้องมีสิทธิ์เข้าถึงเต็มรูปแบบ" + "ตัวฟังคำปลุก" + "ขยายตัวอย่างลิงก์" + "ล้างการค้นหาเธรด" + "NULL (ล้มเหลว)" + "อัปเดต" + "ผู้ดูแลระบบ" + "ต้องตรวจสอบ" + "จับคู่อุปกรณ์นี้กับ Gateway ของคุณเพื่อปลุกอุปกรณ์เฉพาะเมื่อต้องทำงานจริง ดูภาพรวมของเอเจนต์แบบสดได้อย่างสะดวก และหลีกเลี่ยงลูปเบื้องหลังที่สิ้นเปลืองแบตเตอรี่" + "บทบาท" + "ตอบกลับ" + "แค็ตตาล็อกผู้ให้บริการ" + "เปิดใช้สิทธิ์อนุญาตในการตั้งค่า" + "A2UI push" + "ตรวจสอบการเข้าถึง" + "หากสามารถเข้าถึง Gateway ได้ การเชื่อมต่อใหม่ควรเสร็จสมบูรณ์โดยไม่ต้องดำเนินการใดๆ" + "รอบการทำงานของ Agent" + "กักกัน" + "ต้องดำเนินการ" + "กำลังค้นหา…" + "ฉันจะรับรหัสตั้งค่าได้จากที่ไหน?" + "ไม่สามารถเปิดใช้งาน Skill ได้" + "pdf" + "นำออก" + "%1$s%% ออนไลน์" + "ไม่มีช่องทาง" + "เสียงแบบเรียลไทม์" + "การดำเนินการปฏิเสธและกักกันของ Skill Workshop" + "โหนดและอุปกรณ์" + "ศูนย์คำสั่งภายในเครื่อง" + "emoji upload" + "กำลังโหลดตัวอย่าง…" + "สูง" + "focus" + "describe" + "บริบท %1$s" + "กำลังฟังคำตอบ..." + "voice" + "เชื่อมต่อกับ %1$s แล้ว" + "role add" + "แชทต้องได้รับการตรวจสอบ" + "เปิดใช้งานไมโครโฟน" + "OpenClaw จะรวบรวมและส่งชื่อ, รหัสแพ็กเกจ และสถานะของแอปที่มองเห็นได้บนโทรศัพท์เครื่องนี้เมื่อ OpenClaw Gateway ที่จับคู่ไว้ร้องขอ ซึ่งช่วยให้ผู้ช่วยของคุณตอบคำถามและดำเนินการโดยใช้แอปที่ติดตั้งไว้" + "Gateway ไม่ได้เชื่อมต่อ" + "นโยบาย" + "หมดเวลายืนยันข้อความที่ส่ง โปรดรีเฟรชเพื่อตรวจสอบการส่ง" + "ไฟล์สนับสนุน" + "นิพจน์" + "งานเบื้องหลัง" + "ฝัน" + "ยังไม่ได้บล็อกแอป แอปสามารถส่งต่อได้เว้นแต่คุณจะเพิ่มรายการบล็อก" + "ไม่สามารถใช้ตัวจดจำเสียงพูดได้" + "แพลตฟอร์ม" + "Gateway ไม่ได้ส่งคืนการตั้งค่า %1$s" + "ลืม Gateway หรือไม่?" + "คำอธิบายเพิ่มเติม (ไม่บังคับ)" + "เปิด %1$s" + "แคนวาสหน้าแรก" + "กำลังฝัน" + "%1$s ถึง %2$s" + "แชร์ไฟล์" + "เรียลไทม์" + "API" + "OpenClaw กำลังทำงาน…" + "พูดหรือป้อนตามคำบอกด้วย OpenClaw" + "แชร์ข้อมูลแอปที่ติดตั้งไว้หรือไม่?" + "กำลังโหลดการทำงานอัตโนมัติ…" + "ลบการทำงานอัตโนมัติ" + "ผู้ช่วยเริ่มต้น" + "เลือกผู้ให้บริการ %1$s ที่รองรับบน Gateway" + "ไม่พร้อมใช้งาน" + "โฟลเดอร์ว่าง" + "เปิดการตั้งค่า" + "ปิด" + "รูปแบบตัวอักษร" + "หยุด" + "ยังไม่มีเธรดที่ตรงกัน" + "จับคู่ Gateway สำเร็จแล้ว\nอนุมัติความสามารถของโหนดสำหรับโทรศัพท์เครื่องนี้จาก UI ของผู้ดูแลระบบ" + "Skills นี้ได้รับการติดตั้งแล้ว แต่ยังไม่พร้อมใช้งานในขณะนี้ โปรดใช้เดสก์ท็อปหรือ CLI เพื่อเปลี่ยนแปลงการกำหนดค่า" + "ตัวรู้จำไม่ว่าง" + "Gateway หลัก" + "เรียกใช้คำสั่งอนุมัติบน Gateway" + "ปิดใช้งานบริการแล้ว" + "ไม่สามารถโหลดข้อเสนอ Skill Workshop ได้" + "สรุปความคืบหน้าจากเธรด OpenClaw ล่าสุดของฉันและแนะนำขั้นตอนถัดไป" + "ไม่ใช่ตอนนี้" + "openclaw qr" + "start" + "OpenClaw Node · สนทนา" + "อ่านและอัปเดตกิจกรรม" + "การพูดคุยล้มเหลว: ผู้ให้บริการเรียลไทม์ปิด: %1$s" + "เชื่อมต่อ Gateway เพื่อเรียกดูไฟล์ในพื้นที่ทำงาน" + "%1$s ผ่านรีเลย์ Gateway" + "ไม่สามารถโหลดแค็ตตาล็อกการพูดของ Gateway ได้" + "กำลังตรวจสอบ · งานที่กำหนดเวลาไว้ 1 รายการ" + "ทุก %1$s ชั่วโมง" + "พื้นผิวหน้าจอ" + "การแปล OpenClaw · %1$s" + "คำขอคำสั่ง" + "เป็นเวอร์ชันล่าสุด" + "ช่อง" + "เปิดเสียง" + "กลุ่มใหม่…" + "กำลังเตรียมเสียง…" + "ปรับอัตโนมัติ" + "เร็วๆ นี้" + "worker อีก %1$s รายการ" + "Web Search" + "ลองใช้แชท เสียง เธรด ผู้ให้บริการ หรือการตั้งค่า" + "OpenClaw ทำงานอยู่" + "navigate" + "ส่งคำขอเมื่อ %1$s" + "เชื่อมต่อ Gateway เพื่อตรวจสอบประวัติการทำงานของระบบอัตโนมัติ" + "การเข้าถึงอุปกรณ์ โดยยังต้องเลือกเปิดใช้ Gateway" + "ยกเลิกแล้ว" + "ป้อนรหัสการตั้งค่าหรือที่อยู่ Gateway ที่ถูกต้อง" + "โมเดล" + "OpenClaw แบบพาสซีฟ" + "รหัสผ่าน Gateway ไม่ถูกต้อง" + "ไม่สามารถตรวจสอบการเปลี่ยนแปลงการจับคู่อุปกรณ์ได้ โปรดรีเฟรชแล้วลองอีกครั้ง" + "ดูรายละเอียด" + "Bash" + "โทเค็น" + "เอเจนต์ OpenClaw ที่เชื่อมต่ออยู่สามารถใช้ความสามารถของอุปกรณ์ที่คุณเปิดใช้งานได้ ดำเนินการต่อเฉพาะเมื่อคุณเชื่อถือ Gateway และเอเจนต์ที่คุณเชื่อมต่อเท่านั้น" + "กำลังเกาะเพรียง" + "อนุญาตการเข้าถึงรูปภาพที่เลือกหรือทั้งหมดแล้ว" + "ตัวดำเนินการช่วยการเข้าถึง" + "ขาด %1$s รายการ" + "ยุบรายการตรวจสอบแผน" + "ต้องอนุมัติโหนด" + "เชื่อมต่อ Gateway" + "... +%1$s เพิ่มเติม" + "ขยายรายการตรวจสอบแผน" + "เบราว์เซอร์" + "screen record" + "รอการเรียกใช้" + "การเปิดใช้งานจะทำให้ OpenClaw สังเกตและควบคุมหน้าจอของแอปอื่นได้เมื่อเปิดใช้ ต้องมีการเข้าถึงการช่วยการเข้าถึงของ Android" + "ต้นทาง" + "AI ส่วนตัวบนอุปกรณ์ของคุณ" + "Attach" + "อัตโนมัติ" + "ภาพรวม" + "ขอคืนค่าไม่สำเร็จ แตะเพื่อลองอีกครั้ง" + "วิดีโอ" + "%1$s\n\n" + "ไม่เข้ารหัส" + "ปฏิทิน" + "สถานะ Gateway ไม่ปกติ ไม่สามารถส่งได้" + "📎 %1$s" + "สถานะล่าสุด" + "รอให้การตอบกลับปัจจุบันเสร็จสิ้นก่อนเริ่มแชตใหม่" + "โปรไฟล์" + "ขีดจำกัดของผู้ให้บริการจะแสดงที่นี่เมื่อ Gateway ของคุณรายงานข้อมูลดังกล่าว" + "ปัญหา 1 รายการ" + "เธรดใน \"%1$s\" จะยังคงอยู่และย้ายกลับไปที่ไม่ได้จัดกลุ่ม" + "แนะนำ" + "สร้างเมื่อ" + "โทเค็นที่ใช้งานอยู่ %1$s/%2$s" + "ไม่มีผลลัพธ์การดำเนินการ" + "กำลังงับ" + "%1$s…" + "เปิดรายละเอียด Skill" + "อ่านออกเสียงไม่สำเร็จ: %1$s" + "เริ่มการสนทนา" + "ไม่สามารถโหลดโฟลเดอร์นี้ได้" + "คิวอาร์โค้ดไม่มีรหัสตั้งค่าที่ถูกต้อง" + "ตรวจสอบสิทธิ์การเข้าถึงโหนด" + "เพิ่มวลีปลุก" + "ไม่สามารถเข้าถึง gateway ได้" + "การทำงานอัตโนมัติ" + "ต้องเชื่อมต่อ" + "ไม่สามารถดำเนินการอนุมัติให้เสร็จสิ้นได้ โปรดรีเฟรชแล้วลองอีกครั้ง" + "import" + "ลักษณะที่โทรศัพท์เครื่องนี้ปรากฏต่อ OpenClaw" + "โฟกัสการค้นหาเธรด" + "เชื่อมต่อ Gateway" + "อ่านปฏิทิน" + "ภาพรวมจะรีเฟรชเมื่อเชื่อมต่อใหม่และเมื่อเปิดหน้าจอนี้" + "ไม่สามารถปิดใช้งาน Skill ได้" + "ยังคงกำลังเชื่อมต่อ" + "ในอีก %1$s นาที" + "อ่าน SMS" + "เชื่อมต่อ Gateway เพื่อโหลดการใช้งาน" + "ตอนนี้คุณช่วยฉันทำอะไรจากโทรศัพท์เครื่องนี้ได้บ้าง?" + "ต้องได้รับการอนุมัติ" + "แชทใหม่" + "เชื่อมต่อ Gateway เพื่ออัปเดตข้อเสนอ Skill Workshop" + "คำขอ OpenClaw ล้มเหลว" + "ต้องมีสิทธิ์อนุญาต" + "ตรวจสอบความพร้อมของผู้ให้บริการ\nและโมเดลที่กำหนดค่าไว้" + "กำลังโหลด" + "การแจ้งเตือนความล้มเหลว" + "ธีมและข้อความ Android ที่แปลแล้ว" + "ปิดไมค์ · กำลังส่ง…" + "ไม่มี" + "ดู" + "ชื่อ" + "เวอร์ชัน" + "Cron" + "เชื่อมต่อโทรศัพท์เครื่องนี้กับ Gateway ก่อนเปิด OpenClaw" + "นำวลีปลุกออก" + "ไม่ยอมรับรหัสตั้งค่า โปรดสร้างรหัสใหม่ด้วย openclaw qr" + "14 ข้อความ · Android" + "ถอดเสียงไม่สำเร็จ: %1$s" + "ตลอดเวลา" + "ไม่สามารถโหลดการฝันได้" + "เพิ่มการทำงานของระบบอัตโนมัติลงในคิวแล้ว" + "Conversation Turn" + "ระบบอัตโนมัติเริ่มทำงานแล้ว" + "กลุ่มใหม่" + "ข้อผิดพลาดของเซิร์ฟเวอร์" + "Video Generation" + "การอนุมัติ Gateway อยู่ระหว่างรอดำเนินการ เรียกใช้ openclaw devices list บนโฮสต์ Gateway อนุมัติโทรศัพท์เครื่องนี้ แล้วลองอีกครั้ง" + "รายการจะปรากฏหลังจากรอบการฝันเขียนสรุปเชิงบรรยาย" + "%1$s มิลลิวินาที" + "ที่เก็บหน่วยความจำ" + "ผู้ช่วยกำลังทำงาน" + "OpenClaw สามารถแสดงรายการแอปที่มองเห็นได้ใน Launcher" + "พูดไม่สำเร็จ: %1$s" + "ค้นหา Skills ที่ติดตั้ง" + "ตรวจสอบ" + "Process" + "เธรดล่าสุด" + "เทอร์มินัล" + "ปัจจุบัน" + "1 บัญชี" + "หยุดชั่วคราว" + "อนุญาตกล้อง" + "คำขออนุมัติการ Exec จะปรากฏที่นี่ขณะที่โทรศัพท์เครื่องนี้เชื่อมต่ออยู่" + " · ไมค์: รอดำเนินการ" + "คัดลอก" + "คัดลอกรายละเอียดแล้ว" + "ลบ" + "ขอให้ OpenClaw ใช้ความสามารถของ Android" + "member" + "กำลังตรวจสอบว่า Gateway นี้รองรับผู้ช่วยตั้งค่า OpenClaw หรือไม่" + "ใช้ตัวเลือกการกู้คืนด้านล่างเพื่อเชื่อมต่ออีกครั้ง" + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "ไม่สามารถโหลดช่องได้" + "ในอีก %1$s วัน" + "ข้อผิดพลาดต่อเนื่อง" + "ไม่สามารถอ่านคิวอาร์โค้ดจากรูปภาพนั้นได้ โปรดเลือกรูปภาพที่ชัดเจนกว่านี้หรือป้อนรหัสตั้งค่าด้วยตนเอง" + "Gateway เก่ากว่าแอปนี้ โปรดอัปเดต OpenClaw บนโฮสต์ Gateway แล้วลองอีกครั้ง" + "เชื่อมต่อก่อนใช้แชต เสียง และสถานะแบบเรียลไทม์" + "เชื่อมต่อ Gateway อีกครั้ง" + "บุคคลที่สาม" + "ตรวจสอบความพร้อม" + "จำกัด" + "โลโก้ OpenClaw" + "เลิกปักหมุดโมเดล" + "พื้นผิวการส่งข้อความที่เชื่อมต่อกับ Gateway นี้" + "กำลังส่ง" + "เธรดที่เก็บถาวรจะแสดงที่นี่" + "คัดลอกคำสั่งแล้ว" + "ไม่มีตัวอย่างให้ใช้งาน" + "อนุมัติโทรศัพท์เครื่องนี้บน Gateway\nจากนั้นลองเชื่อมต่ออีกครั้ง" + "สแกนรหัส QR" + "ไดเรกทอรีทำงานของคำสั่ง · ไม่สามารถล้างได้" + "กิจกรรมของเธรด" + "พร้อมใช้งาน" + "ลบระบบอัตโนมัติหรือไม่?" + "%1$s วันนี้ · ทั้งหมด %2$s" + "รหัสผ่าน" + "กักกันข้อเสนอหรือไม่?" + "ไม่มีประกาศใบอนุญาตรวมอยู่ในบิลด์นี้" + "ไม่สามารถบันทึกรูปภาพวิดเจ็ตได้" + "รอมาแล้ว %1$s" + "กำลังพูด…" + "ผู้ให้บริการและโมเดล" + "โหนด" + "%1$s " + "ไม่มีพรอมต์" + "บันทึก" + "เชื่อมต่อ Gateway เพื่อตรวจสอบข้อเสนอ Skill Workshop" + "เครื่องมือ" + "สวิตช์ Gateway" + "ส่ง SMS" + "OpenClaw พร้อมดำเนินการต่อในแชทปกติของคุณแล้ว" + "ไม่พบคำสั่ง" + "ยังไม่มีการอัปเดต Canvas แตะเพื่อลองอีกครั้ง" + "เทอร์มินัลต้องมี Gateway ที่เชื่อมต่ออยู่" + "Exec" + "ตัวกรองแอป" + "หลัก" + "%1$sk" + "ต้องมี Gateway" + "การเข้าถึง" + "แพ็กเกจ: snapshot=%1$s foreground=%2$s" + "ลองเชื่อมต่ออีกครั้ง" + "ตัวจัดกำหนดการ Cron หยุดทำงานแล้ว" + "เปิด" + "คัดลอกข้อความแล้ว" + "เราไม่สามารถเข้าถึง Gateway ของคุณได้\nมาแก้ไขปัญหานี้กัน" + "ตอนนี้" + "ลบหลังจากเรียกใช้" + "เลือกไว้ในโทรศัพท์เครื่องนี้" + "unpin" + "Session History" + "เลิกปักหมุด" + "ใช้โทรศัพท์เครื่องนี้" + "ไม่สามารถโหลดรายละเอียดจาก ClawHub สำหรับ %1$s ได้" + "เครื่องมือกำลังทำงาน" + "แชร์ตำแหน่งที่แม่นยำขณะเปิดใช้งานตำแหน่ง" + "Mobile UI" + "ธีม" + "Gateway ยังคงแสดงการอนุมัตินี้เป็นรอดำเนินการ กรุณาตรวจสอบก่อนลองอีกครั้ง" + "เสร็จสิ้นข้อความเสียง" + "การป้อนตามคำบอก: %1$s" + "ไม่ได้รับอนุญาต" + "เลือกรูปภาพอื่น" + "ตัวอย่างรูปภาพ" + "OpenClaw จะฟังเฉพาะเมื่อคุณเริ่ม Talk หรือ Dictation เท่านั้น" + "แชร์จำนวนก้าวและกิจกรรม" + "ต้องตั้งค่า" + "อัปเดต Gateway นี้เพื่อใช้ผู้ช่วยตั้งค่า OpenClaw" + "การเชื่อมต่อ Gateway นี้ต้องมีสิทธิ์ operator.admin เพื่อติดตั้ง Skills จาก ClawHub" + "นำข้อเสนอไปใช้แล้ว" + "รอดำเนินการ %1$s รายการ" + "%1$s ชั่วโมงที่แล้ว" + "อ่านบันทึกการโทร" + "อยู่ในคิว %1$s รายการ · กำลังรอ Gateway" + "ย้ายไปยังกลุ่ม" + "สแกน QR เพื่อจับคู่" + "ปฏิเสธการอนุมัติแล้ว" + "ไม่สามารถตรวจสอบข้อเสนอ Skill Workshop ได้" + "ปักหมุดแล้ว" + "โปรไฟล์และอุปกรณ์" + "ปิดตัวเลือกระดับการคิด" + "ไม่สามารถเพิ่มข้อความลงในคิวเพื่อส่งภายหลังได้" + "กักกัน" + "กำหนดการ · %1$s" + "ไม่สามารถอัปเดตระดับการคิดได้" + "เปิดตัวเลือกระดับการคิด" + "การตอบกลับด้วยเสียงหมดเวลา กำลังลองส่งรายการที่รออยู่อีกครั้ง" + "เลย์เอาต์: รายละเอียด" + "ไม่สามารถถอดรหัสรูปภาพนี้ได้" + "Gateway, เสียง, การแจ้งเตือน, ความเป็นส่วนตัว" + "ไฟล์พื้นที่ทำงานของเอเจนต์" + "อุปกรณ์นี้จะสูญเสียสิทธิ์การเข้าถึง Gateway ที่เชื่อถือได้" + "ใช้ requestId จากคำสั่งที่รอดำเนินการในคำสั่ง approve" + "กำหนดการ" + "ขีดจำกัดอัตรา" + "ยังไม่ได้ส่ง" + "เพย์โหลด · %1$s" + "กำลังทำงาน" + "กำลังตะปบ" + "สิ้นสุด" + "ใช้ความเชื่อถือของระบบ" + "ไม่มีผู้ให้บริการที่พร้อมใช้งาน" + "ให้ความสำคัญกับไมโครโฟน Bluetooth ที่เชื่อมต่ออยู่" + "บล็อกไม่ให้ส่งต่อ %1$s แอป" + "การดำเนินการกับข้อความ" + "ประเภท" + "ยกเลิกการเก็บถาวร" + "Transcripts" + "คำปลุก" + "กำหนดค่า %1$s บน Gateway" + "สแกนคิวอาร์โค้ดหรือใช้รหัสตั้งค่าจาก OpenClaw Gateway ของคุณ" + "ต้นแบบระบบการออกแบบ" + "กำลังคัดกรอง" + " · สนทนา: เปิด" + "ยังไม่มีข้อมูลการใช้งาน" + "แชตล้มเหลวก่อนเริ่มการทำงาน โปรดลองอีกครั้ง" + "ส่ง" + "รูปภาพที่แชร์บางรูปถูกละเว้นหรือไม่สามารถเพิ่มได้" + "เขียนปฏิทิน" + "timeout" + "ต่ำ" + "รายการที่บล็อก" + "act" + "Dismiss Task" + "แชทล้มเหลว" + "OpenClaw · สด" + "ติดตั้งแล้ว" + "หมดเวลารอการตอบกลับ โปรดลองอีกครั้งหรือรีเฟรช" + "ค้นหาการสนทนาก่อนหน้า" + "เรียกดูเธรด" + "กำลังรีเฟรช" + "กำลังงมหาไข่มุก" + "เปิดกล้องและจัดให้รหัสจาก openclaw qr อยู่ในกรอบ" + "ไม่มีอุปกรณ์" + "ส่งต่อการแจ้งเตือน" + "ฉันจะแยกการสนทนานี้ออกจากแชทของ agent ทั่วไป" + "เซสชัน Gateway กำลังกลับมาออนไลน์ ทางลัดของเอเจนต์ควรกลับมาเป็นปกติโดยอัตโนมัติในอีกสักครู่" + "ลองค้นหาด้วยคำอื่นหรือล้างคำค้นหาปัจจุบัน" + "อนุญาตตำแหน่งเบื้องหลังหรือไม่?" + "กำลังโผล่ขึ้นสู่ผิวน้ำ" + "เริ่มต้นระบบ" + "%1$s · %2$s · %3$s" + "ยกเลิกข้อความเสียง" + "เลื่อนกลับ" + "openclaw gateway" + "จับคู่ Gateway แล้ว" + "กำลังลอกคราบ" + "กำลังฟังรอบถัดไปของคุณ" + "OpenClaw กำลังทำงาน" + "รายการบันทึก" + "ล้มเหลว: ไม่สามารถเข้าถึงปลายทาง gateway ที่ปลอดภัยสำหรับโฮสต์นี้ได้" + "Gateway ออฟไลน์ แก้ไขการเชื่อมต่อด้านล่างหรือคัดลอกข้อมูลการวินิจฉัย" + "สแตนด์บาย" + "ทดสอบ ทดสอบ 1 2 3" + "ไม่สามารถค้นหา Skills ใน ClawHub ได้" + "ไม่มีพรอมต์" + "กล้องหน้า" + "เปิดรายการบันทึก" + "หมดเวลาการเชื่อมต่อเครือข่าย" + "ตอนนี้" + "เปลี่ยนชื่อกลุ่ม…" + "เอเจนต์เพิ่มเติม" + "openclaw nodes approve REQUEST_ID" + "ปักหมุด" + "thread list" + "เปิด %1$s" + "upload" + "ยังไม่ได้กำหนดค่ารหัสผ่าน Gateway" + "การตั้งค่าการป้อนตามคำบอก" + "โหลดโมเดลของผู้ให้บริการแล้ว แต่ไม่มีข้อมูลความพร้อมใช้งาน" + "ลบเธรดหรือไม่?" + "OpenClaw เปลี่ยนโทรศัพท์เครื่องนี้ให้เป็นอินเทอร์เฟซคำสั่งบนมือถือที่เรียบง่ายสำหรับเธรด เสียง ผู้ให้บริการ และ Gateway" + "ใหม่สุดก่อน" + "รอบถัดไป" + diff --git a/app/src/main/res/values-tr/assistant.xml b/app/src/main/res/values-tr/assistant.xml new file mode 100644 index 0000000..935dbcb --- /dev/null +++ b/app/src/main/res/values-tr/assistant.xml @@ -0,0 +1,7 @@ + + + "OpenClaw\'a %1$s sor" + "OpenClaw\'a %1$s söyle" + "OpenClaw\'ı aç ve %1$s sor" + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..76ef60d --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Bu ağ geçidine güvenilsin mi? + Güven ve devam et + İptal + Worktree\'de yeni sohbet + Bu ağ geçidine güvenmeden önce sertifika parmak izini doğrulayın.\n\n%1$s + Ağ geçidi sertifikası değişti. Yalnızca bu değişikliği bekliyorsanız devam edin.\n\nEski SHA-256:\n%1$s\n\nYeni SHA-256:\n%2$s + Bilinmiyor + SÜRÜM + COMMIT + DERLEME + Sürüm %1$s + Git commit\'i %1$s + %1$s UTC tarihinde derlendi, zaman damgası %2$s + Derleme tarihi %1$s + Git commit\'inin tam hash değerini kopyala + Tam derleme zaman damgasını kopyala + OpenClaw Git commit\'i + OpenClaw derleme zaman damgası + Git commit\'i kopyalandı + Derleme zaman damgası kopyalandı + + "Ek, gönderilmek üzere hazırlanamadı." + "Mikrofon kapalı" + "OpenClaw uyarılarını göster" + "Konu dizisi etkinliği" + "Tam" + "Onay verildi ve kaydedildi." + "Son arama geçmişini göster" + "1 beklemede" + "Desteklenmeyen ek" + "0 = tam" + "Konuları aramak için Gateway\'e bağlanın." + "%1$s hesap" + "Cron değişiklikleri operator.admin gerektirir. Kurulum kodları bunu kasıtlı olarak sağlamaz. Yönetici erişimi istemek için Gateway\'in paylaşılan token\'ı veya parolasıyla yeniden bağlanın. Bu cihazda hâlâ bu yetki yoksa bekleyen kapsam yükseltmesini mevcut bir yönetici istemcisinden onaylayın." + "Apply Patch" + "Kıskaçlıyor" + "Hoparlörün sesini aç" + "Ardışık Atlamalar" + "Bu klasörde henüz dosya yok." + "Bağlı değil" + "Yüklü skill durumunu inceleyin ve yönetin." + "Başarısız" + "Varsayılan Agent" + "Kamera" + "Gruptan kaldır" + "Aranıyor" + "Ses oynatma için duraklatıldı" + "Gateway, indirmeden önce bu sürümün aynısını ClawHub ile doğrulayacaktır. Sürüm için açıkça risk kabulü gerekiyorsa Android, yeniden denemeden önce Gateway uyarısını gösterecektir." + "Kurulum kodu bir IPv6 bölge kimliği kullanıyor. Kapsamsız bir IPv6 adresi veya bir LAN ana makine adı kullanın." + "Ek" + "Uyandırma sözcüklerini, konuşmayı ve oynatmayı yapılandırın." + "Dinleniyor (PTT)" + "Teklif reddedildi." + "Kenar Çubuğunu Göster" + "kullanıcı" + "%1$s · %2$s" + "Minimum" + "Reddet" + "AKTİF ARACI" + "1 zamanlanmış" + "Yanıt yok" + "%1$s seçildi" + "Komut argv JSON dizisi" + "Bu görüntü okunamadı. openclaw qr tarafından oluşturulan QR kodunun net bir ekran görüntüsünü veya görselini seçin." + "Skill Workshop teklifi için %1$s işlemi gerçekleştirilemedi." + "Başka yerde yanıtlandı" + "Gateway onayı bir kez kaydetti." + "status" + "OpenClaw, konumu yalnızca eşleştirilmiş Gateway\'iniz istediğinde kontrol eder. Uygulama arka plandayken kontrollere izin vermek için bir sonraki Android ekranında %1$s seçeneğini belirleyin." + "reddet" + "Kontrast" + "Gateway kurulumu değiştirilsin mi?" + "Otomasyonlar yüklenemedi." + "Siz" + "Yerleşik mikrofon" + "Yüzey" + "Öneri yok" + "Ana iletişim dizisi" + "Sohbeti Aç" + "Cihaz eşleştirme işlemleri bu Gateway oturumunda kullanılamıyor. Gateway ana makinesinde openclaw devices list komutunu çalıştırın ve isteği buradan yönetin. Düğüm yeteneği onayı ayrı bir işlemdir ve hâlâ nodes approve <request id> komutunu kullanır." + "Eylem İsteği" + "list pins" + "Skill Atölyesi önerilerini yüklemek için bir Gateway\'e bağlanın." + "Kurulum kodu kabul edilmedi" + "Oturumu Kapat" + "Gerçek zamanlı transkripsiyon sağlayıcısı yapılandırılmamış." + "Sistem Uygulamalarını Göster" + "Sağlayıcı model yapılandırmasını görüntülemek için Gateway\'inizi güncelleyin." + "Dikte gönderiliyor" + "Markdown içeriğini yüklemek için bu teklifi inceleyin." + "OpenClaw\'ı aç ve %1$s sor" + "akıl yürütme" + "İstemci" + "Uygulandı" + "video" + "Yükseltildi" + "Çevrimiçi" + "Kapsamlar" + "Gerçek zamanlı ses sağlayıcısı yapılandırılmamış." + "%1$s · %2$s" + "kick" + "Gateway geçersiz bir otomasyon döndürdü." + "Örnek Kimliği" + "Gateway belirteci gerekli. Tekrar girin veya bu bağlantıyı düzenleyin." + "Kaynak" + "Yenile" + "%1$s kuyrukta" + "Sohbet Başlat" + "Etkin konu dizisinde bekleyen sohbet aracı çağrıları burada görünmeye devam eder." + "Sertifika incelemesi gerekli" + "Mevcut Canvas yüzeyini incelemek veya onunla etkileşime geçmek için açın." + "Otomasyon güncellendi." + "Son oturum yok" + "Betik" + "Gateway durumu, telefon düğümü hazır olma durumu ve son günlük akışı." + "Otomasyon ayrıntısını aç" + "Çalışma zamanı" + "1 çalışan daha" + "Aracı %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Devam etmek için lütfen Android Ayarları’nda %1$s özelliğini etkinleştirin." + "Onar" + "reactions" + "Bitti" + "Sürüm ve güncelleme" + "OpenClaw onayları, başarısız işleri ve kanal sorunlarını burada gösterecek." + "USB mikrofonu" + "Eklenmeyi bekleyen çok fazla paylaşım var." + "Atlandı" + "Gateway bilgisayarının LAN adresini veya güvenli uzak ana makine adını kullanın." + "Açık" + "Konular aranıyor" + "Gerçek Zamanlı Konuşma" + "· %1$s" + "OpenClaw bir yanıt hazırlıyor." + "Onay bir kez verildi." + "Sağlayıcı kurulumu" + "yok" + "Betik yükleri değiştirilmeden korunur. Bu betiği düzenlemek için CLI\'ı kullanın." + "Android kurulum kılavuzu" + "%1$s uygulamanın yönlendirmesi engellendi." + "Eşlenmiş Cihazlar" + ":%1$s" + "%1$s bekliyor" + "Cihaz adı" + "Gönder" + "Konum" + "örn. America/New_York" + "Oturum hedefi" + "ClawHub Skill\'ini incele" + "snapshot" + "Bu cihazdan gelen eşleştirme isteği reddedilsin mi?" + "Tercih edilen mikrofon" + "Düğüm ana makinesi" + "Düzey" + "Uygulama Seçiciyi Kapat" + "Paylaşılan bir Gateway token\'ı veya operatör tarafından verilen token\'ı yapıştırın." + "Tüm sistemler normal" + "Gateway tanılamaları kopyalandı" + "Ses hatası" + "Kurulumu değiştir" + "Hızlı işlemler" + "Gönderme başarısız oldu: Sohbet, çalıştırma başlamadan önce başarısız oldu; tekrar deneyin." + "Mikrofon" + "Sohbet hâlâ Gateway durumunu kontrol ediyor." + "Kesin Konum" + "Bir Kez İzin Ver" + "+%1$s daha" + "thread create" + "Engellendi" + "Uyandırma sözcüğü veya ifadesi" + "Gateway için cihaz onayı gerekiyor" + "Harici mikrofon" + "%1$s/%2$s hazır" + "Bağlandı (operatör çevrimdışı)" + "Yetenek onaylanmadı" + "Bu işlem, otomasyonu ve zamanlamasını Gateway\'den kalıcı olarak kaldırır." + "Görsel yükleniyor…" + "Bağlan" + "Düğüm erişimini onayla" + "Gateway Ekle" + "Transkripsiyon kullanılamıyor: %1$s" + "Görsel" + "Gelgit yapıyor" + "Görsel önizlemesini kapat" + "eval" + "Son komut: %1$s" + "OpenClaw çalıştıran cihazda bir terminal açık olsun." + "Eksik öğe yok" + "Tuval çıktısı için etkin bir Gateway bağlantısı gerekir." + "%1$s · %2$s" + "Yalıtılmış" + "© 2026 OpenClaw Foundation — MIT Lisansı." + "PDF" + "Conversations" + "Bellek pekiştirme ve rüya günlüğü." + "Create Goal" + "Bu otomasyon, siz düzenlerken değişti. Kaydetmeden önce en son Gateway sürümüne geri dönün." + "Bağlantı kurulduğunda Gateway, sürekli açık bir oturum sürdürmek yerine sessiz bir anlık bildirimle telefonu uyandırabilir." + "Uyandırma Modu" + "Eşleştirilmiş cihaz kaldırılsın mı?" + "Sistem etkinliği metni" + "Widget görseli kopyalanamadı" + "Hayır" + "İsteğe bağlı yol" + "Sıradaki ses gönderiliyor" + "Yerleşik" + "hide" + "runs" + "Gateway parolası gerekli. Tekrar girin veya bu bağlantıyı düzenleyin." + "Etkinlik metni" + "Canlı transkript" + "Sağlayıcı model yapılandırması yüklenemedi." + "%1$s uygulamanın yönlendirmesine izin verildi." + "Ses kurulumu" + "Video ekle" + "Ek görseller gizlendi: %1$s" + "Eşleştirme isteği reddedilsin mi?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Yayıncı" + "Buradan çatalla" + " · Konuşma: Konuşuyor" + "İsteğe bağlı geçersiz kılma" + "Varsayılan" + "Komut onayı" + "Uygulama ve Gateway uyumsuz protokol sürümleri kullanıyor. Her ikisindeki OpenClaw\'u da güncelleyin ve ardından yeniden deneyin." + "Ekranı Yenile" + "Son fotoğrafları ve medyayı oku" + "Dinle" + "Bağlandı" + "Tamamlandı" + "Telefonunuz %1$s ile eşleştirildi. Düğüm erişimini tamamlamak için devam edin." + "Geçerli konu" + "Düşünme %1$s" + "Sessize alındı" + "OpenClaw, açık kaynak topluluğundaki iş ortaklarına teşekkür eder." + "açık" + "Gateway\'e Bağlanılıyor" + "Gateway bu yolu değiştirebilir ancak mevcut bir yolu temizleyemez." + "TTS" + "Kaydediliyor…" + "Çapa %1$s" + "search" + "Etkinleştir" + "Zamanlanmış Gateway işlerini inceleyin ve yönetin." + "Önceki bir yanıt bu komuta zaten bir kez izin verdi." + "generate" + "Yalnızca güvenilir bir özel ağda kullanın." + "Ayarlarda ara" + "Konuşma canlı" + "Gateway kimlik doğrulaması yapılandırılmamış. Bu bağlantıyı düzenleyin ve tekrar deneyin." + "Başarısız: Güvenli uç noktaya ulaşıldı, ancak TLS parmak izi doğrulaması zaman aşımına uğradı. Tailscale Serve veya gateway TLS’yi kontrol edip yeniden deneyin." + "1. Adım" + "Dikte" + "Uygulama Seçiciyi Aç" + "Bekleyen onay yok" + "edit" + "Gateway\'nize bağlanın" + "openclaw qr tarafından oluşturulan kurulum kodunu girin." + "Tanılama" + "Diğer uygulamalara dokunulmaz." + "Çatlıyor" + "Bu işlem, konu dizisini ve dökümünü kalıcı olarak siler." + "Cihaz onaylandı." + "Otomatik olarak yeniden deneniyor" + "Widget görseli kopyalandı" + "%1$s rol" + "1 eksik öğe" + "%1$s zamanlanmış" + "react" + "Aracılar" + "Otomasyonları yüklemek için Gateway\'e bağlanın." + "Yeniden bağlanılıyor…" + "Kuruluma dön" + "send" + "Bağlantı test edilemedi" + "Doğrula ve yükle" + "Bu cihazı sohbet, ses, kamera ve cihaz araçları için güvenli bir OpenClaw düğümüne dönüştürün." + "Manuel kurulum" + "Geçerli konu dizisini başlatmak veya sürdürmek için Sohbet\'i açın." + "İpucu: yakalanan sırayı göndermek için dinlemeyi durdurun." + "Atla" + "Sesli istek başarısız oldu" + "Bu işlem \"%1$s\" teklifini reddedecek ve Skill Workshop durumunu Gateway\'den yenileyecek." + "Kabuklanıyor" + "update" + "Paylaş" + "Kamera etkin" + "Telegram, WhatsApp, e-posta ve diğer kanallar kurulumdan sonra burada görünür." + "Ağ hatası" + "Gelgit havuzları araştırılıyor" + "session=%1$s source=%2$s için Canvas\'ı şimdi geri yükle. Mevcut A2UI durumu varsa hemen yeniden oynat. Yoksa Canvas\'ta mobil uyumlu, kompakt bir pano oluştur ve görüntüle." + "Başlatılamadı: %1$s" + "Talep edilmedi" + "Gateway üzerinde bir %1$s sağlayıcısı yapılandırın" + "kill" + "Onaylar" + "Dosyalar kullanılamıyor" + "Okunmadı olarak işaretle" + "Kişileri ve iletişim bilgilerini bul" + "Cihaz kimliği gerekli" + "OpenClaw konusu" + "Fotoğraf arşivine erişime izin verin." + "Önceki bir yanıt bu onayı zaten çözdü." + "Son iletişim dizisi yok" + "Zaman aşımı %1$s sn." + "Eşleşme yok" + "Seçilen uygulamaların bildirimlerini oku" + "Kullanılabilirlik bilinmiyor" + "Konuşmayı Ayarla" + "Ek" + "Gateway eşleştirildi. Operatör erişimi bekleniyor." + "Görsel ekle" + "OpenClaw\'a neyin ulaşacağını seçin." + "Yetenek için yeniden onay bekleniyor" + "Vurgulanan öğeleri inceleyin" + "Dinleniyor..." + "Beni bilgilendir" + "Mesaj" + "Kişileri Oku" + "Çevrimdışı ek depolama alanı dolu; önce kuyruktaki öğeleri silin." + "Tek seferlik" + "Yeniden Adlandır" + "Kanal bulunamadı." + "Tümünü görüntüle" + "Yeni cihaz" + "Session Status" + "Görüntü önizlemesini aç" + "Oturum dalı değişti; bu mesajı gözden geçirip yeniden deneyin." + "close" + "Bu bir kurulum koduna benziyor. Geri dönüp Gateway Kurulumu\'nu, ardından Kurulum kodunu kullan\'ı seçin." + "✦" + "Aracılar ve otomasyon" + "Uygula" + "Otomasyon çalıştırması atlandı." + "Devam" + "İzleniyor · %1$s zamanlanmış görev" + "Göz at" + "tabs" + "Beklemede" + "Konuşma: %1$s" + "read" + "Metni seç" + "Hareket Etkinliği" + "açıklama: %1$s" + "Sesi oynat" + "Zaman" + "Doğrulanmamış" + "Yield" + "Onay komutunu kopyala" + "Geçerli ekran çıktısı ve etkileşimli uygulama yüzeyi." + "Hizmet bağlandı" + "Görünen Ad" + "Hazır olduğunuzda hazırım" + "Sağlayıcı kataloğu yüklenemedi." + "Konuşuyor · yanıt bekliyor" + "Verilmedi" + "Değişiklikleri Kaydet" + "Gateway otomasyon çalıştırmasını reddetti." + "Session Send" + "ClawHub\'da bul" + "OpenClaw arka plandayken istenen konum kontrollerine her zaman izin verir; Android bunu kalıcı node bildiriminde gösterir." + "Sistem olayı" + "Sağlayıcıları görüntülemek için Gateway’e bağlanın" + "Sonraki sinyal" + "Gateway eşleştirildi. Düğüm özelliği onayı bekleniyor." + "Salamura ediyor" + "Canvas\'ı Kapat" + "Kişileri Yaz" + "Bu aramayla eşleşen yüklü skill yok." + "Konuşma Sağlayıcısı Kurulumu" + "Music Generation" + "Konuşma ayarları" + "İzleniyor · 1 iletişim dizisi" + "Yük Metni" + "Metni ayarla" + "Onay %1$s" + "Gateway, %1$s hazır olma durumunu döndürmedi" + "%1$s yapılandırılmış model var. Kullanılabilirliği yeniden kontrol etmek için yenileyin." + "Conversation Send" + "Tuval" + "1 sağlayıcı" + "Gateway sertifikası otomatik olarak okunamadı. Gateway ana bilgisayarından alınan SHA-256 parmak izini yapıştırın." + "Gönderilemedi: %1$s" + "Köprü" + "Teslim Hatası" + "OpenClaw\'ı telefonunuzdan kullanın" + "Görünüm" + "Skill Atölyesi" + "Belirteç Gerekiyor" + "Önizleme · %1$s" + "Mikrofon izni gerekli" + "Skill Workshop önerilerini yüklemek için Gateway\'e bağlanın." + "Tüm sistemler çalışıyor" + "Gateway\'e ulaşılamıyor" + "OC" + "Güncellendi" + "Bağlandı (düğüm çevrimdışı)" + "Ana Sayfa" + "Dikte dinliyor" + "Arşivlenmiş iş parçacığı yok" + "Bu gateway\'de kullanılabilen asistanları seçin ve inceleyin." + "Konuşma modu etkin" + "Çalışıyor · 1 etkin çalıştırma" + "Kabul Et ve Etkinleştir" + "Gateway Güncellemesi Gerekli" + "Görseli kopyala" + "Gateway URL\'si" + "main, isolated, current veya session:<id>" + "Medya kullanılamıyor" + "Aracı çalışma alanında bir shell açmak için Gateway’inize bağlanın." + "%1$s://%2$s:%3$s" + "Onay ayrıntıları yüklenemedi. Yenileyip tekrar deneyin." + "Gateway durumunu kontrol edebilir, yapılandırmayı onarabilir, modelleri değiştirebilir veya kanalları bağlayabilirim." + "Tool Call" + "Mesaj dizileri" + "Write" + "Bir istemle başlayın veya sesinizi kullanın." + "D" + "Ayarları Aç" + "Gözlemleniyor…" + "Konuşmayı Sonlandır" + "Son Hata" + "Dikkatinizi gerektiren eylemleri gözden geçirin." + "Tüm aracılar için devre dışı." + "Sesli Görüşme Başlat" + "Arka plan görevlerine dön" + "Başka bir cron işlemi hâlâ tamamlanıyor." + "Bekleme süresi %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Cihaz üzerinde konuşma tanıma kullanılamıyor." + "Betik · salt okunur" + "Ekler tek bir mesaj için kuyruğa alınamayacak kadar büyük; bazılarını kaldırıp tekrar deneyin." + "1 yapılandırılmış model var. Kullanılabilirliği yeniden kontrol etmek için yenileyin." + "Widget kullanılamıyor" + "Bu ana makine için güvenli bağlantı gereklidir." + "Son kullanılanlar" + "Eşleşen otomasyon yok." + "Telefon Gateway\'ye erişebiliyor" + "Gateway" + "Süresi doldu" + "Gateway\'inizden zamanlanmış OpenClaw işleri." + "Sub-agent" + "Cihaz onayı bekleniyor" + "İletişim dizisi yükleniyor" + "Bu Gateway artık bu cihazın güvendiği bir sertifika sunuyor." + "Kaydırma ms" + "event create" + "belge" + "Gateway\'yi kur" + "Videoyu oynat" + "Kaydedilmiş kimlik doğrulaması geçersiz. Yeniden kimlik doğrulaması yapın veya bu gateway bağlantısını sıfırlayın." + "Kullanırken" + "screenshot" + "Buraya geri sar" + "Cron ifadesi, örn. 0 9 * * *" + "Sese geri dön" + "Konuş" + "Ayrıntılar" + "%1$s/%2$s çevrimiçi" + "%1$s uygulamanın yönlendirmesine izin verildi." + "Sohbet" + "Mikrofon erişimi gerekli." + "Yan yan ilerliyor" + "Düzenle" + "Sessiz Saatler" + "Tanı bilgilerini kopyala" + "Zamanlandı" + "Oluştur" + "%1$s içinde sona eriyor" + "Kapat" + "Öneri reddedilsin mi?" + "Konuşma hatası (%1$s)" + "Sorun" + "Kayıt defteri meta verilerinde arama yapın. Gateway, herhangi bir indirmeden önce güveni tekrar doğrular." + "Yerel kurulum için özel bir LAN IP\'si kullanın veya uzaktan erişim için Tailscale Serve\'ü etkinleştirin / bir wss:// gateway URL\'sini kullanıma açın." + "Gönderiliyor…" + "Hesap %1$s" + "Suggest Task" + "Ara" + "Dinleniyor" + "Otomasyon yüklenmedi." + "Bir Gateway güncellemesi mevcut. Hazır olduğunuzda güncellemeyi Web UI veya CLI üzerinden çalıştırın." + "yakında" + "Gateway onayı yok." + "Ana makine" + "Her alana bir uyandırma sözcüğü veya ifadesi ekleyin. Ardından komutunuzdan önce bunlardan birini söyleyin." + "Metne çevir ve gönder" + "Şurada çalıştır" + "Sesi duraklat" + "Gateway cihazına erişim" + "Önizleme yok" + "Cihazlar" + "Android için OpenClaw." + "Yetenek onayı bekleniyor" + "Bu otomasyonu çalıştırmadan, etkinleştirmeden, devre dışı bırakmadan, silmeden veya yenilemeden önce düzenlemelerinizi kaydedin ya da geri alın." + "Henüz otomasyon yok." + "Bu beceri için %1$s kurulum öğesi gerekiyor. Android nelerin yüklü olduğunu gösterir; kurulum/yapılandırma değişiklikleri yalnızca masaüstünden veya CLI üzerinden yapılabilir." + "%1$s son" + "Kanallar" + "Aşamasız" + "Bu telefonda etkin" + "Düğüm erişimi kontrol ediliyor" + "Saat dilimi" + "Skill Workshop inceleme ve uygulama işlemleri" + "Her Zaman İzin Ver" + "present" + "Gateway\'e yüklenen Skills burada görünür." + "Kodun süresi dolmuş veya başka bir Gateway için oluşturulmuş olabilir." + "İzin gerekli" + "Otomasyonun yapılandırması geçersiz." + "İzin verilenler listesi" + "Kurulum, durum ve onarım" + "groups" + "Açık anahtar" + "Hakkında" + "Bu görüntüde kurulum QR kodu bulunamadı. openclaw qr tarafından oluşturulan QR kodunu seçin veya kurulum kodunu manuel olarak girin." + "permissions" + "Düğümleri ve eşlenmiş cihazları yüklemek için gateway\'i bağlayın." + "Dalı değiştir" + "Skills yok" + "Yanıtlar sesli oynatılır" + "Okundu olarak işaretle" + "Düğüm onayı bekleniyor" + "wake" + "%1$s öneri" + "Gateway kimlik doğrulamasıyla ilgilenilmesi gerekiyor." + "Bağlantı ayrıntıları" + "Milisaniye" + "Konuşma tanıma" + "Açıklama" + "Son konuşmalar" + "Telefonunuz bu bilgileri OpenClaw tarafından çalıştırılan bir sunucuya değil, Gateway\'inize gönderir. Gateway\'iniz bunu, seçtiğiniz AI sağlayıcısına yaptığı isteklere dahil edebilir." + "Teslimat" + "Hoparlörü sessize al" + "%1$s Çalışıyor · %2$s Tamamlandı · %3$s Başarısız" + "Gateway bağlantısı açılıyor" + "İzleniyor · %1$s iletişim dizisi" + "Otomasyon çalıştırması tamamlandı." + "Eşleşen uygulama yok." + "Sohbete Gönder" + "Otomasyon silindi." + "Etkinleştir" + "Son Çalıştırmalar" + "QR kodunu karenin içine hizalayın." + "Onaylar yüklenemedi." + "Onayladım" + "Sağlayıcı hazır olma durumunu yüklemek için Gateway’inizi bağlayın." + "Eşleştirilmedi" + "Bu onay çözülemeden önce süresi doldu." + "%1$ssn içinde gözlemleniyor — hedef uygulamaya geçin" + "Aracı İstemi" + "emoji list" + "Tekrarlanan" + "OpenClaw\'da ara" + "%1$s bekliyor" + "Cihaz üzerinde konuşma tanıma kullanılamıyor" + "Hiçbir uygulama bu mesajı paylaşamaz" + "Aramayı kapat" + "İzlenecek komut" + "Sağlık" + "Bildirim dinleyicisi" + "Hoparlör sessize alındı" + "İleti dizilerinde ara" + "Tamam" + "Kurulum kılavuzu açılamadı." + "OpenClaw\'a %1$s sor" + "Wait for Agents" + "Adres" + "Gateway üzerinde oluşturulan zamanlanmış işler burada görünür." + "En son günlük parçası gösteriliyor." + "Kurulum kodunu kullan" + "sticker" + "Güvenli bir wss:// veya Tailscale Serve Gateway kullanın, Control UI\'da veya openclaw qr ile tam erişimli bir kurulum kodu oluşturun, ardından aşağıya tarayın veya yapıştırın ve ayarları ve yükseltmeleri etkinleştirmek için yeniden bağlanın." + "steer" + "Seçildi" + "Android mevcut bir kurulum kodunu tarayabilir veya yapıştırabilir, ancak bu gateway henüz uygulamaya kurulum kodu oluşturma özelliği sunmuyor. Gateway ana makinesinde openclaw qr ile QR/kod oluşturun, ardından burada tarayın veya kurulum kodunu aşağıya yapıştırın." + "Canvas Durumu" + "Bağlantıyı düzelt" + "Görseli kaydet" + "Düğüm %1$s" + "Gateway parolası gerekli" + "Update Plan" + "Eki kaldır" + "Otomasyon çalıştırması başarısız oldu." + "Sağlayıcı sınırları ve kota durumu." + "Gateway konuşma kataloğu yüklenmedi" + "bu Gateway" + "Henüz son çalıştırma yok." + "Cihaz üzerindeki dil modeli kullanılamıyor" + "Gösterge Paneli için bağlı bir Gateway gerekiyor" + "Eşleşen öneriler, aracılar yeniden kullanılabilir skill taslakları oluşturduktan sonra burada görünecektir." + "Session Search" + "OpenClaw konuşuyor" + "QR Kodunu Tara" + "Seçili Uygulamalar" + "Değişiklikleri Geri Al" + "Onay komutu kopyalandı" + "Teslimat Durumu" + "QR kodu kabul edilmedi" + "Sesli komuta merkeziniz." + "Bağlantıyı test et" + "OPENCLAW" + "Web Fetch" + "İstem" + "Cihaz onaylansın mı?" + "Bu oturumun gösterge panelini açmak için Gateway\'inize bağlanın." + "%1$s ve kayıtlı kimlik bilgileri bu telefondan kaldırılsın mı?" + "QR kodu güvenli olmayan bir uzak gateway\'i işaret ediyor. %1$s %2$s" + "Ekran yüzeyi hazır" + "Gateway\'i Eşleştir" + "Kanalları yüklemek için gateway\'i bağlayın." + "Diğer ses etkinlikleri sırasında duraklatılır." + "Model" + "Fotoğraflar" + "Kurulum kodunu yapıştır" + "OpenClaw konuşuyor" + "Bağlanıyor..." + " · Konum: Her Zaman" + "Mesajlar: %1$s" + "Resifleniyor" + "Gateway\'den yükle" + "metin: %1$s" + "Gerekenler" + "rename group" + "Hazır" + "Günlük ilk kaydını bekliyor." + "Onayla" + "Canlı sayfa" + "Otomasyon zaten çalışıyor." + "Tek seferlik başarılı bir çalıştırmanın ardından bu otomasyonu kaldırın." + "Sohbet ve ses için hazır" + "Bağlandı (operatör: %1$s)" + "Gateway eşleştirmesi tamamlandı. OpenClaw\'ın etkinleştirdiğiniz cihaz özelliklerini kullanabilmesi için bu telefonu bir düğüm olarak onaylayın." + "Yanıt iptal edildi" + "görsel" + "%1$s tutuluyor" + "Eşleşen konu yok" + "delete" + "Düzen: Kompakt" + "channels" + "İzin verildi" + "Her %1$s dakikada bir" + "1 token" + "%1$s %2$s" + "Yüklü Uygulamalar" + "beklemede" + "Sesli not hazırlanıyor…" + "Asla" + "Alt sistem" + "Komut sonlandığında" + "Bağlantı" + "Otomasyon çalıştırma geçmişi yüklenemedi." + "Otomasyon adı" + "2. Adım" + "Tanıla" + "Bazı kanal durumu denetimleri tamamlanmadı." + "pin" + "%1$s Öğesini Kopyala" + "Eşlendi" + "Uyandırma sözcükleri kaydedilemedi" + "Bu işlem \"%1$s\" teklifini karantinaya alacak ve Skill Workshop durumunu Gateway\'den yenileyecek." + "Sesli not kaydet" + "Sıraya alındı" + "Yanıtlandı" + "İstendiğinde kamera araçlarına izin ver." + "Sorunlar" + "Sesle Uyandırma" + "Eşleştirme isteği reddedildi." + "%1$s gün önce" + "roles" + "Skills" + "Arşivle" + "Düğüm çevrimdışı. Yeniden bağlanıp tekrar deneyin." + "Sistem" + "Uzak IP" + "Gruplandırılmamış" + "Zamanlama Ayrıntısı" + "Telefon Özellikleri" + "Kullanılamıyor" + "Gösterge paneli" + "Token\'ı yapıştır" + "Sağlayıcı yok" + "SHA-256 parmak izi" + "Henüz konu dizisi yok" + "Bluetooth mikrofonu" + "Son Kullanılanlar" + "Konuyu yeniden adlandır" + "Çözüm sonucu bilinmiyor. Gateway kaydı doğrulanana kadar eylemler devre dışı kalır." + "dialog" + "Uyandırma sözcüklerini dinle" + "camera snap" + "Oynatma hazırlanıyor…" + "Gateway bilinmeyen sağlayıcıyı seçti: %1$s" + "delete group" + "Android\'i takip et · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Aracıları yüklemek için Gateway\'e bağlanın." + "Geri dön" + "Mesajı paylaş" + "Bir QR kodu oluşturun." + "Yeniden Başlat" + "Hoparlör açık" + "Grup silinsin mi?" + "Eksik" + "Tekliflerde ara" + "stop" + "Güvenli (TLS)" + "Düğüm veya eşlenmiş cihaz yok." + "%%%1$s kaldı %2$s" + "Kurulum kodunun süresi doldu" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "GÜNLÜK" + "notify" + "Bu telefon, Gateway ihtiyaç duyana kadar beklemede kalır; ardından uyanır, senkronize olur ve tekrar uykuya geçer." + "Yapılandırılmış %1$s model" + "Lisanslar" + "ClawHub Skills\'ı aramak için Gateway\'e bağlanın." + "Beceri" + "Gateway bağlantısı değişti. Yeniden bağlanmak için OpenClaw\'ı yeniden başlatın." + "Cihaz kimliği" + "Gateway, etkin %1$s sağlayıcısını tanımlamadı" + "Bekleniyor" + "Uyandırma sözcükleri kaydedildi" + "Önce en eski" + "Ekran" + "Çalışmaya Başlama" + "IPv6 bölge kimlikleri desteklenmiyor. Kapsamsız bir IPv6 adresi veya bir LAN ana makine adı kullanın." + "Gönderildi — teslimat doğrulanıyor…" + "ses" + "This gateway connection needs operator.admin to update skills." + "Kurulum kodu" + "Gateway uyarısını kabul et ve yükle" + "Sohbeti yenile" + "Aralık" + "Skill Workshop teklif işlemleri operator.admin kapsamı gerektirir." + "Oturumlar" + "Yeniden adlandır…" + "Rüya görmeyi yüklemek için gateway\'i bağlayın." + "Kurulum" + "Talk’ı aç" + "poll" + "Aracılarınızı yüklemek için bağlanın" + "role remove" + " · Konuşma: Dinliyor" + "ClawHub, %1$s için yüklenebilir bir sürüm döndürmedi." + "Komut" + "Bu onay çözülemeden önce iptal edildi." + "Mikrofon açık · Gateway bekleniyor" + "Metin" + "%2$s içinden %1$s gösteriliyor. Daha fazlası için aramayı daraltın." + "v%1$s kullanılabilir" + "%1$s://%2$s" + "%1$s... (OK)" + "Sağlayıcılar ve yapılandırılmış modeller" + "Bağlanıyor…" + "Uyandırma sözcüklerini kaydetmek için bir Gateway\'e bağlanın" + "Profili aç" + "Gateway\'nizi başlatın." + "Bu hedefi pratik bir kontrol listesine dönüştürmeme yardımcı olun: " + "Oturum aramasını temizle" + "Bağlantı noktası" + "Kurulum kodunu gir" + "Gateway günlükleri yüklenemedi." + "%1$s sağlayıcı hazır" + "Ajanlarınız hazır" + "Gateway üzerinde hiçbir %1$s sağlayıcısı yapılandırılmamış" + "Tek bir tur için dinleniyor" + "Gözlemle" + "Epoch milisaniye (isteğe bağlı)" + "Yapılandırılmış model yok. Kullanılabilirliği yeniden kontrol etmek için yenileyin." + "Ayarlar" + "Arka kamera" + "approve" + "Başlamadan önce" + "Skills yüklenemedi." + "Devre Dışı" + "Hâlâ onay bekleniyor" + "Arka plan görevleri yüklenemedi" + "OpenClaw\'un bu telefonda net konuşabildiğini kontrol edin." + "Çalışıyor · %1$s etkin çalıştırma" + "Komut çalışma dizini" + "Grup adı" + "Galeriden seç" + "Version %1$s" + "Geri" + "Connect the gateway to update skills." + "Çalıştırmadan Sonra Sil" + "Kurulum kodu güvenli olmayan bir uzak gateway\'i işaret ediyor. %1$s %2$s" + "Computer" + "Gateway bağlantısı kesildi." + "Session Settings" + "Başlamak için Gateway\'i bağlayın" + "Güvenlik bildirimi" + "Diğer yanıt" + "Paylaşılan görüntü uyarısını kapat" + "Gateway farklı bir ClawHub sürümünü değerlendirdi. Yüklemeden önce Skill\'i tekrar inceleyin." + "Sistem Erişimini Aç" + "Tamamlandı" + "Görsel kullanılamıyor" + "Bildirimler" + "Uygulama, reddetme ve karantina işlemleri operator.admin kapsamı gerektirir. Yaşam döngüsü işlemlerini etkinleştirmek için paylaşılan gateway kimlik doğrulamasıyla yeniden bağlanın veya bir operator.admin cihaz kapsamı yükseltmesini onaylayın." + "sticker upload" + "Istakoz avlama" + "Messages to recover" + "openclaw devices approve %1$s" + "Okunabilir gateway günlük ayrıntısı." + "Oluşturulan skill önerilerini canlı skill\'e dönüşmeden önce inceleyin." + "Paketle birlikte gelen" + "%1$s kullanılabilir" + "Düğüm Onayı Bekleniyor" + "Gateway Beklemede" + "Kimlik doğrulama gerekli" + "Düğümler" + "Uyanık Tut" + "OpenClaw yanıt veriyor" + "Belgeler" + "%1$s hazır" + "Henüz çıktı yok" + "Cihaz dili desteklenmiyor" + "Sıraya alındı — yeniden bağlanıldığında gönderilecek" + "%1$s dk önce" + "Geçerli dal" + "Eşleştirme erişimi kontrol ediliyor" + "Sınırlı Gateway erişimi" + "Araçlar çalıştırılıyor..." + "Onay kontrol ediliyor…" + "Bu telefondan fotoğraf ve klip çekin" + "Bağlandı ve hazır" + "Kapat" + "Bir hedefi uygulanabilir bir kontrol listesine dönüştürün." + "Kurulum kodunda geçersiz gateway URL\'si var." + "Yalnızca bu telefon bağlıyken OpenClaw\'ın kullanmasına izin vermekten rahat olduğunuz erişimleri etkinleştirin. Bunları daha sonra Android Ayarları\'ndan değiştirebilirsiniz." + "Hesap" + "remove" + "Parola isteğe bağlı" + "Gateway kimlik doğrulamasının gözden geçirilmesi gerekiyor. Gateway ayarlarını kontrol edin, ardından tekrar deneyin." + "QR kodu bir IPv6 bölge kimliği kullanıyor. Kapsamsız bir IPv6 adresi veya bir LAN ana makine adı kullanın." + "add" + "Kril avlanıyor" + "Sağlıklı" + "%1$s içinde tamamlandı" + "Argümanlar" + "Yükleme Seçenekleri" + "%1$s sa. içinde" + "Gateway onayı bekleniyor. Gateway ana makinesinde şunu çalıştırın:" + "Yönetici erişimi gerekli" + "set groups" + "Modeli sabitle" + "Aramayı Temizle" + "Uygun aracılar için etkin." + "Geçerli konu dizisi yok" + "sınırlar: %1$s" + "%1$s sonrasında" + "Zamanlayıcının bu otomasyonu çalıştırmasına izin verin." + "%1$s uygulandı" + "Henüz rüya günlüğü yok." + "Arka plan görevlerini yenile" + "Son iletişim dizilerini ve sonraki adımları özetle." + "OpenClaw görünür durumdayken cihaz üzerinde çalışır." + "%1$s çalışıyor" + "%1$s %2$s" + "Ham" + "Çalıştırmalar" + "Şimdi Çalıştır" + "Adsız dal" + "Yapılandırıldı" + "camera list" + "1 uygulandı" + "camera clip" + "Evet" + "Ses Testi" + "Tutuldu" + "events" + "Çalışma dizini" + "En yeniye atla" + "Her zaman izin ver" + "QR veya kurulum kodunu tarayın" + "Installing" + "Canlı düğümler, eşleştirilmiş telefonlar ve bekleyen cihaz istekleri." + "Anlık görüntü: %1$s" + "Önceki bir yanıt bu komuta zaten izin verdi ve seçimi kaydetti." + "Bekleyen İstekler" + "Onaylandı" + "Çalışma alanı" + "Ses" + "Konuşmaya hazır" + "Subagents" + "Başarısız: güvenli bir gateway uç noktası bulunamadı. Gateway TLS veya Tailscale Serve\'i etkinleştirin ya da Şifrelenmemiş seçeneği seçili şekilde güvenilir bir özel LAN adresi kullanın." + "Sinyaller" + "Oturum Hedefi" + "Gateway bir ret kaydetti." + "Kabul Et" + "OpenClaw\'a istediğinizi sorun" + "Devam etmek için yeniden bağlanın" + "%1$s eşleştirildi" + "Bu işlem \"%1$s\" teklifini uygulayacak ve Skill Workshop durumunu Gateway\'den yenileyecek." + "Gateway çevrimdışı" + "openclaw devices list" + "OpenClaw düğümü bağlantı durumu" + "Uyarılar bu telefonda kalır." + "OpenClaw seçilen uyarıları alabilir." + "Ekranı Aç" + "Sohbet eylemleri" + "Diğer uygulamaların kontrolüne izin verilsin mi?" + "İnceleniyor" + "Başka bir Gateway eklemek için bir kurulum kodunu tarayın veya yapıştırın." + "Sürü" + "TLS zaman aşımına uğradı" + "Son oturumlar" + "Eşleştirilmiş cihaz kaldırıldı." + "Gateway eşleştirildi. Düğüm yeteneği onayı kontrol ediliyor." + "Hareket" + "Cron işlemi başarısız oldu." + "Gateway bilgisayarında şunu çalıştırın:" + "Oturumlarda ara" + "Günlükleri Yenile" + "Görsel kullanılamıyor · Yeniden denemek için dokunun" + "openclaw nodes approve %1$s" + "Sesli not · %1$s" + "Kullanım" + "Nautiluslanıyor" + "Bağlam %1$s%%" + "Sesli istemleri metne dönüştür" + "Sessize al" + "Yeni bir konuşma başlatın; burada görünecektir." + "Bağlantı sorunu" + "Orta" + "Çatalla" + "Hoparlörü etkinleştir" + "Sistem Olayı Metni" + "Sıralama: %1$s" + "%1$s bekliyor" + "Image Generation" + "Sesli not" + "İlginizi gerektiren bir şey yok" + "Devam etmek için OpenClaw’ın %1$s izinlerine ihtiyacı var." + "Kablolu kulaklık mikrofonu" + "Sayfalar" + "Teslim edildi" + "Süresi geldi" + "Skill ayrıntısı mevcut Skills durumunda kullanılamıyor." + "Bu telefonun neleri paylaşabileceğini seçin." + "Bu otomasyonun zaten sıraya alınmış bir çalıştırması var." + "Otomasyonları yönetmek için Gateway\'e bağlanın." + "Otomasyonun zamanı henüz gelmedi." + "Ayrıntı yok" + "Onay işlemi devam ediyor.\nOpenClaw otomatik olarak yeniden bağlanacak." + "Sağlayıcı hazırlığını görüntülemek için Gateway’inizi bağlayın." + "Eşleştirme bekleniyor" + "Bir konuşma başlatın veya sürdürün" + "Zamanlanmış iş yok" + "OpenClaw\'a yanıt verin…" + "Durum" + "OpenClaw Düğümü · Bağlı" + "Etkin" + "Ekran paylaşımı hata ayıklama durumunu göster." + "Bildirilen sınır yok" + "Tarayıcıyı kapat" + "Her %1$s günde bir" + "Etkin" + "Etkinleştir ve Ayarları Aç" + "Çevrimiçi ve hazır" + "Ask User" + "Sohbet hatası" + "İleri kaydır" + "%1$s / %2$s" + "Çalışmayı planlayın" + "console" + "Yeniden dene" + "Bir sohbet başlatın; etkin OpenClaw konuşmalarınız burada görünecek." + "Otomasyon yüklenemedi." + "Aracı çalışma alanındaki kabuk" + "%1$s aktif" + "Cihaz izinlerini seçin" + "Son Süre" + "Varsayılan aracı" + "%1$s sa" + "Görüşme devam ediyor" + "%1$s, ClawHub\'dan yüklenemedi." + "OpenClaw\'a hoş geldiniz" + "Diğer uygulamaları kontrol et" + "Sinyal Dizini" + "Gizli anahtarı girin…" + "%1$s:%2$s" + "OpenClaw\'a %1$s söyle" + "Keşfedildi" + "Kenar Çubuğunu Gizle" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Ses oynatma kullanılamıyor" + "uygula" + "Kullanım verileri yüklenemedi." + "Etkin çalışma sırasında düğümü kullanılabilir durumda tut." + "Sonraki Uyanma" + "%1$s/%2$s" + "Son günlük girdisi yok." + "Manuel Gateway" + "Grubu yeniden adlandır" + "Update Goal" + "Sağlayıcı erişilebilirliği bilinmiyor" + "Sağlayıcılar" + "Grubu sil…" + "Yük" + "Arama Kaydı" + "Memory Search" + "%1$s sağlayıcı" + "Telefon bağlamı ve gizlilik" + "%1$s/%2$s bağlı" + "%1$s %2$s" + "Gateway yeniden başlatma kurtarma işlemi hâlâ devam ediyor." + "Kurulum kodunun değiştirilmesi, yeniden bağlanmadan önce bu telefonda kayıtlı kurulum kimlik bilgilerini ve cihaz token\'larını temizler. Bu telefonun düğüm yetenekleri için yeniden onay gerekebilir; yalnızca yeni bir gateway kurulum koduyla eşleştirmek istiyorsanız devam edin." + "Yapılandırmasını ve çalıştırma geçmişini incelemek için bir otomasyonu açın. Yönetici kapsamlı bağlantılar ayrıca otomasyonu çalıştırabilir, düzenleyebilir, etkinleştirebilir, devre dışı bırakabilir veya silebilir." + "Bağlam --" + "Teklif karantinaya alındı." + "Otomasyon duraklatıldı." + "OpenClaw mobil" + "A2UI reset" + "Gateway kullanılamıyor" + "Read" + "Bu beceri için 1 kurulum öğesi gerekiyor. Android nelerin yüklü olduğunu gösterir; kurulum/yapılandırma değişiklikleri yalnızca masaüstünden veya CLI üzerinden yapılabilir." + "Son Çalıştırma" + "Kurulum QR kodunu taramak için kamera erişimi gerekir." + "Model güncellenemedi." + "Kabarcıklanıyor" + "thread reply" + "Sil…" + "Otomasyonları incelemek için Gateway\'e bağlanın." + "SON GÜNLÜKLER" + "Son çalıştırmalar yükleniyor…" + "Bu dosya önizlenemiyor. İkili biçimde veya çok büyük olabilir." + "Denetle" + "Bu telefonun konumunu okuyun" + "Skill Anahtarı" + "%1$s yüklendi." + "Gateway\'ler" + "eylemler: %1$s" + "Yönlendirme Modu" + "%1$s B" + "Konu düzenini değiştir" + "TLS uç noktası yok" + "OpenClaw Gateway" + "Manuel kurulum yap" + "Düşünüyor…" + "Gateway erişiminin incelenmesi gerekiyor" + "1 tutuluyor" + "%1$s sn" + "Öneri uygulansın mı?" + "Şimdi değil" + "Onaylanmadı" + "Uygulamalarda ara" + "Yapılandırılmış 1 model" + "Onay bildirimini kapat" + "·" + "Çevrimdışı" + "Konuşma sağlayıcısı" + "Gateway onayı devam ediyor. OpenClaw otomatik olarak yeniden deneyecek." + "Maks." + "Cron değişiklikleri operator.admin erişimi gerektirir." + "Düşünüyor" + "screen snapshot" + "Gözlemlenen düğümler: %1$s" + "İşlem bulunamadı" + "Kaydet ve Bağlan" + "list" + "Gateway onayı kaydetti ve seçimi sakladı." + "Bağlanmak için geçerli bir manuel uç nokta girin." + "asistan" + "Sohbete gönderiliyor..." + "Profili Kaydet" + "Kilitli" + "Otomasyonu Düzenle" + "Aynı ağı veya güvenli bir uzak Gateway URL’sini kullanın." + "Bağlantı noktası" + "Dil" + "Bu uygulama Gateway\'den daha eski. Bu cihazdaki OpenClaw\'u güncelleyin ve ardından yeniden deneyin." + "Tümü" + "Gateway oturumu devam ediyor" + "İnceleme bekleniyor" + "Yüklü Skills yok." + "Gateway Kontrol Ediliyor" + "Kademelendirme %1$s" + "%1$s için sonuç bilinmiyor. Yeniden bağlanın, Skills\'i yenileyin ve tekrar deneyin; Gateway, hâlâ devam eden eşleşen bir yüklemeye güvenli bir şekilde katılır." + "Unut" + "Eşleştirilmiş Gateway yok." + "%1$s · %2$s" + "<gizli bilgi çıkarıldı>" + "%1$s sorun" + "OpenClaw" + "Dinleniyor · %1$s kuyrukta" + "Asistan konuşması sessize alındı" + "Düğüm işlemleri yalnızca hedef uygulama ön plandayken çalışır (uzak yol üzerinden doğrulanır). Genel işlemler ve aynı uygulama işlemleri burada çalışır." + "Henüz Gateway bulunamadı. Keşif engellenmişse manuel kurulumu kullanın." + "Konuyu aç" + "Çalışıyor" + "Konuşmaya başlayın..." + "Telefon Düğümü" + "Çok yüksek" + "Gateway ana makinesinde çalıştırın:" + "Skill değişiklikleri operator.admin gerektirir. Yönetici yetkisine sahip bir Gateway token\'ı ile yeniden bağlanın." + "ClawHub Skills\'ı incelemek için Gateway\'e bağlanın." + "Uygulama listesi bu telefonda kalır." + "Boşta" + "Android Erişilebilirlik ayarlarında gösterilir." + "Akıllı teslimat" + "Reddet" + "Gateway, %2$s işleminden sonra \'%1$s\' durumunu döndürdü." + "Gateway belirteci yapılandırılmamış" + "Not available to this agent" + "Dosyalar" + "İzinler" + "Kamera başlatılamadı. Galeriden bir QR görseli seçin veya kurulum kodunu manuel olarak girin." + "Kopyalamak için dokunun" + "%1$s dk bekleniyor" + "%1$s." + "ClawHub Skills\'ı yüklemek için Gateway\'e bağlanın." + "Ses ara" + " · Mikrofon: Dinliyor" + "Gateway ayarlarını incelemek ve değiştirmek için operator.admin erişimiyle yeniden bağlanın." + "Daha fazla yükle" + "3sn içinde gözlemle" + "run" + "Ses oluşturuluyor…" + "← Geri" + "Bağlantıyı Kes" + "Gateway bilgisayarında onay komutunu çalıştırın, ardından tekrar kontrol edin." + "Otomasyonlar" + "%1$s dk" + "Güven" + "Bu QR kodu bir OpenClaw kurulum QR kodu değil. openclaw qr ile yeni bir kod oluşturup tekrar deneyin." + "Tercih edilen mikrofon kullanılamıyor; otomatik yönlendirme kullanılıyor." + "Reddedildi" + "Android ve arka plan paketlerini dahil edin." + "Gateway\'iniz hazır." + "Tetiklendi" + "Structured Output" + "Bu işlem beklenenden uzun sürüyor.\nGateway\'in çalıştığını ve erişilebilir olduğunu kontrol edin." + "Bu temsilci için arka plan görevi yok." + "Yeniden bağlanıyor" + "OpenClaw, Gateway ve düğüm erişimini kontrol ediyor." + "Code Execution" + "Sağlayıcı kullanımı yok" + "İncele" + "Mikrofon izni gereklidir." + "%1$s gün" + "%1$s kullanılabilir" + "OpenClaw yeniden senkronize oluyor" + "Etkinlik akışı kesintiye uğradı; yenilemeyi deneyin." + "Düğümler ve cihazlar yüklenemedi." + "Skills\'i yüklemek için Gateway\'i bağlayın." + "bilinmiyor" + "Çıktı" + "Konuşma başarısız oldu: Gerçek zamanlı sağlayıcı beklenmedik şekilde kapandı." + "OpenClaw Zamana Duyarlı" + "ban" + "Gateway belirteci gerekli" + "Eşleştirilmiş cihaz" + "Yeniden onay gerekiyor" + "Planlanmadı" + "Kişiler" + "Telefonunuz ihtiyaç duyulana kadar sessiz kalır" + "Dinliyor · sıradaki ses gönderiliyor" + "Görev ayrıntıları yüklenemedi" + "Aracı mesajı" + "Gateway bu cihaz kimliğini gerektiriyor. Yeniden kimlik doğrulaması yapın veya bu gateway bağlantısını sıfırlayın." + "Sonraki oturum" + "Bağlantı güvenliği" + "Şimdilik atla" + "Web sitesi" + "Uygulamadaki onay isteklerini yüklemek için Gateway\'e bağlanın." + "%1$s kopyalandı" + "Hiçbir uygulama seçilmedi. Uygulama ekleyene kadar hiçbir şey yönlendirilmez." + "%1$s %2$s" + "Kurulum gerekiyor" + "Eşlenmedi" + "Gateway bu telefonu aldı" + "Yapılandırılmış model yok" + "Devre dışı bırak" + "Uygulama dili" + "Gateway Eşleştiriliyor" + "Kaydedilen kimlik doğrulama geçersiz" + "%1$s kapsam" + "Son günlükleri yüklemek için gateway\'i bağlayın." + "Uyandırma sözcüklerini kaydet" + "Yüklü skill\'leri yönetin ve ClawHub\'dan güvenilir sürümler ekleyin." + "Gönderiliyor…" + "Henüz yüklenmiş aracı yok." + "ClawHub\'da ara" + "Sohbet, Gateway durumunu kontrol ediyor." + "Eşleştirme gerekli" + "Etkin Çalıştırmalar" + "Başarısız — %1$s" + "Bu telefon ile OpenClaw arasındaki bağlantı." + "summarize" + "Widget görseli İndirilenler\'e kaydedildi" + "Başlatılıyor…" + "%1$s token" + "İstemci hatası" + "Erişim izni vermeden önce istekte bulunan bu cihazı doğrulayın." + "Bluetooth LE mikrofonu" + "%1$s %2$s" + "Otomasyon etkinleştirildi." + "%1$s Mn" + "Memory Get" + "%1$s · %2$s" + "Arşivlendi" + "Yeniden yükle" + "Otomasyonlarda ara" + "Bağlı telefonlar ve düğüm ana makineleri eşleştirmeden sonra burada görünecek." + "%1$s: %2$s" + "Bu otomasyon Gateway üzerinde değiştirildi. Yeniden kaydetmeden önce en son sürümü inceleyin." + "Dikteyi Durdur" + "Okunaklı" + "OpenClaw\'a mesaj gönder" + "Gateway parolası geçersiz. Tekrar girin veya bu gateway bağlantısını sıfırlayın." + "Yeniden bağlan" + "ISO zamanı, örn. 2026-07-09T09:30:00Z" + "%1$s araç" + "Önceki bir yanıt bu onayı zaten reddetti." + "Bağlı" + "%1$s aç" + "%1$s/%2$s" + "Otomasyon çalıştırması bilinmeyen bir durumla tamamlandı." + "Çevrimdışı kuyruk dolu (%1$s mesaj); önce kuyruktaki öğeleri silin." + "Herkese açık gateway\'ler wss:// veya Tailscale Serve gerektirir. ws://; localhost, .local ana makineleri, Android emülatörü ve özel LAN IP\'leri için kullanılabilir." + "Skill ayrıntılarını yüklemek için Gateway\'i bağlayın." + "Tam Erişim Gerekli" + "Uyandırma dinleyicisi" + "Bağlantı önizlemesini genişlet" + "Konu aramasını temizle" + "NULL (BAŞARISIZ)" + "Güncelle" + "Yönetici" + "Dikkat gerekiyor" + "Bu cihazı Gateway\'inizle eşleştirerek yalnızca gerçek işler için uyandırılmasını sağlayın, canlı aracı genel görünümünü elinizin altında tutun ve pili tüketen arka plan döngülerinden kaçının." + "Roller" + "Yanıtla" + "Sağlayıcı kataloğu" + "İzni Ayarlar’dan etkinleştirin" + "A2UI push" + "Erişimi Kontrol Et" + "Gateway erişilebilirse yeniden bağlantı müdahale olmadan tamamlanmalıdır." + "Aracı sırası" + "karantinaya al" + "Dikkat" + "Aranıyor…" + "Kurulum kodunu nereden alabilirim?" + "Skill etkinleştirilemedi." + "pdf" + "Kaldır" + "%1$s%% çevrimiçi" + "Kanal yok" + "Gerçek zamanlı ses" + "Skill Workshop reddetme ve karantina işlemleri" + "Düğümler ve Cihazlar" + "Yerel komuta merkezi" + "emoji upload" + "Önizleme yükleniyor…" + "Yüksek" + "focus" + "describe" + "%1$s bağlamı" + "Yanıt dinleniyor..." + "voice" + "%1$s Gateway\'ine bağlandı" + "role add" + "Sohbet ilgi gerektiriyor" + "Mikrofonu Etkinleştir" + "OpenClaw, eşleştirilmiş OpenClaw Gateway\'iniz istediğinde bu telefonda görünen uygulamaların adlarını, paket kimliklerini ve durumunu toplar ve gönderir. Bu, asistanınızın yüklü uygulamaları kullanarak soruları yanıtlamasını ve işlemler yapmasını sağlar." + "Gateway bağlı değil" + "Politika" + "Gönderilen mesaj onaylanırken zaman aşımına uğradı; teslimatı kontrol etmek için yenileyin." + "Destek Dosyaları" + "İfade" + "Arka plan görevleri" + "Hayal" + "Engellenen uygulama yok. Engelleme eklemediğiniz sürece uygulamalar yönlendirme yapabilir." + "Konuşma tanıyıcı kullanılamıyor" + "Platform" + "Gateway, %1$s kurulumunu döndürmedi" + "Gateway unutulsun mu?" + "İsteğe bağlı açıklama" + "%1$s öğesini aç" + "Ana tuval" + "Düş kuruyor" + "%1$s - %2$s" + "Dosyayı paylaş" + "Gerçek zamanlı" + "API" + "OpenClaw çalışıyor…" + "OpenClaw ile konuşun veya dikte edin" + "Yüklü uygulama bilgileri paylaşılsın mı?" + "Otomasyon yükleniyor…" + "Otomasyonu Sil" + "Varsayılan asistan" + "Gateway üzerinde desteklenen bir %1$s sağlayıcısı seçin" + "Kullanılamıyor" + "Boş klasör" + "Ayarları aç" + "Kapalı" + "Tipografi" + "Durdur" + "Henüz eşleşen konu yok." + "Gateway eşleştirmesi başarılı oldu.\nBu telefonun düğüm özelliklerini bir operatör kullanıcı arayüzünden onaylayın." + "Bu beceri yüklü ancak şu anda çalıştırılmaya uygun değil. Yapılandırma değişiklikleri için masaüstünü veya CLI\'ı kullanın." + "Ses tanıyıcı meşgul" + "Ev Gateway\'i" + "Onay komutunu Gateway üzerinde çalıştırın" + "Hizmet devre dışı" + "Skill Workshop önerileri yüklenemedi." + "Son OpenClaw iletişim dizilerimdeki gelişmeleri aktar ve sonraki adımları öner." + "Şimdi Değil" + "openclaw qr" + "start" + "OpenClaw Düğümü · Konuşma" + "Etkinlikleri oku ve güncelle" + "Konuşma başarısız oldu: Gerçek zamanlı sağlayıcı kapandı: %1$s" + "Çalışma alanı dosyalarına göz atmak için Gateway\'i bağlayın." + "Gateway aktarması üzerinden %1$s" + "Gateway konuşma kataloğu yüklenemedi" + "İzleniyor · 1 zamanlanmış görev" + "Her %1$s saatte bir" + "Ekran yüzeyi" + "OpenClaw çevirileri · %1$s" + "Komut isteği" + "Güncel" + "Kanal" + "Sessizden çıkar" + "Yeni grup…" + "Ses hazırlanıyor…" + "Uyarlanabilir" + "Yakında" + "%1$s çalışan daha" + "Web Search" + "Sohbet, Ses, Konular, Sağlayıcılar veya Ayarlar\'ı deneyin." + "OpenClaw Aktif" + "navigate" + "%1$s tarihinde istendi" + "Otomasyon çalıştırma geçmişini incelemek için Gateway\'e bağlanın." + "Cihaz erişimi; Gateway için yine de onay gerekli" + "İptal edildi" + "Geçerli bir kurulum kodu veya gateway adresi girin." + "Modeller" + "OpenClaw Pasif" + "Gateway parolası geçersiz" + "Cihaz eşleştirme değişikliği doğrulanamadı. Yenileyip tekrar deneyin." + "Ayrıntıları görüntüle" + "Bash" + "Belirteç" + "Bağlı OpenClaw aracısı, etkinleştirdiğiniz cihaz özelliklerini kullanabilir. Yalnızca bağlandığınız Gateway\'ye ve aracıya güveniyorsanız devam edin." + "Kaya midyesi toplanıyor" + "Seçili veya tam fotoğraf erişimi verildi." + "Erişilebilirlik yürütücüsü" + "%1$s eksik öğe" + "Plan kontrol listesini daralt" + "Düğüm onayı gerekli" + "Gateway\'e Bağlan" + "... +%1$s daha" + "Plan kontrol listesini genişlet" + "Tarayıcı" + "screen record" + "Çalıştırma Bekliyor" + "Etkinleştirmek, OpenClaw\'ın etkin olduğunda diğer uygulamaların ekranlarını gözlemlemesine ve kontrol etmesine olanak tanır. Android erişilebilirlik erişimi gereklidir." + "Kaynak" + "Cihazlarınızda kişisel AI" + "Attach" + "Otomatik" + "Genel Bakış" + "Geri yükleme isteği başarısız oldu. Tekrar denemek için dokunun." + "Video" + "%1$s\n\n" + "Şifrelenmemiş" + "Takvim" + "Gateway durumu iyi değil; gönderilemiyor" + "📎 %1$s" + "Son Durum" + "Yeni bir sohbet başlatmadan önce mevcut yanıtın tamamlanmasını bekleyin." + "Profil" + "Gateway\'iniz bildirdiğinde sağlayıcı limitleri burada görünecek." + "1 sorun" + "\"%1$s\" grubundaki konular korunur ve Gruplandırılmamış\'a geri taşınır." + "Önerilen" + "Oluşturulma" + "%1$s/%2$s etkin belirteç" + "İşlem sonucu yok" + "Şaklatma" + "%1$s…" + "Skill ayrıntısını aç" + "Seslendirilemedi: %1$s" + "Konuşmayı Başlat" + "Bu klasör yüklenemedi." + "QR kodu geçerli bir kurulum kodu içermiyordu." + "Düğüm erişimini inceleyin" + "Uyandırma ifadesi ekle" + "Gateway\'e ulaşılamıyor" + "Otomasyon" + "Bağlantı gerekiyor" + "Onay sonuçlandırılamadı. Yenileyip tekrar deneyin." + "import" + "Bu telefonun OpenClaw\'da nasıl göründüğü." + "Konu aramasına odaklan" + "Gateway’i bağla" + "Takvimi Oku" + "Genel bakış, yeniden bağlanıldığında ve bu ekran açıldığında yenilenir." + "Skill devre dışı bırakılamadı." + "Bağlantı hâlâ kuruluyor" + "%1$s dk. içinde" + "SMS Oku" + "Kullanımı yüklemek için Gateway\'e bağlanın." + "Şu anda bu telefondan ne yapmama yardımcı olabilirsiniz?" + "Onay gerekiyor" + "Yeni sohbet" + "Skill Workshop tekliflerini güncellemek için Gateway\'e bağlanın." + "OpenClaw isteği başarısız oldu." + "İzin gerekli" + "Sağlayıcı hazırlığını\nve yapılandırılmış modelleri inceleyin." + "Yükleniyor" + "Hata Uyarısı" + "Tema ve çevrilmiş Android metni." + "Mikrofon kapalı · gönderiliyor…" + "Yok" + "Görüntüle" + "Ad" + "Sürüm" + "Cron" + "OpenClaw\'ı açmadan önce bu telefonu bir Gateway\'e bağlayın." + "Uyandırma ifadesini kaldır" + "Kurulum kodu kabul edilmedi. openclaw qr ile yeni bir kod oluşturun." + "14 mesaj · Android" + "Transkripsiyon başarısız oldu: %1$s" + "Her Zaman" + "Rüya görme yüklenemedi." + "Otomasyon çalıştırması sıraya alındı." + "Conversation Turn" + "Otomasyon başlatıldı." + "Yeni grup" + "Sunucu hatası" + "Video Generation" + "Gateway onayı bekleniyor. Gateway ana makinesinde openclaw devices list komutunu çalıştırın, bu telefonu onaylayın ve ardından yeniden deneyin." + "Bir rüya görme döngüsü anlatı özeti yazdıktan sonra kayıtlar görünür." + "%1$s ms" + "Bellek Deposu" + "Asistan çalışıyor" + "OpenClaw, başlatıcıda görünen uygulamaları listeleyebilir." + "Konuşma başarısız oldu: %1$s" + "Yüklü Skills içinde ara" + "İncele" + "Process" + "Son İletişim Dizileri" + "Terminal" + "Geçerli" + "1 hesap" + "Duraklatıldı" + "Kameraya izin ver" + "Exec onay istekleri, bu telefon bağlıyken burada görünecek." + " · Mikrofon: Beklemede" + "Kopyala" + "Ayrıntılar kopyalandı" + "Sil" + "OpenClaw\'dan Android özelliklerini kullanmasını isteyin." + "member" + "Bu Gateway\'in OpenClaw ayarlar asistanını destekleyip desteklemediği kontrol ediliyor." + "Yeniden bağlanmak için aşağıdaki kurtarma seçeneklerini kullanın." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Kanallar yüklenemedi." + "%1$s gün içinde" + "Ardışık Hatalar" + "Bu görüntüden QR kodu okunamadı. Daha net bir görüntü seçin veya kurulum kodunu manuel olarak girin." + "Gateway bu uygulamadan daha eski. Gateway ana makinesindeki OpenClaw\'u güncelleyin ve ardından yeniden deneyin." + "Sohbet, ses ve canlı durumdan önce bağlanın." + "Gateway’i yeniden bağla" + "Üçüncü taraf" + "Hazır olma durumunu incele" + "Sınırlı" + "OpenClaw logosu" + "Modelin sabitlemesini kaldır" + "Bu gateway’e bağlı mesajlaşma yüzeyleri." + "Gönderiliyor" + "Arşivlenen konu dizileri burada görünür." + "Komut kopyalandı" + "Önizleme yok" + "Bu telefonu Gateway üzerinde onaylayın.\nArdından bağlantıyı yeniden deneyin." + "QR kodunu tara" + "Komut çalışma dizini · temizlenemez" + "Konu Dizisi Etkinliği" + "Kullanılabilir" + "Otomasyon silinsin mi?" + "Bugün %1$s · Toplam %2$s" + "Parola" + "Teklif karantinaya alınsın mı?" + "Bu derlemede lisans bildirimleri paketlenmemiştir." + "Widget görseli kaydedilemedi" + "%1$s bekleniyor" + "Konuşuyor…" + "Sağlayıcılar ve Modeller" + "Düğüm" + "%1$s " + "İstem kullanılamıyor" + "Günlükler" + "Skill Workshop önerilerini incelemek için Gateway\'e bağlanın." + "Araçlar" + "Gateway anahtarı" + "SMS Gönder" + "OpenClaw normal sohbetinizde devam etmeye hazır." + "Komut bulunamadı" + "Henüz canvas güncellemesi yok. Yeniden denemek için dokunun." + "Terminal için bağlı bir Gateway gerekir" + "Exec" + "Uygulama Filtresi" + "Ana" + "%1$sk" + "Gateway Gerekli" + "Erişim" + "Paketler: snapshot=%1$s foreground=%2$s" + "Bağlantıyı yeniden dene" + "Cron zamanlayıcı durduruldu." + "Aç" + "Mesaj kopyalandı" + "Gateway\'inize ulaşamadık.\nBunu düzeltelim." + "şimdi" + "Çalıştırdıktan sonra sil" + "Bu telefonda seçildi" + "unpin" + "Session History" + "Sabitlemeyi kaldır" + "Bu telefonu kullan" + "%1$s için ClawHub ayrıntıları yüklenemedi." + "Araçlar çalışıyor" + "Konum etkinken kesin konumu paylaş." + "Mobile UI" + "Tema" + "Gateway bu onayı hâlâ beklemede olarak gösteriyor. Tekrar denemeden önce inceleyin." + "Sesli notu bitir" + "Dikte: %1$s" + "İzin verilmiyor" + "Başka bir görüntü seç" + "Görsel önizlemesi" + "OpenClaw yalnızca Konuşma veya Dikteyi başlattığınızda dinler." + "Adım ve aktivite verilerini paylaş" + "Kurulum Gerekiyor" + "OpenClaw ayarlar asistanını kullanmak için bu Gateway\'i güncelleyin." + "Bu Gateway bağlantısının ClawHub Skills\'ı yükleyebilmesi için operator.admin yetkisi gerekir." + "Teklif uygulandı." + "%1$s beklemede" + "%1$s sa önce" + "Arama Günlüğünü Oku" + "%1$s kuyrukta · gateway bekleniyor" + "Gruba taşı" + "Eşleştirmek için QR Kodunu Tara" + "Onay reddedildi." + "Skill Workshop önerisi incelenemedi." + "Sabitlenmiş" + "Profil ve cihaz" + "Düşünme düzeyi seçicisini kapat" + "Mesaj daha sonra teslim edilmek üzere kuyruğa alınamadı." + "Karantina" + "Zamanlama · %1$s" + "Düşünme düzeyi güncellenemedi." + "Düşünme düzeyi seçicisini aç" + "Sesli yanıt zaman aşımına uğradı; sıraya alınan istek yeniden deneniyor" + "Düzen: Ayrıntılı" + "Bu görüntünün kodu çözülemedi." + "Gateway, ses, bildirimler, gizlilik" + "Agent çalışma alanı dosyaları" + "Bu cihaz, güvenilir Gateway erişimini kaybedecek." + "Onay komutunda bekleyen komuttaki requestId değerini kullanın." + "Zamanlama" + "Hız Sınırı" + "Teslim edilmedi" + "Veri yükü · %1$s" + "Çalışıyor" + "Pençeliyor" + "Bitir" + "Sistem güvenini kullan" + "Hazır sağlayıcı yok" + "Bağlı Bluetooth mikrofonlarına öncelik verir." + "%1$s uygulamanın yönlendirmesi engellendi." + "Mesaj eylemleri" + "Tür" + "Arşivden çıkar" + "Transcripts" + "Uyandırma sözcükleri" + "Gateway üzerinde %1$s yapılandırın" + "Bir QR kodu tarayın veya OpenClaw Gateway’inizdeki kurulum kodunu kullanın." + "Tasarım sistemi prototipi" + "Eleniyor" + " · Konuşma: Açık" + "Henüz kullanım verisi yok." + "Sohbet, çalıştırma başlamadan önce başarısız oldu; tekrar deneyin." + "Gönder" + "Paylaşılan bazı görseller atlandı veya eklenemedi." + "Takvimi Yaz" + "timeout" + "Düşük" + "Engellenenler listesi" + "act" + "Dismiss Task" + "Sohbet başarısız oldu" + "OpenClaw · Canlı" + "Yüklü" + "Yanıt beklenirken zaman aşımına uğradı; tekrar deneyin veya yenileyin." + "Önceki konuşmaları bulun" + "Konulara Göz At" + "Yenileniyor" + "İnci aranıyor" + "Kamerayı açın ve openclaw qr kodunu çerçeveye alın." + "Cihaz yok" + "Bildirimleri Yönlendir" + "Bu görüşmeyi normal aracı sohbetinden ayrı tutacağım." + "Gateway oturumu yeniden çevrimiçi oluyor. Aracı kısayolları kısa süre içinde otomatik olarak normale dönmelidir." + "Farklı bir arama deneyin veya mevcut sorguyu temizleyin." + "Arka planda konuma izin verilsin mi?" + "Yüzeye çıkma" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Sesli notu iptal et" + "Geri kaydır" + "openclaw gateway" + "Gateway eşleştirildi" + "Kabuk değiştiriyor" + "Bir sonraki sıranız için dinleniyor." + "OpenClaw çalışıyor" + "Günlük Girdisi" + "Başarısız: Bu ana makine için güvenli gateway uç noktasına ulaşılamadı." + "Gateway çevrimdışı. Aşağıdan bağlantıyı düzeltin veya tanılama bilgilerini kopyalayın." + "Beklemede" + "Test test 1 2 3" + "ClawHub Skills aranamadı." + "İstem yok" + "Ön kamera" + "Günlük kaydını aç" + "Ağ zaman aşımına uğradı" + "Şimdi" + "Grubu yeniden adlandır…" + "Daha Fazla Aracı" + "openclaw nodes approve REQUEST_ID" + "Sabitle" + "thread list" + "%1$s öğesini aç" + "upload" + "Gateway parolası yapılandırılmamış" + "Dikte ayarları" + "Sağlayıcı modelleri yüklendi ancak hazır olma durumu kullanılamıyor." + "Konu silinsin mi?" + "OpenClaw bu telefonu ileti dizileri, ses, sağlayıcılar ve Gateway için sade bir mobil komuta arayüzüne dönüştürür." + "Önce en yeni" + "Sonraki Döngü" + diff --git a/app/src/main/res/values-uk/assistant.xml b/app/src/main/res/values-uk/assistant.xml new file mode 100644 index 0000000..72568d6 --- /dev/null +++ b/app/src/main/res/values-uk/assistant.xml @@ -0,0 +1,7 @@ + + + "запитати OpenClaw %1$s" + "сказати OpenClaw %1$s" + "відкрити OpenClaw і запитати %1$s" + + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..063aaa7 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Довіряти цьому шлюзу? + Довіряти й продовжити + Скасувати + Новий чат у worktree + Перевірте відбиток сертифіката, перш ніж довіряти цьому шлюзу.\n\n%1$s + Сертифікат шлюзу змінився. Продовжуйте, лише якщо ви очікували цю зміну.\n\nСтарий SHA-256:\n%1$s\n\nНовий SHA-256:\n%2$s + Невідомо + ВЕРСІЯ + КОМІТ + ЗІБРАНО + Версія %1$s + Git-коміт %1$s + Зібрано %1$s UTC, позначка часу %2$s + Дата збірки %1$s + Скопіювати повний хеш Git-коміту + Скопіювати повну позначку часу збірки + Git-коміт OpenClaw + Позначка часу збірки OpenClaw + Git-коміт скопійовано + Позначку часу збірки скопійовано + + "Не вдалося підготувати вкладення до надсилання." + "Мікрофон вимкнено" + "Показувати сповіщення OpenClaw" + "Активність гілки" + "Повна" + "Схвалення дозволено та збережено." + "Показувати недавню історію викликів" + "1 в очікуванні" + "Непідтримуване вкладення" + "0 = точно" + "Підключіть Gateway, щоб шукати гілки." + "%1$s облікових записів" + "Для змін Cron потрібен дозвіл operator.admin. Коди налаштування навмисно не надають його. Повторно підключіться за допомогою спільного токена або пароля Gateway, щоб запросити доступ адміністратора. Якщо цей пристрій усе ще не має такого доступу, схваліть запит на розширення дозволів з наявного клієнта адміністратора." + "Apply Patch" + "Щипання" + "Увімкнути звук динаміка" + "Послідовні пропуски" + "У цій папці ще немає файлів." + "Не підключено" + "Переглядайте стан установлених навичок і керуйте ним." + "Не вдалося" + "Агент за замовчуванням" + "Камера" + "Видалити з групи" + "Пошук" + "Призупинено для відтворення голосу" + "Перед завантаженням Gateway перевірить саме цей випуск за допомогою ClawHub. Якщо для випуску потрібно явно підтвердити усвідомлення ризику, Android покаже попередження Gateway перед повторною спробою." + "Код налаштування використовує ідентифікатор зони IPv6. Використайте IPv6-адресу без області або ім’я хоста LAN." + "Вкладення" + "Налаштуйте слова активації, мовлення та відтворення." + "Прослуховування (PTT)" + "Пропозицію відхилено." + "Показати бічну панель" + "користувач" + "%1$s · %2$s" + "Мінімальний" + "Відхилити" + "АКТИВНИЙ АГЕНТ" + "1 заплановано" + "Немає відповіді" + "Вибрано %1$s" + "JSON-масив argv команди" + "Не вдалося прочитати це зображення. Виберіть чіткий знімок екрана або зображення QR-коду з openclaw qr." + "Не вдалося %1$s пропозицію Skill Workshop." + "Відповідь надано в іншому місці" + "Gateway зафіксував схвалення один раз." + "status" + "OpenClaw перевіряє місцезнаходження, лише коли цього запитує підключений Gateway. На наступному екрані Android виберіть %1$s, щоб дозволити перевірки, коли застосунок працює у фоновому режимі." + "відхилити" + "Контрастність" + "Замінити налаштування gateway?" + "Не вдалося завантажити автоматизації." + "Ви" + "Вбудований мікрофон" + "Інтерфейс" + "Немає пропозицій" + "Основна гілка" + "Відкрити чат" + "Дії зі сполучення пристроїв недоступні в цьому сеансі Gateway. Виконайте openclaw devices list на хості Gateway і керуйте запитом там. Схвалення можливостей вузла виконується окремо й досі використовує nodes approve <request id>." + "Запит на дію" + "list pins" + "Підключіться до Gateway, щоб завантажити пропозиції Майстерні Skills." + "Код налаштування не прийнято" + "Вийти" + "Постачальника транскрибування в реальному часі не налаштовано." + "Показати системні застосунки" + "Оновіть Gateway, щоб переглянути конфігурацію моделей постачальника." + "Надсилання диктування" + "Перегляньте цю пропозицію, щоб завантажити її Markdown." + "відкрити OpenClaw і запитати %1$s" + "міркування" + "Клієнт" + "Застосовано" + "відео" + "Просунуті" + "Онлайн" + "Області" + "Постачальника голосового зв’язку в реальному часі не налаштовано." + "%1$s · %2$s" + "kick" + "Gateway повернув недійсну автоматизацію." + "Ідентифікатор екземпляра" + "Потрібен токен Gateway. Введіть його знову або відредагуйте це підключення." + "Джерело" + "Оновити" + "%1$s у черзі" + "Почати чат" + "Виклики інструментів чату, що очікують в активній гілці, залишаються видимими тут." + "Потрібна перевірка сертифіката" + "Відкрити поточну поверхню Canvas для перегляду чи взаємодії." + "Автоматизацію оновлено." + "Немає недавніх сеансів" + "Скрипт" + "Стан Gateway, готовність телефонного вузла та потік останніх журналів." + "Відкрити відомості про автоматизацію" + "Середовище виконання" + "Ще 1 воркер" + "Агент %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Увімкніть %1$s в Android Settings, щоб продовжити." + "Відновити" + "reactions" + "Готово" + "Версія та оновлення" + "OpenClaw показуватиме тут схвалення, невдалі завдання та проблеми з каналами." + "USB-мікрофон" + "Забагато спільних ресурсів очікують на додавання." + "Пропущено" + "Використайте LAN-адресу комп’ютера Gateway або захищене віддалене ім’я хоста." + "Увімкнено" + "Пошук гілок" + "Розмова в реальному часі" + "· %1$s" + "OpenClaw готує відповідь." + "Схвалення дозволено один раз." + "Налаштування провайдера" + "немає" + "Вміст скрипту зберігається без змін. Скористайтеся CLI, щоб відредагувати цей скрипт." + "Посібник із налаштування Android" + "Пересилання заблоковано для %1$s застосунків." + "Спарені пристрої" + ":%1$s" + "%1$s очікують" + "Назва пристрою" + "Надіслати" + "Місцезнаходження" + "напр. America/New_York" + "Ціль сеансу" + "Переглянути навичку ClawHub" + "snapshot" + "Відхилити запит на сполучення від цього пристрою?" + "Бажаний мікрофон" + "Хост вузла" + "Рівень" + "Закрити вибір застосунків" + "Вставте спільний токен Gateway або токен, виданий оператором." + "Усі системи працюють нормально" + "Діагностику Gateway скопійовано" + "Помилка аудіо" + "Замінити налаштування" + "Швидкі дії" + "Не вдалося надіслати: помилка чату до початку виконання; спробуйте ще раз." + "Мікрофон" + "Чат усе ще перевіряє стан Gateway." + "Точне місцезнаходження" + "Дозволити один раз" + "+%1$s ще" + "thread create" + "Заблоковано" + "Слово або фраза активації" + "Gateway потребує схвалення пристрою" + "Зовнішній мікрофон" + "%1$s/%2$s готово" + "Підключено (оператор не в мережі)" + "Можливості не схвалено" + "Це назавжди видалить автоматизацію та її розклад із Gateway." + "Завантаження зображення…" + "Підключити" + "Схвалити доступ вузла" + "Додати Gateway" + "Транскрибування недоступне: %1$s" + "Зображення" + "Припливання" + "Закрити попередній перегляд зображення" + "eval" + "Остання команда: %1$s" + "Відкрийте термінал на пристрої, на якому запущено OpenClaw." + "Нічого не бракує" + "Для виводу полотна потрібне активне підключення до Gateway." + "%1$s · %2$s" + "Ізольовано" + "© 2026 OpenClaw Foundation — Ліцензія MIT." + "PDF" + "Conversations" + "Консолідація пам’яті та щоденник снів." + "Create Goal" + "Цю автоматизацію було змінено під час редагування. Перед збереженням відновіть останню версію з Gateway." + "Після підключення Gateway може активувати телефон за допомогою тихого push-сповіщення замість постійно активної сесії." + "Режим пробудження" + "Видалити сполучений пристрій?" + "Текст системної події" + "Не вдалося скопіювати зображення віджета" + "Ні" + "Необов\'язковий шлях" + "Надсилання голосу з черги" + "Вбудований" + "hide" + "runs" + "Потрібен пароль Gateway. Введіть його знову або відредагуйте це підключення." + "Текст події" + "Жива транскрипція" + "Не вдалося завантажити конфігурацію моделей постачальника." + "Застосунку %1$s дозволено пересилання." + "Налаштування голосу" + "Прикріпити відео" + "Додаткові зображення приховано: %1$s" + "Відхилити запит на сполучення?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Видавець" + "Відгалузити звідси" + " · Розмова: мовлення" + "Необов\'язкове перевизначення" + "За замовчуванням" + "Схвалення команди" + "Застосунок і Gateway використовують несумісні версії протоколу. Оновіть OpenClaw на обох і повторіть спробу." + "Оновити екран" + "Читати нещодавні фотографії та медіафайли" + "Слухати" + "Підключено" + "Завершено" + "Ваш телефон спарено з %1$s. Продовжте, щоб завершити налаштування доступу до вузла." + "Поточна гілка" + "Міркування %1$s" + "Вимкнено" + "OpenClaw цінує своїх партнерів у спільноті відкритого коду." + "відкрито" + "Підключення до Gateway" + "Gateway може змінити цей шлях, але не може очистити наявний шлях." + "TTS" + "Збереження…" + "Прив’язка %1$s" + "search" + "Активувати" + "Переглядайте заплановані завдання Gateway і керуйте ними." + "Попередня відповідь уже дозволила цю команду один раз." + "generate" + "Використовуйте лише в надійній приватній мережі." + "Пошук налаштувань" + "Розмова активна" + "Автентифікацію Gateway не налаштовано. Відредагуйте це підключення й повторіть спробу." + "Помилка: захищену кінцеву точку досягнуто, але час очікування перевірки TLS-відбитка минув. Перевірте Tailscale Serve або TLS Gateway і повторіть спробу." + "Крок 1" + "Диктування" + "Відкрити вибір застосунків" + "Немає очікуваних схвалень" + "edit" + "Підключіться до свого Gateway" + "Введіть код налаштування з openclaw qr." + "Діагностика" + "Інші застосунки залишаються недоторканими." + "Розколювання" + "Це назавжди видалить гілку та її журнал." + "Пристрій схвалено." + "Автоматична повторна спроба" + "Зображення віджета скопійовано" + "%1$s ролей" + "Бракує 1 елемента" + "%1$s заплановано" + "react" + "Агенти" + "Підключіть Gateway, щоб завантажити автоматизації." + "Повторне підключення…" + "Повернутися до налаштування" + "send" + "Не вдалося перевірити з’єднання" + "Перевірити та встановити" + "Перетворіть цей пристрій на захищений вузол OpenClaw для чату, голосу, камери та інструментів пристрою." + "Ручне налаштування" + "Відкрийте чат, щоб почати або продовжити поточну гілку." + "Порада: зупиніть прослуховування, щоб надіслати записану репліку." + "Пропустити" + "Не вдалося виконати голосовий запит" + "Буде відхилено \"%1$s\", а стан Майстерні навичок оновлено з Gateway." + "Облаштування мушлі" + "update" + "Поділитися" + "Камеру ввімкнено" + "Telegram, WhatsApp, email та інші канали з’являться тут після налаштування." + "Помилка мережі" + "Дослідження припливних басейнів" + "Відновіть Canvas зараз для session=%1$s source=%2$s. Якщо наявний стан A2UI існує, негайно відтворіть його. Якщо ні, створіть і відобразіть у Canvas компактну інформаційну панель, адаптовану для мобільних пристроїв." + "Не вдалося запустити: %1$s" + "Не запитано" + "Налаштуйте постачальника %1$s на Gateway" + "kill" + "Схвалення" + "Файли недоступні" + "Позначити як непрочитане" + "Знаходити людей і контактні дані" + "Потрібна ідентифікація пристрою" + "Гілка OpenClaw" + "Дозвольте доступ до фототеки." + "Попередня відповідь уже вирішила це схвалення." + "Немає нещодавніх гілок" + "Час очікування: %1$s с" + "Збігів немає" + "Читати сповіщення вибраних застосунків" + "Доступність невідома" + "Налаштувати розмову" + "Додатково" + "Gateway спарено. Очікується доступ оператора." + "Прикріпити зображення" + "Виберіть, що надходить до OpenClaw." + "Очікується повторне схвалення можливостей" + "Перегляньте виділені елементи" + "Слухаю..." + "Введи мене в курс справ" + "Повідомлення" + "Читання контактів" + "Сховище офлайн-вкладень заповнене; спочатку видаліть елементи з черги." + "Один раз" + "Перейменувати" + "Каналів не знайдено." + "Переглянути все" + "Новий пристрій" + "Session Status" + "Відкрити попередній перегляд зображення" + "Гілку сеансу змінено; перегляньте та повторіть це повідомлення." + "close" + "Схоже, це код налаштування. Поверніться, виберіть «Налаштувати Gateway», а потім — «Використати код налаштування»." + "✦" + "Агенти й автоматизація" + "Застосувати" + "Виконання автоматизації пропущено." + "Продовжити" + "Відстеження · %1$s запланованих завдань" + "Огляд" + "tabs" + "Очікує" + "Розмова: %1$s" + "read" + "Вибрати текст" + "Рухова активність" + "опис: %1$s" + "Відтворити аудіо" + "Час" + "Не перевірено" + "Yield" + "Копіювати команду схвалення" + "Поточний вивід екрана та інтерактивна поверхня застосунку." + "Службу підключено" + "Відображення" + "Готово, коли будете готові" + "Не вдалося завантажити каталог постачальників." + "Говорить · очікування відповіді" + "Не надано" + "Зберегти зміни" + "Gateway відхилив запуск автоматизації." + "Session Send" + "Знайти на ClawHub" + "Завжди дозволяє запитані перевірки місцезнаходження, поки OpenClaw працює у фоновому режимі; Android показує це в постійному сповіщенні вузла." + "Системна подія" + "Підключіть Gateway, щоб переглянути провайдерів" + "Наступний сигнал активності" + "Gateway спарено. Очікується схвалення можливостей вузла." + "Засолювання" + "Закрити Canvas" + "Запис контактів" + "Жодна встановлена навичка не відповідає цьому пошуку." + "Налаштування постачальника розмов" + "Music Generation" + "Налаштування Розмови" + "Моніторинг · 1 гілка" + "Текст payload" + "Установити текст" + "Схвалення %1$s" + "Gateway не повернув готовність %1$s" + "Налаштовано моделей: %1$s. Оновіть, щоб повторно перевірити доступність." + "Conversation Send" + "Полотно" + "1 провайдер" + "Не вдалося автоматично прочитати сертифікат Gateway. Вставте відбиток SHA-256, отриманий на хості Gateway." + "Не вдалося надіслати: %1$s" + "Міст" + "Помилка доставки" + "Використовуйте OpenClaw зі свого телефона" + "Вигляд" + "Майстерня Skills" + "Потрібен токен" + "Попередній перегляд · %1$s" + "Потрібен дозвіл на доступ до мікрофона" + "Підключіть Gateway, щоб завантажити пропозиції Skill Workshop." + "Усі системи працюють" + "Gateway недоступний" + "OC" + "Оновлено" + "Підключено (вузол не в мережі)" + "Головна" + "Диктування слухає" + "Немає архівованих гілок" + "Виберіть і перегляньте помічників, доступних на цьому gateway." + "Режим розмови активний" + "Виконується · 1 активний запуск" + "Погодитися та ввімкнути" + "Потрібне оновлення Gateway" + "Копіювати зображення" + "URL Gateway" + "main, isolated, current або session:<id>" + "Медіа недоступне" + "Підключіться до свого Gateway, щоб відкрити оболонку в робочому просторі агента." + "%1$s://%2$s:%3$s" + "Не вдалося завантажити відомості про схвалення. Оновіть і повторіть спробу." + "Я можу перевірити стан Gateway, відновити конфігурацію, змінити моделі або підключити канали." + "Tool Call" + "Гілки" + "Write" + "Почніть із запиту або скористайтеся голосовим введенням." + "D" + "Відкрити налаштування" + "Спостереження…" + "Завершити розмову" + "Остання помилка" + "Перегляньте дії, які потребують вашої уваги." + "Вимкнено для всіх агентів." + "Запустити голосовий режим" + "Назад до фонових завдань" + "Інша дія cron ще завершується." + "Період очікування %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Розпізнавання мовлення на пристрої недоступне." + "Скрипт · лише для читання" + "Вкладення завеликі, щоб додати їх до черги в одному повідомленні; видаліть деякі з них і спробуйте ще раз." + "Налаштовано 1 модель. Оновіть, щоб повторно перевірити доступність." + "Віджет недоступний" + "Для цього хоста потрібне захищене з’єднання." + "Останні" + "Відповідних автоматизацій не знайдено." + "Телефон може підключитися до Gateway" + "Gateway" + "Термін дії минув" + "Запланована робота OpenClaw з вашого gateway." + "Sub-agent" + "Очікування схвалення пристрою" + "Завантаження гілки" + "Тепер цей Gateway надає сертифікат, якому довіряє цей пристрій." + "Зсув мс" + "event create" + "документ" + "Налаштувати Gateway" + "Відтворити відео" + "Збережена автентифікація недійсна. Повторно автентифікуйтеся або скиньте це підключення до Gateway." + "Під час використання" + "screenshot" + "Перемотати сюди" + "Вираз cron, напр. 0 9 * * *" + "Назад до голосу" + "Говорити" + "Відомості" + "%1$s/%2$s онлайн" + "Дозволено пересилання для %1$s застосунків." + "Чат" + "Потрібен доступ до мікрофона." + "Дріботіння" + "Редагувати" + "Тихі години" + "Копіювати діагностику" + "Заплановано" + "Створити" + "Спливає через %1$s" + "Відхилити" + "Відхилити пропозицію?" + "Помилка розпізнавання мовлення (%1$s)" + "Проблема" + "Пошук у метаданих реєстру. Gateway повторно перевіряє надійність перед будь-яким завантаженням." + "Для локального налаштування використовуйте приватну IP-адресу локальної мережі або ввімкніть Tailscale Serve / надайте доступ до URL-адреси шлюзу wss:// для віддаленого доступу." + "Надсилання…" + "Обліковий запис %1$s" + "Suggest Task" + "Пошук" + "Прослуховування" + "Автоматизацію не завантажено." + "Доступне оновлення Gateway. Коли будете готові, запустіть оновлення через вебінтерфейс або CLI." + "незабаром" + "Немає затверджень Gateway." + "Хост" + "Додайте одне слово або фразу активації в кожне поле. Потім промовте його перед командою." + "Транскрибувати й надіслати" + "Виконати о" + "Призупинити аудіо" + "Доступ до пристрою Gateway" + "Немає попереднього перегляду" + "Пристрої" + "OpenClaw для Android." + "Очікується схвалення можливостей" + "Збережіть або скасуйте свої зміни, перш ніж запускати, вмикати, вимикати, видаляти чи оновлювати цю автоматизацію." + "Автоматизацій поки немає." + "Для цієї навички потрібно налаштувати таку кількість елементів: %1$s. Android показує, що встановлено; початкове налаштування та зміни конфігурації виконуються на комп’ютері або через CLI." + "%1$s нещодавні" + "Канали" + "Без фази" + "Активний на цьому телефоні" + "Перевірка доступу до вузла" + "Часовий пояс" + "Дії перевірки та застосування Skill Workshop" + "Дозволяти завжди" + "present" + "Skills, установлені на Gateway, з’являться тут." + "Можливо, код застарів або був згенерований для іншого Gateway." + "Потрібен дозвіл" + "Автоматизація має недійсну конфігурацію." + "Список дозволених" + "Налаштування, стан і відновлення" + "groups" + "Відкритий ключ" + "Про застосунок" + "На цьому зображенні не знайдено QR-коду налаштування. Виберіть QR-код, згенерований за допомогою openclaw qr, або введіть код налаштування вручну." + "permissions" + "Підключіть gateway, щоб завантажити вузли та спарені пристрої." + "Змінити гілку" + "Немає навичок" + "Відповіді відтворюються вголос" + "Позначити як прочитане" + "Очікується схвалення вузла" + "wake" + "%1$s пропозицій" + "Автентифікація Gateway потребує уваги." + "Подробиці з’єднання" + "Мілісекунди" + "Розпізнавання мовлення" + "Опис" + "Нещодавні розмови" + "Ваш телефон надсилає цю інформацію на ваш Gateway, а не на сервер, керований OpenClaw. Ваш Gateway може включати її в запити до вибраного вами AI-провайдера." + "Доставка" + "Вимкнути динамік" + "%1$s Виконується · %2$s Завершено · %3$s Помилок" + "Відкриття підключення до Gateway" + "Моніторинг · гілок: %1$s" + "Виконання автоматизації завершено." + "Немає відповідних застосунків." + "Надіслати в чат" + "Автоматизацію видалено." + "Увімкнути" + "Останні запуски" + "Розмістіть QR-код усередині квадрата." + "Не вдалося завантажити схвалення." + "Я схвалив" + "Підключіть ваш Gateway, щоб завантажити готовність провайдера." + "Не спарено" + "Термін дії цього схвалення закінчився до його вирішення." + "Спостереження за %1$s с — перейдіть до цільового застосунку" + "Підказка агента" + "emoji list" + "Повторюване" + "Пошук в OpenClaw" + "%1$s очікують" + "Розпізнавання мовлення на пристрої недоступне" + "Немає застосунку, який може поділитися цим повідомленням" + "Закрити пошук" + "Команда для відстеження" + "Стан" + "Слухач сповіщень" + "Динамік вимкнено" + "Пошук гілок" + "Гаразд" + "Не вдалося відкрити посібник із налаштування." + "запитати OpenClaw %1$s" + "Wait for Agents" + "Адреса" + "Заплановані завдання, створені на Gateway, з’являться тут." + "Показано останній фрагмент журналу." + "Використати код налаштування" + "sticker" + "Використайте захищений wss:// або Tailscale Serve Gateway, згенеруйте код налаштування з повним доступом у Control UI чи за допомогою openclaw qr, потім відскануйте або вставте його нижче та повторно підключіться, щоб увімкнути налаштування й оновлення." + "steer" + "Вибрано" + "Android може відсканувати або вставити наявний код налаштування, але цей gateway ще не надає застосунку можливість створювати коди налаштування. Згенеруйте QR/код на хості gateway за допомогою openclaw qr, а потім відскануйте його тут або вставте код налаштування нижче." + "Стан Canvas" + "Виправити підключення" + "Зберегти зображення" + "Вузол %1$s" + "Потрібен пароль Gateway" + "Update Plan" + "Видалити вкладення" + "Не вдалося виконати автоматизацію." + "Ліміти провайдера та стан квоти." + "Каталог розмов Gateway не завантажено" + "цей Gateway" + "Ще немає останніх запусків." + "Мовна модель на пристрої недоступна" + "Для панелі керування потрібен підключений Gateway" + "Відповідні пропозиції з’являться тут після того, як агенти створять чернетки багаторазових Skills." + "Session Search" + "OpenClaw говорить" + "Сканувати QR" + "Вибрані застосунки" + "Скасувати зміни" + "Команду схвалення скопійовано" + "Статус доставки" + "QR-код не прийнято" + "Ваш центр голосового керування." + "Перевірити з’єднання" + "OPENCLAW" + "Web Fetch" + "Запит" + "Схвалити пристрій?" + "Підключіться до свого Gateway, щоб відкрити панель керування цього сеансу." + "Видалити %1$s і збережені облікові дані з цього телефона?" + "QR-код вказує на небезпечний віддалений gateway. %1$s %2$s" + "Поверхня екрана готова" + "Спарити Gateway" + "Підключіть Gateway, щоб завантажити канали." + "Призупиняється під час іншої голосової активності." + "Модель" + "Фото" + "Вставити код налаштування" + "OpenClaw говорить" + "Підключення..." + " · Місцезнаходження: Завжди" + "Повідомлень: %1$s" + "Рифування" + "Завантажити з Gateway" + "текст: %1$s" + "Потрібно" + "rename group" + "Готово" + "Щоденник очікує на перший запис." + "Схвалити" + "Сторінка наживо" + "Автоматизація вже виконується." + "Видалити цю автоматизацію після успішного одноразового запуску." + "Готово до чату й голосового спілкування" + "Підключено (оператор: %1$s)" + "Сполучення з Gateway завершено. Схваліть цей телефон як вузол, щоб OpenClaw міг використовувати можливості пристрою, які ви ввімкнете." + "Відповідь перервано" + "зображення" + "%1$s утримано" + "Немає відповідних гілок" + "delete" + "Макет: компактний" + "channels" + "Надано" + "Кожні %1$s хв." + "1 токен" + "%1$s %2$s" + "Установлені застосунки" + "очікується" + "Підготовка голосового повідомлення…" + "Ніколи" + "Підсистема" + "При завершенні команди" + "Підключення" + "Не вдалося завантажити історію запусків автоматизації." + "Назва автоматизації" + "Крок 2" + "Діагностувати" + "Деякі перевірки стану каналів не завершилися." + "pin" + "Копіювати %1$s" + "Спарено" + "Не вдалося зберегти слова активації" + "Буде ізольовано \"%1$s\", а стан Майстерні навичок оновлено з Gateway." + "Записати голосове повідомлення" + "У черзі" + "Відповідь надано" + "Дозволяти інструменти камери за запитом." + "Проблеми" + "Голосова активація" + "Запит на сполучення відхилено." + "%1$s дн тому" + "roles" + "Skills" + "Архівувати" + "Вузол не в мережі. Підключіться знову та повторіть спробу." + "Система" + "Віддалена IP-адреса" + "Без групи" + "Відомості про розклад" + "Можливості телефона" + "Недоступно" + "Панель керування" + "Вставити токен" + "Немає постачальників" + "Відбиток SHA-256" + "Гілок ще немає" + "Мікрофон Bluetooth" + "Нещодавні" + "Перейменувати гілку" + "Результат вирішення невідомий. Дії залишатимуться недоступними, доки запис у Gateway не буде перевірено." + "dialog" + "Слухати слова активації" + "camera snap" + "Підготовка відтворення…" + "Gateway вибрав невідомого постачальника %1$s" + "delete group" + "Слідувати Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Підключіть Gateway, щоб завантажити агентів." + "Повернутися" + "Поділитися повідомленням" + "Згенеруйте QR-код." + "Перезапустити" + "Динамік увімкнено" + "Видалити групу?" + "Відсутнє" + "Пошук пропозицій" + "stop" + "Захищено (TLS)" + "Немає вузлів або спарених пристроїв." + "Залишилося %1$s%% %2$s" + "Термін дії коду налаштування минув" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "ЩОДЕННИК" + "notify" + "Цей телефон залишається в режимі сну, доки не знадобиться Gateway, потім прокидається, синхронізується й знову переходить у режим сну." + "%1$s налаштованих моделей" + "Ліцензії" + "Підключіть Gateway, щоб шукати навички ClawHub." + "Навичка" + "З\'єднання Gateway змінилося. Перезапустіть OpenClaw, щоб повторно підключитися." + "Ідентифікатор пристрою" + "Gateway не визначив активного постачальника %1$s" + "Очікування" + "Слова активації збережено" + "Спочатку найстаріші" + "Екран" + "Виконується з" + "Ідентифікатори зон IPv6 не підтримуються. Використайте IPv6-адресу без області або ім’я хоста LAN." + "Надіслано — підтвердження доставки…" + "аудіо" + "This gateway connection needs operator.admin to update skills." + "Код налаштування" + "Підтвердити попередження Gateway та встановити" + "Оновити чат" + "Інтервал" + "Дії з пропозиціями Skill Workshop потребують області доступу operator.admin." + "Сеанси" + "Перейменувати…" + "Підключіть gateway, щоб завантажити dreaming." + "Налаштування" + "Відкрити Розмову" + "poll" + "Підключіться, щоб завантажити своїх агентів" + "role remove" + " · Розмова: прослуховування" + "ClawHub не повернув версію %1$s, придатну для встановлення." + "Команда" + "Це схвалення було скасовано до його вирішення." + "Мікрофон увімкнено · очікування gateway" + "Текст" + "Показано %1$s із %2$s. Уточніть пошук, щоб побачити більше." + "Доступна версія v%1$s" + "%1$s://%2$s" + "%1$s... (OK)" + "Провайдери та налаштовані моделі" + "Підключення…" + "Підключіться до Gateway, щоб зберегти слова активації" + "Відкрити профіль" + "Запустіть свій Gateway." + "Допоможіть мені перетворити цю мету на практичний контрольний список: " + "Очистити пошук сесій" + "Порт" + "Введіть код налаштування" + "Не вдалося завантажити журнали Gateway." + "%1$s провайдерів готові" + "Ваші агенти готові" + "На Gateway не налаштовано постачальника %1$s" + "Очікування однієї репліки" + "Спостерігати" + "Мілісекунди епохи (необов’язково)" + "Немає налаштованих моделей. Оновіть, щоб повторно перевірити доступність." + "Налаштування" + "Задня камера" + "approve" + "Перш ніж почати" + "Не вдалося завантажити Skills." + "Вимкнено" + "Досі очікується схвалення" + "Не вдалося завантажити фонові завдання" + "Перевірте, що OpenClaw може чітко говорити на цьому телефоні." + "Виконується · %1$s активних запусків" + "Робочий каталог команди" + "Назва групи" + "Вибрати з галереї" + "Version %1$s" + "Назад" + "Connect the gateway to update skills." + "Видалити після запуску" + "Код налаштування вказує на небезпечний віддалений gateway. %1$s %2$s" + "Computer" + "Gateway відключено." + "Session Settings" + "Підключіть Gateway, щоб почати" + "Повідомлення про безпеку" + "Інша відповідь" + "Закрити попередження про спільне зображення" + "Gateway перевірив інший випуск ClawHub. Перегляньте навичку ще раз перед установленням." + "Відкрити системний доступ" + "Завершено" + "Зображення недоступне" + "Сповіщення" + "Для застосування, відхилення та карантину потрібна область operator.admin. Повторно підключіться зі спільною автентифікацією gateway або схваліть підвищення області operator.admin для пристрою, щоб увімкнути дії життєвого циклу." + "sticker upload" + "Ловля омарів" + "Messages to recover" + "openclaw devices approve %1$s" + "Зручні для читання відомості журналу Gateway." + "Переглядайте згенеровані пропозиції Skills, перш ніж вони стануть активними." + "Вбудовано" + "Доступно: %1$s" + "Очікується підтвердження вузла" + "Очікування Gateway" + "Потрібна автентифікація" + "Вузли" + "Не вимикати екран" + "OpenClaw відповідає" + "Документація" + "Готово: %1$s" + "Результатів ще немає" + "Мова пристрою не підтримується" + "У черзі — буде надіслано після відновлення з’єднання" + "%1$s хв тому" + "Поточна гілка" + "Перевірка доступу для спарювання" + "Обмежений доступ до Gateway" + "Виконуються інструменти..." + "Перевірка схвалення…" + "Знімати фото й відеокліпи цим телефоном" + "Підключено й готово" + "Закрити" + "Перетворіть мету на практичний контрольний список." + "Код налаштування містить недійсну URL-адресу gateway." + "Вмикайте лише той доступ, який вам комфортно дозволити OpenClaw використовувати, поки цей телефон підключено. Ви можете змінити це пізніше в Android Settings." + "Обліковий запис" + "remove" + "Пароль необов’язковий" + "Автентифікація Gateway потребує перевірки. Перевірте налаштування Gateway, а потім повторіть спробу." + "QR-код використовує ідентифікатор зони IPv6. Використайте IPv6-адресу без області або ім’я хоста LAN." + "add" + "Крилювання" + "Справний" + "Виконано за %1$s" + "Аргументи" + "Параметри встановлення" + "Через %1$s год" + "Очікується схвалення Gateway. Виконайте цю команду на хості Gateway:" + "Потрібен доступ адміністратора" + "set groups" + "Закріпити модель" + "Очистити пошук" + "Увімкнено для відповідних агентів." + "Немає поточної гілки" + "межі: %1$s" + "Після %1$s" + "Дозволити планувальнику запускати цю автоматизацію." + "%1$s застосовано" + "Щоденника снів ще немає." + "Оновити фонові завдання" + "Підсумувати нещодавні гілки та наступні кроки." + "Працює на пристрої, поки OpenClaw відображається на екрані." + "%1$s працює" + "%1$s %2$s" + "Необроблені дані" + "Запуски" + "Запустити зараз" + "Гілка без назви" + "Налаштовано" + "camera list" + "1 застосовано" + "camera clip" + "Так" + "Тест аудіо" + "Утримано" + "events" + "Робочий каталог" + "Перейти до останнього" + "Дозволяти завжди" + "Скануйте QR-код або код налаштування" + "Installing" + "Активні вузли, спарені телефони та запити пристроїв в очікуванні." + "Знімок: %1$s" + "Попередня відповідь уже дозволила цю команду та зберегла вибір." + "Запити в очікуванні" + "Схвалено" + "Робочий простір" + "Голос" + "Готово до розмови" + "Subagents" + "Помилка: не виявлено захищеної кінцевої точки gateway. Увімкніть TLS gateway або Tailscale Serve, або використайте довірену приватну LAN-адресу з вибраним параметром Незашифровано." + "Сигнали" + "Ціль сеансу" + "Gateway зафіксував відхилення." + "Прийняти" + "Запитайте OpenClaw будь-що" + "Повторно підключіться, щоб продовжити" + "%1$s спарено" + "Буде застосовано \"%1$s\", а стан Майстерні навичок оновлено з Gateway." + "Gateway офлайн" + "openclaw devices list" + "Статус підключення вузла OpenClaw" + "Оповіщення залишаються на цьому телефоні." + "OpenClaw може отримувати вибрані оповіщення." + "Відкрити екран" + "Дії в чаті" + "Дозволити керування іншими застосунками?" + "Перевірка" + "Відскануйте або вставте код налаштування, щоб додати ще один gateway." + "Рій" + "Час очікування TLS вичерпано" + "Недавні сеанси" + "Сполучений пристрій видалено." + "Gateway сполучено. Перевіряємо схвалення можливостей вузла." + "Рух" + "Не вдалося виконати дію cron." + "На комп’ютері Gateway виконайте:" + "Пошук сесій" + "Оновити журнали" + "Зображення недоступне · Торкніться, щоб повторити" + "openclaw nodes approve %1$s" + "Голосове повідомлення · %1$s" + "Використання" + "Наутилення" + "Контекст %1$s%%" + "Транскрибувати голосові запити" + "Вимкнути мікрофон" + "Почніть нову розмову, і вона з’явиться тут." + "Проблема з підключенням" + "Середній" + "Відгалузити" + "Увімкнути динамік" + "Текст системної події" + "Сортування: %1$s" + "%1$s очікують" + "Image Generation" + "Голосове повідомлення" + "Нічого не потребує вашої уваги" + "OpenClaw потребує дозволів %1$s, щоб продовжити." + "Мікрофон дротової гарнітури" + "Сторінки" + "Доставлено" + "Термін" + "Відомості про Skill недоступні в поточному статусі Skills." + "Виберіть, чим цей телефон може ділитися." + "Запуск цієї автоматизації вже додано до черги." + "Підключіть Gateway, щоб керувати автоматизаціями." + "Час запуску автоматизації ще не настав." + "Немає подробиць" + "Триває підтвердження.\nOpenClaw автоматично відновить підключення." + "Підключіть ваш Gateway, щоб переглянути готовність постачальників." + "Очікування сполучення" + "Почніть або продовжте розмову" + "Немає запланованих завдань" + "Відповісти OpenClaw…" + "Статус" + "Вузол OpenClaw · Підключено" + "Активні" + "Показувати стан налагодження спільного доступу до екрана." + "Обмежень не вказано" + "Закрити сканер" + "Кожні %1$s дн." + "Увімкнено" + "Увімкнути та відкрити налаштування" + "У мережі й готово" + "Ask User" + "Помилка чату" + "Прокрутити вперед" + "%1$s з %2$s" + "Спланувати роботу" + "console" + "Повторити" + "Почніть чат, і ваші активні розмови OpenClaw з’являться тут." + "Не вдалося завантажити автоматизацію." + "Оболонка в робочому просторі агента" + "%1$s активні" + "Виберіть дозволи пристрою" + "Тривалість останнього запуску" + "Агент за замовчуванням" + "%1$s год" + "Розмова триває" + "Не вдалося встановити %1$s із ClawHub." + "Вітаємо в OpenClaw" + "Керувати іншими застосунками" + "Індекс сигналів" + "Введіть секрет…" + "%1$s:%2$s" + "сказати OpenClaw %1$s" + "Виявлено" + "Приховати бічну панель" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Відтворення аудіо недоступне" + "застосувати" + "Не вдалося завантажити дані про використання." + "Підтримувати доступність вузла під час активної роботи." + "Наступний запуск" + "%1$s/%2$s" + "Немає останніх записів журналу." + "Ручне налаштування Gateway" + "Перейменувати групу" + "Update Goal" + "Доступність постачальника невідома" + "Провайдери" + "Видалити групу…" + "Корисне навантаження" + "Журнал викликів" + "Memory Search" + "%1$s провайдерів" + "Контекст телефона й конфіденційність" + "%1$s/%2$s підключено" + "%1$s %2$s" + "Відновлення після перезапуску Gateway ще триває." + "Заміна коду налаштування видалить збережені на цьому телефоні облікові дані налаштування та токени пристрою перед повторним підключенням. Можливо, для цього телефона доведеться знову схвалити можливості вузла; продовжуйте, лише якщо справді хочете виконати сполучення за допомогою нового коду налаштування Gateway." + "Відкрийте автоматизацію, щоб переглянути її конфігурацію та історію запусків. Підключення з правами адміністратора також дають змогу запускати, редагувати, вмикати, вимикати або видаляти її." + "Контекст --" + "Пропозицію поміщено в карантин." + "Автоматизацію призупинено." + "OpenClaw для мобільних пристроїв" + "A2UI reset" + "Gateway недоступний" + "Read" + "Для цієї навички потрібно налаштувати 1 елемент. Android показує, що встановлено; початкове налаштування та зміни конфігурації виконуються на комп’ютері або через CLI." + "Останній запуск" + "Для сканування QR-коду налаштування потрібен доступ до камери." + "Не вдалося оновити модель." + "Булькотіння" + "thread reply" + "Видалити…" + "Підключіть Gateway, щоб переглянути автоматизації." + "ОСТАННІ ЖУРНАЛИ" + "Завантаження останніх запусків…" + "Попередній перегляд цього файлу неможливий. Він може бути двійковим або завеликим." + "Перевірити" + "Отримувати дані про місцезнаходження цього телефону" + "Ключ навички" + "Установлено %1$s." + "Шлюзи" + "дії: %1$s" + "Режим пересилання" + "%1$s тис." + "Змінити компонування гілок" + "Немає TLS-кінцевої точки" + "Gateway OpenClaw" + "Налаштувати вручну" + "Обмірковування…" + "Доступ до Gateway потребує перевірки" + "1 утримано" + "%1$s с" + "Застосувати пропозицію?" + "Не зараз" + "Не схвалено" + "Пошук застосунків" + "1 налаштована модель" + "Закрити сповіщення про схвалення" + "·" + "Офлайн" + "Постачальник розпізнавання мовлення" + "Триває схвалення Gateway. OpenClaw автоматично повторить спробу." + "Максимальний" + "Для внесення змін до cron потрібен доступ operator.admin." + "Мислення" + "screen snapshot" + "Спостережувані вузли: %1$s" + "Дій не знайдено" + "Зберегти й підключитися" + "list" + "Gateway зафіксував схвалення та зберіг вибір." + "Введіть дійсну ручну кінцеву точку для підключення." + "асистент" + "Надсилання в чат..." + "Зберегти профіль" + "Заблоковано" + "Редагувати автоматизацію" + "Використовуйте ту саму мережу або захищену віддалену URL-адресу Gateway." + "Прив’язка" + "Мова" + "Ця версія застосунку старіша за версію Gateway. Оновіть OpenClaw на цьому пристрої та повторіть спробу." + "Усі" + "Триває сесія Gateway" + "Очікує на перевірку" + "Skills не встановлено." + "Перевірка Gateway" + "Зміщення %1$s" + "Результат для %1$s невідомий. Повторно підключіться, оновіть Skills і спробуйте ще раз; Gateway безпечно приєднається до відповідного встановлення, якщо воно ще триває." + "Забути" + "Немає спарених gateway." + "%1$s · %2$s" + "<прихований секрет>" + "%1$s проблем" + "OpenClaw" + "Прослуховування · %1$s у черзі" + "Мовлення асистента вимкнено" + "Дії з вузлами виконуються, лише коли цільовий застосунок на передньому плані (перевіряється через віддалений шлях). Глобальні дії та дії в межах того самого застосунку працюють тут." + "Шлюзів ще не знайдено. Скористайтеся ручним налаштуванням, якщо виявлення заблоковано." + "Відкрити гілку" + "Виконується" + "Почніть говорити..." + "Вузол телефона" + "Надвисокий" + "Запустіть на хості Gateway:" + "Для змін навичок потрібен дозвіл operator.admin. Повторно підключіться, використовуючи токен Gateway із правами адміністратора." + "Підключіть Gateway, щоб переглядати навички ClawHub." + "Список застосунків залишається на цьому телефоні." + "Неактивно" + "Показано в налаштуваннях спеціальних можливостей Android." + "Розумна доставка" + "Відхилити" + "Gateway повернув статус \'%1$s\' після %2$s." + "Токен Gateway не налаштовано" + "Not available to this agent" + "Файли" + "Дозволи" + "Не вдалося запустити камеру. Виберіть зображення QR-коду з галереї або введіть код налаштування вручну." + "Торкніться, щоб скопіювати" + "Очікування %1$s хв" + "%1$s." + "Підключіть Gateway, щоб установлювати навички ClawHub." + "Пошук голосу" + " · Мікрофон: слухає" + "Повторно підключіться з доступом operator.admin, щоб переглянути та змінити налаштування Gateway." + "Завантажити ще" + "Спостерігати через 3 с" + "run" + "Створення голосу…" + "← Назад" + "Відключити" + "Виконайте команду approve на комп’ютері Gateway, а потім перевірте ще раз." + "Автоматизації" + "%1$s хв" + "Довіряти" + "Цей QR-код не є QR-кодом налаштування OpenClaw. Згенеруйте новий код за допомогою openclaw qr, а потім повторіть спробу." + "Бажаний мікрофон недоступний; використовується автоматичне маршрутизування." + "Відхилено" + "Включати Android і фонові пакети." + "Ваш Gateway готовий." + "Активовано" + "Structured Output" + "Це займає більше часу, ніж очікувалося.\nПереконайтеся, що Gateway запущено й він доступний." + "Для цього агента немає фонових завдань." + "Повторне підключення" + "OpenClaw перевіряє доступ до Gateway і вузла." + "Code Execution" + "Немає використання провайдерів" + "Переглянути" + "Потрібен дозвіл на використання мікрофона." + "%1$s дн" + "Доступно: %1$s" + "OpenClaw відновлює синхронізацію" + "Потік подій перервано; спробуйте оновити." + "Не вдалося завантажити вузли та пристрої." + "Підключіть Gateway, щоб завантажити Skills." + "невідомо" + "Результат" + "Помилка розмови: постачальник Realtime несподівано закрив з’єднання." + "OpenClaw: термінові" + "ban" + "Потрібен токен Gateway" + "Спарений пристрій" + "Потрібне повторне схвалення" + "Не заплановано" + "Контакти" + "Ваш телефон залишається неактивним, доки не знадобиться" + "Слухає · надсилання голосу з черги" + "Не вдалося завантажити відомості про завдання" + "Повідомлення агента" + "Gateway вимагає цю ідентичність пристрою. Повторно автентифікуйтеся або скиньте це підключення до Gateway." + "Наступний сеанс" + "Безпека з\'єднання" + "Пропустити поки що" + "Вебсайт" + "Підключіть Gateway, щоб завантажити запити на затвердження в застосунку." + "%1$s скопійовано" + "Не вибрано жодного застосунку. Нічого не пересилатиметься, доки ви не додасте застосунки." + "%1$s %2$s" + "Потрібне налаштування" + "Не спарено" + "Gateway отримав дані цього телефона" + "Немає налаштованих моделей" + "Вимкнути" + "Мова застосунку" + "Спарювання з Gateway" + "Збережені дані автентифікації недійсні" + "%1$s областей доступу" + "Підключіть gateway, щоб завантажити останні журнали." + "Зберегти слова активації" + "Керуйте встановленими навичками та додавайте довірені випуски з ClawHub." + "Надсилання…" + "Агентів ще не завантажено." + "Пошук у ClawHub" + "Чат перевіряє стан Gateway." + "Потрібне спарювання" + "Активні виконання" + "Помилка — %1$s" + "З’єднання між цим телефоном і OpenClaw." + "summarize" + "Зображення віджета збережено в папці «Завантаження»" + "Запуск…" + "%1$s токенів" + "Помилка клієнта" + "Перевірте пристрій, що надсилає запит, перш ніж надавати доступ." + "Мікрофон Bluetooth LE" + "%1$s %2$s" + "Автоматизацію ввімкнено." + "%1$s млн" + "Memory Get" + "%1$s · %2$s" + "Заархівовано" + "Перезавантажити" + "Пошук автоматизацій" + "Пов’язані телефони та хости вузлів з’являться тут після спарювання." + "%1$s: %2$s" + "Цю автоматизацію змінено на Gateway. Перегляньте останню версію, перш ніж зберігати її знову." + "Зупинити диктування" + "Читабельний" + "Написати OpenClaw" + "Пароль Gateway недійсний. Введіть його повторно або скиньте це підключення до Gateway." + "Повторно підключити" + "Час ISO, напр. 2026-07-09T09:30:00Z" + "Інструментів: %1$s" + "Попередня відповідь уже відхилила це схвалення." + "Зв’язано" + "Відкрити %1$s" + "%1$s/%2$s" + "Виконання автоматизації завершено з невідомим статусом." + "Офлайн-черга заповнена (%1$s повідомлень); спочатку видаліть елементи з черги." + "Загальнодоступні шлюзи мають використовувати wss:// або Tailscale Serve. ws:// дозволено для localhost, хостів .local, емулятора Android і приватних IP-адрес локальної мережі." + "Підключіть Gateway, щоб завантажити відомості про Skill." + "Потрібен повний доступ" + "Прослуховування слів активації" + "Розгорнути попередній перегляд посилання" + "Очистити пошук гілок" + "NULL (ПОМИЛКА)" + "Оновити" + "Адміністратор" + "Потребує уваги" + "Підключіть цей пристрій до свого Gateway, щоб активувати його лише для реальної роботи, мати під рукою актуальний огляд агентів і уникати фонових циклів, що розряджають акумулятор." + "Ролі" + "Відповісти" + "Каталог постачальників" + "Увімкніть дозвіл у Налаштуваннях" + "A2UI push" + "Перевірити доступ" + "Якщо Gateway доступний, повторне підключення має завершитися без втручання." + "Хід агента" + "помістити в карантин" + "Увага" + "Пошук…" + "Де отримати код налаштування?" + "Не вдалося ввімкнути навичку." + "pdf" + "Видалити" + "%1$s%% у мережі" + "Немає каналів" + "Голос у реальному часі" + "Дії відхилення та карантину Skill Workshop" + "Вузли й пристрої" + "Локальний командний центр" + "emoji upload" + "Завантаження попереднього перегляду…" + "Високий" + "focus" + "describe" + "Контекст: %1$s" + "Очікування відповіді..." + "voice" + "Підключено до %1$s" + "role add" + "Чат потребує уваги" + "Увімкнути мікрофон" + "OpenClaw збирає та надсилає назви, ідентифікатори пакетів і статус застосунків, видимих на цьому телефоні, коли ваш підключений OpenClaw Gateway запитує їх. Це дозволяє вашому асистенту відповідати на запитання та виконувати дії за допомогою встановлених застосунків." + "Gateway не підключено" + "Політика" + "Час очікування підтвердження надісланого повідомлення минув; оновіть, щоб перевірити доставку." + "Допоміжні файли" + "Вираз" + "Фонові завдання" + "Мрія" + "Не заблоковано жодного застосунку. Застосунки можуть пересилати дані, якщо ви не додасте блокування." + "Розпізнавання мовлення недоступне" + "Платформа" + "Gateway не повернув налаштування %1$s" + "Забути gateway?" + "Необов’язковий опис" + "Відкрити %1$s" + "Домашнє полотно" + "Сновидіння" + "з %1$s до %2$s" + "Поділитися файлом" + "У реальному часі" + "API" + "OpenClaw працює…" + "Говоріть або диктуйте за допомогою OpenClaw" + "Поділитися інформацією про встановлені застосунки?" + "Завантаження автоматизації…" + "Видалити автоматизацію" + "Асистент за замовчуванням" + "Виберіть підтримуваного постачальника %1$s на Gateway" + "Недоступно" + "Порожня папка" + "Відкрити налаштування" + "Вимкнено" + "Типографіка" + "Зупинити" + "Поки немає відповідних гілок." + "Спарювання з Gateway виконано.\nПідтвердьте можливості вузла цього телефона в інтерфейсі оператора." + "Цю навичку встановлено, але наразі її не можна запустити. Для зміни конфігурації скористайтеся комп’ютером або CLI." + "Розпізнавач зайнятий" + "Домашній Gateway" + "Виконайте команду схвалення на Gateway" + "Службу вимкнено" + "Не вдалося завантажити пропозиції Skill Workshop." + "Ознайом мене з моїми нещодавніми гілками OpenClaw і запропонуй наступні кроки." + "Не зараз" + "openclaw qr" + "start" + "Вузол OpenClaw · Розмова" + "Читати й оновлювати події" + "Помилка розмови: постачальник Realtime закрив з’єднання: %1$s" + "Підключіть Gateway, щоб переглядати файли робочого простору." + "%1$s через ретранслятор Gateway" + "Не вдалося завантажити каталог розмов Gateway" + "Відстеження · 1 заплановане завдання" + "Кожні %1$s год." + "Поверхня екрана" + "Переклади OpenClaw · %1$s" + "Запит команди" + "Оновлено" + "Канал" + "Увімкнути мікрофон" + "Нова група…" + "Підготовка аудіо…" + "Адаптивний" + "Незабаром" + "Ще %1$s воркерів" + "Web Search" + "Спробуйте чат, голос, гілки, постачальників або налаштування." + "OpenClaw активний" + "navigate" + "запит надіслано %1$s" + "Підключіть Gateway, щоб переглянути історію запусків автоматизації." + "Доступ до пристрою; у Gateway усе одно потрібно надати згоду" + "Перервано" + "Введіть дійсний код налаштування або адресу gateway." + "Моделі" + "OpenClaw: пасивні" + "Недійсний пароль Gateway" + "Не вдалося перевірити зміну сполучення пристрою. Оновіть сторінку та повторіть спробу." + "Переглянути подробиці" + "Bash" + "Токен" + "Підключений агент OpenClaw може використовувати ввімкнені вами можливості пристрою. Продовжуйте, лише якщо довіряєте Gateway і агенту, до яких підключаєтеся." + "Збирання морських жолудів" + "Надано вибраний або повний доступ до фото." + "Виконавець спеціальних можливостей" + "Бракує елементів: %1$s" + "Згорнути контрольний список плану" + "Потрібне схвалення вузла" + "Підключити Gateway" + "... +%1$s ще" + "Розгорнути контрольний список плану" + "Браузер" + "screen record" + "Очікує запуску" + "Увімкнення дозволяє OpenClaw спостерігати за екранами інших застосунків і керувати ними, коли активовано. Потрібен доступ до спеціальних можливостей Android." + "Джерело" + "Персональний ШІ на ваших пристроях" + "Attach" + "Автоматично" + "Огляд" + "Не вдалося надіслати запит на відновлення. Торкніться, щоб повторити спробу." + "Відео" + "%1$s\n\n" + "Без шифрування" + "Календар" + "Стан Gateway незадовільний; неможливо надіслати" + "📎 %1$s" + "Останній статус" + "Перш ніж починати новий чат, дочекайтеся завершення поточної відповіді." + "Профіль" + "Обмеження провайдера з’являться тут, коли ваш Gateway повідомить про них." + "1 проблема" + "Гілки в групі \"%1$s\" буде збережено й переміщено назад до розділу «Без групи»." + "Рекомендовано" + "Створено" + "%1$s/%2$s активних токенів" + "Немає результату дії" + "Клацання" + "%1$s…" + "Відкрити відомості про навичку" + "Не вдалося озвучити: %1$s" + "Почати розмову" + "Не вдалося завантажити цю папку." + "QR-код не містив дійсного коду налаштування." + "Перевірте доступ до вузла" + "Додати фразу активації" + "Не вдається зв\'язатися з gateway" + "Автоматизація" + "Потрібне підключення" + "Не вдалося вирішити запит на схвалення. Оновіть і повторіть спробу." + "import" + "Як цей телефон відображається в OpenClaw." + "Перейти до пошуку гілок" + "Підключити Gateway" + "Читання календаря" + "Огляд оновлюється після повторного підключення та під час відкриття цього екрана." + "Не вдалося вимкнути навичку." + "Підключення все ще триває" + "Через %1$s хв" + "Читання SMS" + "Підключіть Gateway, щоб завантажити дані про використання." + "Що ти можеш допомогти мені зробити з цього телефону просто зараз?" + "Потрібне схвалення" + "Новий чат" + "Підключіть Gateway, щоб оновити пропозиції Skill Workshop." + "Запит OpenClaw не вдався." + "Потрібен дозвіл" + "Перегляньте готовність постачальників\nі налаштовані моделі." + "Завантаження" + "Сповіщення про помилку" + "Тема та перекладений текст Android." + "Мікрофон вимкнено · надсилання…" + "Немає" + "Переглянути" + "Назва" + "Версія" + "Cron" + "Підключіть цей телефон до Gateway перед відкриттям OpenClaw." + "Видалити фразу активації" + "Код налаштування не прийнято. Згенеруйте новий код за допомогою openclaw qr." + "14 повідомлень · Android" + "Не вдалося транскрибувати: %1$s" + "Завжди" + "Не вдалося завантажити сновидіння." + "Запуск автоматизації додано до черги." + "Conversation Turn" + "Автоматизацію запущено." + "Нова група" + "Помилка сервера" + "Video Generation" + "Очікується схвалення Gateway. Виконайте openclaw devices list на хості Gateway, схваліть цей телефон і повторіть спробу." + "Записи з’являться після того, як цикл dreaming запише наративний підсумок." + "%1$s мс" + "Сховище пам’яті" + "Асистент працює" + "OpenClaw може показувати застосунки, видимі в лаунчері." + "Не вдалося розпочати розмову: %1$s" + "Пошук установлених навичок" + "Переглянути" + "Process" + "Нещодавні гілки" + "Термінал" + "Поточний" + "1 обліковий запис" + "Призупинено" + "Дозволити камеру" + "Запити на схвалення виконання з’являтимуться тут, поки цей телефон підключено." + " · Мікрофон: очікує" + "Копіювати" + "Подробиці скопійовано" + "Видалити" + "Попросіть OpenClaw скористатися можливостями Android." + "member" + "Перевірка того, чи підтримує цей Gateway помічника з налаштувань OpenClaw." + "Скористайтеся наведеними нижче варіантами відновлення, щоб повторно підключитися." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Не вдалося завантажити канали." + "Через %1$s дн." + "Послідовні помилки" + "Не вдалося прочитати QR-код із цього зображення. Виберіть чіткіше зображення або введіть код налаштування вручну." + "Версія Gateway старіша за версію цього застосунку. Оновіть OpenClaw на хості Gateway та повторіть спробу." + "Підключіться, щоб користуватися чатом, голосовим зв’язком і статусом у реальному часі." + "Повторно підключити gateway" + "Сторонній" + "Перевірка готовності" + "Обмежено" + "Логотип OpenClaw" + "Відкріпити модель" + "Поверхні обміну повідомленнями, підключені до цього Gateway." + "Надсилання" + "Тут відображатимуться архівовані гілки." + "Команду скопійовано" + "Попередній перегляд недоступний" + "Підтвердьте цей телефон у Gateway.\nПотім повторіть спробу підключення." + "Сканувати QR-код" + "Робочий каталог команди · не можна очистити" + "Активність гілки" + "Доступно" + "Видалити автоматизацію?" + "%1$s сьогодні · %2$s загалом" + "Пароль" + "Помістити пропозицію в карантин?" + "У цій збірці не включено жодних повідомлень про ліцензії." + "Не вдалося зберегти зображення віджета" + "Очікування %1$s" + "Говорить…" + "Постачальники й моделі" + "Вузол" + "%1$s " + "Запит недоступний" + "Журнали" + "Підключіть Gateway, щоб переглянути пропозиції Skill Workshop." + "Інструменти" + "Перемикач Gateway" + "Надсилання SMS" + "OpenClaw готовий продовжити у вашому звичайному чаті." + "Команд не знайдено" + "Оновлень полотна ще немає. Торкніться, щоб повторити спробу." + "Для термінала потрібен підключений Gateway" + "Exec" + "Фільтр застосунків" + "Головний" + "%1$sk" + "Потрібен Gateway" + "Доступ" + "Пакети: snapshot=%1$s foreground=%2$s" + "Повторити підключення" + "Планувальник Cron зупинено." + "Відкрити" + "Повідомлення скопійовано" + "Не вдалося зв’язатися з вашим Gateway.\nСпробуймо це виправити." + "зараз" + "Видалити після виконання" + "Вибрано на цьому телефоні" + "unpin" + "Session History" + "Відкріпити" + "Використовувати цей телефон" + "Не вдалося завантажити відомості ClawHub для %1$s." + "Інструменти виконуються" + "Надавати точне місцезнаходження, коли геолокацію ввімкнено." + "Mobile UI" + "Тема" + "Gateway досі показує це схвалення як в очікуванні. Перегляньте його, перш ніж спробувати знову." + "Завершити голосове повідомлення" + "Диктування: %1$s" + "Не дозволено" + "Вибрати інше зображення" + "Попередній перегляд зображення" + "OpenClaw слухає лише тоді, коли ви запускаєте Розмову або Диктування." + "Надавати дані про кроки й активність" + "Потребує налаштування" + "Оновіть цей Gateway, щоб використовувати асистента налаштувань OpenClaw." + "Для встановлення навичок ClawHub цьому підключенню до Gateway потрібен дозвіл operator.admin." + "Пропозицію застосовано." + "%1$s в очікуванні" + "%1$s год тому" + "Читання журналу викликів" + "%1$s у черзі · очікування на gateway" + "Перемістити до групи" + "Відскануйте QR-код для сполучення" + "Схвалення відхилено." + "Не вдалося переглянути пропозицію Skill Workshop." + "Закріплено" + "Профіль і пристрій" + "Закрити вибір рівня мислення" + "Не вдалося додати повідомлення до черги для подальшого доставлення." + "Карантин" + "Розклад · %1$s" + "Не вдалося оновити рівень міркування." + "Відкрити вибір рівня мислення" + "Час очікування голосової відповіді минув; повторна спроба для запиту в черзі" + "Макет: докладний" + "Не вдалося декодувати це зображення." + "Gateway, голос, сповіщення, конфіденційність" + "Файли робочого простору агента" + "Цей пристрій утратить довірений доступ до Gateway." + "Використайте requestId з команди, що очікує, у команді approve." + "Розклад" + "Обмеження частоти" + "Не доставлено" + "Корисне навантаження · %1$s" + "Виконується" + "Клешнювання" + "Завершити" + "Використовувати системну довіру" + "Немає готових провайдерів" + "Надає пріоритет підключеним мікрофонам Bluetooth." + "Пересилання заблоковано для %1$s застосунку." + "Дії з повідомленням" + "Тип" + "Розархівувати" + "Transcripts" + "Слова активації" + "Налаштуйте %1$s на Gateway" + "Відскануйте QR-код або використайте код налаштування з вашого OpenClaw Gateway." + "Прототип дизайн-системи" + "Просіювання" + " · Розмова: увімкнено" + "Даних про використання ще немає." + "Не вдалося розпочати чат; спробуйте ще раз." + "Надіслати" + "Деякі поширені зображення було пропущено або не вдалося додати." + "Запис календаря" + "timeout" + "Низький" + "Заблокований список" + "act" + "Dismiss Task" + "Помилка чату" + "OpenClaw · Наживо" + "Встановлено" + "Час очікування відповіді минув; повторіть спробу або оновіть." + "Знайдіть попередні розмови" + "Переглянути гілки" + "Оновлення" + "Добування перлів" + "Відкрийте камеру й наведіть її на код з openclaw qr." + "Немає пристроїв" + "Пересилати сповіщення" + "Я триматиму цю розмову окремо від звичайного чату агента." + "Сесія Gateway знову підключається. Ярлики агентів мають автоматично відновитися за мить." + "Спробуйте інший пошук або очистьте поточний запит." + "Дозволити фонове визначення місцезнаходження?" + "Виринання" + "Початкове налаштування" + "%1$s · %2$s · %3$s" + "Скасувати голосове повідомлення" + "Прокрутити назад" + "openclaw gateway" + "Gateway спарено" + "Линяння" + "Очікування вашої наступної репліки." + "OpenClaw працює" + "Запис журналу" + "Помилка: не вдалося досягти захищеної кінцевої точки Gateway для цього хоста." + "Gateway не в мережі. Виправте підключення нижче або скопіюйте діагностичні дані." + "Очікування" + "Тестування тестування 1 2 3" + "Не вдалося виконати пошук навичок ClawHub." + "Без запиту" + "Передня камера" + "Відкрити запис журналу" + "Час очікування мережі вичерпано" + "Зараз" + "Перейменувати групу…" + "Інші агенти" + "openclaw nodes approve REQUEST_ID" + "Закріпити" + "thread list" + "Відкрити %1$s" + "upload" + "Пароль Gateway не налаштовано" + "Налаштування диктування" + "Моделі постачальників завантажено, але дані про готовність недоступні." + "Видалити гілку?" + "OpenClaw перетворює цей телефон на зручний мобільний інтерфейс керування гілками, голосом, постачальниками та Gateway." + "Спочатку найновіші" + "Наступний цикл" + diff --git a/app/src/main/res/values-vi/assistant.xml b/app/src/main/res/values-vi/assistant.xml new file mode 100644 index 0000000..9c27e6b --- /dev/null +++ b/app/src/main/res/values-vi/assistant.xml @@ -0,0 +1,7 @@ + + + "hỏi OpenClaw %1$s" + "yêu cầu OpenClaw %1$s" + "mở OpenClaw và hỏi %1$s" + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..99b02da --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Tin cậy cổng này? + Tin cậy và tiếp tục + Hủy + Trò chuyện mới trong worktree + Xác minh dấu vân tay chứng chỉ trước khi tin cậy cổng này.\n\n%1$s + Chứng chỉ cổng đã thay đổi. Chỉ tiếp tục nếu bạn mong đợi thay đổi này.\n\nSHA-256 cũ:\n%1$s\n\nSHA-256 mới:\n%2$s + Không xác định + PHIÊN BẢN + COMMIT + NGÀY TẠO + Phiên bản %1$s + Commit Git %1$s + Bản dựng được tạo lúc %1$s UTC, dấu thời gian %2$s + Ngày tạo bản dựng %1$s + Sao chép toàn bộ mã băm commit Git + Sao chép toàn bộ dấu thời gian bản dựng + Commit Git của OpenClaw + Dấu thời gian bản dựng của OpenClaw + Đã sao chép commit Git + Đã sao chép dấu thời gian bản dựng + + "Không thể chuẩn bị tệp đính kèm để gửi." + "Mic tắt" + "Hiển thị cảnh báo của OpenClaw" + "Hoạt động của luồng" + "Đầy đủ" + "Đã cho phép và lưu phê duyệt." + "Hiển thị lịch sử cuộc gọi gần đây" + "1 đang chờ" + "Tệp đính kèm không được hỗ trợ" + "0 = chính xác" + "Kết nối Gateway để tìm kiếm luồng." + "%1$s tài khoản" + "Các thay đổi Cron yêu cầu operator.admin. Mã thiết lập không cấp quyền này theo thiết kế. Hãy kết nối lại bằng token dùng chung hoặc mật khẩu của Gateway để yêu cầu quyền truy cập quản trị viên. Nếu thiết bị này vẫn chưa có quyền, hãy phê duyệt yêu cầu nâng cấp phạm vi đang chờ từ một ứng dụng quản trị viên hiện có." + "Apply Patch" + "Đang kẹp" + "Bật tiếng loa" + "Số lần bỏ qua liên tiếp" + "Thư mục này chưa có tệp nào." + "Chưa kết nối" + "Kiểm tra và quản lý trạng thái của các skill đã cài đặt." + "Thất bại" + "Tác nhân mặc định" + "Máy ảnh" + "Xóa khỏi nhóm" + "Đang tìm kiếm" + "Đã tạm dừng để phát giọng nói" + "Gateway sẽ xác minh chính xác bản phát hành này với ClawHub trước khi tải xuống. Nếu bản phát hành yêu cầu xác nhận rõ ràng về rủi ro, Android sẽ hiển thị cảnh báo của Gateway trước khi thử lại." + "Mã thiết lập sử dụng ID vùng IPv6. Hãy dùng địa chỉ IPv6 không có phạm vi hoặc tên máy chủ LAN." + "Tệp đính kèm" + "Thiết lập từ đánh thức, trò chuyện và phát âm thanh." + "Đang nghe (PTT)" + "Đã từ chối đề xuất." + "Hiện thanh bên" + "người dùng" + "%1$s · %2$s" + "Tối thiểu" + "Từ chối" + "TÁC NHÂN ĐANG HOẠT ĐỘNG" + "1 tác vụ đã lên lịch" + "Không có phản hồi" + "Đã chọn %1$s" + "Mảng JSON argv của lệnh" + "Không thể đọc hình ảnh đó. Hãy chọn ảnh chụp màn hình rõ nét hoặc hình ảnh mã QR từ openclaw qr." + "Không thể %1$s đề xuất Skill Workshop." + "Đã trả lời ở nơi khác" + "Gateway đã ghi nhận phê duyệt một lần." + "status" + "OpenClaw chỉ kiểm tra vị trí khi Gateway đã ghép nối yêu cầu. Trên màn hình Android tiếp theo, hãy chọn %1$s để cho phép kiểm tra khi ứng dụng chạy trong nền." + "từ chối" + "Độ tương phản" + "Thay thế thiết lập Gateway?" + "Không thể tải các tác vụ tự động." + "Bạn" + "Micrô tích hợp" + "Bề mặt" + "Không có đề xuất" + "Luồng chính" + "Mở Chat" + "Các thao tác ghép nối thiết bị không khả dụng trong phiên Gateway này. Chạy openclaw devices list trên máy chủ Gateway và quản lý yêu cầu tại đó. Việc phê duyệt quyền của node là riêng biệt và vẫn sử dụng nodes approve <request id>." + "Yêu cầu hành động" + "list pins" + "Kết nối tới Gateway để tải các đề xuất Xưởng Skill." + "Mã thiết lập không được chấp nhận" + "Đăng xuất" + "Nhà cung cấp phiên âm theo thời gian thực chưa được cấu hình." + "Hiển thị ứng dụng hệ thống" + "Hãy cập nhật Gateway để xem cấu hình mô hình của nhà cung cấp." + "Đang gửi nội dung đọc chính tả" + "Kiểm tra đề xuất này để tải nội dung markdown." + "mở OpenClaw và hỏi %1$s" + "lập luận" + "Ứng dụng khách" + "Đã áp dụng" + "video" + "Đã thăng hạng" + "Trực tuyến" + "Phạm vi" + "Nhà cung cấp giọng nói theo thời gian thực chưa được cấu hình." + "%1$s · %2$s" + "kick" + "Gateway trả về một tác vụ tự động không hợp lệ." + "ID phiên bản" + "Cần có token Gateway. Nhập lại hoặc chỉnh sửa kết nối này." + "Nguồn" + "Làm mới" + "%1$s đang chờ" + "Bắt đầu trò chuyện" + "Các lệnh gọi công cụ Chat đang chờ trong luồng đang hoạt động vẫn hiển thị tại đây." + "Cần xem xét chứng chỉ" + "Mở bề mặt Canvas hiện tại để kiểm tra hoặc tương tác với nó." + "Đã cập nhật tác vụ tự động." + "Không có phiên gần đây" + "Tập lệnh" + "Trạng thái Gateway, mức sẵn sàng của nút điện thoại và luồng nhật ký gần đây." + "Mở chi tiết tác vụ tự động" + "Môi trường chạy" + "1 worker khác" + "Tác nhân %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Vui lòng bật %1$s trong Cài đặt Android để tiếp tục." + "Sửa chữa" + "reactions" + "Xong" + "Phiên bản và cập nhật" + "OpenClaw sẽ hiển thị các phê duyệt, tác vụ thất bại và sự cố kênh tại đây." + "Micrô USB" + "Quá nhiều lượt chia sẻ đang chờ được thêm vào." + "Đã bỏ qua" + "Sử dụng địa chỉ LAN của máy tính Gateway hoặc tên máy chủ từ xa bảo mật." + "Bật" + "Đang tìm kiếm luồng" + "Trò chuyện thời gian thực" + "· %1$s" + "OpenClaw đang chuẩn bị phản hồi." + "Đã cho phép phê duyệt một lần." + "Thiết lập nhà cung cấp" + "không" + "Nội dung tập lệnh được giữ nguyên. Hãy dùng CLI để chỉnh sửa tập lệnh này." + "Hướng dẫn thiết lập Android" + "%1$s ứng dụng bị chặn chuyển tiếp." + "Thiết bị đã ghép đôi" + ":%1$s" + "%1$s đang chờ" + "Tên thiết bị" + "Gửi" + "Vị trí" + "ví dụ: America/New_York" + "Đích phiên" + "Xem lại Skill trên ClawHub" + "snapshot" + "Từ chối yêu cầu ghép nối từ thiết bị này?" + "Micrô ưu tiên" + "Máy chủ nút" + "Cấp độ" + "Đóng bộ chọn ứng dụng" + "Dán token Gateway được chia sẻ hoặc token do operator cấp." + "Tất cả hệ thống hoạt động bình thường" + "Đã sao chép chẩn đoán gateway" + "Lỗi âm thanh" + "Thay thế thiết lập" + "Tác vụ nhanh" + "Gửi không thành công: Cuộc trò chuyện gặp lỗi trước khi bắt đầu chạy; hãy thử lại." + "Micrô" + "Cuộc trò chuyện vẫn đang kiểm tra trạng thái của Gateway." + "Vị trí chính xác" + "Cho phép một lần" + "+%1$s nữa" + "thread create" + "Bị chặn" + "Từ hoặc cụm từ đánh thức" + "Gateway cần phê duyệt thiết bị" + "Micrô ngoài" + "%1$s/%2$s đã sẵn sàng" + "Đã kết nối (người vận hành đang ngoại tuyến)" + "Quyền chưa được phê duyệt" + "Thao tác này sẽ xóa vĩnh viễn tác vụ tự động và lịch chạy của tác vụ khỏi Gateway." + "Đang tải hình ảnh…" + "Kết nối" + "Phê duyệt quyền truy cập nút" + "Thêm Gateway" + "Không thể phiên âm: %1$s" + "Hình ảnh" + "Đang dâng triều" + "Đóng bản xem trước hình ảnh" + "eval" + "Lệnh gần nhất: %1$s" + "Mở sẵn một terminal trên thiết bị đang chạy OpenClaw." + "Không thiếu mục nào" + "Đầu ra canvas cần có kết nối Gateway đang hoạt động." + "%1$s · %2$s" + "Cô lập" + "© 2026 OpenClaw Foundation — Giấy phép MIT." + "PDF" + "Conversations" + "Hợp nhất bộ nhớ và nhật ký giấc mơ." + "Create Goal" + "Tác vụ tự động này đã thay đổi trong khi bạn chỉnh sửa. Hãy hoàn nguyên về phiên bản mới nhất trên Gateway trước khi lưu." + "Khi đã kết nối, Gateway có thể đánh thức điện thoại bằng thông báo đẩy im lặng thay vì duy trì một phiên luôn bật." + "Chế độ đánh thức" + "Xóa thiết bị được ghép nối?" + "Văn bản sự kiện hệ thống" + "Không thể sao chép hình ảnh tiện ích" + "Không" + "Đường dẫn tùy chọn" + "Đang gửi giọng nói trong hàng đợi" + "Tích hợp sẵn" + "hide" + "runs" + "Cần có mật khẩu Gateway. Nhập lại hoặc chỉnh sửa kết nối này." + "Nội dung sự kiện" + "Bản chép lời trực tiếp" + "Không thể tải cấu hình mô hình của nhà cung cấp." + "%1$s ứng dụng được phép chuyển tiếp." + "Thiết lập giọng nói" + "Đính kèm video" + "Hình ảnh bổ sung đã bị ẩn: %1$s" + "Từ chối yêu cầu ghép nối?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Nhà phát hành" + "Phân nhánh từ đây" + " · Trò chuyện: Đang nói" + "Ghi đè tùy chọn" + "Mặc định" + "Phê duyệt lệnh" + "Ứng dụng và Gateway sử dụng các phiên bản giao thức không tương thích. Hãy cập nhật OpenClaw trên cả hai rồi thử lại." + "Làm mới màn hình" + "Đọc ảnh và nội dung đa phương tiện gần đây" + "Nghe" + "Đã kết nối" + "Đã hoàn tất" + "Điện thoại của bạn đã được ghép nối với %1$s. Hãy tiếp tục để hoàn tất quyền truy cập nút." + "Luồng hiện tại" + "Suy nghĩ %1$s" + "Đã tắt tiếng" + "OpenClaw trân trọng các đối tác của mình trong cộng đồng mã nguồn mở." + "mở" + "Đang kết nối với Gateway" + "Gateway có thể thay đổi đường dẫn này nhưng không thể xóa đường dẫn hiện có." + "TTS" + "Đang lưu…" + "Mốc %1$s" + "search" + "Kích hoạt" + "Kiểm tra và quản lý công việc Gateway đã lên lịch." + "Một phản hồi trước đó đã cho phép lệnh này một lần." + "generate" + "Chỉ sử dụng trên mạng riêng đáng tin cậy." + "Tìm kiếm cài đặt" + "Trò chuyện đang diễn ra" + "Xác thực Gateway chưa được cấu hình. Hãy chỉnh sửa kết nối này rồi thử lại." + "Không thành công: đã truy cập điểm cuối bảo mật, nhưng xác minh dấu vân tay TLS đã hết thời gian chờ. Kiểm tra Tailscale Serve hoặc gateway TLS rồi thử lại." + "Bước 1" + "Đọc chính tả" + "Mở bộ chọn ứng dụng" + "Không có phê duyệt nào đang chờ" + "edit" + "Kết nối với Gateway của bạn" + "Nhập mã thiết lập từ openclaw qr." + "Chẩn đoán" + "Các ứng dụng khác vẫn không bị ảnh hưởng." + "Đang tách vỏ" + "Thao tác này sẽ xóa vĩnh viễn luồng và bản ghi của luồng." + "Đã phê duyệt thiết bị." + "Đang tự động thử lại" + "Đã sao chép hình ảnh tiện ích" + "%1$s vai trò" + "Thiếu 1 mục" + "%1$s tác vụ đã lên lịch" + "react" + "Tác nhân" + "Kết nối Gateway để tải các tác vụ tự động." + "Đang kết nối lại…" + "Quay lại thiết lập" + "send" + "Không thể kiểm tra kết nối" + "Xác minh và cài đặt" + "Biến thiết bị này thành một nút OpenClaw an toàn cho trò chuyện, thoại, camera và công cụ thiết bị." + "Thiết lập thủ công" + "Mở Chat để bắt đầu hoặc tiếp tục luồng hiện tại." + "Mẹo: dừng nghe để gửi lượt đã ghi lại." + "Bỏ qua" + "Yêu cầu giọng nói không thành công" + "Thao tác này sẽ từ chối \"%1$s\" và làm mới trạng thái Skill Workshop từ Gateway." + "Đang bóc vỏ" + "update" + "Chia sẻ" + "Đã bật camera" + "Telegram, WhatsApp, email và các kênh khác sẽ xuất hiện ở đây sau khi thiết lập." + "Lỗi mạng" + "Khám phá vũng triều" + "Khôi phục canvas ngay cho session=%1$s source=%2$s. Nếu trạng thái A2UI hiện có tồn tại, hãy phát lại ngay lập tức. Nếu không, hãy tạo và hiển thị một bảng điều khiển nhỏ gọn, thân thiện với thiết bị di động trong Canvas." + "Khởi động không thành công: %1$s" + "Chưa được yêu cầu" + "Cấu hình nhà cung cấp %1$s trên Gateway" + "kill" + "Phê duyệt" + "Tệp không khả dụng" + "Đánh dấu là chưa đọc" + "Tìm người và thông tin liên hệ" + "Cần danh tính thiết bị" + "Luồng OpenClaw" + "Cho phép truy cập thư viện ảnh." + "Một phản hồi trước đó đã xử lý phê duyệt này." + "Không có luồng gần đây" + "Hết thời gian chờ %1$ss" + "Không có kết quả phù hợp" + "Đọc thông báo từ các ứng dụng đã chọn" + "Không rõ trạng thái khả dụng" + "Thiết lập trò chuyện" + "Bổ sung" + "Đã ghép nối Gateway. Đang chờ quyền truy cập của người vận hành." + "Đính kèm hình ảnh" + "Chọn nội dung được gửi đến OpenClaw." + "Đang chờ phê duyệt lại quyền" + "Xem lại các mục được đánh dấu" + "Đang nghe..." + "Cập nhật thông tin cho tôi" + "Tin nhắn" + "Đọc danh bạ" + "Bộ nhớ tệp đính kèm ngoại tuyến đã đầy; hãy xóa các mục đang chờ trước." + "Một lần" + "Đổi tên" + "Không tìm thấy kênh nào." + "Xem tất cả" + "Thiết bị mới" + "Session Status" + "Mở xem trước hình ảnh" + "Nhánh phiên đã thay đổi; hãy xem lại và thử lại tin nhắn này." + "close" + "Đây có vẻ là mã thiết lập. Hãy quay lại, chọn Thiết lập Gateway, rồi chọn Sử dụng mã thiết lập." + "✦" + "Tác nhân & tự động hóa" + "Áp dụng" + "Đã bỏ qua lượt chạy tác vụ tự động." + "Tiếp tục" + "Đang giám sát · %1$s tác vụ đã lên lịch" + "Duyệt" + "tabs" + "Đang chờ" + "Trò chuyện: %1$s" + "read" + "Chọn văn bản" + "Hoạt động chuyển động" + "description: %1$s" + "Phát âm thanh" + "Thời gian" + "Chưa xác minh" + "Yield" + "Sao chép lệnh phê duyệt" + "Đầu ra màn hình hiện tại và bề mặt ứng dụng tương tác." + "Dịch vụ đã kết nối" + "Hiển thị" + "Sẵn sàng khi bạn cần" + "Không thể tải danh mục nhà cung cấp." + "Đang nói · đang chờ phản hồi" + "Chưa cấp" + "Lưu thay đổi" + "Gateway đã từ chối lượt chạy tác vụ tự động." + "Session Send" + "Tìm trên ClawHub" + "Luôn cho phép kiểm tra vị trí được yêu cầu khi OpenClaw đang chạy trong nền; Android hiển thị điều này trong thông báo nút cố định." + "Sự kiện hệ thống" + "Kết nối Gateway để xem nhà cung cấp" + "Nhịp tim tiếp theo" + "Đã ghép nối Gateway. Đang chờ phê duyệt quyền của nút." + "Đang ngâm muối" + "Đóng Canvas" + "Ghi danh bạ" + "Không có skill đã cài đặt nào khớp với tìm kiếm này." + "Thiết lập nhà cung cấp Talk" + "Music Generation" + "Cài đặt Talk" + "Đang theo dõi · 1 luồng" + "Văn bản payload" + "Đặt văn bản" + "Phê duyệt %1$s" + "Gateway không trả về trạng thái sẵn sàng của %1$s" + "%1$s mô hình đã được cấu hình. Làm mới để kiểm tra lại tính khả dụng." + "Conversation Send" + "Bảng vẽ" + "1 nhà cung cấp" + "Không thể tự động đọc chứng chỉ của Gateway. Hãy dán dấu vân tay SHA-256 lấy được trên máy chủ Gateway." + "Gửi không thành công: %1$s" + "Cầu nối" + "Lỗi gửi" + "Sử dụng OpenClaw từ điện thoại của bạn" + "Giao diện" + "Xưởng Skill" + "Cần token" + "Xem trước · %1$s" + "Cần có quyền truy cập micrô" + "Kết nối Gateway để tải các đề xuất Skill Workshop." + "Tất cả hệ thống hoạt động bình thường" + "Không thể kết nối với Gateway" + "OC" + "Đã cập nhật" + "Đã kết nối (node ngoại tuyến)" + "Trang chủ" + "Tính năng đọc chính tả đang lắng nghe" + "Không có luồng nào được lưu trữ" + "Chọn và kiểm tra các trợ lý có sẵn trên gateway này." + "Chế độ nói đang hoạt động" + "Đang hoạt động · 1 lượt chạy đang hoạt động" + "Đồng ý và Bật" + "Cần Cập Nhật Gateway" + "Sao chép hình ảnh" + "URL Gateway" + "main, isolated, current hoặc session:<id>" + "Không có phương tiện" + "Kết nối với Gateway của bạn để mở shell trong không gian làm việc của agent." + "%1$s://%2$s:%3$s" + "Không thể tải chi tiết phê duyệt. Hãy làm mới và thử lại." + "Tôi có thể kiểm tra trạng thái Gateway, sửa chữa cấu hình, thay đổi mô hình hoặc kết nối các kênh." + "Tool Call" + "Luồng" + "Write" + "Bắt đầu bằng một câu lệnh hoặc sử dụng giọng nói." + "D" + "Mở Cài đặt" + "Đang quan sát…" + "Kết thúc cuộc trò chuyện" + "Lỗi gần nhất" + "Xem lại các hành động cần bạn chú ý." + "Đã tắt cho tất cả agent." + "Bắt đầu trò chuyện bằng giọng nói" + "Quay lại tác vụ nền" + "Một thao tác cron khác vẫn đang hoàn tất." + "Thời gian chờ %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "Tính năng nhận dạng giọng nói trên thiết bị không khả dụng." + "Tập lệnh · chỉ đọc" + "Các tệp đính kèm quá lớn để đưa vào hàng đợi trong một tin nhắn; hãy xóa bớt rồi thử lại." + "1 mô hình đã được cấu hình. Làm mới để kiểm tra lại tính khả dụng." + "Tiện ích không khả dụng" + "Máy chủ này yêu cầu kết nối bảo mật." + "Gần đây" + "Không có tác vụ tự động nào phù hợp." + "Điện thoại có thể truy cập Gateway" + "Gateway" + "Đã hết hạn" + "Công việc OpenClaw đã lên lịch từ gateway của bạn." + "Sub-agent" + "Đang chờ phê duyệt thiết bị" + "Đang tải luồng" + "Gateway này hiện cung cấp chứng chỉ được thiết bị này tin cậy." + "Độ lệch ms" + "event create" + "tài liệu" + "Thiết lập Gateway" + "Phát video" + "Xác thực đã lưu không hợp lệ. Xác thực lại hoặc đặt lại kết nối gateway này." + "Khi sử dụng" + "screenshot" + "Tua lại về đây" + "Biểu thức cron, ví dụ 0 9 * * *" + "Quay lại giọng nói" + "Nói" + "Chi tiết" + "%1$s/%2$s đang trực tuyến" + "%1$s ứng dụng được phép chuyển tiếp." + "Trò chuyện" + "Cần quyền truy cập micrô." + "Đang bò ngang" + "Chỉnh sửa" + "Giờ yên lặng" + "Sao chép chẩn đoán" + "Đã lên lịch" + "Tạo" + "Hết hạn sau %1$s" + "Bỏ qua" + "Từ chối đề xuất?" + "Lỗi giọng nói (%1$s)" + "Sự cố" + "Tìm kiếm siêu dữ liệu trong registry. Gateway sẽ xác minh lại độ tin cậy trước mọi lượt tải xuống." + "Sử dụng địa chỉ IP LAN riêng để thiết lập cục bộ, hoặc bật Tailscale Serve / công khai URL Gateway wss:// để truy cập từ xa." + "Đang gửi…" + "Tài khoản %1$s" + "Suggest Task" + "Tìm kiếm" + "Đang nghe" + "Chưa tải tác vụ tự động." + "Hiện có bản cập nhật Gateway. Hãy chạy bản cập nhật từ giao diện web hoặc CLI khi bạn sẵn sàng." + "sắp tới" + "Không có phê duyệt gateway." + "Máy chủ" + "Thêm một từ hoặc cụm từ đánh thức vào mỗi trường. Sau đó, hãy nói một từ hoặc cụm từ đó trước lệnh của bạn." + "Chuyển lời nói thành văn bản rồi gửi" + "Chạy lúc" + "Tạm dừng âm thanh" + "Quyền truy cập vào thiết bị Gateway" + "Không có bản xem trước" + "Thiết bị" + "OpenClaw cho Android." + "Đang chờ phê duyệt quyền" + "Hãy lưu hoặc hoàn nguyên các chỉnh sửa trước khi chạy, bật, tắt, xóa hoặc làm mới tác vụ tự động này." + "Chưa có tác vụ tự động nào." + "Skill này cần thiết lập %1$s mục. Android hiển thị những gì đã được cài đặt; việc thiết lập/thay đổi cấu hình chỉ có thể thực hiện trên máy tính hoặc CLI." + "%1$s gần đây" + "Kênh" + "Unphased" + "Đang hoạt động trên điện thoại này" + "Đang kiểm tra quyền truy cập nút" + "Múi giờ" + "Hành động kiểm tra và áp dụng của Skill Workshop" + "Luôn cho phép" + "present" + "Skills được cài đặt trên Gateway sẽ xuất hiện tại đây." + "Mã có thể đã hết hạn hoặc được tạo cho một Gateway khác." + "Cần có quyền" + "Tác vụ tự động có cấu hình không hợp lệ." + "Danh sách cho phép" + "Thiết lập, trạng thái và sửa chữa" + "groups" + "Khóa công khai" + "Giới thiệu" + "Không tìm thấy mã QR thiết lập trong hình ảnh đó. Hãy chọn mã QR do openclaw qr tạo hoặc nhập mã thiết lập theo cách thủ công." + "permissions" + "Kết nối gateway để tải các nút và thiết bị đã ghép đôi." + "Chuyển nhánh" + "Không có Skills" + "Câu trả lời được phát thành tiếng" + "Đánh dấu là đã đọc" + "Đang chờ phê duyệt nút" + "wake" + "%1$s đề xuất" + "Cần kiểm tra việc xác thực Gateway." + "Chi tiết kết nối" + "Mili giây" + "Nhận dạng giọng nói" + "Mô tả" + "Cuộc trò chuyện gần đây" + "Điện thoại của bạn gửi thông tin này đến Gateway của bạn, không phải đến máy chủ do OpenClaw vận hành. Gateway của bạn có thể đưa nó vào các yêu cầu gửi đến nhà cung cấp AI bạn đã chọn." + "Gửi" + "Tắt tiếng loa" + "%1$s Đang chạy · %2$s Hoàn tất · %3$s Thất bại" + "Đang mở kết nối Gateway" + "Đang theo dõi · %1$s luồng" + "Lượt chạy tác vụ tự động đã hoàn tất." + "Không có ứng dụng phù hợp." + "Gửi đến cuộc trò chuyện" + "Đã xóa tác vụ tự động." + "Bật" + "Lần chạy gần đây" + "Căn chỉnh mã QR bên trong hình vuông." + "Không thể tải các phê duyệt." + "Tôi đã phê duyệt" + "Kết nối Gateway của bạn để tải trạng thái sẵn sàng của provider." + "Chưa ghép nối" + "Phê duyệt này đã hết hạn trước khi có thể được xử lý." + "Đang quan sát trong %1$ss — chuyển sang ứng dụng mục tiêu" + "Lời nhắc tác tử" + "emoji list" + "Lặp lại" + "Tìm kiếm OpenClaw" + "%1$s đang chờ" + "Nhận dạng giọng nói trên thiết bị không khả dụng" + "Không có ứng dụng nào có thể chia sẻ tin nhắn này" + "Đóng tìm kiếm" + "Lệnh cần theo dõi" + "Tình trạng" + "Trình nghe thông báo" + "Đã tắt tiếng loa" + "Tìm kiếm luồng" + "OK" + "Không thể mở hướng dẫn thiết lập." + "hỏi OpenClaw %1$s" + "Wait for Agents" + "Địa chỉ" + "Công việc theo lịch được tạo trên Gateway sẽ xuất hiện tại đây." + "Đang hiển thị phần nhật ký mới nhất." + "Sử dụng mã thiết lập" + "sticker" + "Sử dụng Gateway wss:// bảo mật hoặc Tailscale Serve, tạo mã thiết lập quyền truy cập đầy đủ trong Control UI hoặc bằng openclaw qr, sau đó quét hoặc dán mã bên dưới và kết nối lại để bật cài đặt và nâng cấp." + "steer" + "Đã chọn" + "Android có thể quét hoặc dán mã thiết lập hiện có, nhưng gateway này chưa cung cấp tính năng tạo mã thiết lập cho ứng dụng. Tạo QR/mã trên máy chủ gateway bằng openclaw qr, sau đó quét tại đây hoặc dán mã thiết lập bên dưới." + "Trạng thái Canvas" + "Khắc phục kết nối" + "Lưu hình ảnh" + "Nút %1$s" + "Cần mật khẩu Gateway" + "Update Plan" + "Xóa tệp đính kèm" + "Lượt chạy tác vụ tự động không thành công." + "Giới hạn của nhà cung cấp và tình trạng hạn mức." + "Chưa tải danh mục trò chuyện Gateway" + "Gateway này" + "Chưa có lần chạy nào gần đây." + "Mô hình ngôn ngữ trên thiết bị không khả dụng" + "Bảng điều khiển cần Gateway được kết nối" + "Các đề xuất phù hợp sẽ xuất hiện ở đây sau khi agent tạo bản nháp skill có thể tái sử dụng." + "Session Search" + "OpenClaw đang nói" + "Quét QR" + "Ứng dụng đã chọn" + "Hoàn tác thay đổi" + "Đã sao chép lệnh phê duyệt" + "Trạng thái phân phối" + "Mã QR không được chấp nhận" + "Trung tâm điều khiển bằng giọng nói của bạn." + "Kiểm tra kết nối" + "OPENCLAW" + "Web Fetch" + "Câu lệnh" + "Phê duyệt thiết bị?" + "Kết nối với Gateway của bạn để mở bảng điều khiển của phiên này." + "Xóa %1$s và thông tin đăng nhập đã lưu của Gateway này khỏi điện thoại?" + "Mã QR trỏ đến một gateway từ xa không an toàn. %1$s %2$s" + "Bề mặt màn hình đã sẵn sàng" + "Ghép nối Gateway" + "Kết nối Gateway để tải kênh." + "Tạm dừng khi có hoạt động giọng nói khác." + "Mô hình" + "Ảnh" + "Dán mã thiết lập" + "OpenClaw đang nói" + "Đang kết nối..." + " · Vị trí: Luôn luôn" + "Tin nhắn: %1$s" + "Đang qua rạn san hô" + "Tải từ Gateway" + "text: %1$s" + "Cần" + "rename group" + "Sẵn sàng" + "Nhật ký đang chờ mục đầu tiên." + "Phê duyệt" + "Trang trực tiếp" + "Tác vụ tự động đang chạy." + "Xóa tác vụ tự động này sau khi chạy một lần thành công." + "Sẵn sàng để trò chuyện và dùng giọng nói" + "Đã kết nối (người vận hành: %1$s)" + "Ghép nối Gateway đã hoàn tất. Phê duyệt điện thoại này làm một nút để OpenClaw có thể sử dụng các khả năng thiết bị mà bạn bật." + "Phản hồi đã bị hủy" + "hình ảnh" + "%1$s đang giữ" + "Không có luồng phù hợp" + "delete" + "Bố cục: Thu gọn" + "channels" + "Đã cấp" + "Mỗi %1$s phút" + "1 token" + "%1$s %2$s" + "Ứng dụng đã cài đặt" + "đang chờ" + "Đang chuẩn bị ghi chú thoại…" + "Không bao giờ" + "Hệ thống con" + "Khi lệnh kết thúc" + "Kết nối" + "Không thể tải lịch sử chạy tác vụ tự động." + "Tên tác vụ tự động" + "Bước 2" + "Chẩn đoán" + "Một số kiểm tra trạng thái kênh chưa hoàn tất." + "pin" + "Sao chép %1$s" + "Đã ghép đôi" + "Không thể lưu từ đánh thức" + "Thao tác này sẽ cách ly \"%1$s\" và làm mới trạng thái Skill Workshop từ Gateway." + "Ghi ghi chú thoại" + "Đang chờ" + "Đã trả lời" + "Cho phép dùng công cụ camera khi được yêu cầu." + "Sự cố" + "Đánh thức bằng giọng nói" + "Đã từ chối yêu cầu ghép nối." + "%1$s ngày trước" + "roles" + "Skills" + "Lưu trữ" + "Nút đang ngoại tuyến. Hãy kết nối lại và thử lại." + "Hệ thống" + "IP từ xa" + "Chưa phân nhóm" + "Chi tiết lịch trình" + "Khả năng của điện thoại" + "Không khả dụng" + "Bảng điều khiển" + "Dán token" + "Không có nhà cung cấp" + "Dấu vân tay SHA-256" + "Chưa có luồng nào" + "Micrô Bluetooth" + "Gần đây" + "Đổi tên luồng" + "Không rõ kết quả xử lý. Các thao tác vẫn bị vô hiệu hóa cho đến khi bản ghi Gateway được xác minh." + "dialog" + "Lắng nghe từ đánh thức" + "camera snap" + "Đang chuẩn bị phát…" + "Gateway đã chọn nhà cung cấp không xác định %1$s" + "delete group" + "Theo Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Kết nối gateway để tải agents." + "Quay lại" + "Chia sẻ tin nhắn" + "Tạo mã QR." + "Khởi động lại" + "Loa đang bật" + "Xóa nhóm?" + "Thiếu" + "Tìm kiếm đề xuất" + "stop" + "Bảo mật (TLS)" + "Không có nút hoặc thiết bị đã ghép đôi." + "còn %1$s%% %2$s" + "Mã thiết lập đã hết hạn" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "NHẬT KÝ" + "notify" + "Điện thoại này sẽ duy trì trạng thái ngủ cho đến khi Gateway cần, sau đó sẽ thức dậy, đồng bộ hóa và quay lại trạng thái ngủ." + "%1$s mô hình đã được cấu hình" + "Giấy phép" + "Kết nối Gateway để tìm kiếm Skills trên ClawHub." + "Kỹ năng" + "Kết nối Gateway đã thay đổi. Khởi động lại OpenClaw để kết nối lại." + "ID thiết bị" + "Gateway không xác định nhà cung cấp %1$s đang hoạt động" + "Đang chờ" + "Đã lưu từ đánh thức" + "Cũ nhất trước" + "Màn hình" + "Đang chạy từ" + "ID vùng IPv6 không được hỗ trợ. Hãy dùng địa chỉ IPv6 không có phạm vi hoặc tên máy chủ LAN." + "Đã gửi — đang xác nhận chuyển phát…" + "âm thanh" + "This gateway connection needs operator.admin to update skills." + "Mã thiết lập" + "Xác nhận cảnh báo của Gateway và cài đặt" + "Làm mới cuộc trò chuyện" + "Khoảng thời gian" + "Các thao tác đề xuất trong Skill Workshop yêu cầu phạm vi operator.admin." + "Phiên" + "Đổi tên…" + "Kết nối gateway để tải dreaming." + "Thiết lập" + "Mở Talk" + "poll" + "Kết nối để tải các tác nhân của bạn" + "role remove" + " · Trò chuyện: Đang nghe" + "ClawHub không trả về phiên bản có thể cài đặt cho %1$s." + "Lệnh" + "Phê duyệt này đã bị hủy trước khi có thể được xử lý." + "Mic bật · đang chờ Gateway" + "Văn bản" + "Đang hiển thị %1$s trên %2$s. Thu hẹp tìm kiếm để xem thêm." + "Đã có phiên bản v%1$s" + "%1$s://%2$s" + "%1$s... (OK)" + "Nhà cung cấp và mô hình đã cấu hình" + "Đang kết nối…" + "Kết nối với Gateway để lưu từ đánh thức" + "Mở hồ sơ" + "Khởi động Gateway của bạn." + "Hãy giúp tôi biến mục tiêu này thành một danh sách kiểm tra thiết thực: " + "Xóa tìm kiếm phiên" + "Cổng" + "Nhập mã thiết lập" + "Không thể tải nhật ký Gateway." + "%1$s nhà cung cấp đã sẵn sàng" + "Các tác nhân của bạn đã sẵn sàng" + "Chưa cấu hình nhà cung cấp %1$s trên Gateway" + "Đang lắng nghe một lượt" + "Quan sát" + "Mili giây epoch (tùy chọn)" + "Không có mô hình nào được cấu hình. Làm mới để kiểm tra lại tính khả dụng." + "Cài đặt" + "Camera sau" + "approve" + "Trước khi bạn bắt đầu" + "Không thể tải Skills." + "Đã tắt" + "Vẫn đang chờ phê duyệt" + "Không thể tải tác vụ nền" + "Kiểm tra để đảm bảo OpenClaw có thể nói rõ trên điện thoại này." + "Đang hoạt động · %1$s lượt chạy đang hoạt động" + "Thư mục làm việc của lệnh" + "Tên nhóm" + "Chọn từ thư viện" + "Version %1$s" + "Quay lại" + "Connect the gateway to update skills." + "Xóa sau khi chạy" + "Mã thiết lập trỏ đến một gateway từ xa không an toàn. %1$s %2$s" + "Computer" + "Gateway đã ngắt kết nối." + "Session Settings" + "Kết nối Gateway để bắt đầu" + "Thông báo bảo mật" + "Câu trả lời khác" + "Bỏ qua cảnh báo hình ảnh được chia sẻ" + "Gateway đã đánh giá một bản phát hành ClawHub khác. Hãy xem lại skill trước khi cài đặt." + "Mở quyền truy cập hệ thống" + "Đã hoàn tất" + "Hình ảnh không khả dụng" + "Thông báo" + "Áp dụng, từ chối và cách ly yêu cầu phạm vi operator.admin. Kết nối lại bằng xác thực gateway dùng chung hoặc phê duyệt nâng cấp phạm vi thiết bị operator.admin để bật các hành động vòng đời." + "sticker upload" + "Bắt tôm hùm" + "Messages to recover" + "openclaw devices approve %1$s" + "Chi tiết nhật ký gateway dễ đọc." + "Xem xét các đề xuất skill được tạo trước khi chúng trở thành skill hoạt động." + "Đi kèm" + "Có %1$s tác nhân khả dụng" + "Đang chờ phê duyệt nút" + "Gateway đang chờ xử lý" + "Cần xác thực" + "Nút" + "Duy trì hoạt động" + "OpenClaw đang trả lời" + "Tài liệu" + "%1$s sẵn sàng" + "Chưa có đầu ra" + "Ngôn ngữ của thiết bị không được hỗ trợ" + "Đã xếp hàng — sẽ gửi khi kết nối lại" + "%1$s phút trước" + "Nhánh hiện tại" + "Đang kiểm tra quyền ghép nối" + "Quyền truy cập Gateway bị giới hạn" + "Đang chạy công cụ..." + "Đang kiểm tra phê duyệt…" + "Chụp ảnh và quay đoạn phim bằng điện thoại này" + "Đã kết nối và sẵn sàng" + "Đóng" + "Chuyển một mục tiêu thành danh sách kiểm tra có thể thực hiện." + "Mã thiết lập có URL gateway không hợp lệ." + "Chỉ bật quyền truy cập mà bạn thấy thoải mái khi cho phép OpenClaw sử dụng trong khi điện thoại này được kết nối. Bạn có thể thay đổi các quyền này sau trong Cài đặt Android." + "Tài khoản" + "remove" + "Mật khẩu không bắt buộc" + "Cần xem xét lại xác thực Gateway. Kiểm tra cài đặt gateway, rồi thử lại." + "Mã QR sử dụng ID vùng IPv6. Hãy dùng địa chỉ IPv6 không có phạm vi hoặc tên máy chủ LAN." + "add" + "Đang bắt moi" + "Hoạt động tốt" + "Hoàn tất sau %1$s" + "Đối số" + "Tùy chọn cài đặt" + "Sau %1$s giờ" + "Đang chờ phê duyệt Gateway. Chạy lệnh này trên máy chủ Gateway:" + "Cần quyền quản trị" + "set groups" + "Ghim mô hình" + "Xóa tìm kiếm" + "Đã bật cho các agent đủ điều kiện." + "Không có luồng hiện tại" + "bounds: %1$s" + "Sau %1$s" + "Cho phép trình lập lịch chạy tác vụ tự động này." + "%1$s đã áp dụng" + "Chưa có nhật ký giấc mơ." + "Làm mới tác vụ nền" + "Tóm tắt các luồng gần đây và các bước tiếp theo." + "Chạy trên thiết bị khi OpenClaw đang hiển thị." + "%1$s đang làm việc" + "%1$s %2$s" + "Thô" + "Lượt chạy" + "Chạy ngay" + "Nhánh chưa có tiêu đề" + "Đã cấu hình" + "camera list" + "1 đã áp dụng" + "camera clip" + "Có" + "Kiểm tra âm thanh" + "Đang giữ" + "events" + "Thư mục làm việc" + "Chuyển đến mới nhất" + "Luôn cho phép" + "Quét mã QR hoặc mã thiết lập" + "Installing" + "Các nút đang hoạt động, điện thoại đã ghép đôi và yêu cầu thiết bị đang chờ." + "Ảnh chụp: %1$s" + "Một phản hồi trước đó đã cho phép lệnh này và lưu lựa chọn." + "Yêu cầu đang chờ" + "Đã phê duyệt" + "Không gian làm việc" + "Giọng nói" + "Sẵn sàng trò chuyện" + "Subagents" + "Thất bại: không phát hiện được điểm cuối gateway bảo mật. Hãy bật TLS gateway hoặc Tailscale Serve, hoặc dùng địa chỉ LAN riêng đáng tin cậy với tùy chọn Không mã hóa được chọn." + "Tín hiệu" + "Mục tiêu phiên" + "Gateway đã ghi nhận việc từ chối." + "Chấp nhận" + "Hỏi OpenClaw bất cứ điều gì" + "Kết nối lại để tiếp tục" + "%1$s thiết bị đã ghép nối" + "Thao tác này sẽ áp dụng \"%1$s\" và làm mới trạng thái Skill Workshop từ Gateway." + "Gateway ngoại tuyến" + "openclaw devices list" + "Trạng thái kết nối của OpenClaw node" + "Cảnh báo vẫn ở trên điện thoại này." + "OpenClaw có thể nhận các cảnh báo đã chọn." + "Mở Màn hình" + "Thao tác trò chuyện" + "Cho phép điều khiển ứng dụng khác?" + "Đang kiểm tra" + "Quét hoặc dán mã thiết lập để thêm Gateway khác." + "Swarm" + "TLS đã hết thời gian" + "Phiên gần đây" + "Đã xóa thiết bị được ghép nối." + "Đã ghép đôi Gateway. Đang kiểm tra phê duyệt khả năng của nút." + "Vận động" + "Thao tác cron không thành công." + "Trên máy tính Gateway, chạy:" + "Tìm kiếm phiên" + "Làm mới nhật ký" + "Hình ảnh không khả dụng · Nhấn để thử lại" + "openclaw nodes approve %1$s" + "Tin nhắn thoại · %1$s" + "Mức sử dụng" + "Đang hóa ốc anh vũ" + "Ngữ cảnh %1$s%%" + "Chuyển lời nhắc bằng giọng nói thành văn bản" + "Tắt tiếng" + "Bắt đầu một cuộc trò chuyện mới và cuộc trò chuyện đó sẽ hiển thị ở đây." + "Sự cố kết nối" + "Trung bình" + "Phân nhánh" + "Bật loa" + "Văn bản sự kiện hệ thống" + "Sắp xếp: %1$s" + "%1$s đang chờ" + "Image Generation" + "Ghi chú thoại" + "Không có gì cần bạn chú ý" + "OpenClaw cần quyền %1$s để tiếp tục." + "Micrô tai nghe có dây" + "Trang" + "Đã gửi" + "Đến hạn" + "Thông tin chi tiết về Skill không có trong trạng thái Skills hiện tại." + "Chọn những gì điện thoại này có thể chia sẻ." + "Tác vụ tự động này đã có một lượt chạy trong hàng đợi." + "Kết nối Gateway để quản lý các tác vụ tự động." + "Chưa đến thời điểm chạy tác vụ tự động." + "Không có chi tiết" + "Đang tiến hành phê duyệt.\nOpenClaw sẽ tự động kết nối lại." + "Kết nối Gateway của bạn để xem mức độ sẵn sàng của nhà cung cấp." + "Đang chờ ghép nối" + "Bắt đầu hoặc tiếp tục cuộc trò chuyện" + "Không có tác vụ đã lên lịch" + "Trả lời OpenClaw…" + "Trạng thái" + "OpenClaw Node · Đã kết nối" + "Đang hoạt động" + "Hiển thị trạng thái gỡ lỗi chia sẻ màn hình." + "Không có giới hạn nào được báo cáo" + "Đóng trình quét" + "Mỗi %1$s ngày" + "Đã bật" + "Bật và Mở Cài đặt" + "Đang trực tuyến và sẵn sàng" + "Ask User" + "Lỗi trò chuyện" + "Cuộn tới" + "%1$s trên %2$s" + "Lập kế hoạch công việc" + "console" + "Thử lại" + "Bắt đầu một cuộc trò chuyện và các cuộc trò chuyện OpenClaw đang hoạt động của bạn sẽ xuất hiện tại đây." + "Không thể tải tác vụ tự động." + "Shell trong không gian làm việc của tác nhân" + "%1$s đang hoạt động" + "Chọn quyền thiết bị" + "Thời lượng gần nhất" + "Tác nhân mặc định" + "%1$s giờ" + "Cuộc trò chuyện đang diễn ra" + "Không thể cài đặt %1$s từ ClawHub." + "Chào mừng đến với OpenClaw" + "Điều khiển ứng dụng khác" + "Chỉ mục tín hiệu" + "Nhập bí mật…" + "%1$s:%2$s" + "yêu cầu OpenClaw %1$s" + "Đã phát hiện" + "Ẩn thanh bên" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Không thể phát âm thanh" + "áp dụng" + "Không thể tải dữ liệu sử dụng." + "Duy trì trạng thái khả dụng của nút trong khi đang hoạt động." + "Lần đánh thức tiếp theo" + "%1$s/%2$s" + "Không có mục nhật ký gần đây." + "Gateway thủ công" + "Đổi tên nhóm" + "Update Goal" + "Không rõ tình trạng khả dụng của nhà cung cấp" + "Nhà cung cấp" + "Xóa nhóm…" + "Dữ liệu" + "Nhật ký cuộc gọi" + "Memory Search" + "%1$s nhà cung cấp" + "Ngữ cảnh điện thoại & quyền riêng tư" + "%1$s/%2$s đã kết nối" + "%1$s %2$s" + "Quá trình khôi phục sau khi khởi động lại Gateway vẫn đang diễn ra." + "Việc thay thế mã thiết lập sẽ xóa thông tin xác thực thiết lập và token thiết bị đã lưu trên điện thoại này trước khi kết nối lại. Điện thoại này có thể cần được phê duyệt lại quyền của nút; chỉ tiếp tục khi bạn thực sự muốn ghép nối bằng mã thiết lập Gateway mới." + "Mở một tác vụ tự động để xem cấu hình và lịch sử chạy. Các kết nối có phạm vi quản trị viên cũng có thể chạy, chỉnh sửa, bật, tắt hoặc xóa tác vụ." + "Ngữ cảnh --" + "Đã cách ly đề xuất." + "Đã tạm dừng tác vụ tự động." + "OpenClaw dành cho thiết bị di động" + "A2UI reset" + "Gateway không khả dụng" + "Read" + "Skill này cần thiết lập 1 mục. Android hiển thị những gì đã được cài đặt; việc thiết lập/thay đổi cấu hình chỉ có thể thực hiện trên máy tính hoặc CLI." + "Lần chạy gần nhất" + "Cần quyền truy cập camera để quét mã QR thiết lập." + "Không thể cập nhật mô hình." + "Đang sủi bọt" + "thread reply" + "Xóa…" + "Kết nối Gateway để kiểm tra các tác vụ tự động." + "NHẬT KÝ GẦN ĐÂY" + "Đang tải các lần chạy gần đây…" + "Không thể xem trước tệp này. Tệp có thể là tệp nhị phân hoặc quá lớn." + "Kiểm tra" + "Đọc vị trí của điện thoại này" + "Khóa Skill" + "Đã cài đặt %1$s." + "Các Gateway" + "actions: %1$s" + "Chế độ chuyển tiếp" + "%1$sk" + "Chuyển đổi bố cục luồng" + "Không có điểm cuối TLS" + "Gateway OpenClaw" + "Thiết lập thủ công" + "Đang suy nghĩ…" + "Quyền truy cập Gateway cần được xem xét" + "1 đang giữ" + "%1$sgi" + "Áp dụng đề xuất?" + "Để sau" + "Chưa được phê duyệt" + "Tìm kiếm ứng dụng" + "1 mô hình đã được cấu hình" + "Bỏ qua thông báo phê duyệt" + "·" + "Ngoại tuyến" + "Nhà cung cấp nhận dạng giọng nói" + "Quá trình phê duyệt Gateway đang diễn ra. OpenClaw sẽ tự động thử lại." + "Tối đa" + "Các thay đổi đối với cron yêu cầu quyền truy cập operator.admin." + "Đang suy nghĩ" + "screen snapshot" + "Nút được quan sát: %1$s" + "Không tìm thấy tác vụ nào" + "Lưu & Kết nối" + "list" + "Gateway đã ghi nhận phê duyệt và lưu lựa chọn." + "Nhập endpoint thủ công hợp lệ để kết nối." + "trợ lý" + "Đang gửi đến cuộc trò chuyện..." + "Lưu hồ sơ" + "Đã khóa" + "Chỉnh sửa tác vụ tự động" + "Dùng cùng mạng hoặc URL Gateway từ xa an toàn." + "Neo" + "Ngôn ngữ" + "Ứng dụng này cũ hơn Gateway. Hãy cập nhật OpenClaw trên thiết bị này rồi thử lại." + "Tất cả" + "Phiên Gateway đang diễn ra" + "Đang chờ xem xét" + "Chưa cài đặt Skills nào." + "Đang kiểm tra Gateway" + "Độ lệch %1$s" + "Không xác định được kết quả cho %1$s. Hãy kết nối lại, làm mới Skills rồi thử lại; Gateway sẽ tham gia an toàn vào một quá trình cài đặt tương ứng vẫn đang chạy." + "Quên" + "Không có Gateway nào đã ghép nối." + "%1$s · %2$s" + "<bí mật đã được ẩn>" + "%1$s sự cố" + "OpenClaw" + "Đang nghe · %1$s đang chờ" + "Giọng nói của trợ lý đã bị tắt tiếng" + "Hành động trên nút chỉ chạy khi ứng dụng mục tiêu đang ở nền trước (được xác thực qua đường dẫn từ xa). Hành động toàn cục và hành động cùng ứng dụng hoạt động tại đây." + "Chưa tìm thấy Gateway nào. Hãy thiết lập thủ công nếu tính năng khám phá bị chặn." + "Mở luồng" + "Đang xử lý" + "Bắt đầu nói..." + "Nút điện thoại" + "Cực cao" + "Chạy trên máy chủ Gateway:" + "Việc thay đổi skill yêu cầu operator.admin. Hãy kết nối lại bằng token gateway có quyền quản trị viên." + "Kết nối Gateway để kiểm tra Skills trên ClawHub." + "Danh sách ứng dụng vẫn ở trên điện thoại này." + "Không hoạt động" + "Hiển thị trong cài đặt Trợ năng của Android." + "Phân phối thông minh" + "Từ chối" + "Gateway trả về trạng thái \'%1$s\' sau khi %2$s." + "Mã thông báo Gateway chưa được cấu hình" + "Not available to this agent" + "Tệp" + "Quyền" + "Không thể khởi động camera. Hãy chọn hình ảnh mã QR từ thư viện hoặc nhập mã thiết lập theo cách thủ công." + "Nhấn để sao chép" + "Đang chờ %1$s phút" + "%1$s." + "Kết nối Gateway để cài đặt Skills từ ClawHub." + "Tìm kiếm giọng nói" + " · Mic: Đang nghe" + "Kết nối lại với quyền operator.admin để xem lại và thay đổi cài đặt Gateway." + "Tải thêm" + "Quan sát sau 3s" + "run" + "Đang tạo giọng nói…" + "← Quay lại" + "Ngắt kết nối" + "Chạy lệnh approve trên máy tính Gateway, rồi kiểm tra lại." + "Tự động hóa" + "%1$s phút" + "Tin cậy" + "Mã QR đó không phải là mã QR thiết lập OpenClaw. Hãy tạo mã mới bằng openclaw qr rồi thử lại." + "Micrô ưu tiên không khả dụng; đang sử dụng định tuyến tự động." + "Đã từ chối" + "Bao gồm Android và các gói chạy nền." + "Gateway của bạn đã sẵn sàng." + "Đã kích hoạt" + "Structured Output" + "Quá trình này mất nhiều thời gian hơn dự kiến.\nHãy kiểm tra để đảm bảo Gateway đang chạy và có thể truy cập được." + "Không có tác vụ nền nào cho tác nhân này." + "Đang kết nối lại" + "OpenClaw đang kiểm tra quyền truy cập Gateway và nút." + "Code Execution" + "Không có mức sử dụng nhà cung cấp" + "Xem xét" + "Cần có quyền truy cập micrô." + "%1$s ngày" + "Có %1$s mô hình" + "OpenClaw đang đồng bộ hóa lại" + "Luồng sự kiện bị gián đoạn; hãy thử làm mới." + "Không thể tải các nút và thiết bị." + "Kết nối Gateway để tải Skills." + "không xác định" + "Kết quả" + "Trò chuyện thất bại: Nhà cung cấp thời gian thực đã đóng ngoài dự kiến." + "OpenClaw Nhạy cảm về thời gian" + "ban" + "Cần mã thông báo Gateway" + "Thiết bị đã ghép nối" + "Cần phê duyệt lại" + "Chưa lên lịch" + "Danh bạ" + "Điện thoại của bạn sẽ không hoạt động cho đến khi cần thiết" + "Đang nghe · đang gửi giọng nói trong hàng đợi" + "Không thể tải chi tiết tác vụ" + "Tin nhắn agent" + "Gateway yêu cầu danh tính thiết bị này. Xác thực lại hoặc đặt lại kết nối gateway này." + "Phiên tiếp theo" + "Bảo mật kết nối" + "Bỏ qua lúc này" + "Trang web" + "Kết nối gateway để tải các yêu cầu phê duyệt trong ứng dụng." + "Đã sao chép %1$s" + "Chưa chọn ứng dụng nào. Không có nội dung nào được chuyển tiếp cho đến khi bạn thêm ứng dụng." + "%1$s %2$s" + "Cần thiết lập" + "Chưa ghép đôi" + "Gateway đã nhận diện điện thoại này" + "Chưa có mô hình nào được cấu hình" + "Tắt" + "Ngôn ngữ ứng dụng" + "Đang ghép nối Gateway" + "Xác thực đã lưu không hợp lệ" + "%1$s phạm vi" + "Kết nối gateway để tải nhật ký gần đây." + "Lưu từ đánh thức" + "Quản lý các skill đã cài đặt và thêm các bản phát hành đáng tin cậy từ ClawHub." + "Đang gửi…" + "Chưa tải agent nào." + "Tìm kiếm trên ClawHub" + "Cuộc trò chuyện đang kiểm tra trạng thái của Gateway." + "Cần ghép nối" + "Lượt chạy đang hoạt động" + "Không thành công — %1$s" + "Kết nối giữa điện thoại này và OpenClaw." + "summarize" + "Đã lưu hình ảnh tiện ích vào thư mục Tải xuống" + "Đang khởi động…" + "%1$s token" + "Lỗi máy khách" + "Hãy xác minh thiết bị đang yêu cầu này trước khi cấp quyền truy cập." + "Micrô Bluetooth LE" + "%1$s %2$s" + "Đã bật tác vụ tự động." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Đã lưu trữ" + "Tải lại" + "Tìm kiếm tác vụ tự động" + "Điện thoại đã liên kết và máy chủ nút sẽ xuất hiện ở đây sau khi ghép đôi." + "%1$s: %2$s" + "Tác vụ tự động này đã thay đổi trên Gateway. Hãy xem lại phiên bản mới nhất trước khi lưu lại." + "Dừng đọc chính tả" + "Dễ đọc" + "Nhắn tin cho OpenClaw" + "Mật khẩu Gateway không hợp lệ. Nhập lại hoặc đặt lại kết nối gateway này." + "Kết nối lại" + "Thời gian ISO, ví dụ 2026-07-09T09:30:00Z" + "%1$s công cụ" + "Một phản hồi trước đó đã từ chối phê duyệt này." + "Đã liên kết" + "Mở %1$s" + "%1$s/%2$s" + "Lượt chạy tác vụ tự động đã hoàn tất với trạng thái không xác định." + "Hàng đợi ngoại tuyến đã đầy (%1$s tin nhắn); trước tiên, hãy xóa các mục đang chờ." + "Gateway công khai yêu cầu wss:// hoặc Tailscale Serve. ws:// được phép dùng cho localhost, máy chủ .local, trình giả lập Android và các địa chỉ IP LAN riêng." + "Kết nối Gateway để tải thông tin chi tiết về Skill." + "Cần Quyền Truy Cập Đầy Đủ" + "Trình nghe từ đánh thức" + "Mở rộng bản xem trước liên kết" + "Xóa nội dung tìm kiếm luồng" + "NULL (THẤT BẠI)" + "Cập nhật" + "Quản trị viên" + "Cần chú ý" + "Ghép nối thiết bị này với Gateway của bạn để chỉ đánh thức thiết bị khi có công việc thực sự, thuận tiện theo dõi trực tiếp các tác nhân và tránh các vòng lặp chạy nền gây hao pin." + "Vai trò" + "Trả lời" + "Danh mục nhà cung cấp" + "Bật quyền trong Cài đặt" + "A2UI push" + "Kiểm tra quyền truy cập" + "Nếu có thể kết nối với Gateway, quá trình kết nối lại sẽ hoàn tất mà không cần can thiệp." + "Lượt của tác nhân" + "cách ly" + "Cần chú ý" + "Đang tìm kiếm…" + "Tôi lấy mã thiết lập ở đâu?" + "Không thể bật skill." + "pdf" + "Xóa" + "%1$s%% đang trực tuyến" + "Không có kênh" + "Giọng nói theo thời gian thực" + "Hành động từ chối và cách ly của Skill Workshop" + "Nút & Thiết bị" + "Trung tâm lệnh cục bộ" + "emoji upload" + "Đang tải bản xem trước…" + "Cao" + "focus" + "describe" + "ngữ cảnh %1$s" + "Đang lắng nghe phản hồi..." + "voice" + "Đã kết nối với %1$s" + "role add" + "Cuộc trò chuyện cần chú ý" + "Bật micrô" + "OpenClaw thu thập và gửi tên, ID gói và trạng thái của các ứng dụng hiển thị trên điện thoại này khi Gateway OpenClaw đã ghép nối của bạn yêu cầu. Điều này cho phép trợ lý của bạn trả lời câu hỏi và thực hiện hành động bằng các ứng dụng đã cài đặt." + "Gateway chưa được kết nối" + "Chính sách" + "Đã hết thời gian chờ xác nhận tin nhắn đã gửi; hãy làm mới để kiểm tra trạng thái gửi." + "Tệp hỗ trợ" + "Biểu thức" + "Tác vụ nền" + "Mơ" + "Không có ứng dụng nào bị chặn. Các ứng dụng có thể chuyển tiếp trừ khi bạn thêm quy tắc chặn." + "Trình nhận dạng giọng nói không khả dụng" + "Nền tảng" + "Gateway không trả về thiết lập %1$s" + "Quên Gateway?" + "Mô tả tùy chọn" + "Mở %1$s" + "Canvas trang chủ" + "Đang mơ" + "%1$s đến %2$s" + "Chia sẻ tệp" + "Thời gian thực" + "API" + "OpenClaw đang hoạt động…" + "Trò chuyện hoặc đọc chính tả với OpenClaw" + "Chia sẻ thông tin ứng dụng đã cài đặt?" + "Đang tải tác vụ tự động…" + "Xóa tác vụ tự động" + "Trợ lý mặc định" + "Chọn nhà cung cấp %1$s được hỗ trợ trên Gateway" + "Không khả dụng" + "Thư mục trống" + "Mở cài đặt" + "Tắt" + "Kiểu chữ" + "Dừng" + "Chưa có luồng phù hợp." + "Đã ghép nối Gateway thành công.\nPhê duyệt các chức năng của nút trên điện thoại này từ giao diện người vận hành." + "Skill này đã được cài đặt nhưng hiện không đủ điều kiện để chạy. Hãy sử dụng máy tính hoặc CLI để thay đổi cấu hình." + "Trình nhận dạng đang bận" + "Gateway gia đình" + "Chạy lệnh phê duyệt trên Gateway" + "Dịch vụ đã tắt" + "Không thể tải các đề xuất Skill Workshop." + "Cập nhật cho tôi về các luồng OpenClaw gần đây và đề xuất các bước tiếp theo." + "Không phải bây giờ" + "openclaw qr" + "start" + "OpenClaw Node · Trò chuyện" + "Đọc và cập nhật sự kiện" + "Trò chuyện thất bại: Nhà cung cấp thời gian thực đã đóng: %1$s" + "Kết nối Gateway để duyệt các tệp trong không gian làm việc." + "%1$s qua chuyển tiếp Gateway" + "Không thể tải danh mục trò chuyện Gateway" + "Đang giám sát · 1 tác vụ đã lên lịch" + "Mỗi %1$s giờ" + "Bề mặt màn hình" + "Bản dịch OpenClaw · %1$s" + "Yêu cầu lệnh" + "Đã cập nhật" + "Kênh" + "Bật tiếng" + "Nhóm mới…" + "Đang chuẩn bị âm thanh…" + "Thích ứng" + "Sắp có" + "Thêm %1$s worker" + "Web Search" + "Thử Trò chuyện, Giọng nói, Luồng, Nhà cung cấp hoặc Cài đặt." + "OpenClaw Hoạt động" + "navigate" + "đã yêu cầu %1$s" + "Kết nối Gateway để xem lịch sử chạy tác vụ tự động." + "Quyền truy cập thiết bị; vẫn cần bật tùy chọn Gateway" + "Đã hủy" + "Nhập mã thiết lập hoặc địa chỉ gateway hợp lệ." + "Mô hình" + "OpenClaw Thụ động" + "Mật khẩu Gateway không hợp lệ" + "Không thể xác minh thay đổi ghép nối thiết bị. Hãy làm mới và thử lại." + "Xem chi tiết" + "Bash" + "Mã thông báo" + "Tác nhân OpenClaw đã kết nối có thể sử dụng các khả năng của thiết bị mà bạn bật. Chỉ tiếp tục nếu bạn tin cậy Gateway và tác nhân mà bạn kết nối." + "Bám hà" + "Đã cấp quyền truy cập ảnh đã chọn hoặc toàn bộ." + "Trình thực thi Trợ năng" + "Thiếu %1$s mục" + "Thu gọn danh sách kiểm tra kế hoạch" + "Cần phê duyệt nút" + "Kết nối Gateway" + "... +%1$s nữa" + "Mở rộng danh sách kiểm tra kế hoạch" + "Trình duyệt" + "screen record" + "Lượt chạy đang chờ" + "Bật tính năng này cho phép OpenClaw quan sát và điều khiển màn hình của các ứng dụng khác khi được kích hoạt. Cần có quyền truy cập trợ năng của Android." + "Nguồn gốc" + "AI cá nhân trên các thiết bị của bạn" + "Attach" + "Tự động" + "Tổng quan" + "Không thể yêu cầu khôi phục. Nhấn để thử lại." + "Video" + "%1$s\n\n" + "Không mã hóa" + "Lịch" + "Trạng thái Gateway không ổn định; không thể gửi" + "📎 %1$s" + "Trạng thái gần nhất" + "Hãy đợi phản hồi hiện tại hoàn tất trước khi bắt đầu cuộc trò chuyện mới." + "Hồ sơ" + "Giới hạn của nhà cung cấp sẽ xuất hiện ở đây khi gateway báo cáo." + "1 sự cố" + "Các luồng trong \"%1$s\" được giữ lại và chuyển về Chưa nhóm." + "Được đề xuất" + "Đã tạo" + "%1$s/%2$s token đang hoạt động" + "Không có kết quả hành động" + "Búng càng" + "%1$s…" + "Mở chi tiết Skill" + "Phát giọng nói không thành công: %1$s" + "Bắt đầu trò chuyện" + "Không thể tải thư mục này." + "Mã QR không chứa mã thiết lập hợp lệ." + "Xem lại quyền truy cập nút" + "Thêm cụm từ đánh thức" + "Không thể kết nối tới gateway" + "Tác vụ tự động" + "Cần kết nối" + "Không thể xử lý phê duyệt. Hãy làm mới và thử lại." + "import" + "Cách điện thoại này hiển thị với OpenClaw." + "Đưa con trỏ vào ô tìm kiếm luồng" + "Kết nối Gateway" + "Đọc lịch" + "Phần tổng quan sẽ được làm mới khi kết nối lại và khi màn hình này mở." + "Không thể tắt skill." + "Vẫn đang kết nối" + "Sau %1$s phút" + "Đọc SMS" + "Kết nối gateway để tải mức sử dụng." + "Bạn có thể giúp tôi làm gì từ điện thoại này ngay bây giờ?" + "Cần phê duyệt" + "Cuộc trò chuyện mới" + "Kết nối Gateway để cập nhật các đề xuất trong Skill Workshop." + "Yêu cầu OpenClaw thất bại." + "Yêu cầu quyền" + "Xem xét mức độ sẵn sàng của nhà cung cấp\nvà các mô hình đã cấu hình." + "Đang tải" + "Cảnh báo lỗi" + "Chủ đề và văn bản Android đã dịch." + "Mic tắt · đang gửi…" + "Không có" + "Xem" + "Tên" + "Phiên bản" + "Cron" + "Kết nối điện thoại này với Gateway trước khi mở OpenClaw." + "Xóa cụm từ đánh thức" + "Mã thiết lập không được chấp nhận. Hãy tạo mã mới bằng openclaw qr." + "14 tin nhắn · Android" + "Phiên âm không thành công: %1$s" + "Luôn luôn" + "Không thể tải trạng thái mơ." + "Đã thêm lượt chạy tác vụ tự động vào hàng đợi." + "Conversation Turn" + "Tác vụ tự động đã bắt đầu." + "Nhóm mới" + "Lỗi máy chủ" + "Video Generation" + "Đang chờ phê duyệt Gateway. Chạy openclaw devices list trên máy chủ Gateway, phê duyệt điện thoại này rồi thử lại." + "Các mục sẽ xuất hiện sau khi một chu kỳ dreaming ghi bản tóm tắt tường thuật." + "%1$s mili giây" + "Kho bộ nhớ" + "Trợ lý đang làm việc" + "OpenClaw có thể liệt kê các ứng dụng hiển thị trong launcher." + "Trò chuyện không thành công: %1$s" + "Tìm kiếm Skills đã cài đặt" + "Kiểm tra" + "Process" + "Luồng gần đây" + "Dòng lệnh" + "Hiện tại" + "1 tài khoản" + "Đã tạm dừng" + "Cho phép camera" + "Các yêu cầu phê duyệt exec sẽ xuất hiện tại đây khi điện thoại này được kết nối." + " · Mic: Đang chờ" + "Sao chép" + "Đã sao chép chi tiết" + "Xóa" + "Yêu cầu OpenClaw sử dụng các tính năng của Android." + "member" + "Đang kiểm tra xem Gateway này có hỗ trợ trợ lý cài đặt OpenClaw hay không." + "Sử dụng các tùy chọn khôi phục bên dưới để kết nối lại." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Không thể tải các kênh." + "Sau %1$s ngày" + "Lỗi liên tiếp" + "Không thể đọc mã QR từ hình ảnh đó. Hãy chọn hình ảnh rõ nét hơn hoặc nhập mã thiết lập theo cách thủ công." + "Gateway cũ hơn ứng dụng này. Hãy cập nhật OpenClaw trên máy chủ Gateway rồi thử lại." + "Kết nối trước khi trò chuyện, sử dụng giọng nói và xem trạng thái trực tiếp." + "Kết nối lại Gateway" + "Bên thứ ba" + "Xem xét mức độ sẵn sàng" + "Giới hạn" + "Logo OpenClaw" + "Bỏ ghim mô hình" + "Các bề mặt nhắn tin được kết nối với gateway này." + "Đang gửi" + "Các luồng đã lưu trữ sẽ xuất hiện tại đây." + "Đã sao chép lệnh" + "Không có bản xem trước" + "Phê duyệt điện thoại này trên Gateway.\nSau đó, thử kết nối lại." + "Quét mã QR" + "Thư mục làm việc của lệnh · không thể xóa" + "Hoạt động của luồng" + "Có sẵn" + "Xóa tác vụ tự động?" + "%1$s hôm nay · tổng cộng %2$s" + "Mật khẩu" + "Cách ly đề xuất?" + "Không có thông báo giấy phép nào được đóng gói trong bản dựng này." + "Không thể lưu hình ảnh tiện ích" + "Đang chờ %1$s" + "Đang nói…" + "Nhà cung cấp & Mô hình" + "Nút" + "%1$s " + "Câu lệnh không khả dụng" + "Nhật ký" + "Kết nối Gateway để kiểm tra các đề xuất Skill Workshop." + "Công cụ" + "Công tắc Gateway" + "Gửi SMS" + "OpenClaw đã sẵn sàng tiếp tục trong chat thông thường của bạn." + "Không tìm thấy lệnh nào" + "Chưa có bản cập nhật canvas. Nhấn để thử lại." + "Terminal cần một Gateway đã kết nối" + "Exec" + "Bộ lọc ứng dụng" + "Chính" + "%1$sk" + "Cần Gateway" + "Quyền truy cập" + "Gói: snapshot=%1$s foreground=%2$s" + "Thử kết nối lại" + "Trình lập lịch Cron đã dừng." + "Mở" + "Đã sao chép tin nhắn" + "Không thể kết nối với Gateway của bạn.\nHãy cùng khắc phục sự cố này." + "bây giờ" + "Xóa sau khi chạy" + "Đã chọn trên điện thoại này" + "unpin" + "Session History" + "Bỏ ghim" + "Sử dụng điện thoại này" + "Không thể tải thông tin chi tiết trên ClawHub cho %1$s." + "Công cụ đang chạy" + "Chia sẻ vị trí chính xác khi tính năng vị trí được bật." + "Mobile UI" + "Chủ đề" + "Gateway vẫn hiển thị phê duyệt này đang chờ xử lý. Hãy xem lại trước khi thử lại." + "Hoàn tất ghi chú giọng nói" + "Đọc chính tả: %1$s" + "Không được phép" + "Chọn hình ảnh khác" + "Xem trước hình ảnh" + "OpenClaw chỉ nghe khi bạn bắt đầu Trò chuyện hoặc Đọc chính tả." + "Chia sẻ số bước chân và hoạt động" + "Cần thiết lập" + "Cập nhật Gateway này để sử dụng trợ lý cài đặt OpenClaw." + "Kết nối Gateway này cần quyền operator.admin để cài đặt Skills từ ClawHub." + "Đã áp dụng đề xuất." + "%1$s đang chờ" + "%1$s giờ trước" + "Đọc nhật ký cuộc gọi" + "%1$s đang chờ · đang chờ gateway" + "Chuyển đến nhóm" + "Quét mã QR để ghép nối" + "Đã từ chối phê duyệt." + "Không thể kiểm tra đề xuất Skill Workshop." + "Đã ghim" + "Hồ sơ & thiết bị" + "Đóng bộ chọn mức độ suy nghĩ" + "Không thể đưa tin nhắn vào hàng đợi để gửi sau." + "Cách ly" + "Lịch · %1$s" + "Không thể cập nhật mức độ suy luận." + "Mở bộ chọn mức độ suy nghĩ" + "Phản hồi bằng giọng nói đã hết thời gian chờ; đang thử lại lượt đã xếp hàng" + "Bố cục: Chi tiết" + "Không thể giải mã hình ảnh này." + "Gateway, giọng nói, thông báo, quyền riêng tư" + "Tệp trong không gian làm việc của tác nhân" + "Thiết bị này sẽ mất quyền truy cập Gateway đáng tin cậy." + "Sử dụng requestId từ lệnh đang chờ trong lệnh approve." + "Lịch trình" + "Giới hạn tốc độ" + "Chưa gửi" + "Payload · %1$s" + "Đang chạy" + "Đang dùng càng" + "Kết thúc" + "Sử dụng độ tin cậy của hệ thống" + "Không có nhà cung cấp nào sẵn sàng" + "Ưu tiên các micrô Bluetooth đã kết nối." + "%1$s ứng dụng bị chặn chuyển tiếp." + "Hành động với tin nhắn" + "Loại" + "Bỏ lưu trữ" + "Transcripts" + "Từ đánh thức" + "Cấu hình %1$s trên Gateway" + "Quét mã QR hoặc dùng mã thiết lập từ OpenClaw Gateway của bạn." + "Nguyên mẫu hệ thống thiết kế" + "Đang sàng lọc" + " · Trò chuyện: Bật" + "Chưa có dữ liệu sử dụng." + "Cuộc trò chuyện gặp lỗi trước khi bắt đầu chạy; hãy thử lại." + "Gửi" + "Một số hình ảnh được chia sẻ đã bị bỏ qua hoặc không thể thêm." + "Ghi lịch" + "timeout" + "Thấp" + "Danh sách chặn" + "act" + "Dismiss Task" + "Trò chuyện thất bại" + "OpenClaw · Trực tiếp" + "Đã cài đặt" + "Đã hết thời gian chờ phản hồi; hãy thử lại hoặc làm mới." + "Tìm các cuộc trò chuyện trước đây" + "Duyệt luồng" + "Đang làm mới" + "Tìm ngọc trai" + "Mở camera và đưa mã từ openclaw qr vào khung." + "Không có thiết bị" + "Chuyển tiếp thông báo" + "Tôi sẽ giữ cuộc trò chuyện này tách biệt với chat tác nhân thông thường." + "Phiên Gateway đang trực tuyến trở lại. Các lối tắt tác nhân sẽ tự động ổn định sau giây lát." + "Hãy thử tìm kiếm khác hoặc xóa truy vấn hiện tại." + "Cho phép vị trí nền?" + "Nổi lên" + "Khởi tạo" + "%1$s · %2$s · %3$s" + "Hủy ghi chú giọng nói" + "Cuộn lại" + "openclaw gateway" + "Đã ghép nối Gateway" + "Đang lột xác" + "Đang nghe lượt tiếp theo của bạn." + "OpenClaw đang hoạt động" + "Mục nhật ký" + "Không thành công: không thể truy cập điểm cuối gateway bảo mật cho máy chủ này." + "Gateway đang ngoại tuyến. Hãy khắc phục kết nối bên dưới hoặc sao chép thông tin chẩn đoán." + "Chờ" + "Thử nghiệm thử nghiệm 1 2 3" + "Không thể tìm kiếm Skills trên ClawHub." + "Không có lời nhắc" + "Camera trước" + "Mở mục nhật ký" + "Mạng đã hết thời gian chờ" + "Bây giờ" + "Đổi tên nhóm…" + "Thêm tác nhân" + "openclaw nodes approve REQUEST_ID" + "Ghim" + "thread list" + "Mở %1$s" + "upload" + "Mật khẩu Gateway chưa được cấu hình" + "Cài đặt đọc chính tả" + "Đã tải các mô hình của nhà cung cấp, nhưng không có thông tin về trạng thái sẵn sàng." + "Xóa luồng?" + "OpenClaw biến điện thoại này thành một giao diện lệnh di động gọn gàng cho các luồng, giọng nói, nhà cung cấp và Gateway." + "Mới nhất trước" + "Chu kỳ tiếp theo" + diff --git a/app/src/main/res/values-zh-rCN/assistant.xml b/app/src/main/res/values-zh-rCN/assistant.xml new file mode 100644 index 0000000..2305b97 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/assistant.xml @@ -0,0 +1,7 @@ + + + "询问 OpenClaw %1$s" + "告诉 OpenClaw %1$s" + "打开 OpenClaw 并询问 %1$s" + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..67b8d16 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + 信任此网关? + 信任并继续 + 取消 + 在 worktree 中新建聊天 + 验证证书指纹后再信任此网关。\n\n%1$s + 网关证书已更改。仅当这是您预期的更改时才继续。\n\n旧 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s + 未知 + 版本 + 提交 + 构建日期 + 版本 %1$s + Git 提交 %1$s + 构建于 %1$s UTC,时间戳 %2$s + 构建日期 %1$s + 复制完整 Git 提交哈希值 + 复制完整构建时间戳 + OpenClaw Git 提交 + OpenClaw 构建时间戳 + 已复制 Git 提交 + 已复制构建时间戳 + + "无法准备待发送的附件。" + "麦克风关闭" + "显示 OpenClaw 提醒" + "对话线程活动" + "完整" + "已批准并保存。" + "显示最近的通话记录" + "1 个待处理" + "不支持的附件" + "0 = 精确" + "连接 Gateway 以搜索会话。" + "%1$s 个账户" + "Cron 更改需要 operator.admin 权限。设置代码不会授予此权限。请使用 Gateway 的共享令牌或密码重新连接,以请求管理员访问权限。如果此设备仍缺少该权限,请从现有管理员客户端批准待处理的权限范围升级。" + "Apply Patch" + "夹取中" + "取消扬声器静音" + "连续跳过次数" + "此文件夹还没有文件。" + "未连接" + "检查并管理已安装 Skill 的状态。" + "失败" + "默认代理" + "相机" + "从群组中移除" + "正在搜索" + "已暂停以播放语音" + "Gateway 将在下载前通过 ClawHub 验证此确切版本。如果该版本需要明确确认风险,Android 将在重试前显示 Gateway 警告。" + "设置代码使用了 IPv6 区域 ID。请使用无作用域的 IPv6 地址或 LAN 主机名。" + "附件" + "配置唤醒词、语音交互和播放。" + "正在聆听(按住说话)" + "提案已拒绝。" + "显示侧边栏" + "用户" + "%1$s · %2$s" + "最低" + "拒绝" + "活跃代理" + "1 个已计划" + "无回复" + "已选择 %1$s" + "命令 argv JSON 数组" + "无法读取该图片。请选择清晰的截图或 openclaw qr 所生成二维码的图片。" + "无法 %1$s Skill Workshop 提案。" + "已在其他地方回答" + "Gateway 已记录一次批准。" + "status" + "OpenClaw 仅在已配对的 Gateway 请求时检查位置。在下一个 Android 屏幕上,选择 %1$s,以允许应用在后台运行时进行检查。" + "拒绝" + "对比度" + "替换 Gateway 设置?" + "无法加载自动化。" + "你" + "内置麦克风" + "界面" + "没有提案" + "主会话" + "打开聊天" + "此 Gateway 会话中无法执行设备配对操作。请在 Gateway 主机上运行 openclaw devices list,并在那里处理请求。节点能力批准是独立操作,仍需使用 nodes approve <request id>。" + "操作请求" + "list pins" + "连接到 Gateway 以加载 Skill Workshop 提案。" + "设置代码未被接受" + "退出登录" + "尚未配置实时转录提供商。" + "显示系统应用" + "请更新 Gateway 以查看提供商模型配置。" + "正在发送听写内容" + "检查此提案以加载其 Markdown。" + "打开 OpenClaw 并询问 %1$s" + "推理" + "客户端" + "已应用" + "视频" + "已推广" + "在线" + "权限范围" + "尚未配置实时语音提供商。" + "%1$s · %2$s" + "kick" + "Gateway 返回了无效的自动化。" + "实例 ID" + "需要 Gateway 令牌。请重新输入或编辑此连接。" + "来源" + "刷新" + "%1$s 条已排队" + "开始聊天" + "当前对话线程中等待处理的聊天工具调用仍会显示在此处。" + "需要审核证书" + "打开当前 Canvas 界面以检查或与之交互。" + "自动化已更新。" + "没有最近的会话" + "脚本" + "Gateway 状态、手机节点就绪情况和最近的日志流。" + "打开自动化任务详情" + "运行时" + "还有 1 个 worker" + "智能体 %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "请在 Android 设置中启用 %1$s 以继续。" + "修复" + "reactions" + "完成" + "版本和更新" + "OpenClaw 将在此显示审批、失败的任务和频道问题。" + "USB 麦克风" + "等待添加的共享过多。" + "已跳过" + "使用 Gateway 计算机的局域网地址或安全的远程主机名。" + "开启" + "正在搜索会话" + "实时对话" + "· %1$s" + "OpenClaw 正在准备回复。" + "已批准一次。" + "提供商设置" + "无" + "脚本载荷将保持不变。请使用 CLI 编辑此脚本。" + "Android 设置指南" + "已阻止 %1$s 个应用转发。" + "已配对设备" + ":%1$s" + "%1$s 项待审批" + "设备名称" + "提交" + "位置" + "例如 America/New_York" + "会话目标" + "查看 ClawHub 技能" + "snapshot" + "拒绝来自此设备的配对请求?" + "首选麦克风" + "节点主机" + "级别" + "关闭应用选择器" + "粘贴共享的 Gateway 令牌或操作员颁发的令牌。" + "所有系统均正常" + "已复制 Gateway 诊断信息" + "音频错误" + "替换设置" + "快捷操作" + "发送失败:聊天在运行开始前失败;请重试。" + "麦克风" + "聊天仍在检查 Gateway 的运行状况。" + "精确位置" + "仅允许一次" + "+%1$s 个更多" + "thread create" + "已阻止" + "唤醒词或短语" + "Gateway 需要设备批准" + "外接麦克风" + "%1$s/%2$s 个已就绪" + "已连接(操作员离线)" + "功能未批准" + "这将从 Gateway 中永久移除该自动化及其计划。" + "正在加载图片…" + "连接" + "批准节点访问" + "添加 Gateway" + "转录不可用:%1$s" + "图片" + "逐浪中" + "关闭图片预览" + "eval" + "上一条命令:%1$s" + "在运行 OpenClaw 的设备上打开终端。" + "没有缺失项" + "画布输出需要有效的 Gateway 连接。" + "%1$s · %2$s" + "隔离" + "© 2026 OpenClaw Foundation — MIT License。" + "PDF" + "Conversations" + "记忆整合和梦境日记。" + "Create Goal" + "此自动化在你编辑期间发生了更改。保存前,请还原到 Gateway 上的最新版本。" + "连接后,Gateway 可以通过静默推送唤醒手机,而无需维持始终在线的会话。" + "唤醒模式" + "移除配对设备?" + "系统事件文本" + "无法复制小组件图片" + "否" + "可选路径" + "正在发送排队语音" + "内置" + "hide" + "runs" + "需要 Gateway 密码。请重新输入或编辑此连接。" + "事件文本" + "实时转录" + "无法加载提供商模型配置。" + "允许 %1$s 个应用转发。" + "语音设置" + "添加视频" + "已隐藏其他图片:%1$s" + "拒绝配对请求?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "发布者" + "从此处分叉" + " · 通话:正在说话" + "可选覆盖" + "默认" + "命令批准" + "此应用和 Gateway 使用的协议版本不兼容。请更新两者的 OpenClaw,然后重试。" + "刷新屏幕" + "读取最近的照片和媒体" + "收听" + "已连接" + "已完成" + "你的手机已与 %1$s 配对。请继续完成节点访问设置。" + "当前会话" + "思考 %1$s" + "已静音" + "OpenClaw 感谢其在开源社区中的合作伙伴。" + "打开" + "正在连接 Gateway" + "Gateway 可以更改此路径,但无法清除现有路径。" + "TTS" + "正在保存…" + "锚点 %1$s" + "search" + "激活" + "查看和管理已安排的 Gateway 任务。" + "先前的响应已批准此命令一次。" + "generate" + "仅在受信任的专用网络上使用。" + "搜索设置" + "对话已开启" + "Gateway 身份验证未配置。请编辑此连接后重试。" + "失败:已连接到安全端点,但 TLS 指纹验证超时。请检查 Tailscale Serve 或 gateway TLS,然后重试。" + "第 1 步" + "听写" + "打开应用选择器" + "没有待审批项" + "edit" + "连接到你的 Gateway" + "输入 openclaw qr 中的设置代码。" + "诊断" + "其他应用不受影响。" + "破壳中" + "这将永久删除该对话线程及其记录。" + "设备已批准。" + "正在自动重试" + "已复制小组件图片" + "%1$s 个角色" + "缺少 1 项" + "%1$s 个已计划" + "react" + "代理" + "连接 Gateway 以加载自动化。" + "正在重新连接…" + "返回设置" + "send" + "无法测试连接" + "验证并安装" + "将此设备变成安全的 OpenClaw 节点,用于聊天、语音、摄像头和设备工具。" + "手动设置" + "打开聊天以开始或继续当前对话线程。" + "提示:停止聆听即可发送已捕获的发言。" + "跳过" + "语音请求失败" + "这将拒绝“%1$s”,并从 Gateway 刷新 Skill Workshop 状态。" + "披甲中" + "update" + "分享" + "相机已启用" + "Telegram、WhatsApp、电子邮件和其他渠道将在设置后显示在此处。" + "网络错误" + "探索潮池" + "立即恢复 session=%1$s source=%2$s 的画布。如果存在现有的 A2UI 状态,请立即重放。否则,请在 Canvas 中创建并渲染一个紧凑、适合移动设备的仪表板。" + "启动失败:%1$s" + "未请求" + "在 Gateway 上配置 %1$s 提供商" + "kill" + "审批" + "文件不可用" + "标记为未读" + "查找人员和联系方式" + "需要设备身份" + "OpenClaw 会话" + "允许访问照片图库。" + "先前的响应已处理此批准。" + "没有最近的会话" + "超时 %1$s 秒" + "无匹配项" + "读取所选应用的通知" + "可用性未知" + "设置语音对话" + "额外" + "Gateway 已配对。正在等待操作员访问权限。" + "附加图片" + "选择哪些内容可传送到 OpenClaw。" + "功能重新批准待处理" + "查看突出显示的项目" + "正在聆听..." + "帮我了解最新情况" + "消息" + "读取联系人" + "离线附件存储空间已满;请先删除队列中的项目。" + "一次" + "重命名" + "未找到渠道。" + "查看全部" + "新设备" + "Session Status" + "打开图片预览" + "会话分支已更改;请检查并重试此消息。" + "close" + "这看起来像设置代码。请返回并选择“设置 Gateway”,然后选择“使用设置代码”。" + "✦" + "智能体与自动化" + "应用" + "已跳过自动化运行。" + "继续" + "监控中 · %1$s 个计划任务" + "浏览" + "tabs" + "待处理" + "对话:%1$s" + "read" + "选择文本" + "运动活动" + "description: %1$s" + "播放音频" + "时间" + "未验证" + "Yield" + "复制批准命令" + "当前屏幕输出和交互式应用界面。" + "服务已连接" + "显示" + "准备好了,随时开始" + "无法加载提供商目录。" + "正在说话 · 等待回复" + "未授予" + "保存更改" + "Gateway 拒绝了自动化运行。" + "Session Send" + "在 ClawHub 上查找" + "始终允许在 OpenClaw 在后台运行时进行请求的位置检查;Android 会在持久节点通知中显示此内容。" + "系统事件" + "连接 Gateway 以查看提供方" + "下次心跳" + "Gateway 已配对。正在等待节点能力批准。" + "盐渍中" + "关闭画布" + "写入联系人" + "没有与此搜索匹配的已安装 Skills。" + "Talk Provider 设置" + "Music Generation" + "对话设置" + "正在监控 · 1 个会话" + "负载文本" + "设置文本" + "批准 %1$s" + "Gateway 未返回 %1$s 就绪状态" + "已配置 %1$s 个模型。刷新以重新检查可用性。" + "Conversation Send" + "画布" + "1 个提供商" + "无法自动读取 Gateway 证书。请粘贴在 Gateway 主机上获取的 SHA-256 指纹。" + "发送失败:%1$s" + "桥接" + "投递错误" + "从手机使用 OpenClaw" + "外观" + "技能工作坊" + "需要令牌" + "预览 · %1$s" + "需要麦克风权限" + "连接 Gateway 以加载 Skill Workshop 提案。" + "所有系统运行正常" + "无法连接到 Gateway" + "OC" + "更新时间" + "已连接(节点离线)" + "主页" + "正在听写" + "暂无已归档的对话线程" + "选择并检查此 gateway 上可用的助手。" + "通话模式已启用" + "工作中 · 1 个活跃运行" + "同意并启用" + "需要更新 Gateway" + "复制图像" + "Gateway URL" + "main、isolated、current 或 session:<id>" + "媒体不可用" + "连接到你的 Gateway,以在代理工作区中打开 shell。" + "%1$s://%2$s:%3$s" + "无法加载批准详情。请刷新后重试。" + "我可以检查 Gateway 状态、修复配置、更换模型或连接频道。" + "Tool Call" + "会话" + "Write" + "输入提示词开始,或使用语音。" + "D" + "打开设置" + "正在观察…" + "结束对话" + "上次错误" + "查看需要你注意的操作。" + "已对所有代理禁用。" + "启动语音" + "返回后台任务" + "另一个 cron 操作仍在完成中。" + "冷却时间 %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "短信" + "设备端语音识别不可用。" + "脚本 · 只读" + "附件过大,无法加入单条消息的队列;请移除部分附件后重试。" + "已配置 1 个模型。刷新以重新检查可用性。" + "小组件不可用" + "此主机需要安全连接。" + "最近任务" + "没有匹配的自动化。" + "手机可以连接到 Gateway" + "Gateway" + "已过期" + "来自你的 gateway 的已计划 OpenClaw 工作。" + "Sub-agent" + "正在等待设备批准" + "正在加载会话" + "此 Gateway 现在提供的证书已受此设备信任。" + "错开毫秒数" + "event create" + "文档" + "设置 Gateway" + "播放视频" + "保存的认证信息无效。请重新认证或重置此 Gateway 连接。" + "使用期间" + "screenshot" + "回退到此处" + "Cron 表达式,例如 0 9 * * *" + "返回语音" + "说话" + "详情" + "%1$s/%2$s 个在线" + "允许 %1$s 个应用转发。" + "聊天" + "需要麦克风访问权限。" + "疾行中" + "编辑" + "免打扰时段" + "复制诊断信息" + "已计划" + "创建" + "%1$s 后过期" + "关闭" + "拒绝提案?" + "语音错误(%1$s)" + "问题" + "搜索注册表元数据。Gateway 会在下载前再次验证信任状态。" + "本地设置请使用专用局域网 IP;远程访问请启用 Tailscale Serve,或公开一个 wss:// Gateway URL。" + "正在提交…" + "账户 %1$s" + "Suggest Task" + "搜索" + "正在聆听" + "自动化任务未加载。" + "有可用的 Gateway 更新。准备好后,请通过 Web UI 或 CLI 运行更新。" + "即将" + "没有 Gateway 审批。" + "主机" + "每个字段添加一个唤醒词或短语,然后在说出命令前先说出其中一个。" + "转写后发送" + "运行时间" + "暂停音频" + "访问 Gateway 设备" + "无预览" + "设备" + "OpenClaw Android 版。" + "功能批准待处理" + "在运行、启用、停用、删除或刷新此自动化之前,请保存或还原你的编辑。" + "暂无自动化。" + "此技能需要设置 %1$s 项。Android 会显示已安装的内容;设置和配置更改需通过桌面端或 CLI 完成。" + "%1$s 个最近" + "频道" + "未分阶段" + "已在此手机上启用" + "正在检查节点访问权限" + "时区" + "Skill Workshop 检查和应用操作" + "始终允许" + "present" + "安装在 Gateway 上的 Skills 将显示在此处。" + "该代码可能已过期,或是为另一个 Gateway 生成的。" + "需要权限" + "自动化配置无效。" + "允许列表" + "设置、状态和修复" + "groups" + "公钥" + "关于" + "在该图片中未找到设置二维码。请选择由 openclaw qr 生成的二维码,或手动输入设置代码。" + "permissions" + "连接 Gateway 以加载节点和已配对设备。" + "切换分支" + "没有 Skills" + "回复将大声播放" + "标记为已读" + "节点待审批" + "wake" + "%1$s 个提案" + "Gateway 身份验证需要处理。" + "连接详细信息" + "毫秒" + "语音识别" + "描述" + "最近对话" + "您的手机会将此信息发送到您的 Gateway,而不是 OpenClaw 运营的服务器。您的 Gateway 可能会将其包含在向您选择的 AI 提供商发出的请求中。" + "投递" + "将扬声器静音" + "%1$s 运行中 · %2$s 已完成 · %3$s 失败" + "正在打开 Gateway 连接" + "正在监控 · %1$s 个会话" + "自动化运行已完成。" + "没有匹配的应用。" + "发送到聊天" + "自动化已删除。" + "启用" + "最近运行" + "将二维码对准方框内。" + "无法加载批准。" + "我已批准" + "连接您的 Gateway 以加载提供商就绪状态。" + "未配对" + "此批准在处理完成前已过期。" + "将在 %1$s 秒后观察——请切换到目标应用" + "代理提示" + "emoji list" + "重复" + "搜索 OpenClaw" + "%1$s 个待处理" + "设备端语音识别不可用" + "没有应用可以分享此消息" + "关闭搜索" + "要监视的命令" + "健康状况" + "通知监听器" + "扬声器已静音" + "搜索话题" + "确定" + "无法打开设置指南。" + "询问 OpenClaw %1$s" + "Wait for Agents" + "地址" + "在 Gateway 上创建的计划任务将显示在此处。" + "正在显示最新的日志块。" + "使用设置代码" + "sticker" + "使用安全的 wss:// 或 Tailscale Serve Gateway,在 Control UI 中或使用 openclaw qr 生成完全访问的设置代码,然后在下方扫描或粘贴并重新连接,以启用设置和升级。" + "steer" + "已选择" + "Android 可以扫描或粘贴现有设置码,但此 gateway 目前尚未向应用开放设置码生成功能。请在 gateway 主机上使用 openclaw qr 生成二维码/代码,然后在此扫描或在下方粘贴设置码。" + "Canvas 状态" + "修复连接" + "保存图片" + "节点 %1$s" + "需要 Gateway 密码" + "Update Plan" + "移除附件" + "自动化运行失败。" + "提供商限制和配额健康状况。" + "Gateway 对话目录未加载" + "此 Gateway" + "暂无最近运行。" + "设备端语言模型不可用" + "仪表板需要已连接的 Gateway" + "在代理创建可重用的 Skills 草稿后,匹配的提案将显示在此处。" + "Session Search" + "OpenClaw 正在说话" + "扫描二维码" + "已选应用" + "撤销更改" + "审批命令已复制" + "投递状态" + "二维码未被接受" + "您的语音指令中心。" + "测试连接" + "OPENCLAW" + "Web Fetch" + "提示词" + "批准设备?" + "连接到您的 Gateway 以打开此会话仪表板。" + "要从此手机中移除 %1$s 及其保存的凭据吗?" + "二维码指向不安全的远程 Gateway。%1$s %2$s" + "屏幕表面已就绪" + "配对 Gateway" + "连接 Gateway 以加载渠道。" + "检测到其他语音活动时暂停。" + "模型" + "照片" + "粘贴设置代码" + "OpenClaw 正在说话" + "正在连接..." + " · 位置:始终允许" + "消息:%1$s" + "巡礁中" + "从 Gateway 加载" + "text: %1$s" + "需要" + "rename group" + "就绪" + "日记正在等待第一条记录。" + "批准" + "实时页面" + "自动化已在运行。" + "在一次性运行成功后移除此自动化。" + "已准备好进行聊天和语音交互" + "已连接(操作员:%1$s)" + "Gateway 配对已完成。请批准此手机作为节点,以便 OpenClaw 可以使用你启用的设备功能。" + "响应已中止" + "图像" + "%1$s 个已保留" + "没有匹配的会话" + "delete" + "布局:紧凑" + "channels" + "已授予" + "每 %1$s 分钟" + "1 个 token" + "%1$s %2$s" + "已安装的应用" + "待处理" + "正在准备语音留言…" + "从不" + "子系统" + "命令退出时" + "连接" + "无法加载自动化运行历史记录。" + "自动化名称" + "第 2 步" + "诊断" + "部分渠道状态检查未完成。" + "pin" + "复制 %1$s" + "已配对" + "无法保存唤醒词" + "这将隔离“%1$s”,并从 Gateway 刷新 Skill Workshop 状态。" + "录制语音留言" + "已排队" + "已回答" + "在请求时允许使用相机工具。" + "问题" + "语音唤醒" + "配对请求已拒绝。" + "%1$s 天前" + "roles" + "Skills" + "归档" + "节点离线。请重新连接并重试。" + "系统" + "远程 IP" + "未分组" + "计划详情" + "手机功能" + "不可用" + "仪表盘" + "粘贴令牌" + "无提供商" + "SHA-256 指纹" + "暂无对话线程" + "蓝牙麦克风" + "最近" + "重命名会话" + "处理结果未知。在验证 Gateway 记录之前,操作将保持禁用。" + "dialog" + "监听唤醒词" + "camera snap" + "正在准备播放…" + "Gateway 选择了未知提供商 %1$s" + "delete group" + "跟随 Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "连接 Gateway 以加载代理。" + "返回" + "分享消息" + "生成二维码。" + "重启" + "扬声器已开启" + "删除分组?" + "缺失" + "搜索提案" + "stop" + "安全 (TLS)" + "没有节点或已配对设备。" + "剩余 %1$s%% %2$s" + "设置代码已过期" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "日记" + "notify" + "此手机将保持休眠,直到 Gateway 需要它时才会唤醒、同步,然后重新进入休眠。" + "已配置 %1$s 个模型" + "许可证" + "连接 Gateway 以搜索 ClawHub Skills。" + "技能" + "Gateway 连接已更改。请重启 OpenClaw 以重新连接。" + "设备 ID" + "Gateway 未识别活动的 %1$s 提供商" + "等待中" + "唤醒词已保存" + "最早优先" + "屏幕" + "运行开始时间" + "不支持 IPv6 区域 ID。请使用无作用域的 IPv6 地址或 LAN 主机名。" + "已发送 — 正在确认送达…" + "音频" + "This gateway connection needs operator.admin to update skills." + "设置代码" + "确认 Gateway 警告并安装" + "刷新聊天" + "间隔" + "Skill Workshop 提案操作需要 operator.admin 作用域。" + "会话" + "重命名…" + "连接 Gateway 以加载 dreaming。" + "设置" + "打开对话" + "poll" + "连接以加载您的智能体" + "role remove" + " · 通话:正在聆听" + "ClawHub 未返回适用于 %1$s 的可安装版本。" + "命令" + "此批准在处理完成前已取消。" + "麦克风开启 · 正在等待 Gateway" + "文本" + "显示 %1$s / %2$s。优化搜索以查看更多。" + "v%1$s 可用" + "%1$s://%2$s" + "%1$s...(正常)" + "提供商和已配置的模型" + "正在连接…" + "连接到 Gateway 以保存唤醒词" + "打开个人资料" + "启动你的 Gateway。" + "帮我将此目标转化为实用的检查清单:" + "清除会话搜索" + "端口" + "输入设置代码" + "无法加载 Gateway 日志。" + "%1$s 个提供方已就绪" + "你的智能体已准备就绪" + "Gateway 上未配置 %1$s 提供商" + "正在聆听单轮指令" + "观察" + "Epoch 毫秒(可选)" + "没有已配置的模型。刷新以重新检查可用性。" + "设置" + "后置摄像头" + "approve" + "开始之前" + "无法加载 Skills。" + "已禁用" + "仍在等待批准" + "无法加载后台任务" + "检查 OpenClaw 是否能在此手机上清晰说话。" + "工作中 · %1$s 个活跃运行" + "命令工作目录" + "分组名称" + "从图库选择" + "Version %1$s" + "返回" + "Connect the gateway to update skills." + "运行后删除" + "设置代码指向不安全的远程 Gateway。%1$s %2$s" + "Computer" + "Gateway 已断开连接。" + "Session Settings" + "连接 Gateway 以开始" + "安全提示" + "其他答案" + "关闭共享图片警告" + "Gateway 评估了不同的 ClawHub 版本。请重新检查该 Skill 后再安装。" + "打开系统访问权限" + "已完成" + "图片不可用" + "通知" + "应用、拒绝和隔离操作需要 operator.admin 权限范围。请使用共享 gateway 认证重新连接,或批准 operator.admin 设备权限范围升级以启用生命周期操作。" + "sticker upload" + "捕龙虾" + "Messages to recover" + "openclaw devices approve %1$s" + "可读的 Gateway 日志详情。" + "在生成的 Skills 提案成为正式 Skills 之前进行审查。" + "已捆绑" + "%1$s 个可用" + "节点批准待处理" + "Gateway 待处理" + "需要身份验证" + "节点" + "保持唤醒" + "OpenClaw 正在回复" + "文档" + "%1$s 个已就绪" + "暂无输出" + "不支持设备语言" + "已加入队列 — 重新连接后发送" + "%1$s 分钟前" + "当前分支" + "正在检查配对访问权限" + "Gateway 访问受限" + "正在运行工具..." + "正在检查批准状态…" + "使用此手机拍摄照片和视频片段" + "已连接并就绪" + "关闭" + "将目标转化为可执行的检查清单。" + "设置代码包含无效的 Gateway URL。" + "仅启用你愿意让 OpenClaw 在此手机连接期间使用的访问权限。你可以稍后在 Android 设置中更改这些权限。" + "账户" + "remove" + "密码可选" + "Gateway 认证需要检查。请检查 Gateway 设置,然后重试。" + "二维码使用了 IPv6 区域 ID。请使用无作用域的 IPv6 地址或 LAN 主机名。" + "add" + "捕磷虾中" + "健康" + "已完成,用时 %1$s" + "参数" + "安装选项" + "%1$s 小时后" + "Gateway 批准尚待处理。请在 Gateway 主机上运行:" + "需要管理员访问权限" + "set groups" + "固定模型" + "清除搜索" + "已对符合条件的代理启用。" + "暂无当前对话线程" + "bounds: %1$s" + "在 %1$s 后" + "允许调度程序运行此自动化。" + "%1$s 个已应用" + "还没有梦境日记。" + "刷新后台任务" + "总结最近的会话和后续步骤。" + "当 OpenClaw 可见时在设备上运行。" + "%1$s 正在工作" + "%1$s %2$s" + "原始" + "运行" + "立即运行" + "未命名分支" + "已配置" + "camera list" + "1 个已应用" + "camera clip" + "是" + "音频测试" + "已保留" + "events" + "工作目录" + "跳转到最新" + "始终允许" + "扫描二维码或设置代码" + "Installing" + "在线节点、已配对手机和待处理的设备请求。" + "快照:%1$s" + "先前的响应已批准此命令并保存了选择。" + "待处理请求" + "已批准" + "工作区" + "语音" + "可以开始对话" + "Subagents" + "失败:未检测到安全的 gateway 端点。请启用 gateway TLS 或 Tailscale Serve,或使用可信的私有 LAN 地址并选择“未加密”。" + "信号" + "会话目标" + "Gateway 已记录一次拒绝。" + "接受" + "向 OpenClaw 提问任何问题" + "重新连接以继续" + "%1$s 个已配对" + "这将应用“%1$s”,并从 Gateway 刷新 Skill Workshop 状态。" + "Gateway 离线" + "openclaw devices list" + "OpenClaw 节点连接状态" + "提醒会保留在此手机上。" + "OpenClaw 可以接收选定的提醒。" + "打开屏幕" + "聊天操作" + "允许控制其他应用?" + "正在检查" + "扫描或粘贴设置代码以添加另一个 Gateway。" + "Swarm" + "TLS 超时" + "最近的会话" + "已移除配对设备。" + "Gateway 已配对。正在检查节点功能批准状态。" + "运动" + "cron 操作失败。" + "在 Gateway 电脑上运行:" + "搜索会话" + "刷新日志" + "图片不可用 · 点按重试" + "openclaw nodes approve %1$s" + "语音消息 · %1$s" + "用量" + "鹦鹉螺化中" + "上下文 %1$s%%" + "转录语音提示" + "静音" + "开始新的对话后,它会显示在这里。" + "连接问题" + "中" + "创建分支" + "启用扬声器" + "系统事件文本" + "排序:%1$s" + "%1$s 个正在等待" + "Image Generation" + "语音留言" + "没有需要你注意的事项" + "OpenClaw 需要 %1$s 权限才能继续。" + "有线耳机麦克风" + "页面" + "已送达" + "到期" + "当前 Skills 状态中没有可用的 Skill 详情。" + "选择这部手机可以共享的内容。" + "此自动化已有排队中的运行任务。" + "连接 Gateway 以管理自动化。" + "自动化尚未到执行时间。" + "无详细信息" + "正在进行批准。\nOpenClaw 将自动重新连接。" + "连接您的 Gateway 以查看提供商就绪状态。" + "正在等待配对" + "开始或继续对话" + "没有计划任务" + "回复 OpenClaw…" + "状态" + "OpenClaw 节点 · 已连接" + "活跃" + "显示屏幕共享调试状态。" + "未报告限制" + "关闭扫描器" + "每 %1$s 天" + "已启用" + "启用并打开设置" + "在线且已就绪" + "Ask User" + "聊天错误" + "向前滚动" + "%1$s / %2$s" + "规划工作" + "console" + "重试" + "开始聊天后,你的活跃 OpenClaw 对话将显示在这里。" + "无法加载自动化。" + "代理工作区中的 Shell" + "%1$s 个活动项" + "选择设备权限" + "上次持续时间" + "默认代理" + "%1$s小时" + "对话正在进行" + "无法从 ClawHub 安装 %1$s。" + "欢迎使用 OpenClaw" + "控制其他应用" + "信号索引" + "输入密钥…" + "%1$s:%2$s" + "告诉 OpenClaw %1$s" + "已发现" + "隐藏侧边栏" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "音频播放不可用" + "应用" + "无法加载用量。" + "在工作进行期间保持节点可用。" + "下次唤醒" + "%1$s/%2$s" + "没有最近的日志条目。" + "手动 Gateway" + "重命名分组" + "Update Goal" + "提供方可用性未知" + "提供商" + "删除群组…" + "负载" + "通话记录" + "Memory Search" + "%1$s 个提供商" + "手机上下文与隐私" + "%1$s/%2$s 个已连接" + "%1$s %2$s" + "Gateway 重启恢复仍在进行中。" + "替换设置代码会先清除此手机保存的设置凭据和设备令牌,然后再重新连接。此手机可能需要再次获得节点功能批准;仅当你确实要使用新的 Gateway 设置代码进行配对时才继续。" + "打开自动化以查看其配置和运行历史记录。具有管理员权限的连接还可以运行、编辑、启用、停用或删除它。" + "上下文 --" + "提案已隔离。" + "自动化已暂停。" + "OpenClaw 移动版" + "A2UI reset" + "Gateway 不可用" + "Read" + "此技能需要设置 1 项。Android 会显示已安装的内容;设置和配置更改需通过桌面端或 CLI 完成。" + "上次运行" + "需要相机访问权限才能扫描设置二维码。" + "无法更新模型。" + "冒泡中" + "thread reply" + "删除…" + "连接 Gateway 以查看自动化任务。" + "最近日志" + "正在加载最近运行…" + "无法预览此文件。它可能是二进制文件或文件过大。" + "检查" + "读取此手机的位置信息" + "Skill 键" + "已安装 %1$s。" + "Gateway" + "actions: %1$s" + "转发模式" + "%1$sk" + "切换会话布局" + "无 TLS 端点" + "OpenClaw Gateway" + "手动设置" + "正在思考…" + "Gateway 访问需要审核" + "1 个已保留" + "%1$s秒" + "应用提案?" + "暂不" + "未批准" + "搜索应用" + "已配置 1 个模型" + "关闭批准通知" + "·" + "离线" + "语音提供商" + "正在进行 Gateway 批准。OpenClaw 将自动重试。" + "最高" + "更改 cron 需要 operator.admin 访问权限。" + "正在思考" + "screen snapshot" + "已观察到的节点:%1$s" + "未找到操作" + "保存并连接" + "list" + "Gateway 已记录批准并保存了选择。" + "请输入有效的手动端点以连接。" + "助手" + "正在发送到聊天..." + "保存个人资料" + "已锁定" + "编辑自动化" + "使用同一网络,或安全的远程 Gateway URL。" + "锚点" + "语言" + "此应用的版本低于 Gateway。请更新此设备上的 OpenClaw,然后重试。" + "全部" + "Gateway 会话正在进行" + "等待审核" + "未安装 Skills。" + "正在检查 Gateway" + "错开 %1$s" + "%1$s 的结果未知。请重新连接、刷新 Skills,然后重试;Gateway 会安全地加入仍在运行的匹配安装任务。" + "忘记" + "没有已配对的 Gateway。" + "%1$s · %2$s" + "<已隐藏的密钥>" + "%1$s 个问题" + "OpenClaw" + "正在聆听 · %1$s 条排队中" + "助手语音已静音" + "节点操作仅在目标应用处于前台时运行(通过远程路径验证)。全局操作和同应用操作可在此处运行。" + "尚未找到 Gateway。如果发现功能受阻,请使用手动设置。" + "打开会话" + "处理中" + "开始说话..." + "手机节点" + "超高" + "在 Gateway 主机上运行:" + "更改 Skill 需要 operator.admin 权限。请使用具备管理员权限的 Gateway 令牌重新连接。" + "连接 Gateway 以查看 ClawHub Skills。" + "应用列表会保留在这部手机上。" + "空闲" + "显示在 Android 无障碍设置中。" + "智能投递" + "拒绝" + "Gateway 在 %2$s 后返回状态“%1$s”。" + "Gateway 令牌未配置" + "Not available to this agent" + "文件" + "权限" + "无法启动相机。请从图库中选择二维码图片,或手动输入设置代码。" + "点按以复制" + "正在等待 %1$s 分钟" + "%1$s." + "连接 Gateway 以安装 ClawHub Skills。" + "搜索语音" + " · 麦克风:正在聆听" + "使用 operator.admin 权限重新连接,以查看和更改 Gateway 设置。" + "加载更多" + "3 秒后观察" + "run" + "正在生成语音…" + "← 返回" + "断开连接" + "在 Gateway 电脑上运行批准命令,然后再次检查。" + "自动化" + "%1$s分钟" + "信任" + "该二维码不是 OpenClaw 设置二维码。请使用 openclaw qr 生成新代码,然后重试。" + "首选麦克风不可用;正在使用自动路由。" + "已拒绝" + "包括 Android 和后台包。" + "您的 Gateway 已准备就绪。" + "已触发" + "Structured Output" + "所需时间比预期更长。\n请检查 Gateway 是否正在运行且可访问。" + "此代理没有后台任务。" + "正在重新连接" + "OpenClaw 正在检查 Gateway 和节点访问权限。" + "Code Execution" + "无提供商使用情况" + "审核" + "需要麦克风权限。" + "%1$s天" + "%1$s 个可用" + "OpenClaw 正在恢复同步" + "事件流已中断;请尝试刷新。" + "无法加载节点和设备。" + "连接 Gateway 以加载 Skills。" + "未知" + "输出" + "对话失败:实时提供商意外关闭。" + "OpenClaw 时效性" + "ban" + "需要 Gateway 令牌" + "已配对设备" + "需要重新批准" + "未计划" + "联系人" + "您的手机在需要之前会保持安静" + "正在聆听 · 正在发送排队语音" + "无法加载任务详情" + "Agent 消息" + "Gateway 需要此设备身份。请重新认证或重置此 Gateway 连接。" + "下次会话" + "连接安全性" + "暂时跳过" + "网站" + "连接 Gateway 以在应用中加载审批请求。" + "%1$s 已复制" + "未选择任何应用。添加应用之前不会转发任何内容。" + "%1$s %2$s" + "需要设置" + "未配对" + "Gateway 已收到此手机的信息" + "没有已配置的模型" + "禁用" + "应用语言" + "正在配对 Gateway" + "保存的身份验证无效" + "%1$s 个权限范围" + "连接 Gateway 以加载最近日志。" + "保存唤醒词" + "管理已安装的 Skills,并从 ClawHub 添加受信任的版本。" + "正在发送…" + "尚未加载代理。" + "搜索 ClawHub" + "聊天正在检查 Gateway 的运行状况。" + "需要配对" + "正在运行" + "失败 — %1$s" + "这部手机与 OpenClaw 之间的连接。" + "summarize" + "小组件图片已保存到“下载”" + "正在启动…" + "%1$s 个 token" + "客户端错误" + "授予访问权限前,请验证发出请求的设备。" + "Bluetooth LE 麦克风" + "%1$s %2$s" + "自动化已启用。" + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "已归档" + "重新加载" + "搜索自动化" + "配对后,已关联的手机和节点主机将显示在这里。" + "%1$s:%2$s" + "此自动化已在 Gateway 上更改。再次保存前,请查看最新版本。" + "停止听写" + "易读" + "向 OpenClaw 发送消息" + "Gateway 密码无效。请重新输入或重置此 Gateway 连接。" + "重新连接" + "ISO 时间,例如 2026-07-09T09:30:00Z" + "%1$s 个工具" + "先前的响应已拒绝此批准。" + "已关联" + "打开 %1$s" + "%1$s/%2$s" + "自动化运行已完成,但状态未知。" + "离线队列已满(%1$s 条消息);请先删除队列中的项目。" + "公共 Gateway 需要使用 wss:// 或 Tailscale Serve。localhost、.local 主机、Android 模拟器和专用局域网 IP 允许使用 ws://。" + "连接 Gateway 以加载 Skill 详情。" + "需要完全访问权限" + "唤醒监听器" + "展开链接预览" + "清除会话搜索" + "NULL(失败)" + "更新" + "管理员" + "需要注意" + "将此设备与您的 Gateway 配对,使其仅在有实际任务时唤醒设备,方便随时查看实时智能体概览,并避免耗电的后台循环。" + "角色" + "回复" + "提供商目录" + "在“设置”中启用权限" + "A2UI push" + "检查访问权限" + "如果 Gateway 可访问,重新连接应该会自动完成,无需干预。" + "Agent 轮次" + "隔离" + "注意" + "正在搜索…" + "如何获取设置代码?" + "无法启用 Skill。" + "pdf" + "移除" + "%1$s%% 在线" + "没有频道" + "实时语音" + "Skill Workshop 拒绝和隔离操作" + "节点和设备" + "本地命令中心" + "emoji upload" + "正在加载预览…" + "高" + "focus" + "describe" + "%1$s 上下文" + "正在聆听回复..." + "voice" + "已连接到 %1$s" + "role add" + "聊天需要注意" + "启用麦克风" + "当配对的 OpenClaw Gateway 请求时,OpenClaw 会收集并发送此手机上可见应用的名称、包 ID 和状态。这可让您的助手使用已安装的应用回答问题和执行操作。" + "Gateway 未连接" + "策略" + "确认消息已发送时超时;请刷新以检查送达状态。" + "支持文件" + "表达式" + "后台任务" + "梦境" + "未阻止任何应用。除非添加阻止项,否则应用可以转发。" + "语音识别器不可用" + "平台" + "Gateway 未返回 %1$s 设置" + "忘记 Gateway?" + "可选描述" + "打开 %1$s" + "主画布" + "梦境" + "%1$s 至 %2$s" + "共享文件" + "实时" + "API" + "OpenClaw 正在工作…" + "与 OpenClaw 交谈或向其听写" + "分享已安装应用的信息?" + "正在加载自动化任务…" + "删除自动化" + "默认助手" + "在 Gateway 上选择受支持的 %1$s 提供商" + "不可用" + "文件夹为空" + "打开设置" + "关闭" + "字体排版" + "停止" + "暂无匹配的会话。" + "Gateway 配对成功。\n请通过操作员 UI 批准此手机的节点功能。" + "此技能已安装,但目前不符合运行条件。请通过桌面端或 CLI 更改配置。" + "识别器正忙" + "家庭 Gateway" + "在 Gateway 上运行批准命令" + "服务已禁用" + "无法加载 Skill Workshop 提案。" + "帮我了解最近的 OpenClaw 会话,并建议后续步骤。" + "暂不" + "openclaw qr" + "start" + "OpenClaw 节点 · 通话" + "读取和更新事件" + "对话失败:实时提供商已关闭:%1$s" + "连接 Gateway 以浏览工作区文件。" + "%1$s 通过 Gateway 中继" + "无法加载 Gateway 对话目录" + "监控中 · 1 个计划任务" + "每 %1$s 小时" + "屏幕界面" + "OpenClaw 翻译 · %1$s" + "命令请求" + "已是最新版本" + "频道" + "取消静音" + "新建群组…" + "正在准备音频…" + "自适应" + "即将" + "还有 %1$s 个 worker" + "Web Search" + "试试聊天、语音、会话、提供商或设置。" + "OpenClaw 已激活" + "navigate" + "请求于 %1$s" + "连接 Gateway 以查看自动化运行历史记录。" + "设备访问权限;仍需在 Gateway 中选择启用" + "已中止" + "输入有效的设置代码或 Gateway 地址。" + "模型" + "OpenClaw 被动" + "Gateway 密码无效" + "无法验证设备配对变更。请刷新后重试。" + "查看详细信息" + "Bash" + "令牌" + "已连接的 OpenClaw 代理可以使用你启用的设备功能。仅在你信任所连接的 Gateway 和代理时继续。" + "藤壶附着中" + "已授予选定或完整照片访问权限。" + "无障碍执行器" + "缺少 %1$s 项" + "收起计划清单" + "需要节点批准" + "连接 Gateway" + "... +%1$s 个更多" + "展开计划清单" + "浏览器" + "screen record" + "运行待处理项" + "启用后,OpenClaw 在激活时可以观察和控制其他应用的屏幕。需要 Android 无障碍访问权限。" + "来源" + "您设备上的个人 AI" + "Attach" + "自动" + "概览" + "请求恢复失败。点按以重试。" + "视频" + "%1$s\n\n" + "未加密" + "日历" + "Gateway 运行状况异常;无法发送" + "📎 %1$s" + "上次状态" + "请等待当前回复完成后再开始新聊天。" + "个人资料" + "当您的 Gateway 报告提供商限制时,将显示在此处。" + "1 个问题" + "“%1$s”中的会话将被保留并移回“未分组”。" + "推荐" + "创建时间" + "%1$s/%2$s 个活跃令牌" + "无操作结果" + "咔嚓" + "%1$s…" + "打开 Skill 详情" + "朗读失败:%1$s" + "开始语音对话" + "无法加载此文件夹。" + "二维码不包含有效的设置代码。" + "检查节点访问权限" + "添加唤醒短语" + "无法连接到 gateway" + "自动化" + "需要连接" + "无法处理批准。请刷新后重试。" + "import" + "这部手机在 OpenClaw 中的显示方式。" + "聚焦会话搜索" + "连接 Gateway" + "读取日历" + "重新连接以及打开此屏幕时,概览将刷新。" + "无法禁用 Skill。" + "仍在连接" + "%1$s 分钟后" + "读取短信" + "连接 Gateway 以加载使用情况。" + "你现在可以通过这部手机帮我做什么?" + "需要批准" + "新聊天" + "连接 Gateway 以更新 Skill Workshop 提案。" + "OpenClaw 请求失败。" + "需要权限" + "查看提供商就绪状态\n和已配置的模型。" + "正在加载" + "失败提醒" + "主题和已翻译的 Android 文本。" + "麦克风关闭 · 正在发送…" + "无" + "查看" + "名称" + "版本" + "定时任务" + "在打开 OpenClaw 之前,请将此手机连接到 Gateway。" + "移除唤醒短语" + "设置代码未被接受。请使用 openclaw qr 生成新代码。" + "14 条消息 · Android" + "转录失败:%1$s" + "始终" + "无法加载梦境。" + "自动化运行已加入队列。" + "Conversation Turn" + "自动化已启动。" + "新建分组" + "服务器错误" + "Video Generation" + "Gateway 批准尚待处理。请在 Gateway 主机上运行 openclaw devices list,批准此手机,然后重试。" + "在 dreaming 周期写入叙事摘要后,条目会显示在这里。" + "%1$s 毫秒" + "记忆存储" + "助手正在工作" + "OpenClaw 可以列出启动器可见的应用。" + "对话失败:%1$s" + "搜索已安装的 Skills" + "检查" + "Process" + "最近的会话" + "终端" + "当前" + "1 个账户" + "已暂停" + "允许使用相机" + "当此手机连接时,Exec 批准请求将显示在此处。" + " · 麦克风:待处理" + "复制" + "详细信息已复制" + "删除" + "让 OpenClaw 使用 Android 功能。" + "member" + "正在检查此 Gateway 是否支持 OpenClaw 设置助手。" + "使用下方的恢复选项重新连接。" + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "无法加载频道。" + "%1$s 天后" + "连续错误次数" + "无法从该图片中读取二维码。请选择更清晰的图片,或手动输入设置代码。" + "Gateway 的版本低于此应用。请更新 Gateway 主机上的 OpenClaw,然后重试。" + "请先连接,再使用聊天、语音和实时状态功能。" + "重新连接 Gateway" + "第三方" + "检查就绪状态" + "受限" + "OpenClaw 标志" + "取消固定模型" + "连接到此 Gateway 的消息界面。" + "正在发送" + "已归档的对话线程将显示在此处。" + "已复制命令" + "无可用预览" + "请在 Gateway 上批准此手机。\n然后重试连接。" + "扫描二维码" + "命令工作目录 · 无法清除" + "对话线程活动" + "可用" + "删除自动化?" + "今天 %1$s 个 · 共 %2$s 个" + "密码" + "隔离提案?" + "此构建未包含任何许可证声明。" + "无法保存小组件图片" + "已等待 %1$s" + "正在朗读…" + "提供商与模型" + "节点" + "%1$s " + "提示词不可用" + "日志" + "连接 Gateway 以检查 Skill Workshop 提案。" + "工具" + "Gateway 开关" + "发送短信" + "OpenClaw 已准备好在你的普通聊天中继续。" + "未找到命令" + "尚无画布更新。点按以重试。" + "终端需要已连接的 Gateway" + "Exec" + "应用过滤器" + "主智能体" + "%1$sk" + "需要 Gateway" + "访问" + "软件包:snapshot=%1$s foreground=%2$s" + "重试连接" + "Cron 调度程序已停止。" + "打开" + "消息已复制" + "无法访问您的 Gateway。\n让我们来解决此问题。" + "刚刚" + "运行后删除" + "已在此手机上选择" + "unpin" + "Session History" + "取消置顶" + "使用此手机" + "无法加载 %1$s 的 ClawHub 详细信息。" + "工具正在运行" + "启用定位时共享精确位置。" + "Mobile UI" + "主题" + "Gateway 仍将此批准显示为待处理。请先查看再重试。" + "完成语音留言" + "听写:%1$s" + "不允许" + "选择其他图片" + "图片预览" + "OpenClaw 仅在你开始对话或听写时才会收听。" + "共享步数和活动数据" + "需要设置" + "更新此 Gateway 以使用 OpenClaw 设置助手。" + "此 Gateway 连接需要 operator.admin 权限才能安装 ClawHub Skills。" + "提案已应用。" + "%1$s 个待处理" + "%1$s 小时前" + "读取通话记录" + "%1$s 条排队中 · 正在等待 Gateway" + "移至群组" + "扫描二维码以配对" + "已拒绝批准。" + "无法检查 Skill Workshop 提案。" + "已固定" + "个人资料与设备" + "关闭思考级别选择器" + "无法将消息加入队列以供稍后发送。" + "隔离" + "计划 · %1$s" + "无法更新思考级别。" + "打开思考级别选择器" + "语音回复超时;正在重试已排队的轮次" + "布局:详细" + "无法解码此图像。" + "Gateway、语音、通知、隐私" + "Agent 工作区文件" + "此设备将失去其受信任的 Gateway 访问权限。" + "在批准命令中使用待处理命令中的 requestId。" + "计划" + "速率限制" + "未送达" + "负载 · %1$s" + "运行中" + "挥螯中" + "结束" + "使用系统信任" + "没有就绪的提供方" + "优先使用已连接的蓝牙麦克风。" + "已阻止 %1$s 个应用转发。" + "消息操作" + "类型" + "取消归档" + "Transcripts" + "唤醒词" + "在 Gateway 上配置 %1$s" + "扫描二维码或使用来自您的 OpenClaw Gateway 的设置代码。" + "设计系统原型" + "筛选中" + " · 通话:已开启" + "暂无使用数据。" + "聊天在运行开始前失败;请重试。" + "发送" + "部分共享图片已被省略或无法添加。" + "写入日历" + "timeout" + "低" + "阻止列表" + "act" + "Dismiss Task" + "聊天失败" + "OpenClaw · 实时" + "已安装" + "等待回复超时;请重试或刷新。" + "查找之前的对话" + "浏览会话" + "正在刷新" + "采珠" + "打开相机并框选 openclaw qr 中的代码。" + "没有设备" + "转发通知" + "我会将此对话与普通的智能体聊天分开保存。" + "Gateway 会话正在恢复在线。智能体快捷方式应该很快会自动恢复正常。" + "请尝试其他搜索词或清除当前查询。" + "允许后台定位?" + "浮出水面" + "引导设置" + "%1$s · %2$s · %3$s" + "取消语音留言" + "向后滚动" + "openclaw gateway" + "Gateway 已配对" + "蜕壳中" + "正在聆听你的下一轮发言。" + "OpenClaw 正在工作" + "日志条目" + "失败:无法访问此主机的安全 gateway 端点。" + "Gateway 离线。请在下方修复连接或复制诊断信息。" + "待机" + "测试测试 1 2 3" + "无法搜索 ClawHub Skills。" + "无提示词" + "前置摄像头" + "打开日志条目" + "网络超时" + "现在" + "重命名群组…" + "更多代理" + "openclaw nodes approve REQUEST_ID" + "置顶" + "thread list" + "打开 %1$s" + "upload" + "Gateway 密码未配置" + "听写设置" + "已加载提供商模型,但无法获取就绪状态。" + "删除会话?" + "OpenClaw 将此手机变成简洁的移动命令界面,用于管理会话、语音、提供商和 Gateway。" + "最新优先" + "下个周期" + diff --git a/app/src/main/res/values-zh-rTW/assistant.xml b/app/src/main/res/values-zh-rTW/assistant.xml new file mode 100644 index 0000000..5ba65c2 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/assistant.xml @@ -0,0 +1,7 @@ + + + "詢問 OpenClaw %1$s" + "告訴 OpenClaw %1$s" + "開啟 OpenClaw 並詢問 %1$s" + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..e7029e1 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + 信任此閘道? + 信任並繼續 + 取消 + 在 worktree 中新增聊天 + 請先驗證憑證指紋,再信任此閘道。\n\n%1$s + 閘道憑證已變更。僅在這是您預期的變更時繼續。\n\n舊 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s + 未知 + 版本 + 提交 + 建置日期 + 版本 %1$s + Git 提交 %1$s + 建置於 %1$s UTC,時間戳記 %2$s + 建置日期 %1$s + 複製完整 Git 提交雜湊值 + 複製完整建置時間戳記 + OpenClaw Git 提交 + OpenClaw 建置時間戳記 + 已複製 Git 提交 + 已複製建置時間戳記 + + "無法準備要傳送的附件。" + "麥克風關閉" + "顯示 OpenClaw 提醒" + "對話串活動" + "完整" + "已允許核准並儲存。" + "顯示最近的通話記錄" + "1 個待處理" + "不支援的附件" + "0 = 精確" + "連線至 Gateway 以搜尋討論串。" + "%1$s 個帳號" + "變更 Cron 需要 operator.admin 權限。設定代碼刻意不授予此權限。請使用 Gateway 的共用權杖或密碼重新連線,以要求管理員存取權。若此裝置仍缺少該權限,請從現有的管理員用戶端核准待處理的範圍升級。" + "Apply Patch" + "夾取中" + "取消揚聲器靜音" + "連續略過次數" + "此資料夾尚無檔案。" + "未連線" + "檢查及管理已安裝技能的狀態。" + "失敗" + "預設 Agent" + "相機" + "從群組中移除" + "正在搜尋" + "已為語音播放暫停" + "Gateway 會在下載前透過 ClawHub 驗證此確切版本。如果該版本需要明確確認風險,Android 會在重試前顯示 Gateway 警告。" + "設定代碼使用 IPv6 區域 ID。請使用未限定範圍的 IPv6 位址或 LAN 主機名稱。" + "附件" + "設定喚醒詞、對話與播放。" + "正在聆聽(PTT)" + "提案已拒絕。" + "顯示側邊欄" + "使用者" + "%1$s · %2$s" + "最低" + "拒絕" + "使用中的代理程式" + "已排程 1 個" + "沒有回覆" + "已選取 %1$s" + "命令 argv JSON 陣列" + "無法讀取該圖片。請選擇清晰的螢幕截圖或 openclaw qr 所產生的 QR 圖片。" + "無法%1$s Skill Workshop 提案。" + "已在其他地方回答" + "Gateway 已記錄核准一次。" + "status" + "OpenClaw 只會在已配對的 Gateway 要求時檢查位置。在下一個 Android 畫面上,選擇 %1$s,以允許應用程式在背景執行時檢查位置。" + "拒絕" + "對比度" + "要取代 Gateway 設定嗎?" + "無法載入自動化。" + "你" + "內建麥克風" + "介面" + "沒有提案" + "主要討論串" + "開啟聊天" + "此 Gateway 工作階段無法使用裝置配對操作。請在 Gateway 主機上執行 openclaw devices list,並在該處管理請求。節點功能核准是獨立的,仍需使用 nodes approve <request id>。" + "動作請求" + "list pins" + "連線至 Gateway 以載入 Skill Workshop 提案。" + "設定代碼未被接受" + "登出" + "尚未設定即時轉錄服務供應商。" + "顯示系統 App" + "請更新 Gateway 以檢視供應商模型設定。" + "正在傳送聽寫內容" + "檢查此提案以載入其 Markdown。" + "開啟 OpenClaw 並詢問 %1$s" + "推理" + "用戶端" + "已套用" + "影片" + "已推廣" + "線上" + "範圍" + "尚未設定即時語音服務供應商。" + "%1$s · %2$s" + "kick" + "Gateway 傳回了無效的自動化。" + "執行個體 ID" + "需要 Gateway 權杖。請再次輸入,或編輯此連線。" + "來源" + "重新整理" + "%1$s 則排隊中的訊息" + "開始聊天" + "使用中對話串內等待中的聊天工具呼叫仍會顯示在這裡。" + "需要審核憑證" + "開啟目前的 Canvas 介面以檢查或與其互動。" + "自動化已更新。" + "沒有最近的工作階段" + "指令碼" + "Gateway 狀態、手機節點就緒情況與近期記錄串流。" + "開啟自動化詳細資料" + "執行階段" + "另外 1 個 worker" + "代理程式 %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "請在 Android「設定」中啟用 %1$s 以繼續。" + "修復" + "reactions" + "完成" + "版本與更新" + "OpenClaw 會在此顯示核准、失敗的工作和頻道問題。" + "USB 麥克風" + "等待新增的共享太多。" + "已略過" + "使用 Gateway 電腦的 LAN 位址或安全的遠端主機名稱。" + "開啟" + "正在搜尋討論串" + "即時對話" + "· %1$s" + "OpenClaw 正在準備回覆。" + "已允許核准一次。" + "供應商設定" + "無" + "指令碼承載內容會原封不動地保留。請使用 CLI 編輯此指令碼。" + "Android 設定指南" + "已封鎖 %1$s 個應用程式進行轉送。" + "已配對裝置" + ":%1$s" + "%1$s 個待核准項目" + "裝置名稱" + "提交" + "位置" + "例如 America/New_York" + "工作階段目標" + "檢閱 ClawHub Skill" + "snapshot" + "要拒絕此裝置的配對要求嗎?" + "偏好的麥克風" + "節點主機" + "層級" + "關閉 App 選擇器" + "貼上共用的 Gateway token 或操作員核發的 token。" + "所有系統運作正常" + "已複製 Gateway 診斷資訊" + "音訊錯誤" + "取代設定" + "快速動作" + "傳送失敗:聊天在執行開始前失敗;請再試一次。" + "麥克風" + "聊天仍在檢查 Gateway 的健康狀態。" + "精確位置" + "僅允許一次" + "另有 %1$s 個" + "thread create" + "已封鎖" + "喚醒詞或詞組" + "Gateway 需要裝置核准" + "外接麥克風" + "%1$s/%2$s 個已就緒" + "已連線(操作員離線)" + "功能未核准" + "這將從 Gateway 永久移除此自動化及其排程。" + "正在載入圖片…" + "連線" + "核准節點存取權" + "新增 Gateway" + "無法使用轉錄:%1$s" + "圖片" + "逐浪中" + "關閉圖片預覽" + "eval" + "上一個指令:%1$s" + "請在執行 OpenClaw 的裝置上開啟終端機。" + "沒有缺少的項目" + "畫布輸出需要有效的 Gateway 連線。" + "%1$s · %2$s" + "隔離" + "© 2026 OpenClaw Foundation — MIT 授權條款。" + "PDF" + "Conversations" + "記憶整合與夢境日記。" + "Create Goal" + "此自動化在您編輯期間已變更。儲存前,請還原為 Gateway 上的最新版本。" + "連線後,Gateway 可透過靜默推播喚醒手機,而不必維持永遠開啟的工作階段。" + "喚醒模式" + "要移除配對裝置嗎?" + "系統事件文字" + "無法複製小工具圖片" + "否" + "選用路徑" + "正在傳送佇列中的語音" + "內建" + "hide" + "runs" + "需要 Gateway 密碼。請再次輸入,或編輯此連線。" + "事件文字" + "即時轉錄" + "無法載入供應商模型設定。" + "允許 %1$s 個應用程式轉送。" + "語音設定" + "附加影片" + "已隱藏其他圖片:%1$s" + "要拒絕配對要求嗎?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "發布者" + "從此處分叉" + " · 對話:正在說話" + "選用覆寫" + "預設" + "指令核准" + "此應用程式與 Gateway 使用不相容的通訊協定版本。請更新兩者的 OpenClaw,然後重試。" + "重新整理螢幕" + "讀取最近的相片和媒體" + "聆聽" + "已連線" + "已完成" + "您的手機已與 %1$s 配對。請繼續以完成節點存取設定。" + "目前的討論串" + "思考 %1$s" + "已靜音" + "OpenClaw 感謝其開源社群夥伴。" + "開啟" + "正在連線至 Gateway" + "gateway 可以變更此路徑,但無法清除現有路徑。" + "TTS" + "儲存中…" + "錨點 %1$s" + "search" + "啟用" + "檢視及管理已排程的 Gateway 工作。" + "先前的回應已允許此命令一次。" + "generate" + "僅限在受信任的私人網路上使用。" + "搜尋設定" + "對話已啟用" + "未設定 Gateway 驗證。請編輯此連線後再試一次。" + "失敗:已連到安全端點,但 TLS 指紋驗證逾時。請檢查 Tailscale Serve 或 gateway TLS,然後重試。" + "步驟 1" + "聽寫" + "開啟 App 選擇器" + "沒有待核准項目" + "edit" + "連線到您的 Gateway" + "輸入來自 openclaw qr 的設定代碼。" + "診斷" + "其他應用程式不受影響。" + "破殼中" + "這會永久刪除此對話串及其文字記錄。" + "裝置已核准。" + "正在自動重試" + "已複製小工具圖片" + "%1$s 個角色" + "缺少 1 個項目" + "已排程 %1$s 個" + "react" + "代理程式" + "連線至 Gateway 以載入自動化。" + "正在重新連線…" + "返回設定" + "send" + "無法測試連線" + "驗證並安裝" + "將此裝置變成安全的 OpenClaw 節點,用於聊天、語音、相機與裝置工具。" + "手動設定" + "開啟聊天以開始或繼續目前的對話串。" + "提示:停止聆聽即可傳送擷取到的發言。" + "略過" + "語音請求失敗" + "這將拒絕「%1$s」,並從 Gateway 重新整理 Skill Workshop 狀態。" + "換殼中" + "update" + "分享" + "相機已啟用" + "Telegram、WhatsApp、電子郵件和其他頻道會在設定後顯示於此。" + "網路錯誤" + "探索潮池" + "立即還原 session=%1$s source=%2$s 的 Canvas。若已有 A2UI 狀態,請立即重播。若無,請在 Canvas 中建立並呈現適合行動裝置的精簡儀表板。" + "啟動失敗:%1$s" + "未要求" + "在 Gateway 上設定 %1$s 供應商" + "kill" + "核准" + "檔案無法使用" + "標示為未讀" + "尋找人員和聯絡資料" + "需要裝置身分" + "OpenClaw 討論串" + "允許相片圖庫存取權限。" + "先前的回應已解決此核准。" + "沒有最近的討論串" + "逾時 %1$s 秒" + "沒有相符項目" + "讀取所選應用程式的通知" + "可用性未知" + "設定對話" + "額外" + "Gateway 已配對。正在等待操作員存取權限。" + "附加圖片" + "選擇哪些內容會傳送到 OpenClaw。" + "功能重新核准待處理" + "檢查醒目標示的項目" + "正在聆聽..." + "掌握最新進度" + "訊息" + "讀取聯絡人" + "離線附件儲存空間已滿;請先刪除佇列中的項目。" + "單次" + "重新命名" + "找不到頻道。" + "查看全部" + "新裝置" + "Session Status" + "開啟圖片預覽" + "工作階段分支已變更;請檢閱並重試此訊息。" + "close" + "這看起來像是設定代碼。請返回並選擇「設定 Gateway」,然後選擇「使用設定代碼」。" + "✦" + "代理程式與自動化" + "套用" + "已略過自動化執行。" + "繼續" + "監控中 · %1$s 個排程工作" + "瀏覽" + "tabs" + "待處理" + "對話:%1$s" + "read" + "選取文字" + "動作活動" + "description: %1$s" + "播放音訊" + "時間" + "未驗證" + "Yield" + "複製核准命令" + "目前螢幕輸出與互動式 App 介面。" + "服務已連線" + "顯示" + "準備好了,等你開始" + "無法載入供應商目錄。" + "正在說話 · 等待回覆" + "未授予" + "儲存變更" + "Gateway 拒絕執行自動化。" + "Session Send" + "在 ClawHub 上尋找" + "一律允許在 OpenClaw 於背景執行時進行要求的位置檢查;Android 會在常駐節點通知中顯示此項目。" + "系統事件" + "連接 Gateway 以檢視提供者" + "下次心跳" + "Gateway 已配對。正在等待節點功能核准。" + "浸鹽水中" + "關閉 Canvas" + "寫入聯絡人" + "沒有符合此搜尋條件的已安裝技能。" + "Talk Provider 設定" + "Music Generation" + "Talk 設定" + "監控中 · 1 個討論串" + "承載文字" + "設定文字" + "核准 %1$s" + "Gateway 未傳回 %1$s 就緒狀態" + "已設定 %1$s 個模型。請重新整理以再次檢查可用性。" + "Conversation Send" + "畫布" + "1 個提供者" + "無法自動讀取 Gateway 憑證。請貼上從 Gateway 主機取得的 SHA-256 指紋。" + "傳送失敗:%1$s" + "橋接" + "傳送錯誤" + "從您的手機使用 OpenClaw" + "外觀" + "Skill 工作坊" + "需要權杖" + "預覽 · %1$s" + "需要麥克風權限" + "請連線至 Gateway 以載入 Skill Workshop 提案。" + "所有系統運作正常" + "無法連線至 Gateway" + "OC" + "已更新" + "已連線(節點離線)" + "首頁" + "正在聆聽聽寫內容" + "沒有已封存的對話串" + "選擇並檢視此 gateway 上可用的助理。" + "通話模式已啟用" + "運作中 · 1 個執行項目進行中" + "同意並啟用" + "需要更新 Gateway" + "拷貝影像" + "Gateway URL" + "main、isolated、current 或 session:<id>" + "媒體無法使用" + "連線到您的 Gateway,以在代理程式工作區中開啟 shell。" + "%1$s://%2$s:%3$s" + "無法載入核准詳細資料。請重新整理後再試一次。" + "我可以檢查 Gateway 狀態、修復設定、變更模型或連接頻道。" + "Tool Call" + "討論串" + "Write" + "請先輸入提示詞,或使用語音。" + "D" + "開啟設定" + "觀測中…" + "結束對話" + "上次錯誤" + "檢視需要您注意的動作。" + "已對所有代理程式停用。" + "啟動語音" + "返回背景任務" + "另一個 cron 操作仍在完成中。" + "冷卻時間 %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "簡訊" + "無法使用裝置端語音辨識。" + "指令碼 · 唯讀" + "附件過大,無法加入單則訊息的佇列;請移除部分附件後再試一次。" + "已設定 1 個模型。請重新整理以再次檢查可用性。" + "小工具無法使用" + "此主機需要安全連線。" + "最近使用" + "沒有相符的自動化。" + "手機可以連線到 Gateway" + "Gateway" + "已過期" + "來自您 gateway 的已排程 OpenClaw 工作。" + "Sub-agent" + "正在等待裝置核准" + "正在載入討論串" + "此 Gateway 現在提供的憑證已受此裝置信任。" + "錯開毫秒數" + "event create" + "文件" + "設定 Gateway" + "播放影片" + "已儲存的驗證無效。請重新驗證,或重設此 gateway 連線。" + "使用期間" + "screenshot" + "倒回此處" + "Cron 運算式,例如 0 9 * * *" + "返回語音" + "說話" + "詳細資料" + "%1$s/%2$s 個在線上" + "允許轉送的應用程式:%1$s 個。" + "聊天" + "需要麥克風存取權限。" + "橫行中" + "編輯" + "勿擾時段" + "複製診斷資訊" + "已排程" + "建立" + "%1$s 後到期" + "關閉" + "拒絕提案?" + "語音錯誤(%1$s)" + "問題" + "搜尋登錄中繼資料。Gateway 會在下載前再次驗證信任狀態。" + "本機設定請使用私人區域網路 IP;若要遠端存取,請啟用 Tailscale Serve,或公開一個 wss:// Gateway URL。" + "正在提交…" + "帳戶 %1$s" + "Suggest Task" + "搜尋" + "正在聆聽" + "未載入自動化。" + "有可用的 Gateway 更新。準備好後,請從 Web UI 或 CLI 執行更新。" + "即將" + "沒有 Gateway 核准。" + "主機" + "每個欄位新增一個喚醒詞或詞組,然後在說出指令前先說出其中一個。" + "轉錄後傳送" + "執行於" + "暫停音訊" + "存取 Gateway 裝置" + "沒有預覽" + "裝置" + "Android 版 OpenClaw。" + "功能核准待處理" + "在執行、啟用、停用、刪除或重新整理此自動化之前,請先儲存或還原您的編輯。" + "目前尚無自動化。" + "此技能需要設定 %1$s 個項目。Android 會顯示已安裝的內容;設定變更須在桌面版或 CLI 上進行。" + "%1$s 個最近" + "頻道" + "Unphased" + "已在此手機上啟用" + "正在檢查節點存取權限" + "時區" + "Skill Workshop 檢查與套用動作" + "永遠允許" + "present" + "安裝在 Gateway 上的 Skills 將會顯示在這裡。" + "代碼可能已過期,或是為另一個 Gateway 產生。" + "需要權限" + "自動化的設定無效。" + "允許清單" + "設定、狀態與修復" + "groups" + "公開金鑰" + "關於" + "在該圖片中找不到設定用 QR code。請選擇 openclaw qr 所產生的 QR,或手動輸入設定代碼。" + "permissions" + "連線 Gateway 以載入節點和已配對裝置。" + "切換分支" + "沒有 Skills" + "回覆會大聲播放" + "標示為已讀" + "節點待核准" + "wake" + "%1$s 個提案" + "Gateway 驗證需要處理。" + "連線詳細資料" + "毫秒" + "語音辨識" + "描述" + "最近的對話" + "您的手機會將此資訊傳送至您的 Gateway,而非傳送至 OpenClaw 執行的伺服器。您的 Gateway 可能會將其包含在傳送給您所選 AI 供應商的請求中。" + "傳送" + "將喇叭靜音" + "%1$s 執行中 · %2$s 已完成 · %3$s 失敗" + "正在開啟 Gateway 連線" + "監控中 · %1$s 個討論串" + "自動化執行完畢。" + "沒有相符的 App。" + "傳送至聊天" + "自動化已刪除。" + "啟用" + "最近的執行" + "將 QR 碼對齊在方框內。" + "無法載入核准。" + "我已核准" + "連接您的 Gateway 以載入提供者就緒狀態。" + "尚未配對" + "此核准在解決前已過期。" + "將在 %1$s 秒後觀測 — 請切換到目標應用程式" + "代理程式提示" + "emoji list" + "重複" + "搜尋 OpenClaw" + "%1$s 個待處理" + "裝置端語音辨識無法使用" + "沒有應用程式可分享此訊息" + "關閉搜尋" + "要監看的命令" + "健康狀態" + "通知監聽器" + "揚聲器已靜音" + "搜尋對話串" + "確定" + "無法開啟設定指南。" + "詢問 OpenClaw %1$s" + "Wait for Agents" + "位址" + "在 Gateway 上建立的排程工作將顯示於此。" + "正在顯示最新的記錄區塊。" + "使用設定代碼" + "sticker" + "使用安全的 wss:// 或 Tailscale Serve Gateway,在 Control UI 中或透過 openclaw qr 產生完整存取設定碼,然後在下方掃描或貼上並重新連線,以啟用設定與升級。" + "steer" + "已選取" + "Android 可以掃描或貼上現有的設定代碼,但此 gateway 尚未向 App 開放設定代碼產生功能。請在 gateway 主機上使用 openclaw qr 產生 QR/code,然後在此掃描,或在下方貼上設定代碼。" + "Canvas 狀態" + "修復連線" + "儲存圖片" + "節點 %1$s" + "需要 Gateway 密碼" + "Update Plan" + "移除附件" + "自動化執行失敗。" + "Provider 限制與配額狀態。" + "Gateway 對話目錄尚未載入" + "此 Gateway" + "尚無最近的執行。" + "裝置端語言模型無法使用" + "儀表板需要已連線的 Gateway" + "當代理建立可重複使用的 Skill 草稿後,符合的提案會顯示在這裡。" + "Session Search" + "OpenClaw 正在說話" + "掃描 QR" + "已選取的應用程式" + "還原變更" + "已複製核准指令" + "傳送狀態" + "不接受此 QR 碼" + "您的語音指令中心。" + "測試連線" + "OPENCLAW" + "Web Fetch" + "提示詞" + "要核准裝置嗎?" + "請連線至您的 Gateway,以開啟此工作階段的儀表板。" + "要從此手機移除 %1$s 及其已儲存的認證嗎?" + "QR code 指向不安全的遠端 Gateway。%1$s %2$s" + "螢幕介面已就緒" + "配對 Gateway" + "連接 Gateway 以載入頻道。" + "進行其他語音活動時會暫停。" + "模型" + "照片" + "貼上設定代碼" + "OpenClaw 正在說話" + "正在連線..." + " · 位置:永遠允許" + "訊息:%1$s" + "巡礁中" + "從 Gateway 載入" + "text: %1$s" + "需要" + "rename group" + "就緒" + "日記正在等待第一筆項目。" + "核准" + "即時頁面" + "自動化已在執行中。" + "此單次自動化成功執行後將其移除。" + "已準備好進行聊天和語音互動" + "已連線(操作員:%1$s)" + "Gateway 配對已完成。請將這支手機核准為節點,讓 OpenClaw 可以使用你啟用的裝置功能。" + "回應已中止" + "圖片" + "%1$s 個已保留" + "沒有相符的討論串" + "delete" + "版面配置:精簡" + "channels" + "已授予" + "每 %1$s 分鐘" + "1 個權杖" + "%1$s %2$s" + "已安裝的應用程式" + "待處理" + "正在準備語音留言…" + "永不" + "子系統" + "命令結束時" + "連線" + "無法載入自動化執行記錄。" + "自動化名稱" + "步驟 2" + "診斷" + "部分頻道狀態檢查未完成。" + "pin" + "複製 %1$s" + "已配對" + "無法儲存喚醒詞" + "這將隔離「%1$s」,並從 Gateway 重新整理 Skill Workshop 狀態。" + "錄製語音留言" + "已排入佇列" + "已回答" + "在要求時允許使用相機工具。" + "問題" + "語音喚醒" + "配對要求已拒絕。" + "%1$s 天前" + "roles" + "Skills" + "封存" + "節點離線。請重新連線後再試一次。" + "系統" + "遠端 IP" + "未分組" + "排程詳細資料" + "手機功能" + "無法使用" + "儀表板" + "貼上 token" + "沒有提供者" + "SHA-256 指紋" + "尚無對話串" + "藍牙麥克風" + "最近" + "重新命名討論串" + "解析結果不明。在驗證 Gateway 記錄之前,操作將維持停用。" + "dialog" + "聆聽喚醒詞" + "camera snap" + "正在準備播放…" + "Gateway 選取了未知的供應商 %1$s" + "delete group" + "依循 Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "連線至 Gateway 以載入代理程式。" + "返回" + "分享訊息" + "產生 QR 碼。" + "重新啟動" + "揚聲器已開啟" + "刪除群組?" + "缺少" + "搜尋提案" + "stop" + "安全 (TLS)" + "沒有節點或已配對裝置。" + "剩餘 %1$s%% %2$s" + "設定代碼已過期" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "日記" + "notify" + "此手機會保持休眠,直到 Gateway 需要時才喚醒、同步,然後再次進入休眠。" + "已設定 %1$s 個模型" + "授權" + "連線至 Gateway 以搜尋 ClawHub Skills。" + "Skill" + "Gateway 連線已變更。請重新啟動 OpenClaw 以重新連線。" + "裝置 ID" + "Gateway 未識別作用中的 %1$s 供應商" + "等待中" + "已儲存喚醒詞" + "最舊優先" + "螢幕" + "開始執行時間" + "不支援 IPv6 區域 ID。請使用未限定範圍的 IPv6 位址或 LAN 主機名稱。" + "已傳送 — 正在確認送達…" + "音訊" + "This gateway connection needs operator.admin to update skills." + "設定代碼" + "確認 Gateway 警告並安裝" + "重新整理聊天" + "間隔" + "Skill Workshop 提案操作需要 operator.admin 範圍。" + "工作階段" + "重新命名…" + "連接 Gateway 以載入 dreaming。" + "設定" + "開啟 Talk" + "poll" + "連線以載入您的代理程式" + "role remove" + " · 對話:正在聆聽" + "ClawHub 未傳回 %1$s 的可安裝版本。" + "命令" + "此核准在解決前已被取消。" + "麥克風開啟 · 正在等待 gateway" + "文字" + "正在顯示 %1$s / %2$s。請縮小搜尋範圍以查看更多。" + "v%1$s 可供使用" + "%1$s://%2$s" + "%1$s...(正常)" + "供應商與已設定的模型" + "正在連線…" + "連線至 Gateway 以儲存喚醒詞" + "開啟個人檔案" + "啟動您的 Gateway。" + "協助我將此目標轉換為實用的檢查清單: " + "清除工作階段搜尋" + "連接埠" + "輸入設定代碼" + "無法載入 Gateway 記錄。" + "%1$s 個提供者已就緒" + "您的代理已準備就緒" + "Gateway 上未設定 %1$s 供應商" + "正在聆聽單輪指令" + "觀測" + "Epoch 毫秒(選填)" + "沒有已設定的模型。請重新整理以再次檢查可用性。" + "設定" + "後置相機" + "approve" + "開始之前" + "無法載入 Skills。" + "已停用" + "仍在等待核准" + "無法載入背景任務" + "檢查 OpenClaw 是否能在這支手機上清楚說話。" + "運作中 · %1$s 個執行項目進行中" + "命令工作目錄" + "群組名稱" + "從相簿選擇" + "Version %1$s" + "返回" + "Connect the gateway to update skills." + "執行後刪除" + "設定碼指向不安全的遠端 Gateway。%1$s %2$s" + "Computer" + "Gateway 已中斷連線。" + "Session Settings" + "連接 Gateway 以開始" + "安全性通知" + "其他答案" + "關閉分享圖片警告" + "Gateway 評估了不同的 ClawHub 發行版本。請在安裝前再次檢閱此技能。" + "開啟系統存取權限" + "已完成" + "圖片無法使用" + "通知" + "套用、拒絕與隔離需要 operator.admin 範圍。請以共用 gateway 驗證重新連線,或核准 operator.admin 裝置範圍升級以啟用生命週期動作。" + "sticker upload" + "捕龍蝦" + "Messages to recover" + "openclaw devices approve %1$s" + "可讀取的 Gateway 記錄詳細資料。" + "在生成的 Skill 提案變成正式 Skills 前先進行審查。" + "隨附" + "%1$s 個可用" + "節點核准待處理" + "Gateway 待處理" + "需要驗證" + "節點" + "保持喚醒" + "OpenClaw 正在回覆" + "文件" + "%1$s 個已就緒" + "尚無輸出" + "不支援裝置語言" + "已排入佇列 — 重新連線後傳送" + "%1$s 分鐘前" + "目前分支" + "正在檢查配對存取權限" + "Gateway 存取受限" + "正在執行工具..." + "正在檢查核准狀態…" + "使用此手機拍攝相片和短片" + "已連線並就緒" + "關閉" + "將目標轉換為可執行的檢查清單。" + "設定碼包含無效的 Gateway URL。" + "只啟用你願意讓 OpenClaw 在此手機連線期間使用的存取權。你之後可以在 Android 設定中變更這些權限。" + "帳號" + "remove" + "密碼選填" + "Gateway 驗證需要檢查。請檢查 gateway 設定,然後重試。" + "QR 碼使用 IPv6 區域 ID。請使用未限定範圍的 IPv6 位址或 LAN 主機名稱。" + "add" + "捕磷蝦中" + "狀態良好" + "在 %1$s 內完成" + "引數" + "安裝選項" + "%1$s 小時後" + "Gateway 核准待處理。請在 Gateway 主機上執行:" + "需要管理員存取權" + "set groups" + "釘選模型" + "清除搜尋" + "已對符合資格的代理程式啟用。" + "目前沒有對話串" + "bounds: %1$s" + "%1$s 後" + "允許排程器執行此自動化。" + "%1$s 個已套用" + "尚無夢境日記。" + "重新整理背景任務" + "摘要最近的討論串和後續步驟。" + "當 OpenClaw 顯示於畫面時,會在裝置上執行。" + "%1$s 正在運作" + "%1$s %2$s" + "原始" + "執行" + "立即執行" + "未命名分支" + "已設定" + "camera list" + "1 個已套用" + "camera clip" + "是" + "音訊測試" + "已保留" + "events" + "工作目錄" + "跳至最新內容" + "一律允許" + "掃描 QR 或設定碼" + "Installing" + "即時節點、已配對手機與待處理的裝置請求。" + "快照:%1$s" + "先前的回應已允許此命令並儲存了選擇。" + "待處理請求" + "已核准" + "工作區" + "語音" + "可以開始對話" + "Subagents" + "失敗:未偵測到安全的 gateway 端點。請啟用 gateway TLS 或 Tailscale Serve,或使用受信任的私有 LAN 位址並選取「Unencrypted」。" + "訊號" + "工作階段目標" + "Gateway 已記錄拒絕。" + "接受" + "向 OpenClaw 詢問任何事" + "請重新連線以繼續" + "已配對 %1$s 部裝置" + "這將套用「%1$s」,並從 Gateway 重新整理 Skill Workshop 狀態。" + "Gateway 離線" + "openclaw devices list" + "OpenClaw 節點連線狀態" + "提醒會保留在這支手機上。" + "OpenClaw 可以接收所選提醒。" + "開啟畫面" + "聊天動作" + "允許控制其他應用程式嗎?" + "檢查中" + "掃描或貼上設定代碼以新增另一個 gateway。" + "Swarm" + "TLS 逾時" + "最近的工作階段" + "已移除配對裝置。" + "Gateway 已配對。正在檢查節點功能核准狀態。" + "動態" + "cron 操作失敗。" + "在 Gateway 電腦上執行:" + "搜尋工作階段" + "重新整理記錄" + "圖片無法使用 · 點按以重試" + "openclaw nodes approve %1$s" + "語音訊息 · %1$s" + "使用量" + "鸚鵡螺化中" + "上下文 %1$s%%" + "轉錄語音提示" + "靜音" + "開始新的對話後,就會顯示在這裡。" + "連線問題" + "中" + "分支" + "啟用喇叭" + "系統事件文字" + "排序:%1$s" + "%1$s 個等待中" + "Image Generation" + "語音留言" + "沒有需要您注意的事項" + "OpenClaw 需要 %1$s 權限才能繼續。" + "有線耳機麥克風" + "頁面" + "已傳送" + "到期" + "目前 Skills 狀態中沒有可用的 Skill 詳細資料。" + "選擇這部手機可以分享的內容。" + "此自動化已有排入佇列的執行。" + "連線至 Gateway 以管理自動化。" + "自動化尚未到執行時間。" + "沒有詳細資料" + "正在進行核准。\nOpenClaw 將自動重新連線。" + "連線您的 Gateway 以檢視提供者就緒狀態。" + "正在等待配對" + "開始或繼續對話" + "沒有排程工作" + "回覆 OpenClaw…" + "狀態" + "OpenClaw 節點 · 已連線" + "啟用中" + "顯示螢幕分享偵錯狀態。" + "未回報限制" + "關閉掃描器" + "每 %1$s 天" + "已啟用" + "啟用並開啟設定" + "已上線並準備就緒" + "Ask User" + "聊天錯誤" + "向前捲動" + "%1$s / %2$s" + "規劃工作" + "console" + "重試" + "開始聊天後,您使用中的 OpenClaw 對話會顯示在這裡。" + "無法載入自動化。" + "代理程式工作區中的 Shell" + "%1$s 個使用中" + "選擇裝置權限" + "上次持續時間" + "預設代理程式" + "%1$s 小時" + "對話進行中" + "無法從 ClawHub 安裝 %1$s。" + "歡迎使用 OpenClaw" + "控制其他應用程式" + "Signal 索引" + "輸入密鑰…" + "%1$s:%2$s" + "告訴 OpenClaw %1$s" + "已探索" + "隱藏側邊欄" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "音訊播放無法使用" + "套用" + "無法載入使用量。" + "在進行工作期間保持節點可用。" + "下次喚醒" + "%1$s/%2$s" + "沒有最近的記錄項目。" + "手動 Gateway" + "重新命名群組" + "Update Goal" + "供應商可用性未知" + "供應商" + "刪除群組…" + "承載資料" + "通話記錄" + "Memory Search" + "%1$s 個提供者" + "手機情境與隱私權" + "%1$s/%2$s 個已連線" + "%1$s %2$s" + "Gateway 重新啟動復原仍在進行中。" + "更換設定代碼會先清除此手機上已儲存的設定憑證和裝置權杖,再重新連線。此手機可能需要重新核准節點功能;只有在您確定要使用新的 Gateway 設定代碼配對時才繼續。" + "開啟自動化以查看其設定和執行記錄。具備管理員範圍的連線也可以執行、編輯、啟用、停用或刪除自動化。" + "上下文 --" + "提案已隔離。" + "自動化已暫停。" + "OpenClaw 行動版" + "A2UI reset" + "Gateway 無法使用" + "Read" + "此技能需要設定 1 個項目。Android 會顯示已安裝的內容;設定變更須在桌面版或 CLI 上進行。" + "上次執行" + "需要相機存取權限才能掃描設定 QR 碼。" + "無法更新模型。" + "冒泡中" + "thread reply" + "刪除…" + "請連線至 Gateway 以檢視自動化。" + "最近記錄" + "正在載入最近的執行…" + "無法預覽此檔案。它可能是二進位檔案或檔案過大。" + "檢查" + "讀取此手機的位置" + "Skill 金鑰" + "已安裝 %1$s。" + "Gateway" + "actions: %1$s" + "轉發模式" + "%1$sk" + "切換討論串版面配置" + "沒有 TLS 端點" + "OpenClaw Gateway" + "手動設定" + "正在思考…" + "Gateway 存取需要審查" + "1 個已保留" + "%1$s秒" + "套用提案?" + "現在不要" + "未核准" + "搜尋 App" + "已設定 1 個模型" + "關閉核准通知" + "·" + "離線" + "語音提供者" + "Gateway 核准正在進行中。OpenClaw 將自動重試。" + "最高" + "變更 cron 需要 operator.admin 存取權限。" + "思考中" + "screen snapshot" + "已觀測節點:%1$s" + "找不到動作" + "儲存並連線" + "list" + "Gateway 已記錄核准並儲存了選擇。" + "請輸入有效的手動端點以連線。" + "助理" + "正在傳送至聊天..." + "儲存個人資料" + "已鎖定" + "編輯自動化" + "使用相同網路,或安全的遠端 Gateway URL。" + "錨點" + "語言" + "此應用程式版本比 Gateway 舊。請更新此裝置上的 OpenClaw,然後重試。" + "全部" + "Gateway 工作階段進行中" + "等待審查" + "未安裝 Skills。" + "正在檢查 Gateway" + "錯開 %1$s" + "%1$s 的結果不明。請重新連線、重新整理 Skills,然後重試;Gateway 會安全地加入仍在執行且相符的安裝作業。" + "忘記" + "沒有已配對的 gateway。" + "%1$s · %2$s" + "<已遮蔽的密鑰>" + "%1$s 個問題" + "OpenClaw" + "正在聆聽 · %1$s 則已排入佇列" + "助理語音已靜音" + "節點動作僅在目標應用程式位於前景時執行(透過遠端路徑驗證)。全域動作與同一應用程式內的動作可在此執行。" + "尚未找到任何 Gateway。若探索功能遭封鎖,請使用手動設定。" + "開啟討論串" + "處理中" + "開始說話..." + "手機節點" + "極高" + "在 Gateway 主機上執行:" + "變更技能需要 operator.admin。請使用具備管理員權限的 gateway token 重新連線。" + "連線至 Gateway 以檢視 ClawHub Skills。" + "App 清單會保留在這支手機上。" + "閒置" + "顯示於 Android 無障礙設定中。" + "智慧傳送" + "拒絕" + "Gateway 在 %2$s 後傳回狀態「%1$s」。" + "尚未設定 Gateway 權杖" + "Not available to this agent" + "檔案" + "權限" + "無法啟動相機。請從相簿選擇 QR 圖片,或手動輸入設定代碼。" + "點一下以複製" + "等待 %1$s 分鐘" + "%1$s." + "連線至 Gateway 以安裝 ClawHub Skills。" + "搜尋語音" + " · 麥克風:正在聆聽" + "以 operator.admin 存取權限重新連線,以檢視並變更 Gateway 設定。" + "載入更多" + "3 秒後觀測" + "run" + "正在產生語音…" + "← 返回" + "中斷連線" + "在 Gateway 電腦上執行 approve 命令,然後再次檢查。" + "自動化" + "%1$s 分鐘" + "信任" + "該 QR code 不是 OpenClaw 設定用 QR。請使用 openclaw qr 產生新的代碼,然後再試一次。" + "偏好的麥克風無法使用;正在使用自動路由。" + "已拒絕" + "包含 Android 和背景套件。" + "您的 Gateway 已準備就緒。" + "已觸發" + "Structured Output" + "所需時間比預期更長。\n請確認 Gateway 正在執行且可連線。" + "此代理程式沒有背景任務。" + "正在重新連線" + "OpenClaw 正在檢查 Gateway 和節點存取權限。" + "Code Execution" + "無提供者使用量" + "審查" + "需要麥克風權限。" + "%1$s 天" + "有 %1$s 個可用" + "OpenClaw 正在重新同步" + "事件串流已中斷;請嘗試重新整理。" + "無法載入節點和裝置。" + "連接 Gateway 以載入 Skills。" + "未知" + "輸出" + "對話失敗:即時供應商意外關閉。" + "OpenClaw 時效性通知" + "ban" + "需要 Gateway 權杖" + "已配對的裝置" + "需要重新核准" + "未排程" + "聯絡人" + "您的手機會保持安靜,直到需要時才啟動" + "正在聆聽 · 正在傳送佇列中的語音" + "無法載入任務詳細資料" + "代理訊息" + "Gateway 需要此裝置身分。請重新驗證,或重設此 gateway 連線。" + "下次工作階段" + "連線安全性" + "暫時略過" + "網站" + "連線至 Gateway,以在應用程式中載入核准要求。" + "%1$s 已複製" + "尚未選取任何應用程式。新增應用程式前不會轉傳任何內容。" + "%1$s %2$s" + "需要設定" + "未配對" + "Gateway 已收到這支手機" + "沒有已設定的模型" + "停用" + "應用程式語言" + "正在配對 Gateway" + "已儲存的驗證無效" + "%1$s 個範圍" + "連線 Gateway 以載入最近記錄。" + "儲存喚醒詞" + "管理已安裝的技能,並從 ClawHub 新增受信任的發行版本。" + "傳送中…" + "尚未載入任何代理程式。" + "搜尋 ClawHub" + "聊天功能正在檢查 Gateway 的運作狀態。" + "需要配對" + "執行中的作業" + "失敗 — %1$s" + "這部手機與 OpenClaw 之間的連線。" + "summarize" + "小工具圖片已儲存至「下載」" + "正在啟動…" + "%1$s 個權杖" + "用戶端錯誤" + "授予存取權前,請先驗證提出要求的裝置。" + "Bluetooth LE 麥克風" + "%1$s %2$s" + "自動化已啟用。" + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "已封存" + "重新載入" + "搜尋自動化" + "配對後,已連結的手機和節點主機會顯示在這裡。" + "%1$s:%2$s" + "此自動化已在 Gateway 上變更。再次儲存前,請先檢閱最新版本。" + "停止聽寫" + "易於閱讀" + "傳訊息給 OpenClaw" + "Gateway 密碼無效。請重新輸入,或重設此 gateway 連線。" + "重新連線" + "ISO 時間,例如 2026-07-09T09:30:00Z" + "%1$s 個工具" + "先前的回應已拒絕此核准。" + "已連結" + "開啟 %1$s" + "%1$s/%2$s" + "自動化執行完畢,但狀態不明。" + "離線佇列已滿(%1$s 則訊息);請先刪除佇列中的項目。" + "公用 Gateway 必須使用 wss:// 或 Tailscale Serve。localhost、.local 主機、Android 模擬器和私人區域網路 IP 可使用 ws://。" + "連接 Gateway 以載入 Skill 詳細資料。" + "需要完整存取權限" + "喚醒詞偵聽器" + "展開連結預覽" + "清除討論串搜尋" + "NULL(失敗)" + "更新" + "管理" + "需要注意" + "將此裝置與您的 Gateway 配對,僅在有實際工作時喚醒裝置、隨時掌握代理程式即時概況,並避免耗電的背景循環。" + "角色" + "回覆" + "供應商目錄" + "在「設定」中啟用權限" + "A2UI push" + "檢查存取權限" + "如果可以連線至 Gateway,應會自動完成重新連線,無需介入。" + "代理執行回合" + "隔離" + "需要注意" + "搜尋中…" + "如何取得設定代碼?" + "無法啟用技能。" + "pdf" + "移除" + "%1$s%% 在線" + "沒有頻道" + "即時語音" + "Skill Workshop 拒絕與隔離動作" + "節點與裝置" + "本機指令中心" + "emoji upload" + "正在載入預覽…" + "高" + "focus" + "describe" + "%1$s 情境" + "正在聆聽回應..." + "voice" + "已連線至 %1$s" + "role add" + "聊天需要注意" + "啟用麥克風" + "當已配對的 OpenClaw Gateway 提出要求時,OpenClaw 會收集並傳送此手機上可見應用程式的名稱、套件 ID 及狀態。這讓您的助理能使用已安裝的應用程式回答問題並執行操作。" + "Gateway 未連線" + "政策" + "確認訊息已傳送時逾時;請重新整理以檢查傳送狀態。" + "支援檔案" + "表達式" + "背景任務" + "夢境" + "未封鎖任何應用程式。除非您新增封鎖規則,否則應用程式可以轉送。" + "語音辨識器無法使用" + "平台" + "Gateway 未傳回 %1$s 設定" + "忘記 Gateway?" + "選填描述" + "開啟 %1$s" + "首頁畫布" + "夢境" + "%1$s 至 %2$s" + "分享檔案" + "即時" + "API" + "OpenClaw 正在處理…" + "與 OpenClaw 對話或使用語音輸入" + "分享已安裝的應用程式資訊?" + "正在載入自動化…" + "刪除自動化" + "預設助理" + "在 Gateway 上選擇支援的 %1$s 供應商" + "無法使用" + "空資料夾" + "開啟設定" + "關閉" + "字體排印" + "停止" + "目前沒有相符的討論串。" + "Gateway 配對成功。\n請從操作員 UI 核准此手機的節點功能。" + "此技能已安裝,但目前不符合執行條件。請使用桌面版或 CLI 變更設定。" + "辨識器忙碌中" + "住家 Gateway" + "在 Gateway 上執行核准命令" + "服務已停用" + "無法載入 Skill Workshop 提案。" + "讓我掌握最近的 OpenClaw 討論串,並建議後續步驟。" + "暫時不要" + "openclaw qr" + "start" + "OpenClaw 節點 · 對話" + "讀取及更新活動" + "對話失敗:即時供應商已關閉:%1$s" + "連線至 Gateway 以瀏覽工作區檔案。" + "%1$s 透過 Gateway 中繼" + "無法載入 Gateway 對話目錄" + "監控中 · 1 個排程工作" + "每 %1$s 小時" + "螢幕介面" + "OpenClaw 翻譯 · %1$s" + "命令請求" + "已是最新版本" + "頻道" + "取消靜音" + "新增群組…" + "正在準備音訊…" + "自適應" + "即將" + "另外 %1$s 個 worker" + "Web Search" + "試試聊天、語音、討論串、提供者或設定。" + "OpenClaw 主動" + "navigate" + "於 %1$s 要求" + "連線至 Gateway 以查看自動化執行記錄。" + "裝置存取權;仍需在 Gateway 選擇啟用" + "已中止" + "輸入有效的設定代碼或 gateway 位址。" + "模型" + "OpenClaw 被動" + "Gateway 密碼無效" + "無法驗證裝置配對變更。請重新整理後再試一次。" + "檢視詳細資料" + "Bash" + "權杖" + "已連線的 OpenClaw 代理程式可以使用您啟用的裝置功能。請僅在您信任所連線的 Gateway 和代理程式時繼續。" + "採藤壺" + "已授予所選或完整相片存取權限。" + "無障礙執行器" + "缺少 %1$s 個項目" + "收合計畫檢查清單" + "需要節點核准" + "連接 Gateway" + "... 另有 %1$s 個" + "展開計畫檢查清單" + "瀏覽器" + "screen record" + "執行待處理項目" + "啟用後,OpenClaw 在啟動時可觀察並控制其他應用程式的畫面。需要 Android 無障礙存取權限。" + "來源" + "您裝置上的個人 AI" + "Attach" + "自動" + "概覽" + "無法要求還原。請輕觸以重試。" + "影片" + "%1$s\n\n" + "未加密" + "行事曆" + "Gateway 健康狀態異常;無法傳送" + "📎 %1$s" + "上次狀態" + "請等待目前的回應完成,再開始新的聊天。" + "個人資料" + "當您的 Gateway 回報提供者限制時,會顯示在這裡。" + "1 個問題" + "「%1$s」中的討論串會被保留並移回「未分組」。" + "建議" + "建立時間" + "%1$s/%2$s 個有效權杖" + "無動作結果" + "啪嗒作響中" + "%1$s…" + "開啟 Skill 詳細資料" + "朗讀失敗:%1$s" + "開始對話" + "無法載入此資料夾。" + "QR code 未包含有效的設定碼。" + "檢查節點存取權" + "新增喚醒詞組" + "無法連線至 Gateway" + "自動化" + "需要連線" + "無法處理核准。請重新整理後再試一次。" + "import" + "這部手機在 OpenClaw 中的顯示方式。" + "聚焦討論串搜尋" + "連接 Gateway" + "讀取行事曆" + "重新連線及開啟此畫面時,概覽會重新整理。" + "無法停用技能。" + "仍在連線" + "%1$s 分鐘後" + "讀取簡訊" + "連線至 Gateway 以載入用量。" + "你現在可以透過這支手機幫我做什麼?" + "需要核准" + "新增聊天" + "連線至 Gateway 以更新 Skill Workshop 提案。" + "OpenClaw 請求失敗。" + "需要權限" + "檢閱提供者就緒狀態\n和已設定的模型。" + "載入中" + "失敗警示" + "主題和已翻譯的 Android 文字。" + "麥克風關閉 · 傳送中…" + "無" + "檢視" + "名稱" + "版本" + "Cron" + "在開啟 OpenClaw 之前,請將此手機連接到 Gateway。" + "移除喚醒詞組" + "設定代碼未被接受。請使用 openclaw qr 產生新的代碼。" + "14 則訊息 · Android" + "轉錄失敗:%1$s" + "永遠" + "無法載入夢境。" + "自動化執行已排入佇列。" + "Conversation Turn" + "自動化已啟動。" + "新群組" + "伺服器錯誤" + "Video Generation" + "Gateway 核准待處理。請在 Gateway 主機上執行 openclaw devices list,核准此手機,然後重試。" + "當 dreaming 週期寫入敘事摘要後,項目就會出現。" + "%1$s 毫秒" + "記憶儲存區" + "助理正在處理" + "OpenClaw 可以列出啟動器可見的 App。" + "對話失敗:%1$s" + "搜尋已安裝的 Skills" + "檢查" + "Process" + "最近的討論串" + "終端機" + "目前" + "1 個帳號" + "已暫停" + "允許相機" + "當這支手機連線時,Exec 核准請求會顯示在這裡。" + " · 麥克風:待處理" + "複製" + "詳細資料已複製" + "刪除" + "請 OpenClaw 使用 Android 功能。" + "member" + "正在檢查此 Gateway 是否支援 OpenClaw 設定助理。" + "請使用下方的復原選項重新連線。" + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "無法載入頻道。" + "%1$s 天後" + "連續錯誤次數" + "無法從該圖片讀取 QR code。請選擇更清晰的圖片,或手動輸入設定代碼。" + "Gateway 版本比此應用程式舊。請更新 Gateway 主機上的 OpenClaw,然後重試。" + "請先連線,再使用聊天、語音和即時狀態。" + "重新連線 Gateway" + "第三方" + "檢視就緒狀態" + "受限" + "OpenClaw 標誌" + "取消釘選模型" + "連線至此 Gateway 的訊息介面。" + "傳送中" + "已封存的對話串會顯示在這裡。" + "已複製指令" + "沒有可用的預覽" + "請在 Gateway 上核准此手機。\n然後重試連線。" + "掃描 QR 碼" + "命令工作目錄 · 無法清除" + "對話串活動" + "可用" + "要刪除自動化嗎?" + "今天 %1$s · 總計 %2$s" + "密碼" + "隔離提案?" + "此建置版本未包含任何授權聲明。" + "無法儲存小工具圖片" + "已等待 %1$s" + "正在朗讀…" + "提供者與模型" + "節點" + "%1$s " + "提示詞無法使用" + "記錄" + "請連線至 Gateway 以檢查 Skill Workshop 提案。" + "工具" + "Gateway 開關" + "傳送簡訊" + "OpenClaw 已準備好在您的一般聊天中繼續。" + "找不到指令" + "尚無畫布更新。輕觸以重試。" + "終端機需要已連線的 Gateway" + "Exec" + "App 篩選器" + "主要" + "%1$sk" + "需要 Gateway" + "存取" + "套件:snapshot=%1$s foreground=%2$s" + "重試連線" + "Cron 排程器已停止。" + "開啟" + "訊息已複製" + "無法連線至您的 Gateway。\n讓我們來修正此問題。" + "現在" + "執行後刪除" + "已在此手機上選取" + "unpin" + "Session History" + "取消釘選" + "使用這支手機" + "無法載入 %1$s 的 ClawHub 詳細資料。" + "工具執行中" + "啟用定位時分享精確位置。" + "Mobile UI" + "主題" + "Gateway 仍將此核准顯示為待處理。請先檢查再重試。" + "完成語音留言" + "聽寫:%1$s" + "不允許" + "選擇其他圖片" + "圖片預覽" + "OpenClaw 只會在您開始對話或聽寫時聆聽。" + "分享步數和活動資料" + "需要設定" + "更新此 Gateway 以使用 OpenClaw 設定助理。" + "此 Gateway 連線需要 operator.admin 權限才能安裝 ClawHub Skills。" + "提案已套用。" + "%1$s 個待處理" + "%1$s 小時前" + "讀取通話記錄" + "%1$s 則已排入佇列 · 正在等待 Gateway" + "移至群組" + "掃描 QR Code 以配對" + "已拒絕核准。" + "無法檢查 Skill Workshop 提案。" + "已釘選" + "個人資料與裝置" + "關閉思考層級選擇器" + "無法將訊息加入佇列以供稍後傳送。" + "隔離" + "排程 · %1$s" + "無法更新思考層級。" + "開啟思考層級選擇器" + "語音回覆逾時;正在重試已排入佇列的回合" + "版面配置:詳細" + "無法解碼此圖片。" + "Gateway、語音、通知、隱私權" + "代理程式工作區檔案" + "此裝置將失去受信任的 Gateway 存取權。" + "在 approve 命令中使用待處理命令的 requestId。" + "排程" + "速率限制" + "未傳送" + "承載內容 · %1$s" + "執行中" + "揮螯中" + "結束" + "使用系統信任設定" + "沒有已就緒的提供者" + "優先使用已連線的藍牙麥克風。" + "已封鎖 %1$s 個應用程式進行轉送。" + "訊息動作" + "類型" + "取消封存" + "Transcripts" + "喚醒詞" + "在 Gateway 上設定 %1$s" + "掃描 QR code,或使用 OpenClaw Gateway 提供的設定代碼。" + "設計系統原型" + "篩選中" + " · 對話:開啟" + "尚無用量資料。" + "聊天在執行開始前失敗;請再試一次。" + "傳送" + "部分分享的圖片已省略或無法新增。" + "寫入行事曆" + "timeout" + "低" + "封鎖清單" + "act" + "Dismiss Task" + "聊天失敗" + "OpenClaw · 即時" + "已安裝" + "等待回覆逾時;請重試或重新整理。" + "尋找先前的對話" + "瀏覽討論串" + "正在重新整理" + "採珍珠" + "開啟相機並對準 openclaw qr 的代碼。" + "沒有裝置" + "轉傳通知" + "我會將這段對話與一般代理聊天分開。" + "Gateway 工作階段正在重新上線。代理程式捷徑應會在片刻後自動恢復正常。" + "請嘗試其他搜尋條件,或清除目前的查詢。" + "允許背景位置存取?" + "浮出水面" + "啟動程序" + "%1$s · %2$s · %3$s" + "取消語音留言" + "向後捲動" + "openclaw gateway" + "Gateway 已配對" + "換殼中" + "正在聆聽你的下一段發言。" + "OpenClaw 正在運作" + "記錄項目" + "失敗:無法連到此主機的安全 gateway 端點。" + "Gateway 已離線。請修正下方的連線問題,或複製診斷資訊。" + "待命" + "測試測試 1 2 3" + "無法搜尋 ClawHub Skills。" + "無提示詞" + "前置相機" + "開啟記錄項目" + "網路逾時" + "現在" + "重新命名群組…" + "更多代理程式" + "openclaw nodes approve REQUEST_ID" + "釘選" + "thread list" + "開啟 %1$s" + "upload" + "尚未設定 Gateway 密碼" + "聽寫設定" + "已載入供應商模型,但無法取得就緒狀態。" + "要刪除討論串嗎?" + "OpenClaw 將這支手機化為簡潔的行動指令介面,用於討論串、語音、提供者和 Gateway。" + "最新優先" + "下一個週期" + diff --git a/app/src/main/res/values/assistant.xml b/app/src/main/res/values/assistant.xml new file mode 100644 index 0000000..7be8a39 --- /dev/null +++ b/app/src/main/res/values/assistant.xml @@ -0,0 +1,7 @@ + + + ask OpenClaw $prompt + tell OpenClaw to $prompt + open OpenClaw and ask $prompt + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..de55d03 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #FFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f35b91d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,1652 @@ + + OpenClaw Node + Trust this gateway? + Trust and continue + Cancel + New chat in worktree + Verify the certificate fingerprint before trusting this gateway.\n\n%1$s + The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n%1$s\n\nNew SHA-256:\n%2$s + Unknown + VERSION + COMMIT + BUILT + Version %1$s + Git commit %1$s + Built %1$s UTC, timestamp %2$s + Build date %1$s + Copy full Git commit hash + Copy full build timestamp + OpenClaw Git commit + OpenClaw build timestamp + Git commit copied + Build timestamp copied + + "Could not stage an attachment for sending." + "Mic off" + "Show OpenClaw alerts" + "Thread activity" + "Full" + "Approval allowed and saved." + "Show recent call history" + "1 pending" + "Unsupported attachment" + "0 = exact" + "Connect the Gateway to search threads." + "%1$s accounts" + "Cron changes require operator.admin. Setup codes intentionally do not grant it. Reconnect with the gateway\'s shared token or password to request admin access. If this device still lacks it, approve the pending scope upgrade from an existing admin client." + "Apply Patch" + "Pinching" + "Unmute speaker" + "Consecutive Skips" + "This folder has no files yet." + "Not connected" + "Inspect and manage installed skill state." + "Failed" + "Default Agent" + "Camera" + "Remove from group" + "Searching" + "Paused for voice playback" + "The Gateway will verify this exact release with ClawHub before download. If the release needs explicit risk acknowledgement, Android will show the Gateway warning before retrying." + "Setup code uses an IPv6 zone ID. Use an unscoped IPv6 address or a LAN hostname." + "Attachment" + "Configure wake words, talk, and playback." + "Listening (PTT)" + "Proposal rejected." + "Show Sidebar" + "user" + "%1$s · %2$s" + "Minimal" + "Deny" + "ACTIVE AGENT" + "1 scheduled" + "No reply" + "Selected %1$s" + "Command argv JSON array" + "Could not read that image. Choose a clear screenshot or image of the QR from openclaw qr." + "Could not %1$s Skill Workshop proposal." + "Answered elsewhere" + "Gateway recorded approval once." + "status" + "OpenClaw only checks location when your paired Gateway requests it. On the next Android screen, choose %1$s to allow checks while the app is in the background." + "reject" + "Contrast" + "Replace gateway setup?" + "Could not load automations." + "You" + "Built-in microphone" + "Surface" + "No proposals" + "Main thread" + "Open Chat" + "Device pairing actions are unavailable in this Gateway session. Run openclaw devices list on the Gateway host and manage the request there. Node capability approval is separate and still uses nodes approve <request id>." + "Action Request" + "list pins" + "Connect to a Gateway to load Skill Workshop proposals." + "Setup code was not accepted" + "Sign Out" + "Realtime transcription provider is not configured." + "Show System Apps" + "Update your Gateway to view provider model config." + "Sending dictation" + "Inspect this proposal to load its markdown." + "open OpenClaw and ask %1$s" + "reasoning" + "Client" + "Applied" + "video" + "Promoted" + "Online" + "Scopes" + "Realtime voice provider is not configured." + "%1$s · %2$s" + "kick" + "Gateway returned an invalid automation." + "Instance ID" + "Gateway token is required. Enter it again or edit this connection." + "Source" + "Refresh" + "%1$s queued" + "Start Chat" + "Chat tool calls waiting in the active thread remain visible here." + "Certificate review needed" + "Open the current Canvas surface to inspect or interact with it." + "Automation updated." + "No recent sessions" + "Script" + "Gateway status, phone node readiness, and recent log stream." + "Open automation detail" + "Runtime" + "1 more worker" + "Agent %1$s" + "Get Goal" + "OpenClaw %1$s (%2$s)" + "Please enable %1$s in Android Settings to continue." + "Repair" + "reactions" + "Done" + "Version and update" + "OpenClaw will surface approvals, failed jobs, and channel issues here." + "USB microphone" + "Too many shares are waiting to be added." + "Skipped" + "Use the Gateway computer\'s LAN address or secure remote hostname." + "On" + "Searching threads" + "Realtime Talk" + "· %1$s" + "OpenClaw is preparing a response." + "Approval allowed once." + "Provider setup" + "none" + "Script payloads are preserved unchanged. Use the CLI to edit this script." + "Android setup guide" + "%1$s apps blocked from forwarding." + "Paired Devices" + ":%1$s" + "%1$s pending" + "Device name" + "Submit" + "Location" + "e.g. America/New_York" + "Session target" + "Review ClawHub skill" + "snapshot" + "Reject the pairing request from this device?" + "Preferred microphone" + "Node host" + "Level" + "Close App Picker" + "Paste a shared Gateway token or operator-issued token." + "All systems nominal" + "Copied gateway diagnostics" + "Audio error" + "Replace setup" + "Quick actions" + "Send failed: Chat failed before the run started; try again." + "Microphone" + "Chat is still checking Gateway health." + "Precise Location" + "Allow Once" + "+%1$s more" + "thread create" + "Blocked" + "Wake word or phrase" + "Gateway needs device approval" + "External microphone" + "%1$s/%2$s ready" + "Connected (operator offline)" + "Capability unapproved" + "This permanently removes the automation and its schedule from the gateway." + "Loading image…" + "Connect" + "Approve node access" + "Add Gateway" + "Transcription unavailable: %1$s" + "Image" + "Tiding" + "Close image preview" + "eval" + "Last command: %1$s" + "Have a terminal open on the device running OpenClaw." + "No missing items" + "Canvas output needs an active gateway connection." + "%1$s · %2$s" + "Isolated" + "© 2026 OpenClaw Foundation — MIT License." + "PDF" + "Conversations" + "Memory consolidation and dream diary." + "Create Goal" + "This automation changed while you were editing. Revert to the latest gateway version before saving." + "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session." + "Wake Mode" + "Remove paired device?" + "System event text" + "Could not copy widget image" + "No" + "Optional path" + "Sending queued voice" + "Built-in" + "hide" + "runs" + "Gateway password is required. Enter it again or edit this connection." + "Event text" + "Live transcript" + "Could not load provider model config." + "%1$s app allowed to forward." + "Voice setup" + "Attach video" + "Additional images hidden: %1$s" + "Reject pairing request?" + "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI." + "Publisher" + "Fork from here" + " · Talk: Speaking" + "Optional override" + "Default" + "Command approval" + "The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry." + "Refresh Screen" + "Read recent photos and media" + "Listen" + "Connected" + "Completed" + "Your phone is paired with %1$s. Continue to finish node access." + "Current thread" + "Thinking %1$s" + "Muted" + "OpenClaw appreciates its partners in the open-source community." + "open" + "Connecting Gateway" + "The gateway can change this path but cannot clear an existing path." + "TTS" + "Saving…" + "Anchor %1$s" + "search" + "Activate" + "Inspect and manage scheduled gateway work." + "A prior response already allowed this command once." + "generate" + "Use only on a trusted private network." + "Search settings" + "Talk is live" + "Gateway authentication is not configured. Edit this connection and try again." + "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry." + "Step 1" + "Dictation" + "Open App Picker" + "No pending approvals" + "edit" + "Connect to your Gateway" + "Enter the setup code from openclaw qr." + "Diagnostics" + "Other apps stay untouched." + "Cracking" + "This permanently deletes the thread and its transcript." + "Device approved." + "Retrying automatically" + "Widget image copied" + "%1$s roles" + "1 missing item" + "%1$s scheduled" + "react" + "Agents" + "Connect the gateway to load automations." + "Reconnecting…" + "Return to setup" + "send" + "Could not test connection" + "Verify and install" + "Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools." + "Manual setup" + "Open Chat to start or resume the current thread." + "Tip: stop listening to send the captured turn." + "Skip" + "Voice request failed" + "This will reject \"%1$s\" and refresh Skill Workshop state from the gateway." + "Shelling" + "update" + "Share" + "Camera enabled" + "Telegram, WhatsApp, email, and other channels appear here after setup." + "Network error" + "Tidepooling" + "Restore canvas now for session=%1$s source=%2$s. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas." + "Start failed: %1$s" + "Not requested" + "Configure a %1$s provider on the Gateway" + "kill" + "Approvals" + "Files unavailable" + "Mark as unread" + "Find people and contact details" + "Device identity required" + "OpenClaw thread" + "Allow photo library access." + "A prior response already resolved this approval." + "No recent threads" + "Timeout %1$ss" + "No matches" + "Read selected app notifications" + "Availability unknown" + "Set Up Talk" + "Extra" + "Gateway paired. Waiting for operator access." + "Attach image" + "Choose what reaches OpenClaw." + "Capability reapproval pending" + "Review highlighted items" + "Listening..." + "Catch me up" + "Message" + "Read Contacts" + "Offline attachment storage is full; delete queued items first." + "One time" + "Rename" + "No channels found." + "View all" + "New device" + "Session Status" + "Open image preview" + "Session branch changed; review and retry this message." + "close" + "That looks like a setup code. Go back and choose Setup Gateway, then Use setup code." + "✦" + "Agents & automation" + "Apply" + "Automation run skipped." + "Continue" + "Monitoring · %1$s scheduled jobs" + "Browse" + "tabs" + "Pending" + "Talk: %1$s" + "read" + "Select text" + "Motion Activity" + "description: %1$s" + "Play audio" + "Time" + "Unverified" + "Yield" + "Copy approval command" + "Current screen output and interactive app surface." + "Service connected" + "Display" + "Ready when you are" + "Could not load provider catalog." + "Speaking · waiting for reply" + "Not granted" + "Save Changes" + "Gateway rejected the automation run." + "Session Send" + "Find on ClawHub" + "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification." + "System event" + "Connect Gateway to view providers" + "Next heartbeat" + "Gateway paired. Waiting for node capability approval." + "Brining" + "Close Canvas" + "Write Contacts" + "No installed skills match this search." + "Talk Provider Setup" + "Music Generation" + "Talk settings" + "Monitoring · 1 thread" + "Payload Text" + "Set text" + "Approval %1$s" + "Gateway did not return %1$s readiness" + "%1$s configured models. Refresh to recheck availability." + "Conversation Send" + "Canvas" + "1 provider" + "The gateway certificate could not be read automatically. Paste the SHA-256 fingerprint obtained on the gateway host." + "Send failed: %1$s" + "Bridge" + "Delivery Error" + "Use OpenClaw from your phone" + "Appearance" + "Skill Workshop" + "Needs Token" + "Preview · %1$s" + "Microphone permission required" + "Connect the gateway to load Skill Workshop proposals." + "All systems operational" + "Gateway unreachable" + "OC" + "Updated" + "Connected (node offline)" + "Home" + "Dictation is listening" + "No archived threads" + "Choose and inspect the assistants available on this gateway." + "Talk mode active" + "Working · 1 active run" + "Agree and Enable" + "Gateway Update Required" + "Copy image" + "Gateway URL" + "main, isolated, current, or session:<id>" + "Media unavailable" + "Connect to your gateway to open a shell in the agent workspace." + "%1$s://%2$s:%3$s" + "Could not load approval details. Refresh and try again." + "I can check Gateway status, repair configuration, change models, or connect channels." + "Tool Call" + "Threads" + "Write" + "Start with a prompt, or use voice." + "D" + "Open Settings" + "Observing…" + "End Talk" + "Last Error" + "Review actions that need your attention." + "Disabled for all agents." + "Start Voice" + "Back to background tasks" + "Another cron action is still finishing." + "Cooldown %1$s" + "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin." + "SMS" + "On-device speech recognition is unavailable." + "Script · read-only" + "Attachments are too large to queue for one message; remove some and try again." + "1 configured model. Refresh to recheck availability." + "Widget unavailable" + "Secure connection is required for this host." + "Recents" + "No matching automations." + "Phone can reach the Gateway" + "Gateway" + "Expired" + "Scheduled OpenClaw work from your gateway." + "Sub-agent" + "Waiting for device approval" + "Loading thread" + "This gateway now presents a certificate trusted by this device." + "Stagger ms" + "event create" + "document" + "Setup Gateway" + "Play video" + "Saved authentication is invalid. Re-authenticate or reset this gateway connection." + "While Using" + "screenshot" + "Rewind to here" + "Cron expression, e.g. 0 9 * * *" + "Back to voice" + "Talk" + "Details" + "%1$s/%2$s online" + "%1$s apps allowed to forward." + "Chat" + "Microphone access is needed." + "Scuttling" + "Edit" + "Quiet Hours" + "Copy diagnostics" + "Scheduled" + "Create" + "Expires %1$s" + "Dismiss" + "Reject proposal?" + "Speech error (%1$s)" + "Issue" + "Search registry metadata. The Gateway verifies trust again before any download." + "Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access." + "Submitting…" + "Account %1$s" + "Suggest Task" + "Search" + "Listening" + "Automation not loaded." + "A Gateway update is available. Run the update from the Web UI or CLI when you are ready." + "soon" + "No gateway approvals." + "Host" + "Add one wake word or phrase per field. Then say one before your command." + "Transcribe then send" + "Run at" + "Pause audio" + "Access to the Gateway device" + "No preview" + "Devices" + "OpenClaw for Android." + "Capability approval pending" + "Save or revert your edits before running, enabling, disabling, deleting, or refreshing this automation." + "No automations yet." + "This skill needs %1$s setup items. Android shows what is installed; setup/config changes stay on desktop or CLI." + "%1$s recent" + "Channels" + "Unphased" + "Active on this phone" + "Checking node access" + "Timezone" + "Skill Workshop inspect and apply actions" + "Allow Always" + "present" + "Skills installed on the gateway will appear here." + "The code may have expired or been generated for another Gateway." + "Permission needed" + "Automation has an invalid configuration." + "Allowlist" + "Setup, status, and repair" + "groups" + "Public key" + "About" + "No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually." + "permissions" + "Connect the gateway to load nodes and paired devices." + "Switch branch" + "No skills" + "Replies play aloud" + "Mark as read" + "Node approval pending" + "wake" + "%1$s proposals" + "Gateway authentication needs attention." + "Connection details" + "Milliseconds" + "Speech recognition" + "Description" + "Recent conversations" + "Your phone sends this information to your Gateway, not to a server run by OpenClaw. Your Gateway may include it in requests to the AI provider you chose." + "Delivery" + "Mute speaker" + "%1$s Running · %2$s Done · %3$s Failed" + "Opening Gateway connection" + "Monitoring · %1$s threads" + "Automation run finished." + "No matching apps." + "Send to Chat" + "Automation deleted." + "Enable" + "Recent Runs" + "Align the QR code inside the square." + "Could not load approvals." + "I have approved" + "Connect your Gateway to load provider readiness." + "Not paired" + "This approval expired before it could be resolved." + "Observing in %1$ss — switch to the target app" + "Agent Prompt" + "emoji list" + "Repeating" + "Search OpenClaw" + "%1$s pending" + "On-device speech recognition unavailable" + "No app can share this message" + "Close search" + "Command to watch" + "Health" + "Notification listener" + "Speaker muted" + "Search threads" + "OK" + "Could not open setup guide." + "ask OpenClaw %1$s" + "Wait for Agents" + "Address" + "Scheduled work created on the gateway will appear here." + "Showing the latest log chunk." + "Use setup code" + "sticker" + "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." + "steer" + "Selected" + "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below." + "Canvas Status" + "Fix connection" + "Save image" + "Node %1$s" + "Gateway password needed" + "Update Plan" + "Remove attachment" + "Automation run failed." + "Provider limits and quota health." + "Gateway talk catalog not loaded" + "this gateway" + "No recent runs yet." + "On-device language model unavailable" + "Dashboard needs a connected gateway" + "Matching proposals will appear here after agents create reusable skill drafts." + "Session Search" + "OpenClaw is speaking" + "Scan QR" + "Selected Apps" + "Revert Changes" + "Approval command copied" + "Delivery Status" + "QR code not accepted" + "Your voice command center." + "Test connection" + "OPENCLAW" + "Web Fetch" + "Prompt" + "Approve device?" + "Connect to your gateway to open this session dashboard." + "Remove %1$s and its saved credentials from this phone?" + "QR code points to an insecure remote gateway. %1$s %2$s" + "Screen surface ready" + "Pair Gateway" + "Connect the gateway to load channels." + "Pauses during other voice activity." + "Model" + "Photos" + "Paste setup code" + "OpenClaw speaking" + "Connecting..." + " · Location: Always" + "Messages: %1$s" + "Reefing" + "Load from gateway" + "text: %1$s" + "Needs" + "rename group" + "Ready" + "The diary is waiting for its first entry." + "Approve" + "Live page" + "Automation is already running." + "Remove this automation after a successful one-shot run." + "Ready for chat and voice" + "Connected (operator: %1$s)" + "Gateway pairing is complete. Approve this phone as a node so OpenClaw can use the device capabilities you enable." + "Response aborted" + "image" + "%1$s held" + "No matching threads" + "delete" + "Layout: Compact" + "channels" + "Granted" + "Every %1$sm" + "1 token" + "%1$s %2$s" + "Installed Apps" + "pending" + "Preparing voice note…" + "Never" + "Subsystem" + "On command exit" + "Connection" + "Could not load automation run history." + "Automation name" + "Step 2" + "Diagnose" + "Some channel status checks did not complete." + "pin" + "Copy %1$s" + "Paired" + "Could not save wake words" + "This will quarantine \"%1$s\" and refresh Skill Workshop state from the gateway." + "Record voice note" + "Queued" + "Answered" + "Allow camera tools when requested." + "Issues" + "Voice Wake" + "Pairing request rejected." + "%1$sd ago" + "roles" + "Skills" + "Archive" + "Node offline. Reconnect and retry." + "System" + "Remote IP" + "Ungrouped" + "Schedule Detail" + "Phone Capabilities" + "Not available" + "Dashboard" + "Paste token" + "No providers" + "SHA-256 fingerprint" + "No threads yet" + "Bluetooth microphone" + "Recent" + "Rename thread" + "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified." + "dialog" + "Listen for wake words" + "camera snap" + "Preparing playback…" + "Gateway selected unknown provider %1$s" + "delete group" + "Follow Android · %1$s" + "channel" + "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI." + "Connect the gateway to load agents." + "Go back" + "Share message" + "Generate a QR code." + "Restart" + "Speaker on" + "Delete group?" + "Missing" + "Search proposals" + "stop" + "Secure (TLS)" + "No nodes or paired devices." + "%1$s%% left %2$s" + "Setup code expired" + "Help diagnose this OpenClaw Android gateway connection failure.\n\n" + "DIARY" + "notify" + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep." + "%1$s configured models" + "Licenses" + "Connect the gateway to search ClawHub skills." + "Skill" + "The Gateway connection changed. Restart OpenClaw to reconnect." + "Device ID" + "Gateway did not identify the active %1$s provider" + "Waiting" + "Wake words saved" + "Oldest first" + "Screen" + "Running Since" + "IPv6 zone IDs are not supported. Use an unscoped IPv6 address or a LAN hostname." + "Sent — confirming delivery…" + "audio" + "This gateway connection needs operator.admin to update skills." + "Setup code" + "Acknowledge Gateway warning and install" + "Refresh chat" + "Interval" + "Skill Workshop proposal actions require operator.admin scope." + "Sessions" + "Rename…" + "Connect the gateway to load dreaming." + "Setup" + "Open Talk" + "poll" + "Connect to load your agents" + "role remove" + " · Talk: Listening" + "ClawHub did not return an installable version for %1$s." + "Command" + "This approval was cancelled before it could be resolved." + "Mic on · waiting for gateway" + "Text" + "Showing %1$s of %2$s. Refine search for more." + "v%1$s available" + "%1$s://%2$s" + "%1$s... (OK)" + "Providers and configured models" + "Connecting…" + "Connect to a Gateway to save wake words" + "Open profile" + "Start your Gateway." + "Help me turn this goal into a practical checklist: " + "Clear session search" + "Port" + "Enter setup code" + "Could not load gateway logs." + "%1$s providers ready" + "Your agents are ready" + "No %1$s provider is configured on the Gateway" + "Listening for one turn" + "Observe" + "Epoch milliseconds (optional)" + "No configured models. Refresh to recheck availability." + "Settings" + "Back camera" + "approve" + "Before you start" + "Could not load skills." + "Disabled" + "Still waiting for approval" + "Couldn’t load background tasks" + "Check that OpenClaw can speak clearly on this phone." + "Working · %1$s active runs" + "Command working directory" + "Group name" + "Choose from gallery" + "Version %1$s" + "Back" + "Connect the gateway to update skills." + "Delete After Run" + "Setup code points to an insecure remote gateway. %1$s %2$s" + "Computer" + "Gateway disconnected." + "Session Settings" + "Connect gateway to start" + "Security notice" + "Other answer" + "Dismiss shared-image warning" + "The Gateway evaluated a different ClawHub release. Review the skill again before installing." + "Open System Access" + "Finished" + "Image unavailable" + "Notifications" + "Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions." + "sticker upload" + "Lobstering" + "Messages to recover" + "openclaw devices approve %1$s" + "Readable gateway log detail." + "Review generated skill proposals before they become live skills." + "Bundled" + "%1$s available" + "Node Approval Pending" + "Gateway Pending" + "Authentication needed" + "Nodes" + "Keep Awake" + "OpenClaw is replying" + "Docs" + "%1$s ready" + "No output yet" + "Device language not supported" + "Queued — sends when reconnected" + "%1$sm ago" + "Current branch" + "Checking pairing access" + "Limited Gateway access" + "Running tools..." + "Checking approval…" + "Capture photos and clips from this phone" + "Connected and ready" + "Close" + "Turn a goal into an actionable checklist." + "Setup code has invalid gateway URL." + "Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings." + "Account" + "remove" + "Password optional" + "Gateway authentication needs review. Check gateway settings, then retry." + "QR code uses an IPv6 zone ID. Use an unscoped IPv6 address or a LAN hostname." + "add" + "Krilling" + "Healthy" + "Done in %1$s" + "Arguments" + "Install Options" + "In %1$sh" + "Gateway approval is pending. Run this on the gateway host:" + "Admin access required" + "set groups" + "Pin model" + "Clear Search" + "Enabled for eligible agents." + "No current thread" + "bounds: %1$s" + "After %1$s" + "Allow the scheduler to run this automation." + "%1$s applied" + "No dream diary yet." + "Refresh background tasks" + "Summarize recent threads and next steps." + "Runs on-device while OpenClaw is visible." + "%1$s is working" + "%1$s %2$s" + "Raw" + "Runs" + "Run Now" + "Untitled branch" + "Configured" + "camera list" + "1 applied" + "camera clip" + "Yes" + "Audio Test" + "Held" + "events" + "Working directory" + "Jump to latest" + "Allow all the time" + "Scan QR or setup code" + "Installing" + "Live nodes, paired phones, and pending device requests." + "Snapshot: %1$s" + "A prior response already allowed this command and saved the choice." + "Pending Requests" + "Approved" + "Workspace" + "Voice" + "Ready to talk" + "Subagents" + "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected." + "Signals" + "Session Target" + "Gateway recorded a denial." + "Accept" + "Ask OpenClaw anything" + "Reconnect to continue" + "%1$s paired" + "This will apply \"%1$s\" and refresh Skill Workshop state from the gateway." + "Gateway offline" + "openclaw devices list" + "OpenClaw node connection status" + "Alerts stay on this phone." + "OpenClaw can receive selected alerts." + "Open Screen" + "Chat actions" + "Allow control of other apps?" + "Inspecting" + "Scan or paste a setup code to add another gateway." + "Swarm" + "TLS timed out" + "Recent sessions" + "Paired device removed." + "Gateway paired. Checking node capability approval." + "Motion" + "Cron action failed." + "On the Gateway computer, run:" + "Search sessions" + "Refresh Logs" + "Image unavailable · Tap to retry" + "openclaw nodes approve %1$s" + "Voice note · %1$s" + "Usage" + "Nautiling" + "Context %1$s%%" + "Transcribe voice prompts" + "Mute" + "Start a new conversation and it will show up here." + "Connection issue" + "Medium" + "Fork" + "Enable speaker" + "System Event Text" + "Sort: %1$s" + "%1$s waiting" + "Image Generation" + "Voice note" + "Nothing needs your attention" + "OpenClaw needs %1$s permissions to continue." + "Wired headset microphone" + "Pages" + "Delivered" + "Due" + "Skill detail is not available in the current skills status." + "Choose what this phone can share." + "This automation already has a queued run." + "Connect the gateway to manage automations." + "Automation is not due yet." + "No details" + "Approval is in progress.\nOpenClaw will reconnect automatically." + "Connect your Gateway to view provider readiness." + "Waiting for pairing" + "Start or continue a conversation" + "No scheduled jobs" + "Reply to OpenClaw…" + "Status" + "OpenClaw Node · Connected" + "Active" + "Show screen-sharing debug state." + "No limits reported" + "Close scanner" + "Every %1$sd" + "Enabled" + "Enable and Open Settings" + "Online and ready" + "Ask User" + "Chat error" + "Scroll forward" + "%1$s of %2$s" + "Plan the work" + "console" + "Retry" + "Start a chat and your active OpenClaw conversations will appear here." + "Could not load automation." + "Shell in the agent workspace" + "%1$s active" + "Choose device permissions" + "Last Duration" + "Default agent" + "%1$sh" + "Conversation is live" + "Could not install %1$s from ClawHub." + "Welcome to OpenClaw" + "Control other apps" + "Signal Index" + "Enter secret…" + "%1$s:%2$s" + "tell OpenClaw to %1$s" + "Discovered" + "Hide Sidebar" + "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI." + "Audio playback is unavailable" + "apply" + "Could not load usage." + "Keep the node available during active work." + "Next Wake" + "%1$s/%2$s" + "No recent log entries." + "Manual Gateway" + "Rename group" + "Update Goal" + "Provider availability unknown" + "Providers" + "Delete group…" + "Payload" + "Call Log" + "Memory Search" + "%1$s providers" + "Phone context & privacy" + "%1$s/%2$s connected" + "%1$s %2$s" + "Gateway restart recovery is still in progress." + "Replacing the setup code clears this phone\'s saved setup credentials and device tokens before reconnecting. This phone may need node capability approval again; continue only when you mean to pair with a fresh gateway setup code." + "Open an automation to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it." + "Context --" + "Proposal quarantined." + "Automation paused." + "OpenClaw mobile" + "A2UI reset" + "Gateway unavailable" + "Read" + "This skill needs 1 setup item. Android shows what is installed; setup/config changes stay on desktop or CLI." + "Last Run" + "Camera access is needed to scan the setup QR." + "Could not update model." + "Bubbling" + "thread reply" + "Delete…" + "Connect the gateway to inspect automations." + "RECENT LOGS" + "Loading recent runs…" + "This file cannot be previewed. It may be binary or too large." + "Check" + "Read this phone\'s location" + "Skill Key" + "Installed %1$s." + "Gateways" + "actions: %1$s" + "Forwarding Mode" + "%1$sk" + "Toggle thread layout" + "No TLS endpoint" + "OpenClaw gateway" + "Set up manually" + "Thinking…" + "Gateway access needs review" + "1 held" + "%1$ss" + "Apply proposal?" + "Not now" + "Unapproved" + "Search apps" + "1 configured model" + "Dismiss approval notice" + "·" + "Offline" + "Speech provider" + "Gateway approval is in progress. OpenClaw will retry automatically." + "Max" + "Cron changes require operator.admin access." + "Thinking" + "screen snapshot" + "Observed nodes: %1$s" + "No actions found" + "Save & Connect" + "list" + "Gateway recorded approval and saved the choice." + "Enter a valid manual endpoint to connect." + "assistant" + "Sending to chat..." + "Save Profile" + "Locked" + "Edit Automation" + "Use the same network, or a secure remote Gateway URL." + "Anchor" + "Language" + "This app is older than the Gateway. Update OpenClaw on this device, then retry." + "All" + "Gateway session in progress" + "Waiting for review" + "No skills installed." + "Checking Gateway" + "Stagger %1$s" + "The result for %1$s is unknown. Reconnect, refresh Skills, then retry; the Gateway safely joins a matching install that is still running." + "Forget" + "No paired gateways." + "%1$s · %2$s" + "<redacted secret>" + "%1$s issues" + "OpenClaw" + "Listening · %1$s queued" + "Assistant speech muted" + "Node actions run only when the target app is foreground (validated via the remote path). Global actions and same-app actions work here." + "No gateways found yet. Use manual setup if discovery is blocked." + "Open thread" + "Working" + "Start speaking..." + "Phone Node" + "Xhigh" + "Run on the Gateway host:" + "Skill changes require operator.admin. Reconnect with an admin-capable gateway token." + "Connect the gateway to inspect ClawHub skills." + "App list stays on this phone." + "Idle" + "Shown in Android Accessibility settings." + "Smart delivery" + "Reject" + "Gateway returned status \'%1$s\' after %2$s." + "Gateway token not configured" + "Not available to this agent" + "Files" + "Permissions" + "Could not start the camera. Choose a QR image from gallery or enter the setup code manually." + "Tap to copy" + "Waiting %1$sm" + "%1$s." + "Connect the gateway to install ClawHub skills." + "Search voice" + " · Mic: Listening" + "Reconnect with operator.admin access to review and change Gateway settings." + "Load more" + "Observe in 3s" + "run" + "Generating voice…" + "← Back" + "Disconnect" + "Run the approve command on the Gateway computer, then check again." + "Automations" + "%1$sm" + "Trust" + "That QR code is not an OpenClaw setup QR. Generate a fresh code with openclaw qr, then try again." + "Preferred microphone unavailable; using automatic routing." + "Rejected" + "Include Android and background packages." + "Your Gateway is ready." + "Triggered" + "Structured Output" + "This is taking longer than expected.\nCheck that the Gateway is running and reachable." + "No background tasks for this agent." + "Reconnecting" + "OpenClaw is checking gateway and node access." + "Code Execution" + "No provider usage" + "Review" + "Microphone permission is required." + "%1$sd" + "%1$s available" + "OpenClaw is syncing back up" + "Event stream interrupted; try refreshing." + "Could not load nodes and devices." + "Connect the gateway to load skills." + "unknown" + "Output" + "Talk failed: Realtime provider closed unexpectedly." + "OpenClaw Time Sensitive" + "ban" + "Gateway token needed" + "Paired device" + "Needs reapproval" + "Not scheduled" + "Contacts" + "Your phone stays quiet until it is needed" + "Listening · sending queued voice" + "Couldn’t load task details" + "Agent message" + "Gateway requires this device identity. Re-authenticate or reset this gateway connection." + "Next session" + "Connection security" + "Skip for now" + "Website" + "Connect the gateway to load approval requests in the app." + "%1$s copied" + "No apps selected. Nothing forwards until you add apps." + "%1$s %2$s" + "Needs setup" + "Unpaired" + "Gateway received this phone" + "No configured models" + "Disable" + "App language" + "Pairing Gateway" + "Saved auth invalid" + "%1$s scopes" + "Connect the gateway to load recent logs." + "Save wake words" + "Manage installed skills and add trusted releases from ClawHub." + "Sending…" + "No agents loaded yet." + "Search ClawHub" + "Chat is checking Gateway health." + "Pairing needed" + "Active Runs" + "Failed — %1$s" + "Connection between this phone and OpenClaw." + "summarize" + "Widget image saved to Downloads" + "Starting…" + "%1$s tokens" + "Client error" + "Verify this requesting device before granting access." + "Bluetooth LE microphone" + "%1$s %2$s" + "Automation enabled." + "%1$sM" + "Memory Get" + "%1$s · %2$s" + "Archived" + "Reload" + "Search automations" + "Linked phones and node hosts will appear here after pairing." + "%1$s: %2$s" + "This automation changed on the gateway. Review the latest version before saving again." + "Stop Dictation" + "Readable" + "Message OpenClaw" + "Gateway password is invalid. Re-enter it or reset this gateway connection." + "Reconnect" + "ISO time, e.g. 2026-07-09T09:30:00Z" + "%1$s tools" + "A prior response already denied this approval." + "Linked" + "Open %1$s" + "%1$s/%2$s" + "Automation run finished with an unknown status." + "Offline queue is full (%1$s messages); delete queued items first." + "Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs." + "Connect the gateway to load skill details." + "Full Access Required" + "Wake listener" + "Expand link preview" + "Clear thread search" + "NULL (FAILED)" + "Update" + "Admin" + "Needs attention" + "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops." + "Roles" + "Reply" + "Provider catalog" + "Enable permission in Settings" + "A2UI push" + "Check Access" + "If the gateway is reachable, reconnect should complete without intervention." + "Agent turn" + "quarantine" + "Attention" + "Searching…" + "Where do I get a setup code?" + "Could not enable skill." + "pdf" + "Remove" + "%1$s%% online" + "No channels" + "Realtime voice" + "Skill Workshop reject and quarantine actions" + "Nodes & Devices" + "Local command center" + "emoji upload" + "Loading preview…" + "High" + "focus" + "describe" + "%1$s context" + "Listening response..." + "voice" + "Connected to %1$s" + "role add" + "Chat needs attention" + "Enable Microphone" + "OpenClaw collects and sends the names, package IDs, and status of apps visible on this phone when your paired OpenClaw Gateway asks for them. This lets your assistant answer questions and take actions using installed apps." + "Gateway not connected" + "Policy" + "Timed out confirming the sent message; refresh to check delivery." + "Support Files" + "Expression" + "Background tasks" + "Dream" + "No apps blocked. Apps can forward unless you add blocks." + "Speech recognizer unavailable" + "Platform" + "Gateway did not return %1$s setup" + "Forget gateway?" + "Optional description" + "Open %1$s" + "Home canvas" + "Dreaming" + "%1$s to %2$s" + "Share file" + "Realtime" + "API" + "OpenClaw is working…" + "Talk or dictate with OpenClaw" + "Share installed app information?" + "Loading automation…" + "Delete Automation" + "Default assistant" + "Choose a supported %1$s provider on the Gateway" + "Unavailable" + "Empty folder" + "Open settings" + "Off" + "Typography" + "Stop" + "No matching threads yet." + "Gateway pairing worked.\nApprove this phone\'s node capabilities from an operator UI." + "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes." + "Recognizer busy" + "Home Gateway" + "Run the approval command on the Gateway" + "Service disabled" + "Could not load Skill Workshop proposals." + "Catch me up on my recent OpenClaw threads and suggest next steps." + "Not Now" + "openclaw qr" + "start" + "OpenClaw Node · Talk" + "Read and update events" + "Talk failed: Realtime provider closed: %1$s" + "Connect the gateway to browse workspace files." + "%1$s via Gateway relay" + "Could not load Gateway talk catalog" + "Monitoring · 1 scheduled job" + "Every %1$sh" + "Screen surface" + "OpenClaw translations · %1$s" + "Command request" + "Up to date" + "Channel" + "Unmute" + "New group…" + "Preparing audio…" + "Adaptive" + "Soon" + "%1$s more workers" + "Web Search" + "Try Chat, Voice, Threads, Providers, or Settings." + "OpenClaw Active" + "navigate" + "requested %1$s" + "Connect the gateway to inspect automation run history." + "Device access; Gateway opt-in still required" + "Aborted" + "Enter a valid setup code or gateway address." + "Models" + "OpenClaw Passive" + "Gateway password invalid" + "Could not verify the device pairing change. Refresh and try again." + "View details" + "Bash" + "Token" + "The connected OpenClaw agent can use device capabilities you enable. Continue only if you trust the Gateway and agent you connect to." + "Barnacling" + "Selected or full photo access granted." + "Accessibility executor" + "%1$s missing items" + "Collapse plan checklist" + "Node approval required" + "Connect Gateway" + "... +%1$s more" + "Expand plan checklist" + "Browser" + "screen record" + "Run Pending" + "Enabling lets OpenClaw observe and control other apps\' screens when armed. Android accessibility access is required." + "Origin" + "Personal AI on your devices" + "Attach" + "Automatic" + "Overview" + "Failed to request restore. Tap to retry." + "Video" + "%1$s\n\n" + "Unencrypted" + "Calendar" + "Gateway health not OK; cannot send" + "📎 %1$s" + "Last Status" + "Wait for the current response to finish before starting a new chat." + "Profile" + "Provider limits will appear here when your gateway reports them." + "1 issue" + "Threads in \"%1$s\" are kept and move back to Ungrouped." + "Recommended" + "Created" + "%1$s/%2$s active tokens" + "No action result" + "Snapping" + "%1$s…" + "Open skill detail" + "Speak failed: %1$s" + "Start Talk" + "Could not load this folder." + "QR code did not contain a valid setup code." + "Review node access" + "Add wake phrase" + "Cannot reach gateway" + "Automation" + "Needs connection" + "Could not resolve approval. Refresh and try again." + "import" + "How this phone appears to OpenClaw." + "Focus thread search" + "Connect the gateway" + "Read Calendar" + "The overview refreshes on reconnect and when this screen opens." + "Could not disable skill." + "Still connecting" + "In %1$sm" + "Read SMS" + "Connect the gateway to load usage." + "What can you help me do from this phone right now?" + "Needs approval" + "New chat" + "Connect the gateway to update Skill Workshop proposals." + "OpenClaw request failed." + "Permission required" + "Review provider readiness\nand configured models." + "Loading" + "Failure Alert" + "Theme and translated Android text." + "Mic off · sending…" + "None" + "View" + "Name" + "Version" + "Cron" + "Connect this phone to a Gateway before opening OpenClaw." + "Remove wake phrase" + "Setup code was not accepted. Generate a fresh code with openclaw qr." + "14 messages · Android" + "Transcription failed: %1$s" + "Always" + "Could not load dreaming." + "Automation run queued." + "Conversation Turn" + "Automation started." + "New group" + "Server error" + "Video Generation" + "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry." + "Entries appear after a dreaming cycle writes a narrative summary." + "%1$sms" + "Memory Store" + "Assistant working" + "OpenClaw can list launcher-visible apps." + "Talk failed: %1$s" + "Search installed skills" + "Inspect" + "Process" + "Recent Threads" + "Terminal" + "Current" + "1 account" + "Paused" + "Allow camera" + "Exec approval requests will appear here while this phone is connected." + " · Mic: Pending" + "Copy" + "Details copied" + "Delete" + "Ask OpenClaw to use Android capabilities." + "member" + "Checking whether this Gateway supports the OpenClaw settings assistant." + "Use the recovery options below to reconnect." + "%1$s message(s) need recovery. Re-enter anything you want to keep, then delete these rows." + "Could not load channels." + "In %1$sd" + "Consecutive Errors" + "Could not read a QR code from that image. Choose a clearer image or enter the setup code manually." + "The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry." + "Connect before chat, voice, and live status." + "Reconnect gateway" + "Third-party" + "Review readiness" + "Limited" + "OpenClaw logo" + "Unpin model" + "Messaging surfaces connected to this gateway." + "Sending" + "Archived threads will show up here." + "Command copied" + "No preview available" + "Approve this phone on the gateway.\nThen retry the connection." + "Scan QR code" + "Command working directory · cannot clear" + "Thread Activity" + "Available" + "Delete automation?" + "%1$s today · %2$s total" + "Password" + "Quarantine proposal?" + "No license notices are packaged in this build." + "Could not save widget image" + "Waiting %1$s" + "Speaking…" + "Providers & Models" + "Node" + "%1$s " + "Prompt unavailable" + "Logs" + "Connect the gateway to inspect Skill Workshop proposals." + "Tools" + "Gateway switch" + "Send SMS" + "OpenClaw is ready to continue in your ordinary chat." + "No commands found" + "No canvas update yet. Tap to retry." + "Terminal needs a connected gateway" + "Exec" + "App Filter" + "Main" + "%1$sk" + "Gateway Required" + "Access" + "Packages: snapshot=%1$s foreground=%2$s" + "Retry connection" + "Cron scheduler is stopped." + "Open" + "Message copied" + "We could not reach your Gateway.\nLet\'s fix this." + "now" + "Delete after run" + "Selected on this phone" + "unpin" + "Session History" + "Unpin" + "Use this phone" + "Could not load ClawHub details for %1$s." + "Tools running" + "Share precise location while location is enabled." + "Mobile UI" + "Theme" + "The Gateway still shows this approval as pending. Review it before trying again." + "Finish voice note" + "Dictation: %1$s" + "Not allowed" + "Choose another image" + "Image preview" + "OpenClaw only listens when you start Talk or Dictation." + "Share steps and activity" + "Needs Setup" + "Update this Gateway to use the OpenClaw settings assistant." + "This gateway connection needs operator.admin to install ClawHub skills." + "Proposal applied." + "%1$s pending" + "%1$sh ago" + "Read Call Log" + "%1$s queued · waiting for gateway" + "Move to group" + "Scan QR to Pair" + "Approval denied." + "Could not inspect Skill Workshop proposal." + "Pinned" + "Profile & device" + "Close thinking level selector" + "Could not queue message for later delivery." + "Quarantine" + "Schedule · %1$s" + "Could not update thinking level." + "Open thinking level selector" + "Voice reply timed out; retrying queued turn" + "Layout: Detailed" + "This image could not be decoded." + "Gateway, voice, notifications, privacy" + "Agent workspace files" + "This device will lose its trusted Gateway access." + "Use the requestId from the pending command in the approve command." + "Schedule" + "Rate Limit" + "Not delivered" + "Payload · %1$s" + "Running" + "Clawing" + "End" + "Use system trust" + "No ready providers" + "Prioritizes connected Bluetooth microphones." + "%1$s app blocked from forwarding." + "Message actions" + "Kind" + "Unarchive" + "Transcripts" + "Wake words" + "Configure %1$s on the Gateway" + "Scan a QR code or use the setup code from your OpenClaw Gateway." + "Design system prototype" + "Sifting" + " · Talk: On" + "No usage data yet." + "Chat failed before the run started; try again." + "Send" + "Some shared images were omitted or could not be added." + "Write Calendar" + "timeout" + "Low" + "Blocklist" + "act" + "Dismiss Task" + "Chat failed" + "OpenClaw · Live" + "Installed" + "Timed out waiting for a reply; try again or refresh." + "Find previous conversations" + "Browse Threads" + "Refreshing" + "Pearling" + "Open the camera and frame the code from openclaw qr." + "No devices" + "Forward Notifications" + "I’ll keep this conversation separate from ordinary agent chat." + "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment." + "Try a different search or clear the current query." + "Allow background location?" + "Surfacing" + "Bootstrap" + "%1$s · %2$s · %3$s" + "Cancel voice note" + "Scroll back" + "openclaw gateway" + "Gateway paired" + "Molting" + "Listening for your next turn." + "OpenClaw is working" + "Log Entry" + "Failed: couldn\'t reach the secure gateway endpoint for this host." + "Gateway is offline. Fix the connection below or copy diagnostics." + "Standby" + "Testing testing 1 2 3" + "Could not search ClawHub skills." + "No prompt" + "Front camera" + "Open log entry" + "Network timeout" + "Now" + "Rename group…" + "More Agents" + "openclaw nodes approve REQUEST_ID" + "Pin" + "thread list" + "Open %1$s" + "upload" + "Gateway password not configured" + "Dictation settings" + "Provider models loaded, but readiness is unavailable." + "Delete thread?" + "OpenClaw turns this phone into a clean mobile command surface for threads, voice, providers, and Gateway." + "Newest first" + "Next Cycle" + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..3ac5d04 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml new file mode 100644 index 0000000..11ab479 --- /dev/null +++ b/app/src/main/res/values/wear.xml @@ -0,0 +1,7 @@ + + + openclaw_phone_proxy_v1 + + @string/native_android_wear_capability + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..24bad93 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..3c332df --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..3b59cb4 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..78f2d99 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..c6485fe --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt b/app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt new file mode 100644 index 0000000..3207903 --- /dev/null +++ b/app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt @@ -0,0 +1,9 @@ +package ai.openclaw.app + +object SensitiveFeatureConfig { + const val smsEnabled: Boolean = false + const val callLogEnabled: Boolean = false + const val photosEnabled: Boolean = false + const val backgroundLocationEnabled: Boolean = false + const val accessibilityControlEnabled: Boolean = false +} diff --git a/app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt b/app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt new file mode 100644 index 0000000..2beb167 --- /dev/null +++ b/app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt @@ -0,0 +1,54 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import android.content.Context + +internal data class CallLogRecord( + val number: String?, + val cachedName: String?, + val date: Long, + val duration: Long, + val type: Int, +) + +internal data class CallLogSearchRequest( + val limit: Int, + val offset: Int, + val cachedName: String?, + val number: String?, + val date: Long?, + val dateStart: Long?, + val dateEnd: Long?, + val duration: Long?, + val type: Int?, +) + +internal interface CallLogDataSource { + fun hasReadPermission(context: Context): Boolean + + fun search( + context: Context, + request: CallLogSearchRequest, + ): List +} + +class CallLogHandler private constructor() { + constructor( + @Suppress("unused") appContext: Context, + ) : this() + + fun handleCallLogSearch( + @Suppress("unused") paramsJson: String?, + ): GatewaySession.InvokeResult = + GatewaySession.InvokeResult.error( + code = "CALL_LOG_UNAVAILABLE", + message = "CALL_LOG_UNAVAILABLE: call log not available on this build", + ) + + companion object { + internal fun forTesting( + @Suppress("unused") appContext: Context, + @Suppress("unused") dataSource: CallLogDataSource, + ): CallLogHandler = CallLogHandler() + } +} diff --git a/app/src/play/java/ai/openclaw/app/node/MobileUiHandler.kt b/app/src/play/java/ai/openclaw/app/node/MobileUiHandler.kt new file mode 100644 index 0000000..4045b10 --- /dev/null +++ b/app/src/play/java/ai/openclaw/app/node/MobileUiHandler.kt @@ -0,0 +1,26 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class MobileUiHandler { + private val connected = MutableStateFlow(false) + + val isConnected: StateFlow = connected.asStateFlow() + + suspend fun handleObserve( + @Suppress("UNUSED_PARAMETER") paramsJson: String?, + ): GatewaySession.InvokeResult = unavailable() + + suspend fun handleAct( + @Suppress("UNUSED_PARAMETER") paramsJson: String?, + ): GatewaySession.InvokeResult = unavailable() + + private fun unavailable(): GatewaySession.InvokeResult = + GatewaySession.InvokeResult.error( + code = "MOBILE_UI_UNAVAILABLE", + message = "MOBILE_UI_UNAVAILABLE: accessibility control is not available on this build", + ) +} diff --git a/app/src/play/java/ai/openclaw/app/node/SmsHandler.kt b/app/src/play/java/ai/openclaw/app/node/SmsHandler.kt new file mode 100644 index 0000000..c5c82aa --- /dev/null +++ b/app/src/play/java/ai/openclaw/app/node/SmsHandler.kt @@ -0,0 +1,39 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession + +class SmsHandler( + private val sms: SmsManager, +) { + suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult { + val res = sms.send(paramsJson) + if (res.ok) { + return GatewaySession.InvokeResult.ok(res.payloadJson) + } + return errorResult(res.error, defaultCode = "SMS_SEND_FAILED") + } + + suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult { + val res = sms.search(paramsJson) + if (res.ok) { + return GatewaySession.InvokeResult.ok(res.payloadJson) + } + return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED") + } + + private fun errorResult( + error: String?, + defaultCode: String, + ): GatewaySession.InvokeResult { + val rawMessage = error ?: defaultCode + val idx = rawMessage.indexOf(':') + val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode + val message = + if (idx > 0 && code == rawMessage.substring(0, idx).trim()) { + rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage } + } else { + rawMessage + } + return GatewaySession.InvokeResult.error(code = code, message = message) + } +} diff --git a/app/src/play/java/ai/openclaw/app/node/SmsManager.kt b/app/src/play/java/ai/openclaw/app/node/SmsManager.kt new file mode 100644 index 0000000..7ef9aa7 --- /dev/null +++ b/app/src/play/java/ai/openclaw/app/node/SmsManager.kt @@ -0,0 +1,69 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.PermissionRequester +import android.content.Context + +class SmsManager( + @Suppress("unused") private val context: Context, +) { + data class SendResult( + val ok: Boolean, + val to: String, + val message: String?, + val error: String? = null, + val payloadJson: String, + ) + + data class SmsMessage( + val id: Long, + val threadId: Long, + val address: String?, + val person: String?, + val date: Long, + val dateSent: Long, + val read: Boolean, + val type: Int, + val body: String?, + val status: Int, + val transportType: String? = null, + ) + + data class SearchResult( + val ok: Boolean, + val messages: List, + val error: String? = null, + val payloadJson: String, + ) + + fun attachPermissionRequester( + @Suppress("unused") requester: PermissionRequester, + ) { + } + + fun canSendSms(): Boolean = false + + fun canSearchSms(): Boolean = false + + fun canReadSms(): Boolean = false + + fun hasTelephonyFeature(): Boolean = false + + suspend fun send(paramsJson: String?): SendResult = + SendResult( + ok = false, + to = "", + message = null, + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", + payloadJson = unavailablePayload(paramsJson), + ) + + suspend fun search(paramsJson: String?): SearchResult = + SearchResult( + ok = false, + messages = emptyList(), + error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission", + payloadJson = unavailablePayload(paramsJson), + ) + + private fun unavailablePayload(paramsJson: String?): String = """{"ok":false,"error":"SMS_UNAVAILABLE","paramsProvided":${!paramsJson.isNullOrBlank()}}""" +} diff --git a/app/src/play/java/ai/openclaw/app/ui/FlavorPhoneCapabilitiesSettings.kt b/app/src/play/java/ai/openclaw/app/ui/FlavorPhoneCapabilitiesSettings.kt new file mode 100644 index 0000000..8695db5 --- /dev/null +++ b/app/src/play/java/ai/openclaw/app/ui/FlavorPhoneCapabilitiesSettings.kt @@ -0,0 +1,9 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.MainViewModel +import androidx.compose.runtime.Composable + +@Composable +internal fun FlavorPhoneCapabilitiesSettings( + @Suppress("UNUSED_PARAMETER") viewModel: MainViewModel, +) = Unit diff --git a/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt b/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt new file mode 100644 index 0000000..6abc21c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt @@ -0,0 +1,65 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidLicenseNoticesTest { + @Test + fun isAndroidLicenseFileName_acceptsTxtOnly() { + assertTrue(isAndroidLicenseFileName("MANROPE_OFL.txt")) + assertTrue(isAndroidLicenseFileName("notice.TXT")) + assertEquals(false, isAndroidLicenseFileName("notice.md")) + assertEquals(false, isAndroidLicenseFileName("notice")) + } + + @Test + fun androidLicenseTitleFromFileName_usesExactFileNameStem() { + assertEquals("Manrope", androidLicenseTitleFromFileName("Manrope.txt")) + assertEquals("OkHttp and Okio", androidLicenseTitleFromFileName("OkHttp and Okio.txt")) + assertEquals("SLF4J API", androidLicenseTitleFromFileName("SLF4J API.TXT")) + } + + @Test + fun androidLicenseTitleFromFileName_fallsBackForBlankStem() { + assertEquals("License", androidLicenseTitleFromFileName(".txt")) + } + + @Test + fun loadAndroidLicenseNotices_readsPackagedTxtAssets() { + val context = RuntimeEnvironment.getApplication() + val licenses = loadAndroidLicenseNotices(context.assets) + + assertEquals( + listOf( + "AndroidX Compose", + "AndroidX Media3", + "AndroidX Room", + "AndroidX Wear", + "Bouncy Castle Provider", + "Coil", + "CommonMark Java", + "dnsjava", + "KaTeX", + "Kotlin Libraries", + "Manrope", + "nibor autolink", + "OkHttp and Okio", + "SLF4J API", + ), + licenses.map { license -> license.title }, + ) + assertEquals(false, licenses.any { license -> license.text.startsWith("Title:") }) + assertTrue(licenses.any { license -> license.text.contains("SIL Open Font License") }) + assertTrue(licenses.any { license -> license.text.contains("Apache License") }) + assertTrue(licenses.any { license -> license.text.contains("BSD 2-Clause") }) + assertTrue(licenses.any { license -> license.text.contains("BSD 3-Clause") }) + assertTrue(licenses.any { license -> license.text.contains("MIT License") }) + assertTrue(licenses.any { license -> license.text.contains("Bouncy Castle Licence") }) + assertTrue(licenses.any { license -> license.title == "Coil" && license.text.contains("Coil Contributors") }) + } +} diff --git a/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt b/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt new file mode 100644 index 0000000..89cb443 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt @@ -0,0 +1,190 @@ +package ai.openclaw.app + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AndroidScreenshotFixtureTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun providesDeterministicProductionScreenData() { + val sessions = + json + .parseToJsonElement(AndroidScreenshotFixture.request("sessions.list", null)) + .jsonObject["sessions"] + ?.jsonArray + .orEmpty() + val metadata = + json + .parseToJsonElement(AndroidScreenshotFixture.request("chat.metadata", null)) + .jsonObject + val cronJobs = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.list", null)) + .jsonObject["jobs"] + ?.jsonArray + .orEmpty() + val cronDetail = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.get", null)) + .jsonObject + val cronRunEntries = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.runs", null)) + .jsonObject["entries"] + ?.jsonArray + val parsedCronRuns = parseGatewayCronRunHistory(cronRunEntries) + + assertEquals(3, sessions.size) + assertEquals( + AndroidScreenshotFixture.primarySessionTitle, + sessions + .first() + .jsonObject["displayName"] + ?.jsonPrimitive + ?.content, + ) + assertEquals(1, metadata["models"]?.jsonArray?.size) + assertEquals(1, metadata["commands"]?.jsonArray?.size) + assertEquals( + AndroidScreenshotFixture.cronJobName, + cronJobs + .single() + .jsonObject["name"] + ?.jsonPrimitive + ?.content, + ) + assertEquals(AndroidScreenshotFixture.cronJobId, cronDetail["id"]?.jsonPrimitive?.content) + assertEquals(2, parsedCronRuns.size) + assertEquals("android-release-digest-run-2", parsedCronRuns.first().runId) + assertEquals("Release checklist ready", parsedCronRuns.first().summary) + assertEquals("android-release-digest-run-1", parsedCronRuns.last().runId) + assertEquals("Play publish blocked", parsedCronRuns.last().error) + } + + @Test + fun providesSwarmChildRosterForSwarmScene() { + AndroidScreenshotFixture.configure(AndroidScreenshotScene.Swarm) + try { + val params = "{\"spawnedBy\":\"${AndroidScreenshotFixture.mainSessionKey}\"}" + val sessions = + json + .parseToJsonElement(AndroidScreenshotFixture.request("sessions.list", params)) + .jsonObject["sessions"] + ?.jsonArray + .orEmpty() + val metadata = + json + .parseToJsonElement(AndroidScreenshotFixture.request("chat.metadata", null)) + .jsonObject + assertEquals("true", metadata["swarmEnabled"]?.jsonPrimitive?.content) + assertEquals(5, sessions.size) + assertEquals( + "swarm:${AndroidScreenshotFixture.mainSessionKey}:research", + sessions + .first() + .jsonObject["swarmGroupId"] + ?.jsonPrimitive + ?.content, + ) + } finally { + AndroidScreenshotFixture.configure(AndroidScreenshotScene.Home) + } + } + + @Test + fun providesDeterministicChatHistory() { + val messages = + json + .parseToJsonElement(AndroidScreenshotFixture.request("chat.history", null)) + .jsonObject["messages"] + ?.jsonArray + .orEmpty() + + assertEquals( + listOf( + listOf("user", "What is blocking the Android release?", "1783555020000"), + listOf( + "assistant", + "Two review threads are still open on the release branch, and the localization sync needs one more pass. " + + "Once those land, the changelog draft is ready for review and the tag can go out.", + "1783555080000", + ), + listOf("user", "Summarize the open review feedback for me.", "1783555140000"), + listOf( + "assistant", + "The main thread asks for a regression test around session restore, and the second one wants the new " + + "config key documented before merge. Both are small; I can draft patches for each if you want.", + "1783555200000", + ), + listOf("user", "Draft a short status update for the team.", "1783555260000"), + listOf( + "assistant", + "The Android release is close. Two review follow-ups and one localization pass remain; once those land, " + + "the changelog can be reviewed and the tag can go out.", + "1783555320000", + ), + ), + messages.map { message -> + val fields = message.jsonObject + listOf( + fields["role"]?.jsonPrimitive?.content, + fields["content"]?.jsonPrimitive?.content, + fields["timestamp"]?.jsonPrimitive?.content, + ) + }, + ) + } + + @Test + fun providesDeterministicSystemAgentConversation() { + val greeting = + json + .parseToJsonElement( + AndroidScreenshotFixture.request( + "openclaw.chat", + """{"sessionId":"android-settings-openclaw-test"}""", + ), + ).jsonObject + val response = + json + .parseToJsonElement( + AndroidScreenshotFixture.request( + "openclaw.chat", + """{"sessionId":"android-settings-openclaw-test","message":"Check status"}""", + ), + ).jsonObject + + assertEquals("android-screenshot-openclaw", greeting["sessionId"]?.jsonPrimitive?.content) + assertEquals( + "What should we look at first?", + greeting["question"] + ?.jsonObject + ?.get("question") + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "I’ll keep this conversation separate from ordinary agent chat.", + response["reply"]?.jsonPrimitive?.content, + ) + } + + @Test + fun rejectsUnexpectedGatewayCalls() { + val error = + assertThrows(IllegalStateException::class.java) { + AndroidScreenshotFixture.request("gateway.unexpected", null) + } + + assertEquals( + "Screenshot fixture does not implement gateway method gateway.unexpected with params null", + error.message, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt b/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt new file mode 100644 index 0000000..a793ee6 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt @@ -0,0 +1,84 @@ +package ai.openclaw.app + +import ai.openclaw.app.ui.SettingsRoute +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AndroidScreenshotModeTest { + @Test + fun ignoresNormalLaunches() { + assertNull(parseAndroidScreenshotModeIntent(Intent(Intent.ACTION_MAIN))) + } + + @Test + fun parsesRequestedScene() { + val parsed = + parseAndroidScreenshotModeIntent( + Intent(Intent.ACTION_MAIN) + .putExtra(extraAndroidScreenshotMode, true) + .putExtra(extraAndroidScreenshotScene, "chat"), + ) + + assertEquals(AndroidScreenshotScene.Chat, parsed) + } + + @Test + fun defaultsUnknownScenesToHome() { + val parsed = + parseAndroidScreenshotModeIntent( + Intent(Intent.ACTION_MAIN) + .putExtra(extraAndroidScreenshotMode, true) + .putExtra(extraAndroidScreenshotScene, "unknown"), + ) + + assertEquals(AndroidScreenshotScene.Home, parsed) + } + + @Test + fun mapsScenesToProductionShellDestinations() { + assertEquals(HomeDestination.Connect, AndroidScreenshotScene.Home.homeDestination) + assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Chat.homeDestination) + assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Swarm.homeDestination) + assertEquals(HomeDestination.Settings, AndroidScreenshotScene.Settings.homeDestination) + assertEquals(HomeDestination.Settings, AndroidScreenshotScene.VoiceWake.homeDestination) + } + + @Test + fun gatewaySceneTargetsSettingsGatewayRoute() { + val parsed = + parseAndroidScreenshotModeIntent( + Intent(Intent.ACTION_MAIN) + .putExtra(extraAndroidScreenshotMode, true) + .putExtra(extraAndroidScreenshotScene, "gateway"), + ) + + assertEquals(AndroidScreenshotScene.Gateway, parsed) + assertEquals(HomeDestination.Settings, parsed?.homeDestination) + assertEquals(SettingsRoute.Gateway, parsed?.settingsRoute) + assertNull(AndroidScreenshotScene.Settings.settingsRoute) + } + + @Test + fun openClawSceneTargetsSystemAgentSettings() { + val scene = AndroidScreenshotScene.fromRawValue("openclaw") + + assertEquals(AndroidScreenshotScene.OpenClaw, scene) + assertEquals(HomeDestination.Settings, scene.homeDestination) + assertEquals(SettingsRoute.SystemAgent, scene.settingsRoute) + } + + @Test + fun voiceWakeSceneTargetsVoiceSettings() { + val scene = AndroidScreenshotScene.fromRawValue("voice-wake") + + assertEquals(AndroidScreenshotScene.VoiceWake, scene) + assertEquals(SettingsRoute.Voice, scene.settingsRoute) + } +} diff --git a/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt b/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt new file mode 100644 index 0000000..050b38b --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/AppLanguageTest.kt @@ -0,0 +1,185 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeStringResources +import ai.openclaw.app.i18n.joinedNativeText +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.i18n.verbatimText +import ai.openclaw.app.node.NodePresenceAliveBeacon +import ai.openclaw.app.ui.chat.contextMeterThinkingLabel +import ai.openclaw.app.ui.formatApprovalDuration +import ai.openclaw.app.ui.formatCronWake +import ai.openclaw.app.ui.formatUsageUpdated +import ai.openclaw.app.ui.skillWorkshopStatusLabel +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.LocaleListCompat +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.xmlpull.v1.XmlPullParser + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AppLanguageTest { + @Test + fun supportedLanguagesMatchPackagedTranslations() { + assertEquals( + setOf( + "ar", + "de", + "en", + "es", + "fa", + "fr", + "hi", + "id", + "it", + "ja", + "ko", + "nl", + "pl", + "pt-BR", + "ru", + "sv", + "th", + "tr", + "uk", + "vi", + "zh-CN", + "zh-TW", + ), + AppLanguage.entries.mapNotNull(AppLanguage::languageTag).toSet(), + ) + } + + @Test + fun everyLanguageRoundTripsThroughAndroidLocales() { + AppLanguage.entries.forEach { language -> + assertEquals(language, appLanguageFromLocales(localesForAppLanguage(language))) + } + } + + @Test + fun systemUsesAnEmptyLocaleList() { + assertTrue(localesForAppLanguage(AppLanguage.System).isEmpty) + assertEquals(AppLanguage.System, appLanguageFromLocales(LocaleListCompat.getEmptyLocaleList())) + } + + @Test + fun languageTagsNormalizeAtThePlatformBoundary() { + assertEquals(AppLanguage.Indonesian, AppLanguage.fromLanguageTag("in")) + assertEquals(AppLanguage.PortugueseBrazil, AppLanguage.fromLanguageTag("PT-br")) + assertEquals(AppLanguage.English, AppLanguage.fromLanguageTag("en-US")) + assertEquals(AppLanguage.German, AppLanguage.fromLanguageTag("de-DE")) + assertEquals(AppLanguage.ChineseTraditional, AppLanguage.fromLanguageTag("zh-Hant-HK")) + assertEquals(AppLanguage.System, AppLanguage.fromLanguageTag(null)) + } + + @Test + fun requestedLocaleListUsesTheFirstSupportedLanguage() { + assertEquals( + AppLanguage.French, + appLanguageFromLocales(LocaleListCompat.forLanguageTags("xx,fr-FR,de-DE")), + ) + } + + @Test + fun generatedLocaleConfigMatchesPickerLanguages() { + val parser = RuntimeEnvironment.getApplication().resources.getXml(R.xml._generated_res_locale_config) + val packagedTags = mutableSetOf() + var defaultLocaleTag: String? = null + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG) { + when (parser.name) { + "locale-config" -> defaultLocaleTag = parser.getAttributeValue(androidNamespace, "defaultLocale") + "locale" -> packagedTags += AppLanguage.fromLanguageTag(parser.getAttributeValue(androidNamespace, "name")).languageTag + } + } + parser.next() + } + + assertEquals("en", defaultLocaleTag) + assertEquals(AppLanguage.entries.mapNotNull(AppLanguage::languageTag).toSet(), packagedTags) + } + + @Test + fun everyPickerOptionHasAUniqueLabel() { + val labels = AppLanguage.entries.map(AppLanguage::displayName) + assertFalse(labels.any(String::isBlank)) + assertEquals(labels.size, labels.toSet().size) + } + + @Test + fun systemSubtitleReportsTheActualSystemLocale() { + assertEquals("Follow Android · en-US", appLanguageRowSubtitle(AppLanguage.System, "en-US")) + assertEquals("OpenClaw translations · ja", appLanguageRowSubtitle(AppLanguage.Japanese, "en-US")) + } + + @Test + fun retainedNativeTextResolvesAgainstTheCurrentLocale() { + val activity = Robolectric.buildActivity(LocaleTestActivity::class.java).setup() + NativeStringResources.install(activity.get()) + val retained = MutableStateFlow(nativeText("Mic off")).resolveNativeText() + val retainedComposite = + MutableStateFlow( + joinedNativeText( + separator = " · ", + parts = listOf(nativeText("Mic off"), verbatimText("raw")), + ), + ).resolveNativeText() + val previous = currentAppLanguage() + try { + setAppLanguage(AppLanguage.English) + assertEquals("Mic off", retained.value) + assertEquals("Mic off · raw", retainedComposite.value) + assertEquals("Pending", skillWorkshopStatusLabel("pending")) + + setAppLanguage(AppLanguage.French) + assertEquals("Micro désactivé", retained.value) + assertEquals("Micro désactivé · raw", retainedComposite.value) + assertEquals("En attente", skillWorkshopStatusLabel("pending")) + assertEquals("Retenu", skillWorkshopStatusLabel("quarantined")) + assertEquals("Retenu", skillWorkshopStatusLabel("stale")) + assertEquals("Appliqué", skillWorkshopStatusLabel("applied")) + assertEquals("Rejeté", skillWorkshopStatusLabel("rejected")) + assertEquals("Chargement", skillWorkshopStatusLabel("loading")) + assertEquals("future_status", skillWorkshopStatusLabel("future_status")) + assertEquals("Élevé", contextMeterThinkingLabel("high")) + assertEquals("adaptive", contextMeterThinkingLabel("adaptive")) + val androidRelease = + Build.VERSION.RELEASE + ?.trim() + .orEmpty() + .ifEmpty { "unknown" } + assertEquals( + "Android $androidRelease (SDK ${Build.VERSION.SDK_INT})", + NodePresenceAliveBeacon.androidPlatformMetadata(), + ) + assertEquals("Connexion…", gatewayConnectionStatusForDisplay("Connecting…")) + assertEquals( + "Impossible de charger les approbations.", + gatewayExecApprovalTextForDisplay("Could not load approvals."), + ) + assertEquals("1 min", formatApprovalDuration(60_000)) + assertEquals("2 min", formatUsageUpdated(updatedAtMs = 0, nowMs = 120_000)) + assertEquals("3 h", formatCronWake(timeMs = 10_800_000, nowMs = 0)) + } finally { + setAppLanguage(previous) + activity.destroy() + } + } + + private companion object { + const val androidNamespace = "http://schemas.android.com/apk/res/android" + } +} + +private class LocaleTestActivity : AppCompatActivity() diff --git a/app/src/test/java/ai/openclaw/app/AssistantLaunchTest.kt b/app/src/test/java/ai/openclaw/app/AssistantLaunchTest.kt new file mode 100644 index 0000000..6a7d566 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/AssistantLaunchTest.kt @@ -0,0 +1,42 @@ +package ai.openclaw.app + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AssistantLaunchTest { + @Test + fun parsesAssistGestureIntent() { + val parsed = parseAssistantLaunchIntent(Intent(Intent.ACTION_ASSIST)) + + requireNotNull(parsed) + assertEquals("assist", parsed.source) + assertNull(parsed.prompt) + assertFalse(parsed.autoSend) + } + + @Test + fun parsesAppActionPrompt() { + val parsed = + parseAssistantLaunchIntent( + Intent(actionAskOpenClaw).putExtra(extraAssistantPrompt, " summarize my unread texts "), + ) + + requireNotNull(parsed) + assertEquals("app_action", parsed.source) + assertEquals("summarize my unread texts", parsed.prompt) + assertFalse(parsed.autoSend) + } + + @Test + fun ignoresUnrelatedIntents() { + assertNull(parseAssistantLaunchIntent(Intent(Intent.ACTION_VIEW))) + } +} diff --git a/app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt b/app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt new file mode 100644 index 0000000..0322d52 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt @@ -0,0 +1,17 @@ +package ai.openclaw.app + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class BuildMetadataTest { + @Test + fun debugBuildConfigContainsRepositoryCommitAndUtcBuildTimestamp() { + assertTrue(Regex("^[a-f0-9]{40}$").matches(BuildConfig.GIT_COMMIT)) + assertTrue( + Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$") + .matches(BuildConfig.BUILD_TIMESTAMP), + ) + Instant.parse(BuildConfig.BUILD_TIMESTAMP) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ClawHubSkillRuntimeTest.kt b/app/src/test/java/ai/openclaw/app/ClawHubSkillRuntimeTest.kt new file mode 100644 index 0000000..2606b0d --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ClawHubSkillRuntimeTest.kt @@ -0,0 +1,181 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ClawHubSkillRuntimeTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun unknownInstallOutcomeAllowsSafeExactRetryAndConfirmsProvenance() { + val runtime = createTestRuntime() + seedConnectedAdminRuntime(runtime) + val installCalls = AtomicInteger() + var installed = false + var installTimeoutMs: Long? = null + runtime.gatewayDataRequestTimeoutObserverForTests = { method, timeoutMs -> + if (method == "skills.install") installTimeoutMs = timeoutMs + } + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "skills.install" -> { + installCalls.incrementAndGet() + throw GatewayRequestOutcomeUnknown("response lost") + } + "skills.status" -> skillsStatus(installed) + else -> error("unexpected method $method") + } + } + + val firstInstall = + runtime.installClawHubSkill("registry-slug", version = "1.2.3") + ?: error("install job missing") + runBlocking { firstInstall.join() } + + assertEquals(CLAWHUB_INSTALL_REQUEST_TIMEOUT_MS, installTimeoutMs) + assertTrue( + runtime.clawHubSkillSearchState.value.errorText + .orEmpty() + .contains("result for registry-slug is unknown"), + ) + val retryInstall = + runtime.installClawHubSkill("registry-slug", version = "1.2.3") + ?: error("retry job missing") + runBlocking { retryInstall.join() } + + installed = true + val confirmedInstall = + runtime.installClawHubSkill("registry-slug", version = "1.2.3") + ?: error("confirm job missing") + runBlocking { confirmedInstall.join() } + + assertEquals(3, installCalls.get()) + assertFalse( + runtime.clawHubSkillSearchState.value.errorText + .orEmpty() + .contains("unknown"), + ) + assertEquals("Installed registry-slug.", runtime.clawHubSkillSearchState.value.messageText) + } + + @Test + fun staleGatewayCannotClaimAnInstallAfterGatewaySwitch() { + val runtime = createTestRuntime() + seedConnectedAdminRuntime(runtime) + val installCalls = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, _, _ -> + installCalls.incrementAndGet() + error("stale gateway request must not run") + } + val waitingToClaim = CountDownLatch(1) + runtime.clawHubSkillInstallBeforeClaimObserverForTests = { waitingToClaim.countDown() } + val installMutex = readField(runtime, "clawHubSkillInstallMutex") + runBlocking { installMutex.lock() } + + val installJob = + runtime.installClawHubSkill("registry-slug", version = "1.2.3") + ?: error("install job missing") + assertTrue(waitingToClaim.await(5, TimeUnit.SECONDS)) + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.2", 18789)) + writeField(runtime, "gatewayDataGeneration", readField(runtime, "gatewayDataGeneration") + 1) + readField>(runtime, "_clawHubSkillSearchState").value = + GatewayClawHubSkillSearchState() + installMutex.unlock() + + runBlocking { installJob.join() } + assertTrue( + runtime.clawHubSkillSearchState.value.installingSlugs + .isEmpty(), + ) + assertEquals(0, installCalls.get()) + } + + private fun skillsStatus(installed: Boolean): String = + if (!installed) { + """{"managedSkillsDir":"/tmp/skills","skills":[]}""" + } else { + """{"managedSkillsDir":"/tmp/skills","skills":[{"skillKey":"custom-frontmatter-key","name":"Installed skill","source":"openclaw-managed","disabled":false,"eligible":true,"blockedByAllowlist":false,"blockedByAgentFilter":false,"bundled":false,"clawhub":{"status":"linked","valid":true,"slug":"registry-slug","installedVersion":"1.2.3"}}]}""" + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedAdminRuntime(runtime: NodeRuntime) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + readField>>(runtime, "_operatorScopes").value = listOf("operator.admin") + readField>(runtime, "_clawHubSkillMethodsAvailable").value = true + waitUntil { runtime.operatorAdminScopeAvailable.value } + } + + private fun waitUntil(condition: () -> Boolean) { + repeat(100) { + if (condition()) return + Thread.sleep(10) + } + error("Expected condition to become true") + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + field(target, name).set(target, value) + } + + @Suppress("UNCHECKED_CAST") + private fun readField( + target: Any, + name: String, + ): T = field(target, name).get(target) as T + + private fun field( + target: Any, + name: String, + ): Field { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + return type.getDeclaredField(name).apply { isAccessible = true } + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } +} diff --git a/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt b/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt new file mode 100644 index 0000000..a8bd81c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt @@ -0,0 +1,159 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.resolveNativeText +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CronJobDetailTest { + @Test + fun parsesFullGatewayCronJob() { + val detail = parseGatewayCronJobDetail(parseJob()) + + requireNotNull(detail) + assertEquals("job-1", detail.id) + assertEquals("Daily report", detail.name) + assertEquals("sha256:fixture", detail.configRevision) + assertEquals("cron", detail.scheduleKind) + assertEquals("0 9 * * *", detail.scheduleLabel.resolveNativeText()) + assertEquals("0 9 * * * · Europe/Vienna · Stagger Every 5m", detail.scheduleDetail.resolveNativeText()) + assertEquals("0 9 * * *", detail.scheduleCronExpr) + assertEquals("Europe/Vienna", detail.scheduleTimezone) + assertEquals(300000L, detail.scheduleStaggerMs) + assertEquals("Agent turn · openai/gpt-5.5 · Thinking high", detail.payloadLabel.resolveNativeText()) + assertEquals("Summarize the day", detail.payloadText) + assertEquals("openai/gpt-5.5", detail.payloadModel) + assertEquals("high", detail.payloadThinking) + assertEquals("Announce · telegram · chat-42 · Account primary", detail.deliveryLabel.resolveNativeText()) + assertEquals("After 3 · Announce · telegram · ops · Cooldown Every 1h", detail.failureAlertLabel.resolveNativeText()) + assertEquals(2L, detail.consecutiveErrors) + assertEquals("error", detail.lastRunStatus) + } + + @Test + fun commandDetailsDoNotExposeEnvironmentValues() { + val job = + parseJob( + payload = + """{"kind":"command","argv":["printf","done"],"env":{"API_TOKEN":"secret-value"}}""", + ) + + val detail = parseGatewayCronJobDetail(job) + + requireNotNull(detail) + assertEquals("printf done", detail.payloadText) + assertEquals(listOf("printf", "done"), detail.payloadCommandArgv) + assertFalse(detail.payloadText.orEmpty().contains("secret-value")) + } + + @Test + fun parsesScriptPayloadAsReadOnlyDetail() { + val script = "const result = await agent('check status')" + val detail = + parseGatewayCronJobDetail( + parseJob( + payload = + """{"kind":"script","script":"$script","timeoutSeconds":90,"toolBudget":4}""", + ), + ) + + requireNotNull(detail) + assertEquals("script", detail.payloadKind) + assertEquals(script, detail.payloadText) + assertEquals("Script · Timeout 90s · 4 tools", detail.payloadLabel.resolveNativeText()) + assertNull(detail.payloadCommandArgv) + } + + @Test + fun rejectsIncompleteGatewayCronJob() { + val incomplete = Json.parseToJsonElement("""{"id":"job-1","name":"Missing fields"}""").jsonObject + + assertNull(parseGatewayCronJobDetail(incomplete)) + } + + @Test + fun encodesCronGetIdAsJson() { + val encoded = Json.parseToJsonElement(cronJobGetParams("job-\"quoted\\path")).jsonObject + + assertEquals("job-\"quoted\\path", encoded.getValue("id").jsonPrimitive.content) + } + + @Test + fun requestGuardRejectsOlderSelectionAndCancellation() { + val guard = CronJobDetailRequestGuard() + val first = requireNotNull(guard.begin(" job-1 ")) + val second = requireNotNull(guard.begin("job-2")) + var published = "none" + + assertEquals("job-1", first.id) + assertFalse(guard.publishIfCurrent(first) { published = first.id }) + assertTrue(guard.publishIfCurrent(second) { published = second.id }) + assertEquals("job-2", published) + + guard.cancel() + assertFalse(guard.publishIfCurrent(second) { published = "stale" }) + assertEquals("job-2", published) + assertNull(guard.begin(" ")) + } + + @Test + fun requestGuardConditionsReloadAndCancellationOnCurrentSelection() { + val guard = CronJobDetailRequestGuard() + requireNotNull(guard.begin("job-a")) + requireNotNull(guard.begin("job-b")) + var loadingId = "none" + var cancelled = false + + assertNull(guard.beginIfCurrent("job-a") { loadingId = it.id }) + val reload = guard.beginIfCurrent("job-b") { loadingId = it.id } + assertEquals("job-b", reload?.id) + assertEquals("job-b", loadingId) + assertFalse(guard.cancelIfCurrent("job-a") { cancelled = true }) + assertFalse(cancelled) + assertTrue(guard.cancelIfCurrent("job-b") { cancelled = true }) + assertTrue(cancelled) + } + + private fun parseJob( + payload: String = + """{"kind":"agentTurn","message":"Summarize the day","model":"openai/gpt-5.5","thinking":"high"}""", + ): JsonObject = + Json + .parseToJsonElement( + """ + { + "id": "job-1", + "name": "Daily report", + "description": "Daily digest", + "enabled": true, + "deleteAfterRun": false, + "createdAtMs": 1000, + "updatedAtMs": 2000, + "configRevision": "sha256:fixture", + "schedule": {"kind":"cron","expr":"0 9 * * *","tz":"Europe/Vienna","staggerMs":300000}, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": $payload, + "delivery": {"mode":"announce","channel":"telegram","to":"chat-42","accountId":"primary"}, + "failureAlert": {"after":3,"mode":"announce","channel":"telegram","to":"ops","cooldownMs":3600000}, + "state": { + "nextRunAtMs": 3000, + "lastRunAtMs": 2500, + "lastRunStatus": "error", + "lastError": "boom", + "lastDurationMs": 500, + "consecutiveErrors": 2, + "consecutiveSkipped": 1, + "lastDeliveryStatus": "not-delivered", + "lastDeliveryError": "offline" + } + } + """.trimIndent(), + ).jsonObject +} diff --git a/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt b/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt new file mode 100644 index 0000000..dd47dfa --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt @@ -0,0 +1,555 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.ui.cronWakeModeLabel +import ai.openclaw.app.ui.cronWakeModeOptions +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CronJobManagementTest { + @Test + fun wakeModeLabelsPreserveGatewayCodes() { + assertEquals( + listOf("next-heartbeat", "now"), + cronWakeModeOptions().map { it.code }, + ) + assertEquals("Next heartbeat", cronWakeModeLabel("next-heartbeat")) + assertEquals("Now", cronWakeModeLabel("now")) + assertEquals("future-mode", cronWakeModeLabel("future-mode")) + } + + @Test + fun parsesEveryClosedCronRunOutcome() { + val started = parseGatewayCronRunOutcome(objectJson("""{"ok":true,"ran":true}""")) + val queued = + parseGatewayCronRunOutcome( + objectJson("""{"ok":true,"enqueued":true,"runId":"run-1"}"""), + ) + + assertEquals(GatewayCronRunOutcome.Started(runId = null), started) + assertEquals(GatewayCronRunOutcome.Started(runId = "run-1"), queued) + mapOf( + "not-due" to GatewayCronRunSkipReason.NotDue, + "already-running" to GatewayCronRunSkipReason.AlreadyRunning, + "restart-recovery-pending" to GatewayCronRunSkipReason.RestartRecoveryPending, + "invalid-spec" to GatewayCronRunSkipReason.InvalidSpec, + "stopped" to GatewayCronRunSkipReason.Stopped, + ).forEach { (raw, reason) -> + assertEquals( + GatewayCronRunOutcome.Skipped(reason), + parseGatewayCronRunOutcome( + objectJson("""{"ok":true,"ran":false,"reason":"$raw"}"""), + ), + ) + } + assertEquals( + GatewayCronRunOutcome.Rejected, + parseGatewayCronRunOutcome(objectJson("""{"ok":false}""")), + ) + assertEquals(null, parseGatewayCronRunOutcome(objectJson("""{"ok":true,"ran":false,"reason":"future"}"""))) + assertEquals(null, parseGatewayCronRunOutcome(objectJson("""{"ok":true,"enqueued":true}"""))) + } + + @Test + fun updatePatchIsMinimalAndClearsAgentOverridesWithNull() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + val initial = original.toCronJobEdit() + val payload = initial.payload as GatewayCronPayloadEdit.AgentTurn + val edit = initial.copy(payload = payload.copy(model = "", thinking = "")) + + val root = objectJson(buildCronUpdateParams(original = original, edit = edit)) + val patch = root.getValue("patch").jsonObject + val payloadPatch = patch.getValue("payload").jsonObject + + assertEquals("sha256:fixture", root.getValue("expectedConfigRevision").jsonPrimitive.content) + assertEquals(setOf("payload"), patch.keys) + assertEquals("agentTurn", payloadPatch.getValue("kind").jsonPrimitive.content) + assertEquals(JsonNull, payloadPatch["model"]) + assertEquals(JsonNull, payloadPatch["thinking"]) + assertFalse(payloadPatch.containsKey("message")) + } + + @Test + fun intervalPatchPreservesAnchorAndOmitsUnchangedPayload() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(schedule = """{"kind":"every","everyMs":60000,"anchorMs":1000}"""), + ), + ) + val initial = original.toCronJobEdit() + val schedule = initial.schedule as GatewayCronScheduleEdit.Every + val edit = initial.copy(schedule = schedule.copy(everyMs = "120000")) + + val patch = + objectJson(buildCronUpdateParams(original = original, edit = edit)) + .getValue("patch") + .jsonObject + val schedulePatch = patch.getValue("schedule").jsonObject + + assertEquals(setOf("schedule"), patch.keys) + assertEquals("120000", schedulePatch.getValue("everyMs").jsonPrimitive.content) + assertEquals("1000", schedulePatch.getValue("anchorMs").jsonPrimitive.content) + } + + @Test + fun deleteAfterRunStaysAvailableOnlyForOneShotSchedules() { + val recurring = + requireNotNull( + parseGatewayCronJobDetail(jobJson(deleteAfterRun = true)), + ).toCronJobEdit() + val oneShot = + requireNotNull( + parseGatewayCronJobDetail( + jobJson( + deleteAfterRun = true, + schedule = """{"kind":"at","at":"2026-07-10T09:00:00Z"}""", + ), + ), + ).toCronJobEdit() + + assertFalse(recurring.deleteAfterRun) + assertTrue(oneShot.deleteAfterRun) + assertFalse( + oneShot + .withSchedule(GatewayCronScheduleEdit.Every(everyMs = "60000", anchorMs = "")) + .deleteAfterRun, + ) + } + + @Test + fun commandArgvRejectsNonStringJsonPrimitives() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(payload = """{"kind":"command","argv":["echo"],"cwd":"/tmp"}"""), + ), + ) + val initial = original.toCronJobEdit() + val payload = initial.payload as GatewayCronPayloadEdit.Command + val edit = initial.copy(payload = payload.copy(argvJson = """["echo",1,true,null]""")) + + val error = runCatching { buildCronUpdateParams(original = original, edit = edit) }.exceptionOrNull() + + assertEquals("Command argv entries must be non-empty strings.", error?.message) + } + + @Test + fun commandArgvPreservesWhitespaceOnlyEntriesAllowedByGateway() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(payload = """{"kind":"command","argv":["printf"," "],"cwd":"/tmp"}"""), + ), + ) + val edit = original.toCronJobEdit().copy(name = "Renamed command") + + val patch = + objectJson(buildCronUpdateParams(original = original, edit = edit)) + .getValue("patch") + .jsonObject + + assertEquals(setOf("name"), patch.keys) + assertEquals("Renamed command", patch.getValue("name").jsonPrimitive.content) + } + + @Test + fun commandPayloadRejectsClearingAnExistingWorkingDirectory() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(payload = """{"kind":"command","argv":["echo"],"cwd":"/tmp"}"""), + ), + ) + val initial = original.toCronJobEdit() + val payload = initial.payload as GatewayCronPayloadEdit.Command + + val error = + runCatching { + buildCronUpdateParams( + original = original, + edit = initial.copy(payload = payload.copy(cwd = "")), + ) + }.exceptionOrNull() + + assertEquals("The gateway does not support clearing a command working directory.", error?.message) + } + + @Test + fun scriptPayloadStaysReadOnlyDuringMetadataEdits() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson( + payload = + """{"kind":"script","script":"const result = await agent('check status')","toolBudget":4}""", + ), + ), + ) + val initial = original.toCronJobEdit() + val script = initial.payload as GatewayCronPayloadEdit.ReadOnlyScript + + assertEquals("const result = await agent('check status')", script.script) + + val patch = + objectJson( + buildCronUpdateParams( + original = original, + edit = initial.copy(name = "Renamed script"), + ), + ).getValue("patch").jsonObject + + assertEquals(setOf("name"), patch.keys) + assertEquals("Renamed script", patch.getValue("name").jsonPrimitive.content) + } + + @Test + fun historyParserRequiresTimestampAndKeepsUsefulFields() { + val entries = + Json + .parseToJsonElement( + """ + [ + {"ts":1000,"runId":"run-1","status":"ok","summary":"done","durationMs":42}, + {"runId":"missing-ts"} + ] + """.trimIndent(), + ).jsonArray + + val runs = parseGatewayCronRunHistory(entries) + + assertEquals(1, runs.size) + assertEquals("run-1", runs.single().runId) + assertEquals(42L, runs.single().durationMs) + } + + @Test + fun invalidSpecSkipRefreshesPersistedDiagnosticsWithoutRefreshingOtherSkips() { + assertTrue(cronRunShouldRefresh(GatewayCronRunOutcome.Started(runId = "run-1"))) + assertTrue( + cronRunShouldRefresh( + GatewayCronRunOutcome.Skipped(GatewayCronRunSkipReason.InvalidSpec), + ), + ) + assertFalse( + cronRunShouldRefresh( + GatewayCronRunOutcome.Skipped(GatewayCronRunSkipReason.AlreadyRunning), + ), + ) + assertFalse(cronRunShouldRefresh(GatewayCronRunOutcome.Rejected)) + } + + @Test + fun queuedRunCompletionNoticeMatchesTerminalHistoryStatus() { + listOf( + Triple("ok", "Automation run finished.", GatewayCronNoticeKind.Success), + Triple("skipped", "Automation run skipped.", GatewayCronNoticeKind.Warning), + Triple("error", "Automation run failed.", GatewayCronNoticeKind.Error), + Triple(null, "Automation run finished with an unknown status.", GatewayCronNoticeKind.Warning), + ).forEach { (status, message, kind) -> + assertEquals( + GatewayCronActionState.Notice(id = "job", message = message, kind = kind), + cronRunCompletionNotice("job", status), + ) + } + } + + @Test + fun pendingRunRegistryDedupesOnlyTheSameJobAndIgnoresStaleTrackers() { + val registry = PendingCronRunRegistry() + val snapshots = mutableListOf>() + + assertTrue(registry.begin("job-a", "run-a") { snapshots += it }) + assertFalse(registry.begin("job-a", "run-a-duplicate") { snapshots += it }) + assertTrue(registry.begin("job-b", "run-b") { snapshots += it }) + assertTrue(registry.contains("job-a")) + assertTrue(registry.contains("job-b")) + assertFalse(registry.finish("job-a", "stale-run") { snapshots += it }) + assertTrue(registry.finish("job-a", "run-a") { snapshots += it }) + assertFalse(registry.contains("job-a")) + assertTrue(registry.contains("job-b")) + registry.clear { snapshots += it } + assertFalse(registry.contains("job-b")) + assertEquals( + listOf(setOf("job-a"), setOf("job-a", "job-b"), setOf("job-b"), emptySet()), + snapshots, + ) + } + + @Test + fun mapsCronRevisionConflicts() { + val conflict = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "changed", + details = + GatewayErrorDetails( + code = "CRON_JOB_CHANGED", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ) + val generic = conflict.copy(details = conflict.details?.copy(code = "OTHER")) + + assertTrue(isCronJobRevisionConflict(conflict)) + assertFalse(isCronJobRevisionConflict(generic)) + } + + @Test + fun updateFailsClosedWhenGatewayDoesNotProvideConfigRevision() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson(configRevision = null))) + val error = + runCatching { + buildCronUpdateParams( + original = original, + edit = original.toCronJobEdit().copy(name = "Renamed"), + ) + }.exceptionOrNull() + + assertEquals("Update the gateway before saving cron changes from Android.", error?.message) + } + + @Test + fun detailAndHistoryGenerationsAdvanceIndependently() { + val detailGuard = CronJobDetailRequestGuard() + val historyGuard = CronJobDetailRequestGuard() + val detailA = requireNotNull(detailGuard.begin("job-a")) + val historyA = requireNotNull(historyGuard.begin("job-a")) + val historyB = requireNotNull(historyGuard.begin("job-b")) + var detailPublished = false + var historyPublished = "none" + + assertTrue(detailGuard.publishIfCurrent(detailA) { detailPublished = true }) + assertFalse(historyGuard.publishIfCurrent(historyA) { historyPublished = "a" }) + assertTrue(historyGuard.publishIfCurrent(historyB) { historyPublished = "b" }) + assertTrue(detailPublished) + assertEquals("b", historyPublished) + } + + @Test + fun editorDraftPreservesDirtyFieldsAndMarksIncomingRevisionConflict() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + var draft = CronEditorDraftState.from(original) + draft = draft.withEdit(draft.edit.copy(name = "Unsaved name")) + val unrelated = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(name = "Gateway revision"), + ), + ) + + draft = draft.observeJob(unrelated) + + assertEquals("Unsaved name", draft.edit.name) + assertTrue(draft.isDirty) + assertTrue(draft.hasIncomingConflict) + assertTrue(draft.requiresResolution) + val returnedToBaseline = draft.withEdit(draft.baseline) + assertFalse(returnedToBaseline.isDirty) + assertTrue(returnedToBaseline.requiresResolution) + val reverted = CronEditorDraftState.from(unrelated) + assertEquals("Gateway revision", reverted.edit.name) + assertFalse(reverted.isDirty) + assertFalse(reverted.hasIncomingConflict) + } + + @Test + fun editorDraftIgnoresRuntimeOnlyTimestampUpdates() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + val draft = + CronEditorDraftState + .from(original) + .withEdit(original.toCronJobEdit().copy(name = "Unsaved name")) + val runtimeUpdate = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(updatedAtMs = 3000), + ), + ) + + val observed = draft.observeJob(runtimeUpdate) + + assertEquals("Unsaved name", observed.edit.name) + assertTrue(observed.isDirty) + assertFalse(observed.hasIncomingConflict) + } + + @Test + fun editorDraftAdoptsOnlyTheNewRevisionAfterSuccessfulSave() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + var draft = CronEditorDraftState.from(original) + draft = draft.withEdit(draft.edit.copy(name = "Saved name")) + + draft = draft.saveStarted().saveAborted() + assertFalse(draft.savePending) + assertEquals("Saved name", draft.edit.name) + draft = draft.saveStarted().observeSaveNotice(GatewayCronNoticeKind.Error) + assertEquals("Saved name", draft.edit.name) + draft = draft.saveStarted().observeSaveNotice(GatewayCronNoticeKind.Success) + assertEquals("Saved name", draft.observeJob(original).edit.name) + + val saved = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(name = "Saved name", updatedAtMs = 4000, configRevision = "sha256:saved"), + ), + ) + draft = draft.observeJob(saved) + + assertEquals("Saved name", draft.baseline.name) + assertEquals(draft.baseline, draft.edit) + assertFalse(draft.savePending) + assertFalse(draft.saveSucceeded) + } + + @Test + fun restoredPendingSaveTracksRetainedRuntimeAndRecoversAfterProcessDeath() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + val pending = + CronEditorDraftState + .from(original) + .withEdit(original.toCronJobEdit().copy(name = "Saved name")) + .saveStarted() + val running = GatewayCronActionState.Running(id = original.id, action = GatewayCronAction.Save) + val success = + GatewayCronActionState.Notice( + id = original.id, + message = "Automation updated.", + kind = GatewayCronNoticeKind.Success, + ) + + assertEquals( + pending, + pending.reconcileRestoredAction(isConnected = true, jobId = original.id, actionState = running), + ) + assertEquals( + pending, + pending.reconcileRestoredAction(isConnected = true, jobId = original.id, actionState = success), + ) + assertFalse( + pending + .reconcileRestoredAction( + isConnected = true, + jobId = original.id, + actionState = GatewayCronActionState.Idle, + ).savePending, + ) + assertFalse( + pending + .reconcileRestoredAction( + isConnected = false, + jobId = original.id, + actionState = running, + ).savePending, + ) + + val applied = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(name = "Saved name", updatedAtMs = 4000), + ), + ) + val recovered = pending.saveAborted().observeJob(applied) + assertEquals(recovered.baseline, recovered.edit) + assertFalse(recovered.requiresResolution) + } + + @Test + fun latestRefreshGuardRejectsStaleAndInvalidatedResults() { + val guard = LatestGatewayRefreshGuard() + val stale = guard.begin() + val current = guard.begin() + var published = "none" + + assertFalse(guard.publishIfCurrent(stale) { published = "stale" }) + assertTrue(guard.publishIfCurrent(current) { published = "current" }) + guard.invalidate() + assertFalse(guard.publishIfCurrent(current) { published = "invalidated" }) + assertEquals("current", published) + } + + @Test + fun cronJobsPaginationFollowsEveryGatewayPage() { + assertEquals( + 200, + nextCronJobsPageOffset( + objectJson("""{"total":450,"offset":0,"hasMore":true,"nextOffset":200}"""), + requestedOffset = 0, + pageCount = 200, + ), + ) + assertEquals( + 400, + nextCronJobsPageOffset( + objectJson("""{"total":450,"offset":200,"hasMore":true}"""), + requestedOffset = 200, + pageCount = 200, + ), + ) + assertEquals( + null, + nextCronJobsPageOffset( + objectJson("""{"total":450,"offset":400,"hasMore":false,"nextOffset":null}"""), + requestedOffset = 400, + pageCount = 50, + ), + ) + } + + @Test + fun cronJobsPaginationRejectsNonAdvancingGatewayPages() { + val error = + runCatching { + nextCronJobsPageOffset( + objectJson("""{"total":450,"offset":200,"hasMore":true,"nextOffset":200}"""), + requestedOffset = 200, + pageCount = 200, + ) + }.exceptionOrNull() + + assertEquals("Gateway returned a non-advancing cron jobs page.", error?.message) + } + + private fun objectJson(raw: String) = Json.parseToJsonElement(raw).jsonObject + + private fun jobJson( + name: String = "Daily report", + updatedAtMs: Long = 2000, + configRevision: String? = "sha256:fixture", + deleteAfterRun: Boolean = false, + schedule: String = """{"kind":"cron","expr":"0 9 * * *","tz":"UTC"}""", + payload: String = + """{"kind":"agentTurn","message":"Summarize the day","model":"openai/gpt-5.5","thinking":"high"}""", + ): JsonObject { + val configRevisionField = + configRevision?.let { """"configRevision":"$it",""" }.orEmpty() + return objectJson( + """ + { + "id":"job-1", + "name":"$name", + "description":"Daily digest", + "enabled":true, + "deleteAfterRun":$deleteAfterRun, + "createdAtMs":1000, + "updatedAtMs":$updatedAtMs, + $configRevisionField + "schedule":$schedule, + "sessionTarget":"isolated", + "wakeMode":"next-heartbeat", + "payload":$payload, + "state":{} + } + """.trimIndent(), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/CronJobStatusParsingTest.kt b/app/src/test/java/ai/openclaw/app/CronJobStatusParsingTest.kt new file mode 100644 index 0000000..96e1a58 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/CronJobStatusParsingTest.kt @@ -0,0 +1,40 @@ +package ai.openclaw.app + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CronJobStatusParsingTest { + @Test + fun cronJobLastRunStatusReadsGatewayLastStatus() { + val state = + buildJsonObject { + put("lastStatus", JsonPrimitive(" error ")) + put("lastRunStatus", JsonPrimitive("success")) + } + + assertEquals("error", cronJobLastRunStatus(state)) + } + + @Test + fun cronJobLastRunStatusReadsLastRunStatus() { + val state = + buildJsonObject { + put("lastRunStatus", JsonPrimitive("error")) + } + + assertEquals("error", cronJobLastRunStatus(state)) + } + + @Test + fun cronJobLastRunStatusIgnoresEmptyStatus() { + val state = + buildJsonObject { + put("lastStatus", JsonPrimitive(" ")) + } + + assertNull(cronJobLastRunStatus(state)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt b/app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt new file mode 100644 index 0000000..cda1e60 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt @@ -0,0 +1,371 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class CronRuntimeGuardTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun nonAdminConnectionRejectsMutationBeforeGatewayRequest() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + + runtime.runCronJob("job-1") + + assertEquals( + GatewayCronActionState.Notice( + id = "job-1", + message = "Cron changes require operator.admin access.", + kind = GatewayCronNoticeKind.Error, + ), + runtime.cronActionState.value, + ) + } + + @Test + fun activeCronActionSerializesLaterMutationCalls() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + readField>>(runtime, "_operatorScopes").value = + listOf("operator.admin") + withTimeout(2_000) { + while (!runtime.operatorAdminScopeAvailable.value) delay(10) + } + val actionMutex = readField(runtime, "cronActionMutex") + actionMutex.lock() + try { + runtime.runCronJob("job-1") + runtime.setCronJobEnabled(id = "job-1", enabled = false) + delay(50) + + assertEquals( + GatewayCronActionState.Notice( + id = "job-1", + message = "Another cron action is still finishing.", + kind = GatewayCronNoticeKind.Warning, + ), + runtime.cronActionState.value, + ) + } finally { + actionMutex.unlock() + } + } + + @Test + fun completedDeleteDoesNotClearNewerJobSelection() { + val runtime = createTestRuntime() + val detailState = readField>(runtime, "_cronJobDetailState") + val historyState = readField>(runtime, "_cronRunHistoryState") + requireNotNull(readField(runtime, "cronJobDetailRequestGuard").begin("job-b")) + requireNotNull(readField(runtime, "cronRunHistoryRequestGuard").begin("job-b")) + detailState.value = GatewayCronJobDetailState.Loading("job-b") + historyState.value = GatewayCronRunHistoryState.Loading("job-b") + + invokeStringMethod(runtime, "clearDeletedCronSelection", "job-a") + + assertEquals(GatewayCronJobDetailState.Loading("job-b"), detailState.value) + assertEquals(GatewayCronRunHistoryState.Loading("job-b"), historyState.value) + + invokeStringMethod(runtime, "clearDeletedCronSelection", "job-b") + + assertEquals(GatewayCronJobDetailState.Idle, detailState.value) + assertEquals(GatewayCronRunHistoryState.Idle, historyState.value) + } + + @Test + fun detailDisposalRetainsNoticeUntilExplicitJobDismissal() { + val runtime = createTestRuntime() + val actionState = readField>(runtime, "_cronActionState") + val notice = + GatewayCronActionState.Notice( + id = "job-a", + message = "Automation updated.", + kind = GatewayCronNoticeKind.Success, + ) + actionState.value = notice + + runtime.clearCronJobDetail() + assertEquals(notice, actionState.value) + runtime.dismissCronActionNotice("job-b") + assertEquals(notice, actionState.value) + runtime.dismissCronActionNotice("job-a") + assertEquals(GatewayCronActionState.Idle, actionState.value) + } + + @Test + fun pendingCronRunSurvivesReconnectButClearsWhenGatewayScopeRetires() { + val runtime = createTestRuntime() + val registry = readField(runtime, "pendingCronRunRegistry") + val pending = readField>>(runtime, "_pendingCronRunJobIds") + assertEquals(true, registry.begin("job-1", "run-1") { pending.value = it }) + + invokeBooleanMethod(runtime, "clearOperatorGatewayState", false) + assertEquals(setOf("job-1"), pending.value) + + invokeBooleanMethod(runtime, "clearOperatorGatewayState", true) + assertEquals(emptySet(), pending.value) + } + + @Test + fun refreshCronJobsLoadsEveryGatewayPage() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + val requestedOffsets = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + when (method) { + "cron.status" -> """{"enabled":true,"jobs":201}""" + "cron.list" -> { + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + val offset = + request + .getValue("offset") + .jsonPrimitive.content + .toInt() + assertEquals( + 200, + request + .getValue("limit") + .jsonPrimitive.content + .toInt(), + ) + assertEquals("name", request.getValue("sortBy").jsonPrimitive.content) + requestedOffsets += offset + val jobs = + if (offset == 0) { + (0 until 200).joinToString(",") { cronJobSummaryJson(it) } + } else { + cronJobSummaryJson(200) + } + val hasMore = offset == 0 + val nextOffset = if (hasMore) "200" else "null" + """{"jobs":[$jobs],"snapshotRevision":"rev-1","total":201,"offset":$offset,"limit":200,"hasMore":$hasMore,"nextOffset":$nextOffset}""" + } + else -> error("unexpected method $method") + } + } + + runtime.refreshCronJobs() + withTimeout(5_000) { + while (runtime.cronJobs.value.size != 201 && runtime.cronErrorText.value == null) delay(10) + } + + assertEquals(null, runtime.cronErrorText.value) + assertEquals(201, runtime.cronJobs.value.size) + assertEquals( + "job-200", + runtime.cronJobs.value + .first() + .id, + ) + assertEquals(listOf(0, 200), requestedOffsets) + } + + @Test + fun refreshCronJobsLoadsLegacyGatewayPagesWithoutSnapshotRevision() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + val requestedOffsets = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + when (method) { + "cron.status" -> """{"enabled":true,"jobs":201}""" + "cron.list" -> { + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + val offset = + request + .getValue("offset") + .jsonPrimitive.content + .toInt() + requestedOffsets += offset + val jobs = + if (offset == 0) { + (0 until 200).joinToString(",") { cronJobSummaryJson(it) } + } else { + cronJobSummaryJson(200) + } + val hasMore = offset == 0 + val nextOffset = if (hasMore) "200" else "null" + """{"jobs":[$jobs],"total":201,"offset":$offset,"limit":200,"hasMore":$hasMore,"nextOffset":$nextOffset}""" + } + else -> error("unexpected method $method") + } + } + + runtime.refreshCronJobs() + withTimeout(5_000) { + while (runtime.cronJobs.value.size != 201 && runtime.cronErrorText.value == null) delay(10) + } + + assertEquals(null, runtime.cronErrorText.value) + assertEquals(201, runtime.cronJobs.value.size) + assertEquals(listOf(0, 200), requestedOffsets) + } + + @Test + fun refreshCronJobsRetriesWhenSnapshotRevisionChangesBetweenPages() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + val requestedOffsets = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + when (method) { + "cron.status" -> """{"enabled":true,"jobs":201}""" + "cron.list" -> { + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + val offset = + request + .getValue("offset") + .jsonPrimitive.content + .toInt() + val requestIndex = requestedOffsets.size + requestedOffsets += offset + when (requestIndex) { + 0 -> { + val jobs = (0 until 200).joinToString(",") { cronJobSummaryJson(it) } + """{"jobs":[$jobs],"snapshotRevision":"rev-1","total":201,"offset":0,"limit":200,"hasMore":true,"nextOffset":200}""" + } + 1 -> + """{"jobs":[${cronJobSummaryJson(999)}],"snapshotRevision":"rev-2","total":201,"offset":200,"limit":200,"hasMore":false,"nextOffset":null}""" + 2 -> { + val jobs = (0 until 200).joinToString(",") { cronJobSummaryJson(it) } + """{"jobs":[$jobs],"snapshotRevision":"rev-2","total":201,"offset":0,"limit":200,"hasMore":true,"nextOffset":200}""" + } + else -> + """{"jobs":[${cronJobSummaryJson(200)}],"snapshotRevision":"rev-2","total":201,"offset":200,"limit":200,"hasMore":false,"nextOffset":null}""" + } + } + else -> error("unexpected method $method") + } + } + + runtime.refreshCronJobs() + withTimeout(5_000) { + while (runtime.cronJobs.value.size != 201 && runtime.cronErrorText.value == null) delay(10) + } + + assertEquals(null, runtime.cronErrorText.value) + assertEquals(201, runtime.cronJobs.value.size) + assertEquals(listOf(0, 200, 0, 200), requestedOffsets) + } + + @Test + fun runningStateBlocksMutationAfterMutexRelease() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + readField>>(runtime, "_operatorScopes").value = + listOf("operator.admin") + withTimeout(2_000) { + while (!runtime.operatorAdminScopeAvailable.value) delay(10) + } + val running = GatewayCronActionState.Running(id = "job-1", action = GatewayCronAction.Save) + readField>(runtime, "_cronActionState").value = running + + runtime.runCronJob("job-1") + delay(50) + + assertEquals(running, runtime.cronActionState.value) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.cron.guard.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime(runtime: NodeRuntime) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + } + + private fun cronJobSummaryJson(index: Int): String { + val nextRunAtMs = if (index == 200) 0 else index + 1 + return """{"id":"job-$index","name":"Job $index","enabled":true,"schedule":{"kind":"every","everyMs":60000},"payload":{"kind":"systemEvent","text":"Run"},"state":{"nextRunAtMs":$nextRunAtMs}}""" + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + findField(target, name).set(target, value) + } + + private fun readField( + target: Any, + name: String, + ): T { + @Suppress("UNCHECKED_CAST") + return findField(target, name).get(target) as T + } + + private fun findField( + target: Any, + name: String, + ): Field { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + return type.getDeclaredField(name).apply { isAccessible = true } + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun invokeStringMethod( + target: Any, + name: String, + value: String, + ) { + target.javaClass + .getDeclaredMethod(name, String::class.java) + .apply { isAccessible = true } + .invoke(target, value) + } + + private fun invokeBooleanMethod( + target: Any, + name: String, + value: Boolean, + ) { + target.javaClass + .getDeclaredMethod(name, java.lang.Boolean.TYPE) + .apply { isAccessible = true } + .invoke(target, value) + } +} diff --git a/app/src/test/java/ai/openclaw/app/DreamingRuntimeTest.kt b/app/src/test/java/ai/openclaw/app/DreamingRuntimeTest.kt new file mode 100644 index 0000000..597d726 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/DreamingRuntimeTest.kt @@ -0,0 +1,25 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DreamingRuntimeTest { + @Test + fun missingDiaryDateUsesOwnedFallbackText() { + val entry = parseGatewayDreamDiaryEntry("# Dream\n\nA narrative summary.") + + assertEquals("A narrative summary.", entry?.text) + assertEquals(NativeText.Resource(source = "Dream", formatArgs = emptyList()), entry?.date) + } + + @Test + fun gatewayDiaryDateEqualToFallbackRemainsVerbatim() { + val entry = parseGatewayDreamDiaryEntry("*Dream*\n\nGateway-authored summary.") + + assertEquals("Gateway-authored summary.", entry?.text) + assertTrue(entry?.date is NativeText.Verbatim) + assertEquals(NativeText.Verbatim("Dream"), entry?.date) + } +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt b/app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt new file mode 100644 index 0000000..19e96c8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt @@ -0,0 +1,79 @@ +package ai.openclaw.app + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayAgentSummaryTest { + @Test + fun parsesAvatarAndResolvedAvatarUrlFromAgentsListRow() { + val agent = + parse( + """{"id":"main","name":" Main ","identity":{"emoji":" 🦞 ","avatar":" raw ","avatarUrl":" resolved "},"workspaceGit":true}""", + ) + + assertEquals("main", agent?.id) + assertEquals("Main", agent?.name) + assertEquals("🦞", agent?.emoji) + assertEquals("raw", agent?.avatar) + assertEquals("resolved", agent?.avatarUrl) + assertTrue(agent?.workspaceGit == true) + } + + @Test + fun parsesKindAndExcludesSystemRowsFromSelectableAgents() { + val agents = + parseGatewayAgentSummaries( + Json + .parseToJsonElement( + """{"agents":[{"id":"main","kind":"agent"},{"id":"ordinary-looking-id","kind":"system"},{"id":"legacy"}]}""", + ).jsonObject, + ) + + assertEquals("system", agents[1].kind) + assertEquals(listOf("main", "legacy"), agents.selectableAgents().map(GatewayAgentSummary::id)) + } + + @Test + fun normalizesMissingAndBlankIdentityValues() { + val missing = parse("""{"id":"main"}""") + val blank = + parse( + """{"id":"blank","identity":{"emoji":" ","avatar":"\n","avatarUrl":"\t"},"workspaceGit":false}""", + ) + + assertNull(missing?.name) + assertNull(missing?.avatar) + assertNull(missing?.avatarUrl) + assertFalse(missing?.workspaceGit == true) + assertNull(blank?.emoji) + assertNull(blank?.avatar) + assertNull(blank?.avatarUrl) + } + + @Test + fun ignoresMalformedIdentityShapesAndRowsWithoutIds() { + val malformedIdentity = parse("""{"id":"main","identity":["not-an-object"]}""") + val malformedAvatarFields = + parse( + """{"id":"main","identity":{"avatar":{"data":"x"},"avatarUrl":["x"]}}""", + ) + + assertNull(malformedIdentity?.avatar) + assertNull(malformedIdentity?.avatarUrl) + assertNull(malformedAvatarFields?.avatar) + assertNull(malformedAvatarFields?.avatarUrl) + assertNull(parse("""{"name":"missing id"}""")) + assertNull(parse("""{"id":" "}""")) + assertNull(parse("[]")) + } + + private fun parse(value: String): GatewayAgentSummary? = + parseGatewayAgentSummaries( + Json.parseToJsonElement("""{"agents":[$value]}""").jsonObject, + ).singleOrNull() +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt new file mode 100644 index 0000000..5cb56da --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -0,0 +1,1605 @@ +package ai.openclaw.app + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatTranscriptCache +import ai.openclaw.app.gateway.DeviceAuthStore +import ai.openclaw.app.gateway.DeviceIdentityStore +import ai.openclaw.app.gateway.GatewayConnectOptions +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewayRegistryEntry +import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.GatewayTlsParams +import ai.openclaw.app.gateway.GatewayTlsProbeFailure +import ai.openclaw.app.gateway.GatewayTlsProbeResult +import ai.openclaw.app.node.ConnectionManager +import ai.openclaw.app.node.InvokeDispatcher +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawTalkCommand +import ai.openclaw.app.voice.MicCaptureManager +import ai.openclaw.app.voice.TalkModeManager +import android.Manifest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +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 java.lang.reflect.Field +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewayBootstrapAuthTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun standaloneStatusPreservesLiveOperatorConnection() { + val runtime = createTestRuntime(RuntimeEnvironment.getApplication()) + writeField(runtime, "operatorConnected", true) + val method = runtime.javaClass.getDeclaredMethod("setStandaloneGatewayStatus", String::class.java) + method.isAccessible = true + + method.invoke(runtime, "Verify gateway TLS fingerprint…") + + assertTrue(runtime.gatewayConnectionDisplay.value.isConnected) + assertEquals("Verify gateway TLS fingerprint…", runtime.gatewayConnectionDisplay.value.statusText) + assertNull(runtime.gatewayConnectionDisplay.value.problem) + } + + @Test + fun unstructuredRetryClearsEarlierOperatorAuthProblem() { + val runtime = createTestRuntime(RuntimeEnvironment.getApplication()) + val session = readField(runtime, "operatorSession") + val onDisconnected = readField<(String) -> Unit>(session, "onDisconnected") + val onConnectFailure = readField<(GatewaySession.ErrorShape, Boolean) -> Unit>(session, "onConnectFailure") + + onDisconnected("Gateway error: unauthorized") + onConnectFailure( + GatewaySession.ErrorShape( + code = "UNAUTHORIZED", + message = "unauthorized", + details = + GatewayErrorDetails( + code = "AUTH_TOKEN_MISSING", + canRetryWithDeviceToken = false, + recommendedNextStep = "provide_token", + ), + ), + true, + ) + val problemCode = + runtime.gatewayConnectionDisplay.value.problem + ?.code + assertEquals( + "AUTH_TOKEN_MISSING", + problemCode, + ) + + onDisconnected("Reconnecting…") + assertEquals("Reconnecting…", runtime.gatewayConnectionDisplay.value.statusText) + assertNull(runtime.gatewayConnectionDisplay.value.problem) + + onDisconnected("Gateway error: timeout") + assertEquals("Gateway error: timeout", runtime.gatewayConnectionDisplay.value.statusText) + assertNull(runtime.gatewayConnectionDisplay.value.problem) + } + + @Test + fun retryableNodePairingProblemSurvivesReconnectStatus() { + val runtime = createTestRuntime(RuntimeEnvironment.getApplication()) + val session = readField(runtime, "nodeSession") + val onDisconnected = readField<(String) -> Unit>(session, "onDisconnected") + val onConnectFailure = readField<(GatewaySession.ErrorShape, Boolean) -> Unit>(session, "onConnectFailure") + + onDisconnected("Gateway error: pairing required") + onConnectFailure( + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + reason = "not-paired", + requestId = "request-1", + retryable = true, + ), + ), + false, + ) + + onDisconnected("Reconnecting…") + + val reconnectDisplay = runtime.gatewayConnectionDisplay.value + assertEquals("Reconnecting…", reconnectDisplay.statusText) + assertEquals("PAIRING_REQUIRED", reconnectDisplay.problem?.code) + assertEquals("request-1", reconnectDisplay.problem?.requestId) + + onDisconnected("Gateway error: timeout") + assertNull(runtime.gatewayConnectionDisplay.value.problem) + } + + @Test + fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() { + assertFalse( + resolveOperatorSessionConnectAuth( + NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""), + storedOperatorToken = "", + ) != null, + ) + assertFalse( + resolveOperatorSessionConnectAuth( + NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = null, + ) != null, + ) + } + + @Test + fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() { + assertTrue( + resolveOperatorSessionConnectAuth( + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = null, + ) != null, + ) + assertTrue( + resolveOperatorSessionConnectAuth( + NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"), + storedOperatorToken = null, + ) != null, + ) + assertTrue( + resolveOperatorSessionConnectAuth( + NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = "stored-token", + ) != null, + ) + assertTrue( + resolveOperatorSessionConnectAuth( + NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null), + storedOperatorToken = null, + ) != null, + ) + } + + @Test + fun resolveOperatorSessionConnectAuthUsesStoredTokenPathAfterBootstrapHandoff() { + val resolved = + resolveOperatorSessionConnectAuth( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = "stored-token", + ) + + assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved) + } + + @Test + fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() { + val resolved = + resolveOperatorSessionConnectAuth( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = null, + ) + + assertNull(resolved) + } + + @Test + fun resolveOperatorSessionConnectAuthUsesNoAuthWhenGatewayHasNoAuth() { + val resolved = + resolveOperatorSessionConnectAuth( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), + storedOperatorToken = null, + ) + + assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved) + } + + @Test + fun resolveOperatorSessionConnectAuthPrefersExplicitSharedAuth() { + val resolved = + resolveOperatorSessionConnectAuth( + auth = NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = "shared-password"), + storedOperatorToken = "stored-token", + ) + + assertEquals( + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + resolved, + ) + } + + @Test + fun resolveGatewayControlPageAuthFallsBackToStoredOperatorToken() { + val resolved = + resolveGatewayControlPageAuth( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = " stored-token ", + ) + + assertEquals( + NodeRuntime.GatewayConnectAuth(token = "stored-token", bootstrapToken = null, password = null), + resolved, + ) + } + + @Test + fun resolveGatewayControlPageAuthPrefersExplicitSharedAuth() { + assertEquals( + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + resolveGatewayControlPageAuth( + auth = + NodeRuntime.GatewayConnectAuth( + token = " shared-token ", + bootstrapToken = "bootstrap-1", + password = "shared-password", + ), + storedOperatorToken = "stored-token", + ), + ) + assertEquals( + NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = "shared-password"), + resolveGatewayControlPageAuth( + auth = + NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = "bootstrap-1", + password = " shared-password ", + ), + storedOperatorToken = "stored-token", + ), + ) + } + + @Test + fun operatorConnectScopesForAuthUsesNativeScopesWhenNoStoredOperatorMetadata() { + assertEquals( + listOf( + "operator.admin", + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ), + operatorConnectScopesForAuth( + usesStoredDeviceToken = false, + storedOperatorScopes = null, + ), + ) + } + + @Test + fun operatorConnectScopesForAuthPreservesStoredScopesForReconnects() { + val storedScopes = listOf("operator.approvals", "operator.read", "operator.write") + + assertEquals( + storedScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = true, + storedOperatorScopes = storedScopes, + ), + ) + } + + @Test + fun operatorConnectScopesForAuthFallsBackToLegacyScopesForOldStoredDeviceTokens() { + assertEquals( + ConnectionManager.legacyOperatorScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = true, + storedOperatorScopes = emptyList(), + ), + ) + } + + @Test + fun operatorConnectScopesForAuthUsesNativeScopesForExplicitReauth() { + assertEquals( + ConnectionManager.nativeClientOperatorScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = false, + storedOperatorScopes = listOf("operator.approvals", "operator.read", "operator.write"), + ), + ) + } + + @Test + fun operatorSessionUsesStoredDeviceTokenOnlyWithoutExplicitSharedAuth() { + assertTrue( + operatorSessionUsesStoredDeviceToken( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = "stored-token", + ), + ) + assertFalse( + operatorSessionUsesStoredDeviceToken( + auth = NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + storedOperatorToken = "stored-token", + ), + ) + assertFalse( + operatorSessionUsesStoredDeviceToken( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = "password"), + storedOperatorToken = "stored-token", + ), + ) + } + + @Test + fun nodeConnectStartsOperatorAfterBootstrapHandoffWhenOperatorWasConnecting() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val runtime = NodeRuntime(app, prefs) + val deviceId = DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate().deviceId + val endpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 18789) + DeviceAuthStore(prefs).saveToken(endpoint.stableId, deviceId, "operator", "bootstrap-operator-token") + + writeField(runtime, "operatorStatusText", "Connecting…") + invokeMaybeStartOperatorSessionAfterNodeConnect( + runtime = runtime, + endpoint = endpoint, + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "setup-bootstrap-token", password = null), + ) + + val desired = desiredConnection(runtime, "operatorSession") + assertNotNull(desired) + assertNull(readField(desired!!, "bootstrapToken")) + } + + @Test + fun resolveGatewayConnectAuth_prefersExplicitSetupAuthOverStoredPrefs() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual("gateway.example", 18789) + prefs.saveGatewayCredentials(endpoint.stableId, token = "stale-shared-token", password = "stale-password") + val runtime = NodeRuntime(app, prefs) + + val auth = + runtime.resolveGatewayConnectAuth( + endpoint, + NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = "setup-bootstrap-token", + password = null, + ), + ) + + assertNull(auth.token) + assertEquals("setup-bootstrap-token", auth.bootstrapToken) + assertNull(auth.password) + } + + @Test + fun acceptGatewayTrustPrompt_preservesExplicitSetupAuth() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + prefs.saveGatewayCredentials(endpoint.stableId, token = "stale-shared-token", password = "stale-password") + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "ab".repeat(32)) }, + ) + val explicitAuth = + NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = "setup-bootstrap-token", + password = null, + ) + + runtime.connect(endpoint, explicitAuth) + val prompt = waitForGatewayTrustPrompt(runtime) + assertEquals("setup-bootstrap-token", prompt.auth.bootstrapToken) + + runtime.acceptGatewayTrustPrompt() + + assertEquals("ab".repeat(32), prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "nodeSession")) + assertEquals("ab".repeat(32), runtime.gatewayControlPage.value?.tlsFingerprintSha256) + assertNull(desiredBootstrapToken(runtime, "operatorSession")) + } + + @Test + fun connect_promptsBeforeReplacingChangedTlsFingerprint() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + val oldFingerprint = "aa".repeat(32) + val newFingerprint = "bb".repeat(32) + prefs.saveGatewayTlsFingerprint(endpoint.stableId, oldFingerprint) + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = newFingerprint, systemTrusted = true) }, + ) + + runtime.connect( + endpoint, + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + ) + + val prompt = waitForGatewayTrustPrompt(runtime) + assertEquals(oldFingerprint, prompt.previousFingerprintSha256) + assertEquals(newFingerprint, prompt.fingerprintSha256) + assertTrue(prompt.systemTrustAvailable) + assertEquals(oldFingerprint, prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + + runtime.declineGatewayTrustPrompt() + + assertEquals(oldFingerprint, prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + + runtime.connect( + endpoint, + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + ) + waitForGatewayTrustPrompt(runtime) + runtime.acceptGatewayTrustPrompt() + + assertEquals(newFingerprint, prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + } + + @Test + fun connect_systemTrustedCandidateWithoutStoredPinUsesPlatformTrust() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "bb".repeat(32), systemTrusted = true) }, + ) + + runtime.connect( + endpoint, + NodeRuntime.GatewayConnectAuth(token = "test-token-placeholder", bootstrapToken = null, password = null), + ) + + val desired = waitForDesiredConnection(runtime, "nodeSession") + val tls = readField(desired, "tls") + assertNull(tls.expectedFingerprint) + assertNull(prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + assertEquals(endpoint.stableId, prefs.gatewayRegistry.activeStableId.value) + assertNull(runtime.pendingGatewayTrust.value) + } + + @Test + fun connect_systemTrustedChangedPinSwitchClearsPinAndUsesPlatformTrust() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + val oldFingerprint = "aa".repeat(32) + val newFingerprint = "bb".repeat(32) + prefs.saveGatewayTlsFingerprint(endpoint.stableId, oldFingerprint) + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = newFingerprint, systemTrusted = true) }, + ) + + runtime.connect( + endpoint, + NodeRuntime.GatewayConnectAuth(token = "test-token-placeholder", bootstrapToken = null, password = null), + ) + + val prompt = waitForGatewayTrustPrompt(runtime) + assertTrue(prompt.systemTrustAvailable) + assertEquals(oldFingerprint, prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + + runtime.useSystemGatewayTrustPrompt() + + val desired = waitForDesiredConnection(runtime, "nodeSession") + val tls = readField(desired, "tls") + assertNull(tls.expectedFingerprint) + assertNull(prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + } + + @Test + fun connect_ignoresStaleTlsProbeAfterDisconnect() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + val fingerprint = "aa".repeat(32) + prefs.saveGatewayTlsFingerprint(endpoint.stableId, fingerprint) + val probeStarted = CompletableDeferred() + val probeResult = CompletableDeferred() + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> + probeStarted.complete(Unit) + probeResult.await() + }, + ) + val runtimeScope = readField(runtime, "scope") + val existingJobs = + runtimeScope.coroutineContext[Job] + ?.children + ?.toSet() + .orEmpty() + + runtime.connect( + endpoint, + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + ) + probeStarted.await() + val probeJob = + runtimeScope.coroutineContext[Job] + ?.children + ?.singleOrNull { it !in existingJobs } + ?: error("Expected one TLS probe job") + + runtime.disconnect() + probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = fingerprint)) + // Join the owning coroutine so assertions run after its stale-attempt guard. + probeJob.join() + + assertNull(runtime.pendingGatewayTrust.value) + assertNull(desiredBootstrapToken(runtime, "nodeSession")) + assertEquals(fingerprint, prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + } + + @Test + fun forgetGatewayCancelsInFlightTlsProbeBeforePurgingAuth() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + val probeStarted = CompletableDeferred() + val probeResult = CompletableDeferred() + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> + probeStarted.complete(Unit) + probeResult.await() + }, + ) + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = endpoint.name, + host = endpoint.host, + port = endpoint.port, + ), + ) + prefs.saveGatewayCredentials(endpoint.stableId, token = "shared-token") + + runtime.connect(endpoint) + probeStarted.await() + assertTrue(runtime.forgetGateway(endpoint.stableId)) + probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = "aa".repeat(32))) + yield() + + assertNull( + prefs.gatewayRegistry.entries.value + .firstOrNull { it.stableId == endpoint.stableId }, + ) + assertEquals(GatewayCredentials(), prefs.loadGatewayCredentials(endpoint.stableId)) + assertNull(runtime.pendingGatewayTrust.value) + assertNull(desiredConnection(runtime, "nodeSession")) + } + + @Test + fun refreshGatewayConnection_reconnectsSavedManualEndpointAfterDisconnect() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + + runtime.connect( + GatewayEndpoint.manual(host = "127.0.0.1", port = 18789), + NodeRuntime.GatewayConnectAuth(token = "initial-token", bootstrapToken = null, password = null), + ) + runtime.disconnect() + assertNull(desiredConnection(runtime, "nodeSession")) + + runtime.refreshGatewayConnection() + + val desired = waitForDesiredConnection(runtime, "nodeSession") + val endpoint = readField(desired, "endpoint") + assertEquals("127.0.0.1", endpoint.host) + assertEquals(18789, endpoint.port) + assertEquals("shared-token", readField(desired, "token")) + } + + @Test + fun foregroundAfterExplicitDisconnectStaysOfflineUntilExplicitReconnect() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + + runtime.connect(GatewayEndpoint.manual(host = "127.0.0.1", port = 18789)) + runtime.disconnect() + runtime.setCameraEnabled(true) + runtime.setLocationMode(LocationMode.WhileUsing) + runtime.setForeground(false) + runtime.setForeground(true) + + assertNull(desiredConnection(runtime, "nodeSession")) + + runtime.refreshGatewayConnection() + + val desired = waitForDesiredConnection(runtime, "nodeSession") + assertEquals("127.0.0.1", readField(desired, "endpoint").host) + } + + @Test + fun advertisedSurfaceSettingsReconnectNodeWithCurrentCommands() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + val endpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 18789) + writeField(runtime, "connectedEndpoint", endpoint) + + runtime.setCameraEnabled(true) + + val cameraOptions = + readField( + waitForDesiredConnection(runtime, "nodeSession"), + "options", + ) + assertTrue(cameraOptions.commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertFalse(cameraOptions.commands.contains(OpenClawLocationCommand.Get.rawValue)) + + runtime.setLocationMode(LocationMode.WhileUsing) + + val locationOptions = + readField( + waitForDesiredConnection(runtime, "nodeSession"), + "options", + ) + assertTrue(locationOptions.commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertTrue(locationOptions.commands.contains(OpenClawLocationCommand.Get.rawValue)) + } + + @Test + fun permissionSurfaceReconnectsOnlyAfterAndroidAuthorityChanges() { + val app: android.app.Application = RuntimeEnvironment.getApplication() + shadowOf(app).denyPermissions(Manifest.permission.CAMERA) + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + writeField( + runtime, + "connectedEndpoint", + GatewayEndpoint.manual(host = "127.0.0.1", port = 18789), + ) + + runtime.refreshNodePermissionSurface() + assertNull(desiredConnection(runtime, "nodeSession")) + + shadowOf(app).grantPermissions(Manifest.permission.CAMERA) + runtime.refreshNodePermissionSurface() + + val options = + readField( + waitForDesiredConnection(runtime, "nodeSession"), + "options", + ) + assertTrue(options.permissions.getValue("camera")) + } + + @Test + fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() { + val app = RuntimeEnvironment.getApplication() + val runtime = + NodeRuntime( + app, + SecurePrefs( + app, + app.getSharedPreferences("openclaw.node.secure.test.${UUID.randomUUID()}", android.content.Context.MODE_PRIVATE), + ), + tlsFingerprintProbe = { _, _ -> + GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE) + }, + ) + + runtime.connect( + GatewayEndpoint.manual(host = "gateway.example", port = 18789), + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + ) + + assertEquals( + "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", + waitForStatusText(runtime), + ) + val prompt = waitForGatewayTrustPrompt(runtime) + assertNull(prompt.fingerprintSha256) + assertEquals(GatewayTlsProbeFailure.TLS_UNAVAILABLE, prompt.probeFailure) + } + + @Test + fun connect_enforcesAcceptedManualFingerprintAfterTlsProbeFailure() { + val app = RuntimeEnvironment.getApplication() + val prefs = + SecurePrefs( + app, + app.getSharedPreferences("openclaw.node.secure.test.${UUID.randomUUID()}", android.content.Context.MODE_PRIVATE), + ) + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE) }, + ) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + + runtime.connect( + endpoint, + NodeRuntime.GatewayConnectAuth(token = "test-token-placeholder", bootstrapToken = null, password = null), + ) + waitForGatewayTrustPrompt(runtime) + val manualFingerprint = "cd".repeat(32) + runtime.acceptGatewayTrustPrompt("SHA256: ${manualFingerprint.uppercase()}") + + val desired = waitForDesiredConnection(runtime, "nodeSession") + val tls = readField(desired, "tls") + assertEquals(manualFingerprint, tls.expectedFingerprint) + assertEquals(manualFingerprint, prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + } + + @Test + fun connect_showsTlsTimeoutGuidanceWhenFingerprintProbeTimesOut() { + val app = RuntimeEnvironment.getApplication() + val runtime = + NodeRuntime( + app, + SecurePrefs( + app, + app.getSharedPreferences("openclaw.node.secure.test.${UUID.randomUUID()}", android.content.Context.MODE_PRIVATE), + ), + tlsFingerprintProbe = { _, _ -> + GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT) + }, + ) + + runtime.connect( + GatewayEndpoint.manual(host = "gateway.example", port = 18789), + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + ) + + assertEquals( + "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", + waitForStatusText(runtime), + ) + val prompt = waitForGatewayTrustPrompt(runtime) + assertNull(prompt.fingerprintSha256) + assertEquals(GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT, prompt.probeFailure) + } + + @Test + fun resetGatewaySetupAuth_clearsOnlyTargetGatewayCredentialsAndDeviceTokens() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val runtime = NodeRuntime(app, prefs) + val deviceId = DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate().deviceId + val authStore = DeviceAuthStore(prefs) + val target = GatewayEndpoint.manual("target.example", 18789).stableId + val other = GatewayEndpoint.manual("other.example", 18789).stableId + prefs.saveGatewayCredentials(target, token = "target-token") + prefs.saveGatewayCredentials(other, token = "other-token") + authStore.saveToken(target, deviceId, "node", "target-node-token") + authStore.saveToken(other, deviceId, "node", "other-node-token") + + assertTrue(runtime.resetGatewaySetupAuth(target)) + + assertEquals(GatewayCredentials(), prefs.loadGatewayCredentials(target)) + assertEquals("other-token", prefs.loadGatewayCredentials(other).token) + assertNull(authStore.loadToken(target, deviceId, "node")) + assertEquals("other-node-token", authStore.loadToken(other, deviceId, "node")) + } + + @Test + fun resetGatewaySetupAuthClearsInjectedTranscriptStore() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val transcriptCache = RecordingTranscriptCache() + val runtime = NodeRuntime(app, prefs, transcriptCache) + val target = GatewayEndpoint.manual("target.example", 18789).stableId + + assertTrue(runtime.resetGatewaySetupAuth(target)) + + assertEquals(listOf(target), transcriptCache.clearedGatewayIds) + } + + @Test + fun switchToUndiscoveredGatewayKeepsCurrentConnectionAndActiveGateway() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val runtime = NodeRuntime(app, prefs) + neutralizeColdStartAutoConnect(runtime) + val current = GatewayEndpoint.manual("127.0.0.1", 18789) + val missingStableId = "bonjour-missing" + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = current.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = current.name, + host = current.host, + port = current.port, + tls = false, + ), + ) + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = missingStableId, + kind = GatewayRegistryEntryKind.DISCOVERED, + name = "Missing gateway", + ), + ) + prefs.gatewayRegistry.setActive(current.stableId) + writeField(runtime, "connectedEndpoint", current) + + assertFalse(runBlocking { runtime.switchToGateway(missingStableId) }) + + assertEquals(current, readField(runtime, "connectedEndpoint")) + assertEquals(current.stableId, prefs.gatewayRegistry.activeStableId.value) + assertEquals("Gateway not currently discoverable", runtime.statusText.value) + } + + @Test + fun gatewayConnectDoesNotHoldAuthMonitorWhileWaitingForSessionLifecycle() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val runtime = NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + val endpoint = GatewayEndpoint.manual("127.0.0.1", 18789) + val auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap", password = null) + val nodeSession = readField(runtime, "nodeSession") + val lifecycleLock = readField(nodeSession, "lifecycleLock") + val connectWithAuth = + runtime.javaClass.declaredMethods.single { method -> + method.name == "connectWithAuth" && method.parameterTypes.size == 4 + } + connectWithAuth.isAccessible = true + val lockHeld = CompletableDeferred() + val releaseLock = CompletableDeferred() + val lifecycleDispatcher = Executors.newFixedThreadPool(3).asCoroutineDispatcher() + val lockHolder = + async(lifecycleDispatcher) { + synchronized(lifecycleLock) { + lockHeld.complete(Unit) + runBlocking { releaseLock.await() } + } + } + lockHeld.await() + + val connect = + async(lifecycleDispatcher) { + connectWithAuth.invoke(runtime, endpoint, auth, false, { Unit }) + } + try { + withTimeout(5_000) { + while (readField(runtime, "gatewayConnectOperationsInFlight") == 0) delay(10) + } + val callback = + async(lifecycleDispatcher) { + val method = + runtime.javaClass.getDeclaredMethod( + "maybeStartOperatorSessionAfterNodeConnect", + GatewayEndpoint::class.java, + NodeRuntime.GatewayConnectAuth::class.java, + ) + method.isAccessible = true + method.invoke(runtime, endpoint, auth) + } + withTimeout(1_000) { callback.await() } + } finally { + releaseLock.complete(Unit) + try { + withTimeout(5_000) { + lockHolder.await() + connect.await() + } + } finally { + lifecycleDispatcher.close() + runtime.disconnect() + readField(runtime, "scope").coroutineContext[Job]?.cancel() + } + } + Unit + } + + @Test + fun restoredManualMicWithoutRecordAudioClearsStalePreference() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).denyPermissions(Manifest.permission.RECORD_AUDIO) + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + prefs.setVoiceMicEnabled(true) + + val runtime = NodeRuntime(app, prefs) + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(prefs.voiceMicEnabled.value) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } + + @Test + fun revokedRecordAudioPermissionStopsGatewayPttBeforeMicStart() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + writeField(talkMode, "activePttCaptureId", "capture-1") + talkMode.ttsOnAllResponses = true + readField>(runtime, "externalAudioCaptureActive").value = true + shadowOf(app).denyPermissions(Manifest.permission.RECORD_AUDIO) + + runtime.setMicEnabled(true) + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertNull(talkMode.activePushToTalkCaptureId) + assertFalse(talkMode.ttsOnAllResponses) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + assertFalse(runtime.prefs.voiceMicEnabled.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun voiceNoteMicOwnershipBlocksLocalVoiceAndGatewayPtt() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val dispatcher = readField(runtime, "invokeDispatcher") + Dispatchers.setMain(Dispatchers.Unconfined) + try { + assertTrue(runtime.tryAcquireVoiceNoteMic()) + + runtime.setMicEnabled(true) + runtime.setTalkModeEnabled(true) + val ptt = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertEquals("MIC_BUSY", ptt.error?.code) + assertEquals("MIC_BUSY: voice note recording is active", ptt.error?.message) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } finally { + runtime.releaseVoiceNoteMic() + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun dictationMicOwnershipBlocksLocalVoiceAndGatewayPtt() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val dispatcher = readField(runtime, "invokeDispatcher") + Dispatchers.setMain(Dispatchers.Unconfined) + try { + assertTrue(runtime.tryAcquireDictationMic()) + + runtime.setMicEnabled(true) + runtime.setTalkModeEnabled(true) + val ptt = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertEquals("MIC_BUSY", ptt.error?.code) + assertEquals("MIC_BUSY: dictation is active", ptt.error?.message) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } finally { + runtime.releaseDictationMic() + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun talkPttStart_cleansPreparedCaptureWhenBeginFails() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val dispatcher = readField(runtime, "invokeDispatcher") + Dispatchers.setMain(Dispatchers.Unconfined) + try { + val result = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) + + assertEquals("UNAVAILABLE", result.error?.code) + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + val talkMode = readField>(runtime, "talkMode\$delegate").value + assertFalse(talkMode.ttsOnAllResponses) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun talkPttStart_rejectsNewCaptureWhenBackgrounded() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + runtime.setForeground(false) + val dispatcher = readField(runtime, "invokeDispatcher") + + val result = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) + + assertEquals("NODE_BACKGROUND_UNAVAILABLE", result.error?.code) + assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", result.error?.message) + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } + + @Test + fun staleTalkPttCleanupPreservesNewerManualMicOwnership() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val ownershipEpoch = readField(runtime, "voiceCaptureOwnershipEpoch") + ownershipEpoch.set(41L) + + runtime.setMicEnabled(true) + val cleanup = runtime.javaClass.getDeclaredMethod("cleanupFailedTalkCapture", Long::class.javaPrimitiveType) + cleanup.isAccessible = true + cleanup.invoke(runtime, 41L) + + assertEquals(VoiceCaptureMode.ManualMic, runtime.voiceCaptureMode.value) + assertTrue(readField>(runtime, "externalAudioCaptureActive").value) + } + + @Test + fun talkPttOnceRetryReturnsBusyWithoutPreparingCapture() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + writeField(talkMode, "activePttCaptureId", "capture-1") + val dispatcher = readField(runtime, "invokeDispatcher") + val preparationMutex = readField(runtime, "voiceCapturePreparationMutex") + preparationMutex.lock() + try { + val retry = + withTimeout(1_000) { dispatcher.handleInvoke(OpenClawTalkCommand.PttOnce.rawValue, null) } + assertNull(retry.error) + assertEquals("""{"captureId":"capture-1","status":"busy"}""", retry.payloadJson) + assertEquals("capture-1", talkMode.activePushToTalkCaptureId) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } finally { + preparationMutex.unlock() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun talkPttOnceRechecksFinishingTurnAfterPreparationWait() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + val dispatcher = readField(runtime, "invokeDispatcher") + val preparationMutex = readField(runtime, "voiceCapturePreparationMutex") + preparationMutex.lock() + try { + val request = async { dispatcher.handleInvoke(OpenClawTalkCommand.PttOnce.rawValue, null) } + yield() + writeField(talkMode, "finishingPttCaptureId", "capture-finishing") + preparationMutex.unlock() + + val result = withTimeout(5_000) { request.await() } + + assertNull(result.error) + assertEquals("""{"captureId":"capture-finishing","status":"busy"}""", result.payloadJson) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + } finally { + if (preparationMutex.isLocked) preparationMutex.unlock() + } + } + + @Test + fun talkPttStartRejectsFinishingTurnWithoutPreparingCapture() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + writeField(talkMode, "finishingPttCaptureId", "capture-1") + val dispatcher = readField(runtime, "invokeDispatcher") + val preparationMutex = readField(runtime, "voiceCapturePreparationMutex") + preparationMutex.lock() + try { + val retry = + withTimeout(1_000) { dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) } + + assertEquals("PTT_BUSY", retry.error?.code) + assertEquals("PTT_BUSY: previous push-to-talk turn is still finishing", retry.error?.message) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } finally { + preparationMutex.unlock() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun pttStartQueuedAfterCancelUsesNewCommandEpoch() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val dispatcher = readField(runtime, "invokeDispatcher") + val preparationMutex = readField(runtime, "voiceCapturePreparationMutex") + Dispatchers.setMain(Dispatchers.Unconfined) + try { + preparationMutex.lock() + val cancel = async { dispatcher.handleInvoke(OpenClawTalkCommand.PttCancel.rawValue, null) } + yield() + val start = async { dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) } + yield() + preparationMutex.unlock() + + assertNull(withTimeout(5_000) { cancel.await() }.error) + assertEquals("UNAVAILABLE", withTimeout(5_000) { start.await() }.error?.code) + val talkMode = readField>(runtime, "talkMode\$delegate").value + assertNull(talkMode.activePushToTalkCaptureId) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } finally { + if (preparationMutex.isLocked) preparationMutex.unlock() + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun pttStartWaitingForPreparationIsInvalidatedByCancel() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val dispatcher = readField(runtime, "invokeDispatcher") + val preparationMutex = readField(runtime, "voiceCapturePreparationMutex") + Dispatchers.setMain(Dispatchers.Unconfined) + preparationMutex.lock() + try { + val start = async { dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) } + yield() + val cancel = async { dispatcher.handleInvoke(OpenClawTalkCommand.PttCancel.rawValue, null) } + yield() + preparationMutex.unlock() + + assertEquals("NODE_BACKGROUND_UNAVAILABLE", withTimeout(5_000) { start.await() }.error?.code) + assertNull(withTimeout(5_000) { cancel.await() }.error) + val talkMode = readField>(runtime, "talkMode\$delegate").value + assertNull(talkMode.activePushToTalkCaptureId) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } finally { + if (preparationMutex.isLocked) preparationMutex.unlock() + Dispatchers.resetMain() + } + } + + @Test + fun sameManualMicModeReassertsCaptureAndInvalidatesPendingPtt() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + runtime.setMicEnabled(true) + val commandEpoch = readField(runtime, "talkPttCommandEpoch") + val epochBeforeReassertion = commandEpoch.get() + val micCapture = readField>(runtime, "micCapture\$delegate").value + val talkMode = readField>(runtime, "talkMode\$delegate").value + micCapture.setMicEnabled(false) + writeField(talkMode, "activePttCaptureId", "capture-stale") + + runtime.setMicEnabled(true) + + assertTrue(runtime.micEnabled.value) + assertNull(talkMode.activePushToTalkCaptureId) + assertTrue(commandEpoch.get() > epochBeforeReassertion) + assertEquals(VoiceCaptureMode.ManualMic, runtime.voiceCaptureMode.value) + } + + @Test + fun sameTalkModeReassertionStopsManualMicCapture() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + readField(runtime, "scope").coroutineContext[Job]?.cancel() + runtime.setTalkModeEnabled(true) + val micCapture = readField>(runtime, "micCapture\$delegate").value + micCapture.setMicEnabled(true) + + runtime.setTalkModeEnabled(true) + + assertFalse(runtime.micEnabled.value) + assertEquals(VoiceCaptureMode.TalkMode, runtime.voiceCaptureMode.value) + val talkMode = readField>(runtime, "talkMode\$delegate").value + assertTrue(talkMode.isEnabled.value) + } + + @Test + fun backgroundingStopsTalkModeCapture() { + val app = RuntimeEnvironment.getApplication() + val runtime = createTestRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + readField>(runtime, "_voiceCaptureMode").value = VoiceCaptureMode.TalkMode + readField>(talkMode, "_isEnabled").value = true + readField>(runtime, "externalAudioCaptureActive").value = true + talkMode.ttsOnAllResponses = true + + assertEquals(VoiceCaptureMode.TalkMode, runtime.voiceCaptureMode.value) + assertTrue(talkMode.isEnabled.value) + assertTrue(readField>(runtime, "externalAudioCaptureActive").value) + + runtime.setForeground(false) + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(talkMode.isEnabled.value) + assertFalse(talkMode.ttsOnAllResponses) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } + + @Test + fun backgroundingStopsGatewayPttWhenVoiceModeIsOff() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val runtime = createTestRuntime(app) + val talkMode = readField>(runtime, "talkMode\$delegate").value + writeField(talkMode, "activePttCaptureId", "capture-1") + readField>(runtime, "externalAudioCaptureActive").value = true + + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + + runtime.setForeground(false) + + assertNull(readField(talkMode, "activePttCaptureId")) + assertEquals(VoiceCaptureMode.Off, runtime.voiceCaptureMode.value) + assertFalse(readField>(runtime, "externalAudioCaptureActive").value) + } + + @Test + fun coldStartAutoConnectConnectsSavedActiveGatewayWhenNoExplicitIntentExists() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + + invokeAutoConnectIfNeeded(runtime) + + val desired = desiredConnection(runtime, "nodeSession") ?: error("Expected desired node connection") + assertEquals("127.0.0.1", readField(desired, "endpoint").host) + assertEquals("shared-token", readField(desired, "token")) + } + + @Test + fun coldStartAutoConnectStandsDownAfterExplicitLifecycleIntent() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + runtime.disconnect() + + invokeAutoConnectIfNeeded(runtime) + + assertNull(desiredConnection(runtime, "nodeSession")) + } + + // Arms the registry only after the runtime's background work is neutralized, so the real + // discovery collector can never observe an auto-connectable active gateway. + private fun createNeutralizedRuntime(): Pair { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val runtime = NodeRuntime(app, prefs) + neutralizeColdStartAutoConnect(runtime) + return runtime to prefs + } + + private fun armSavedActiveManualGateway(prefs: SecurePrefs) { + prefs.setManualEnabled(true) + prefs.setManualHost("127.0.0.1") + prefs.setManualPort(18789) + prefs.setManualTls(false) + val savedEndpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 18789) + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = savedEndpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = savedEndpoint.name, + host = savedEndpoint.host, + port = savedEndpoint.port, + tls = false, + ), + ) + prefs.gatewayRegistry.setActive(savedEndpoint.stableId) + prefs.saveGatewayCredentials(savedEndpoint.stableId, token = "shared-token") + } + + private fun invokeAutoConnectIfNeeded(runtime: NodeRuntime) { + val method = runtime.javaClass.getDeclaredMethod("autoConnectIfNeeded") + method.isAccessible = true + method.invoke(runtime) + } + + // NodeRuntime's init collects gateway discovery on a background dispatcher and auto-connects + // the saved active gateway whenever that collector happens to run, racing scripted lifecycle + // steps (observed as CI-only flakes on loaded Linux runners). Cancel and join all runtime-scope + // work so nothing runs in the background; arm the registry only afterwards. + private fun neutralizeColdStartAutoConnect(runtime: NodeRuntime) { + runBlocking { readField(runtime, "scope").coroutineContext[Job]?.cancelAndJoin() } + } + + private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt { + repeat(50) { + runtime.pendingGatewayTrust.value?.let { return it } + Thread.sleep(10) + } + error("Expected pending gateway trust prompt") + } + + private fun createTestRuntime(app: android.app.Application): NodeRuntime { + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun waitForStatusText(runtime: NodeRuntime): String { + repeat(50) { + val status = runtime.statusText.value + if (status != "Verify gateway TLS fingerprint…") { + return status + } + Thread.sleep(10) + } + error("Expected status text update") + } + + private fun desiredBootstrapToken( + runtime: NodeRuntime, + sessionFieldName: String, + ): String? { + val desired = desiredConnection(runtime, sessionFieldName) ?: return null + return readField(desired, "bootstrapToken") + } + + private fun desiredConnection( + runtime: NodeRuntime, + sessionFieldName: String, + ): Any? { + val session = readField(runtime, sessionFieldName) + return readField(session, "desired") + } + + private fun waitForDesiredConnection( + runtime: NodeRuntime, + sessionFieldName: String, + ): Any { + repeat(50) { + desiredConnection(runtime, sessionFieldName)?.let { return it } + Thread.sleep(10) + } + error("Expected desired connection for $sessionFieldName") + } + + private fun invokeMaybeStartOperatorSessionAfterNodeConnect( + runtime: NodeRuntime, + endpoint: GatewayEndpoint, + auth: NodeRuntime.GatewayConnectAuth, + ) { + val method = + runtime.javaClass.getDeclaredMethod( + "maybeStartOperatorSessionAfterNodeConnect", + GatewayEndpoint::class.java, + NodeRuntime.GatewayConnectAuth::class.java, + ) + method.isAccessible = true + method.invoke(runtime, endpoint, auth) + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + field.set(target, value) + return + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun waitForDesiredBootstrapToken( + runtime: NodeRuntime, + sessionFieldName: String, + ): String { + var lastObserved: String? = null + repeat(50) { + desiredBootstrapToken(runtime, sessionFieldName)?.let { token -> + lastObserved = token + return token + } + Thread.sleep(10) + } + error("Expected desired bootstrap token for $sessionFieldName; last observed=$lastObserved") + } + + private fun readField( + target: Any, + name: String, + ): T { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(target) as T + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private class RecordingTranscriptCache : ChatTranscriptCache { + val clearedGatewayIds = mutableListOf() + + override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = null + + override suspend fun saveLastDefaultAgentId( + gatewayId: String, + agentId: String, + ) = Unit + + override suspend fun loadSessions( + gatewayId: String, + agentId: String, + ): List = emptyList() + + override suspend fun loadTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + ): List = emptyList() + + override suspend fun saveSessions( + gatewayId: String, + agentId: String, + sessions: List, + retainedSessionKey: String?, + ) = Unit + + override suspend fun saveTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + messages: List, + ) = Unit + + override suspend fun deleteSession( + gatewayId: String, + agentId: String, + sessionKey: String, + ) = Unit + + override suspend fun clearGateway(gatewayId: String) { + clearedGatewayIds += gatewayId + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayConnectionDisplayTest.kt b/app/src/test/java/ai/openclaw/app/GatewayConnectionDisplayTest.kt new file mode 100644 index 0000000..4c3a921 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayConnectionDisplayTest.kt @@ -0,0 +1,56 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class GatewayConnectionDisplayTest { + @Test + fun operatorProblemStaysCorrelatedWhenNodeConnects() { + val operatorProblem = problem("AUTH_TOKEN_MISSING") + val nodeProblem = problem("DEVICE_IDENTITY_REQUIRED") + + val display = + gatewayConnectionDisplay( + operatorConnected = false, + nodeConnected = true, + operatorStatusText = "Gateway error: unauthorized", + nodeStatusText = "Connected", + operatorProblem = operatorProblem, + nodeProblem = nodeProblem, + ) + + assertEquals("Connected (operator: Gateway error: unauthorized)", display.statusText) + assertSame(operatorProblem, display.problem) + } + + @Test + fun nodeProblemIsSelectedWhenOperatorHasNoStatus() { + val operatorProblem = problem("AUTH_TOKEN_MISSING") + val nodeProblem = problem("DEVICE_IDENTITY_REQUIRED") + + val display = + gatewayConnectionDisplay( + operatorConnected = false, + nodeConnected = false, + operatorStatusText = "Offline", + nodeStatusText = "Gateway error: device identity required", + operatorProblem = operatorProblem, + nodeProblem = nodeProblem, + ) + + assertEquals("Gateway error: device identity required", display.statusText) + assertSame(nodeProblem, display.problem) + } + + private fun problem(code: String): GatewayConnectionProblem = + GatewayConnectionProblem( + code = code, + message = code, + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayDevicePairingRuntimeTest.kt b/app/src/test/java/ai/openclaw/app/GatewayDevicePairingRuntimeTest.kt new file mode 100644 index 0000000..002f302 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayDevicePairingRuntimeTest.kt @@ -0,0 +1,188 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewayDevicePairingRuntimeTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun approveRefetchesListAndPublishesOnlyVerifiedSuccess() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + seedNodesDevices(runtime, pending = listOf(pendingDevice())) + val requests = mutableListOf>() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + requests += method to params + when (method) { + "device.pair.approve" -> """{"requestId":"request-1","device":{"deviceId":"device-1"}}""" + "device.pair.list" -> + """{"pending":[],"paired":[{"deviceId":"device-1","displayName":"Pixel","roles":["operator"],"scopes":["operator.read"],"tokens":[],"approvedAtMs":2}]}""" + else -> error("unexpected method $method") + } + } + + runtime.approveDevicePairing("request-1", "device-1") + waitUntil { runtime.devicePairingMutation.value == null && runtime.nodesDevicesNoticeText.value != null } + + assertEquals( + listOf("device.pair.approve", "device.pair.list"), + requests.map { it.first }, + ) + assertEquals("""{"requestId":"request-1"}""", requests.first().second) + assertEquals("Device approved.", runtime.nodesDevicesNoticeText.value) + assertEquals( + listOf("device-1"), + runtime.nodesDevicesSummary.value.pairedDevices + .map { it.deviceId }, + ) + assertEquals(emptyList(), runtime.nodesDevicesSummary.value.pendingDevices) + } + + @Test + fun ambiguousWriteKeepsOutcomeUnverifiedAfterListReadback() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + seedNodesDevices(runtime, pending = listOf(pendingDevice())) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "device.pair.reject" -> error("outcome unknown") + "device.pair.list" -> """{"pending":[],"paired":[]}""" + else -> error("unexpected method $method") + } + } + + runtime.rejectDevicePairing("request-1") + waitUntil { runtime.devicePairingMutation.value == null && runtime.nodesDevicesErrorText.value != null } + + assertEquals(null, runtime.nodesDevicesNoticeText.value) + assertEquals( + "Could not verify the device pairing change. Refresh and try again.", + runtime.nodesDevicesErrorText.value, + ) + } + + @Test + fun definitivePairingDenialPreservesGatewayErrorMessage() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + seedNodesDevices(runtime, pending = listOf(pendingDevice())) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "device.pair.approve" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape("INVALID_REQUEST", "device pairing approval denied"), + ) + "device.pair.list" -> """{"pending":[{"requestId":"request-1","deviceId":"device-1","roles":["operator"],"scopes":["operator.read"]}],"paired":[]}""" + else -> error("unexpected method $method") + } + } + + runtime.approveDevicePairing("request-1", "device-1") + waitUntil { runtime.devicePairingMutation.value == null && runtime.nodesDevicesErrorText.value != null } + + assertEquals(null, runtime.nodesDevicesNoticeText.value) + assertEquals("device pairing approval denied", runtime.nodesDevicesErrorText.value) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.device.pairing.runtime.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime(runtime: NodeRuntime) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + readField>(runtime, "_devicePairingCapabilities").value = + GatewayDevicePairingCapabilities( + canList = true, + canApprove = true, + canReject = true, + canRemove = true, + ) + } + + private fun seedNodesDevices( + runtime: NodeRuntime, + pending: List, + ) { + readField>(runtime, "_nodesDevicesSummary").value = + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = pending, + pairedDevices = emptyList(), + ) + } + + private fun pendingDevice(): GatewayPendingDeviceSummary = + GatewayPendingDeviceSummary( + requestId = "request-1", + deviceId = "device-1", + displayName = "Pixel", + remoteIp = null, + roles = listOf("operator"), + scopes = listOf("operator.read"), + requestedAtMs = 1L, + repair = false, + ) + + private suspend fun waitUntil(condition: () -> Boolean) { + withTimeout(10_000) { + while (!condition()) delay(10) + } + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + findField(target, name).set(target, value) + } + + @Suppress("UNCHECKED_CAST") + private fun readField( + target: Any, + name: String, + ): T = findField(target, name).get(target) as T + + private fun findField( + target: Any, + name: String, + ): Field = + target.javaClass + .getDeclaredField(name) + .apply { isAccessible = true } +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayDevicePairingTest.kt b/app/src/test/java/ai/openclaw/app/GatewayDevicePairingTest.kt new file mode 100644 index 0000000..7ccf0dd --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayDevicePairingTest.kt @@ -0,0 +1,159 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayDevicePairingTest { + private val allMethods = + setOf( + "device.pair.list", + "device.pair.approve", + "device.pair.reject", + "device.pair.remove", + ) + + @Test + fun capabilitiesRequireAdvertisedMethodAndPairingOrAdminScope() { + assertEquals( + GatewayDevicePairingCapabilities(), + selectGatewayDevicePairingCapabilities(allMethods, listOf("operator.read")), + ) + + val pairing = selectGatewayDevicePairingCapabilities(allMethods, listOf("operator.pairing")) + assertTrue(pairing.canList) + assertTrue(pairing.canApprove) + assertTrue(pairing.canReject) + assertFalse(pairing.canRemove) + assertTrue(pairing.canManage) + + val admin = selectGatewayDevicePairingCapabilities(allMethods, listOf("operator.admin")) + assertTrue(admin.canManage) + assertTrue(admin.supports(GatewayDevicePairingAction.Approve)) + assertTrue(admin.supports(GatewayDevicePairingAction.Reject)) + assertTrue(admin.supports(GatewayDevicePairingAction.Remove)) + + val missingList = + selectGatewayDevicePairingCapabilities( + methods = allMethods - "device.pair.list", + scopes = listOf("operator.admin"), + ) + assertFalse(missingList.supports(GatewayDevicePairingAction.Approve)) + } + + @Test + fun mutationParamsMatchGatewayProtocolIds() { + assertEquals( + """{"requestId":"request-1"}""", + buildGatewayDevicePairingMutationParams( + GatewayDevicePairingMutation(GatewayDevicePairingAction.Approve, "request-1"), + ).toString(), + ) + assertEquals( + """{"deviceId":"device-1"}""", + buildGatewayDevicePairingMutationParams( + GatewayDevicePairingMutation(GatewayDevicePairingAction.Remove, "device-1"), + ).toString(), + ) + } + + @Test + fun pairingOnlyCallerCannotApproveScopesItDoesNotHave() { + val pairingOnly = + selectGatewayDevicePairingCapabilities(allMethods, listOf("operator.pairing", "operator.read")) + val pending = pending("request-1", "device-1").copy(scopes = listOf("operator.read", "operator.admin")) + + assertFalse(canApproveGatewayDevicePairing(pairingOnly, listOf("operator.pairing", "operator.read"), pending)) + assertTrue( + canApproveGatewayDevicePairing( + pairingOnly, + listOf("operator.pairing", "operator.read", "operator.admin"), + pending, + ), + ) + } + + @Test + fun mutationOutcomesRequireCanonicalTerminalState() { + val pending = listOf(pending("request-1", "device-1")) + val paired = listOf(paired("device-1")) + + assertEquals( + GatewayDevicePairingMutationOutcome.Approved, + verifyGatewayDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Approve, "request-1"), + expectedDeviceId = "device-1", + mutationAccepted = true, + pending = emptyList(), + paired = paired, + ), + ) + assertEquals( + GatewayDevicePairingMutationOutcome.NotVerified, + verifyGatewayDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Approve, "request-1"), + expectedDeviceId = "device-1", + mutationAccepted = true, + pending = pending, + paired = paired, + ), + ) + assertEquals( + GatewayDevicePairingMutationOutcome.Rejected, + verifyGatewayDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Reject, "request-1"), + expectedDeviceId = "", + mutationAccepted = true, + pending = emptyList(), + paired = emptyList(), + ), + ) + assertEquals( + GatewayDevicePairingMutationOutcome.Removed, + verifyGatewayDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Remove, "device-1"), + expectedDeviceId = "device-1", + mutationAccepted = true, + pending = emptyList(), + paired = emptyList(), + ), + ) + assertEquals( + GatewayDevicePairingMutationOutcome.NotVerified, + verifyGatewayDevicePairingMutation( + mutation = GatewayDevicePairingMutation(GatewayDevicePairingAction.Reject, "request-1"), + expectedDeviceId = "", + mutationAccepted = false, + pending = emptyList(), + paired = emptyList(), + ), + ) + } + + private fun pending( + requestId: String, + deviceId: String, + ): GatewayPendingDeviceSummary = + GatewayPendingDeviceSummary( + requestId = requestId, + deviceId = deviceId, + displayName = "Pixel", + remoteIp = null, + roles = listOf("operator"), + scopes = listOf("operator.read"), + requestedAtMs = 1L, + repair = false, + ) + + private fun paired(deviceId: String): GatewayPairedDeviceSummary = + GatewayPairedDeviceSummary( + deviceId = deviceId, + displayName = "Pixel", + remoteIp = null, + roles = listOf("operator"), + scopes = listOf("operator.read"), + tokens = emptyList(), + approvedAtMs = 1L, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt b/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt new file mode 100644 index 0000000..4a6b402 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt @@ -0,0 +1,570 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.resolveNativeText +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayExecApprovalParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun legacyListIsOpaqueDiscoveryOnly() { + val rows = + parseGatewayExecApprovalListPayload( + """ + [ + { + "id": "approval-2", + "createdAtMs": 20, + "expiresAtMs": 120, + "request": { + "host": "node", + "nodeId": "node-1", + "agentId": "agent-1", + "command": "pnpm publish --token secret", + "commandPreview": "secret preview" + } + }, + { + "id": "approval-1", + "createdAtMs": 10, + "expiresAtMs": 110 + } + ] + """.trimIndent(), + json, + ) + + assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id }) + assertEquals(listOf("Command request", "Command request"), rows.map { it.commandText.resolveNativeText() }) + assertTrue(rows.all { it.commandPreview == null }) + assertTrue(rows.all { it.allowedDecisions.isEmpty() }) + assertTrue(rows.all { it.host == null && it.nodeId == null && it.agentId == null }) + } + + @Test + fun parsesPendingUnifiedExecApproval() { + val snapshot = + parseGatewayExecApprovalGetPayload( + pendingGetPayload(), + json, + expectedId = "approval-1", + ) + + val pending = snapshot as GatewayExecApprovalSnapshot.Pending + assertEquals("approval-1", pending.id) + assertEquals("rm -rf build", pending.summary.commandText.resolveNativeText()) + assertEquals("rm build", pending.summary.commandPreview) + assertEquals("This command can delete files.", pending.summary.warningText) + assertEquals(listOf("allow-once", "allow-always", "deny"), pending.summary.allowedDecisions) + assertEquals("gateway", pending.summary.host) + assertNull(pending.summary.nodeId) + assertEquals("agent-main", pending.summary.agentId) + assertEquals(100L, pending.summary.createdAtMs) + assertEquals(200L, pending.summary.expiresAtMs) + } + + @Test + fun unifiedGetReturnsCanonicalTerminalSnapshot() { + val snapshot = + parseGatewayExecApprovalGetPayload( + terminalPayload(status = "expired", reason = "timeout"), + json, + expectedId = "approval-1", + ) + + val terminal = snapshot as GatewayExecApprovalSnapshot.Terminal + assertEquals(GatewayApprovalTerminalStatus.Expired, terminal.status) + assertNull(terminal.decision) + } + + @Test + fun resolveAcceptsAnotherSurfacesCanonicalWinner() { + val resolution = + parseGatewayExecApprovalResolvePayload( + """ + { + "applied": false, + "approval": ${terminalApproval(status = "denied", reason = "user", decision = "deny")} + } + """.trimIndent(), + json, + expectedId = "approval-1", + expectedDecision = "allow-once", + ) + + requireNotNull(resolution) + assertFalse(resolution.applied) + assertEquals(GatewayApprovalTerminalStatus.Denied, resolution.approval.status) + assertEquals("deny", resolution.approval.decision) + } + + @Test + fun resolveAcceptsAppliedAllowWinner() { + val resolution = + parseGatewayExecApprovalResolvePayload( + """ + { + "applied": true, + "approval": ${terminalApproval(status = "allowed", reason = "user", decision = "allow-once")} + } + """.trimIndent(), + json, + expectedId = "approval-1", + expectedDecision = "allow-once", + ) + + requireNotNull(resolution) + assertTrue(resolution.applied) + assertEquals(GatewayApprovalTerminalStatus.Allowed, resolution.approval.status) + assertEquals("allow-once", resolution.approval.decision) + } + + @Test + fun unifiedParsingRejectsWrongOwnerIdentityAndMalformedVerdicts() { + assertNull( + parseGatewayExecApprovalGetPayload( + pendingGetPayload().replace("\"kind\": \"exec\"", "\"kind\": \"plugin\""), + json, + expectedId = "approval-1", + ), + ) + assertNull( + parseGatewayExecApprovalGetPayload( + pendingGetPayload(), + json, + expectedId = "approval-other", + ), + ) + assertNull( + parseGatewayExecApprovalResolvePayload( + """{"applied":"false","approval":${terminalApproval(status = "denied", reason = "user", decision = "deny")}}""", + json, + expectedId = "approval-1", + expectedDecision = "deny", + ), + ) + assertNull( + parseGatewayExecApprovalResolvePayload( + """{"applied":false,"approval":${terminalApproval(status = "allowed", reason = "user", decision = "deny")}}""", + json, + expectedId = "approval-1", + expectedDecision = "deny", + ), + ) + assertNull( + parseGatewayExecApprovalResolvePayload( + """{"applied":false,"approval":${pendingApproval()}}""", + json, + expectedId = "approval-1", + expectedDecision = "deny", + ), + ) + assertNull( + parseGatewayExecApprovalResolvePayload( + """{"applied":false,"approval":${terminalApproval(status = "denied", reason = "user", decision = "deny")}}""", + json, + expectedId = "approval-other", + expectedDecision = "deny", + ), + ) + assertNull( + parseGatewayExecApprovalResolvePayload( + """{"applied":true,"approval":${terminalApproval(status = "denied", reason = "user", decision = "deny")}}""", + json, + expectedId = "approval-1", + expectedDecision = "allow-once", + ), + ) + } + + @Test + fun acceptsOnlyExactClosedExecDecisions() { + assertEquals("allow-once", normalizeGatewayExecApprovalDecision("allow-once")) + assertEquals("allow-always", normalizeGatewayExecApprovalDecision("allow-always")) + assertEquals("deny", normalizeGatewayExecApprovalDecision("deny")) + assertNull(normalizeGatewayExecApprovalDecision(" allow-once ")) + assertNull(normalizeGatewayExecApprovalDecision("ALLOW-ONCE")) + assertNull(normalizeGatewayExecApprovalDecision("deny\n")) + assertNull(normalizeGatewayExecApprovalDecision("deny\u0000")) + assertNull(normalizeGatewayExecApprovalDecision("accept")) + assertNull(normalizeGatewayExecApprovalDecision("")) + } + + @Test + fun unifiedParsingRejectsUnknownFieldsAtEverySchemaBoundary() { + assertNull( + parseGatewayExecApprovalGetPayload( + pendingGetPayload().replaceFirst("{", "{\"unexpected\":true,"), + json, + expectedId = "approval-1", + ), + ) + assertNull( + parseGatewayExecApprovalGetPayload( + pendingGetPayload() + .replaceFirst( + "\"status\": \"pending\"", + "\"status\": \"pending\", \"resolvedBy\": \"phone\"", + ), + json, + expectedId = "approval-1", + ), + ) + assertNull( + parseGatewayExecApprovalGetPayload( + pendingGetPayload() + .replaceFirst( + "\"kind\": \"exec\"", + "\"kind\": \"exec\", \"cwd\": \"/tmp\"", + ), + json, + expectedId = "approval-1", + ), + ) + assertNull( + parseGatewayExecApprovalGetPayload( + terminalPayload(status = "denied", reason = "user", decision = "deny") + .replaceFirst( + "\"reason\": \"user\"", + "\"reason\": \"user\", \"resolvedBy\": \"phone\"", + ), + json, + expectedId = "approval-1", + ), + ) + val terminal = terminalApproval(status = "denied", reason = "user", decision = "deny") + assertNull( + parseGatewayExecApprovalResolvePayload( + """{"applied":false,"unexpected":true,"approval":$terminal}""", + json, + expectedId = "approval-1", + expectedDecision = "deny", + ), + ) + } + + @Test + fun unifiedParsingRequiresPathStableWellFormedApprovalIds() { + val malformedIds = + listOf( + "\"\"" to "", + "\".\"" to ".", + "\"..\"" to "..", + "\"\\ud800\"" to "\uD800", + "\"\\udc00\"" to "\uDC00", + ) + for ((encodedId, expectedId) in malformedIds) { + assertNull( + parseGatewayExecApprovalGetPayload( + pendingGetPayload().replaceFirst("\"approval-1\"", encodedId), + json, + expectedId = expectedId, + ), + ) + } + + val astralId = "approval:🦞/percent%" + val snapshot = + parseGatewayExecApprovalGetPayload( + pendingGetPayload().replaceFirst("approval-1", astralId), + json, + expectedId = astralId, + ) + assertEquals(astralId, snapshot?.id) + } + + @Test + fun unifiedAllowedTerminalDecisionMustHaveBeenOffered() { + val payload = + terminalPayload(status = "allowed", reason = "user", decision = "allow-once") + .replace( + "[\"allow-once\", \"allow-always\", \"deny\"]", + "[\"allow-always\", \"deny\"]", + ) + + assertNull(parseGatewayExecApprovalGetPayload(payload, json, expectedId = "approval-1")) + } + + @Test + fun buildsUnifiedRuntimeRequestsWithExplicitOwner() { + assertEquals("""{"id":"approval-1"}""", buildGatewayExecApprovalGetParams("approval-1").toString()) + assertEquals( + """{"id":"approval-1","kind":"exec","decision":"deny"}""", + buildGatewayExecApprovalResolveParams(id = "approval-1", decision = "deny").toString(), + ) + } + + @Test + fun legacyGatewayCompatibilityStillValidatesIdentityAndAck() { + val pending = + parseLegacyGatewayExecApprovalGetPayload( + """ + { + "id": "approval-1", + "commandText": "echo ok", + "commandPreview": "echo", + "allowedDecisions": ["allow-once", "deny"], + "host": "gateway", + "nodeId": null, + "agentId": "main", + "expiresAtMs": 200 + } + """.trimIndent(), + json, + expectedId = "approval-1", + createdAtMs = 100, + ) + + requireNotNull(pending) + assertEquals(listOf("allow-once", "deny"), pending.summary.allowedDecisions) + assertNull( + parseLegacyGatewayExecApprovalGetPayload( + """{"id":"other","commandText":"echo","allowedDecisions":["deny"]}""", + json, + expectedId = "approval-1", + createdAtMs = 100, + ), + ) + assertNull( + parseLegacyGatewayExecApprovalGetPayload( + """{"id":"approval-1","commandText":"echo","expiresAtMs":200}""", + json, + expectedId = "approval-1", + createdAtMs = 100, + ), + ) + assertNull( + parseLegacyGatewayExecApprovalGetPayload( + """{"id":"approval-1","commandText":"echo","allowedDecisions":["deny"]}""", + json, + expectedId = "approval-1", + createdAtMs = 100, + ), + ) + assertNull( + parseLegacyGatewayExecApprovalGetPayload( + """{"id":"approval-1","commandText":"echo","allowedDecisions":["deny"],"expiresAtMs":-1}""", + json, + expectedId = "approval-1", + createdAtMs = 100, + ), + ) + assertNull( + parseLegacyGatewayExecApprovalGetPayload( + """{"id":"approval-1","commandText":"echo","allowedDecisions":["deny"],"expiresAtMs":200}""", + json, + expectedId = "approval-1", + createdAtMs = -1, + ), + ) + assertTrue(parseLegacyGatewayExecApprovalResolvePayload("""{"ok":true}""", json)) + assertFalse(parseLegacyGatewayExecApprovalResolvePayload("""{"ok":"true"}""", json)) + assertFalse(parseLegacyGatewayExecApprovalResolvePayload("""{"ok":false}""", json)) + } + + @Test + fun approvalRpcFamilyPinsOnlyCompleteHelloCatalogs() { + assertEquals( + GatewayApprovalRpcFamily.Canonical, + selectGatewayApprovalRpcFamily( + setOf( + "approval.get", + "approval.resolve", + "exec.approval.get", + "exec.approval.resolve", + ), + ), + ) + assertEquals( + GatewayApprovalRpcFamily.Legacy, + selectGatewayApprovalRpcFamily( + setOf("exec.approval.get", "exec.approval.resolve"), + ), + ) + val unavailableCatalogs: List> = + listOf( + emptySet(), + setOf("approval.get"), + setOf("approval.resolve"), + setOf("exec.approval.get"), + setOf("exec.approval.resolve"), + setOf("approval.get", "exec.approval.get", "exec.approval.resolve"), + setOf("approval.resolve", "exec.approval.get", "exec.approval.resolve"), + ) + for (methods in unavailableCatalogs) { + assertEquals( + GatewayApprovalRpcFamily.Unavailable, + selectGatewayApprovalRpcFamily(methods), + ) + } + } + + @Test + fun localAndRemoteTerminalNoticesPreserveCanonicalOutcome() { + // Field comparison: every constructed notice carries a distinct publication token, + // so whole-value equality would never hold across separately built notices. + assertNoticeContent( + gatewayExecApprovalRemoteTerminalNotice( + terminal(status = GatewayApprovalTerminalStatus.Denied, decision = "deny"), + ), + message = "A prior response already denied this approval.", + warning = true, + ) + assertNoticeContent( + gatewayExecApprovalRemoteTerminalNotice(terminal(status = GatewayApprovalTerminalStatus.Expired)), + message = "This approval expired before it could be resolved.", + warning = true, + ) + assertNoticeContent( + gatewayExecApprovalRemoteTerminalNotice(terminal(status = GatewayApprovalTerminalStatus.Cancelled)), + message = "This approval was cancelled before it could be resolved.", + warning = true, + ) + assertNoticeContent( + gatewayExecApprovalResolutionNotice( + resolution( + applied = false, + status = GatewayApprovalTerminalStatus.Allowed, + decision = "allow-always", + ), + ), + message = "A prior response already allowed this command and saved the choice.", + warning = false, + ) + assertNoticeContent( + gatewayExecApprovalResolutionNotice( + resolution( + applied = false, + status = GatewayApprovalTerminalStatus.Allowed, + decision = "allow-always", + attribution = GatewayExecApprovalResolutionAttribution.Unknown, + ), + ), + message = "Gateway recorded approval and saved the choice.", + warning = false, + ) + assertNoticeContent( + gatewayExecApprovalResolutionNotice( + resolution( + applied = false, + status = GatewayApprovalTerminalStatus.Denied, + decision = "deny", + attribution = GatewayExecApprovalResolutionAttribution.Unknown, + ), + ), + message = "Gateway recorded a denial.", + warning = true, + ) + } + + private fun assertNoticeContent( + notice: GatewayExecApprovalNotice, + approvalId: String = "approval-1", + message: String, + warning: Boolean, + ) { + assertEquals(approvalId, notice.approvalId) + assertEquals(message, notice.message) + assertEquals(warning, notice.warning) + } + + @Test + fun ignoresMalformedGatewayExecApprovalListPayload() { + assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty()) + assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty()) + assertTrue( + parseGatewayExecApprovalListPayload( + """[{"id":"approval-1","createdAtMs":-1,"expiresAtMs":100}]""", + json, + ).isEmpty(), + ) + assertTrue( + parseGatewayExecApprovalListPayload( + """[{"id":"approval-1","createdAtMs":1}]""", + json, + ).isEmpty(), + ) + } + + private fun pendingGetPayload(): String = """{"approval":${pendingApproval()}}""" + + private fun resolution( + applied: Boolean, + status: GatewayApprovalTerminalStatus, + decision: String? = null, + attribution: GatewayExecApprovalResolutionAttribution = + if (applied) GatewayExecApprovalResolutionAttribution.AppliedHere else GatewayExecApprovalResolutionAttribution.PriorResponse, + ): GatewayExecApprovalResolution = + GatewayExecApprovalResolution( + applied = applied, + approval = terminal(status = status, decision = decision), + attribution = attribution, + ) + + private fun terminal( + status: GatewayApprovalTerminalStatus, + decision: String? = null, + ): GatewayExecApprovalSnapshot.Terminal = + GatewayExecApprovalSnapshot.Terminal( + id = "approval-1", + status = status, + decision = decision, + ) + + private fun pendingApproval(): String = + """ + { + "id": "approval-1", + "urlPath": "/approve/approval-1", + "status": "pending", + "createdAtMs": 100, + "expiresAtMs": 200, + "presentation": ${execPresentation()} + } + """.trimIndent() + + private fun terminalPayload( + status: String, + reason: String, + decision: String? = null, + ): String = """{"approval":${terminalApproval(status, reason, decision)}}""" + + private fun terminalApproval( + status: String, + reason: String, + decision: String? = null, + ): String { + val decisionField = decision?.let { ", \"decision\": \"$it\"" }.orEmpty() + return """ + { + "id": "approval-1", + "urlPath": "/approve/approval-1", + "status": "$status", + "createdAtMs": 100, + "expiresAtMs": 200, + "presentation": ${execPresentation()}, + "resolvedAtMs": 150, + "reason": "$reason"$decisionField + } + """.trimIndent() + } + + private fun execPresentation(): String = + """ + { + "kind": "exec", + "commandText": "rm -rf build", + "commandPreview": "rm build", + "warningText": "This command can delete files.", + "host": "gateway", + "nodeId": null, + "agentId": "agent-main", + "allowedDecisions": ["allow-once", "allow-always", "deny"] + } + """.trimIndent() +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt b/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt new file mode 100644 index 0000000..159b3f8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt @@ -0,0 +1,1347 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.i18n.verbatimText +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewayExecApprovalRuntimeTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun anotherSurfaceWinnerClosesLocalCardFromCanonicalResolveResult() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val requests = mutableListOf>() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + requests += method to params + check(method == "approval.resolve") + unifiedResolve(applied = false, status = "denied", decision = "deny") + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals(listOf("approval.resolve"), requests.map { it.first }) + assertEquals( + """{"id":"approval-1","kind":"exec","decision":"allow-once"}""", + requests.single().second, + ) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun exactApprovalIdsCannotCrossTargetThroughKotlinWhitespaceNormalization() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + val controlPrefixedId = "\u001Capproval-1" + seedApprovals( + runtime, + listOf( + approvalSummary(id = controlPrefixedId, commandText = "echo selected"), + approvalSummary(id = "approval-1", commandText = "echo other"), + ), + ) + val requestParams = CompletableDeferred() + val requestCount = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + check(method == "approval.resolve") + requestCount.incrementAndGet() + requestParams.complete(requireNotNull(params)) + unifiedResolve( + applied = true, + status = "denied", + decision = "deny", + id = controlPrefixedId, + ) + } + + runtime.resolveExecApproval(".", "deny") + delay(50) + assertFalse(requestParams.isCompleted) + + runtime.resolveExecApproval(controlPrefixedId, "deny") + val params = Json.parseToJsonElement(withTimeout(2_000) { requestParams.await() }).jsonObject + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-1") } + + assertEquals(1, requestCount.get()) + assertEquals(controlPrefixedId, params["id"]?.jsonPrimitive?.content) + assertEquals( + "approval-1", + runtime.execApprovals.value + .single() + .id, + ) + } + + @Test + fun approvalEventsPreserveExactStringIdsAndRejectNonStrings() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + val controlPrefixedId = "\u001Capproval-1" + val requestedIds = mutableListOf() + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + methods += method + when (method) { + "approval.get" -> { + val parsed = Json.parseToJsonElement(requireNotNull(params)).jsonObject + requestedIds += requireNotNull(parsed["id"]?.jsonPrimitive?.content) + unifiedGet(status = "pending", decision = null, id = controlPrefixedId) + } + "exec.approval.list" -> "[]" + else -> error("unexpected method $method") + } + } + + invokeApprovalEvent( + runtime, + "exec.approval.requested", + """{"id":${JsonPrimitive(controlPrefixedId)}}""", + ) + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.id == controlPrefixedId + } + invokeApprovalEvent(runtime, "exec.approval.requested", """{"id":123}""") + waitUntil { methods.contains("exec.approval.list") } + + assertEquals(listOf(controlPrefixedId), requestedIds) + } + + @Test + fun malformedOrMismatchedWriteResultFreezesThenUsesCanonicalReadback() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + // `applied=true` cannot claim a different decision than this phone sent. + "approval.resolve" -> unifiedResolve(applied = true, status = "allowed", decision = "allow-always") + "approval.get" -> unifiedGet(status = "allowed", decision = "allow-always") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals(listOf("approval.resolve", "approval.get"), methods) + assertEquals( + "A prior response already allowed this command and saved the choice.", + runtime.execApprovalsNotice.value?.message, + ) + } + + @Test + fun unknownWriteOutcomeStaysFrozenAndReconcilesAfterReconnect() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve", "approval.get" -> throw GatewayRequestOutcomeUnknown("disconnected") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + val frozen = runtime.execApprovals.value.single() + assertEquals("deny", frozen.resolvingDecision) + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + val reconnectMethods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + reconnectMethods += method + when (method) { + "exec.approval.list" -> "[]" + "approval.get" -> unifiedGet(status = "denied", decision = "deny") + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { reconnectMethods.contains("approval.get") && runtime.execApprovalsNotice.value != null } + + assertTrue(runtime.execApprovals.value.isEmpty()) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun cancelledApprovalRefreshPreservesOwnerStateWithoutPublishingFailure() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val requestStarted = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + check(method == "exec.approval.list") + requestStarted.complete(Unit) + throw CancellationException("approval gateway generation retired") + } + + runtime.refreshExecApprovals() + withTimeout(2_000) { requestStarted.await() } + waitUntil { !runtime.execApprovalsRefreshing.value } + + val retainedApproval = runtime.execApprovals.value.single() + assertNull(runtime.execApprovalsErrorText.value) + assertEquals("approval-1", retainedApproval.id) + assertNull(retainedApproval.errorText) + } + + @Test + fun reconnectListHydrationPublishesTerminalForRetainedUnknownWrite() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve", "approval.get" -> throw GatewayRequestOutcomeUnknown("disconnected") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> unifiedGet(status = "denied", decision = "deny") + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { runtime.execApprovalsNotice.value != null } + + assertTrue(runtime.execApprovals.value.isEmpty()) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun reconnectKeepsInFlightWriteDisabledBeforeRetiredWaiterFails() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseUnknownOutcome = CompletableDeferred() + val pendingReadCompleted = CompletableDeferred() + val winnerReadStarted = CompletableDeferred() + val releaseWinnerRead = CompletableDeferred() + val approvalReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseUnknownOutcome.await() + throw GatewayRequestOutcomeUnknown("disconnected before response") + } + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> { + if (approvalReads.incrementAndGet() == 1) { + pendingReadCompleted.complete(Unit) + unifiedGet(status = "pending", decision = null) + } else { + winnerReadStarted.complete(Unit) + releaseWinnerRead.await() + unifiedGet(status = "denied", decision = "deny") + } + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(10_000) { resolveStarted.await() } + + // GatewaySession runs onDisconnected before failing the retired socket's + // request waiters. Recreate that production ordering on the same stable ID. + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + runtime.refreshExecApprovals() + withTimeout(2_000) { pendingReadCompleted.await() } + waitUntil { !runtime.execApprovalsRefreshing.value } + + val reconnected = runtime.execApprovals.value.single() + assertEquals("deny", reconnected.resolvingDecision) + assertTrue(reconnected.errorText?.startsWith("Resolution outcome unknown") == true) + assertFalse(releaseUnknownOutcome.isCompleted) + assertFalse(winnerReadStarted.isCompleted) + + releaseUnknownOutcome.complete(Unit) + withTimeout(2_000) { winnerReadStarted.await() } + releaseWinnerRead.complete(Unit) + + waitUntil { runtime.execApprovals.value.isEmpty() } + assertTrue(approvalReads.get() >= 2) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun fullRefreshCannotUnlockApprovalWhileResolveRequestIsInFlight() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + val refreshReadCompleted = CompletableDeferred() + val approvalReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + unifiedResolve(applied = true, status = "denied", decision = "deny") + } + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> { + approvalReads.incrementAndGet() + refreshReadCompleted.complete(Unit) + unifiedGet(status = "pending", decision = null) + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(10_000) { resolveStarted.await() } + runtime.refreshExecApprovals() + withTimeout(2_000) { refreshReadCompleted.await() } + waitUntil { !runtime.execApprovalsRefreshing.value } + delay(100) + + val inFlight = runtime.execApprovals.value.single() + assertEquals("deny", inFlight.resolvingDecision) + assertNull(inFlight.errorText) + assertEquals(1, approvalReads.get()) + + releaseResolve.complete(Unit) + waitUntil { runtime.execApprovals.value.isEmpty() } + assertEquals("Approval denied.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun unknownWriteInvalidatesRefreshSnapshotBuiltWhileRequestWasInFlight() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo selected"), + approvalSummary(id = "approval-2", commandText = "echo retained"), + ), + ) + val resolveStarted = CompletableDeferred() + val releaseUnknownOutcome = CompletableDeferred() + val retainedReadStarted = CompletableDeferred() + val releaseRetainedRead = CompletableDeferred() + val retainedReadReturning = CompletableDeferred() + val selectedReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseUnknownOutcome.await() + throw GatewayRequestOutcomeUnknown("response lost") + } + "exec.approval.list" -> + """ + [ + {"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}, + {"id":"approval-2","createdAtMs":101,"expiresAtMs":4000000000000} + ] + """.trimIndent() + "approval.get" -> { + val id = + Json + .parseToJsonElement(requireNotNull(params)) + .jsonObject["id"] + ?.jsonPrimitive + ?.content + ?: error("missing approval id") + if (id == "approval-2") { + retainedReadStarted.complete(Unit) + releaseRetainedRead.await() + retainedReadReturning.complete(Unit) + unifiedGet(status = "pending", decision = null, id = id) + } else if (selectedReads.incrementAndGet() == 1) { + unifiedGet(status = "pending", decision = null, id = id) + } else { + throw GatewayRequestOutcomeUnknown("readback unavailable") + } + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(10_000) { resolveStarted.await() } + runtime.refreshExecApprovals() + withTimeout(2_000) { retainedReadStarted.await() } + + releaseUnknownOutcome.complete(Unit) + waitUntil { + runtime.execApprovals.value + .firstOrNull { it.id == "approval-1" } + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + + releaseRetainedRead.complete(Unit) + withTimeout(2_000) { retainedReadReturning.await() } + delay(100) + + val selected = runtime.execApprovals.value.first { it.id == "approval-1" } + assertEquals("deny", selected.resolvingDecision) + assertTrue(selected.errorText?.startsWith("Resolution outcome unknown") == true) + assertTrue(runtime.execApprovals.value.any { it.id == "approval-2" }) + assertTrue(selectedReads.get() >= 2) + } + + @Test + fun canonicalPendingReadbackInvalidatesConcurrentStaleRefresh() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val pendingReadStarted = CompletableDeferred() + val staleRefreshReadStarted = CompletableDeferred() + val releasePendingRead = CompletableDeferred() + val releaseStaleRefreshRead = CompletableDeferred() + val staleRefreshResponseReturning = CompletableDeferred() + val approvalReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> throw GatewayRequestOutcomeUnknown("response lost") + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> + when (approvalReads.incrementAndGet()) { + 1 -> { + pendingReadStarted.complete(Unit) + releasePendingRead.await() + unifiedGet(status = "pending", decision = null) + } + 2 -> { + staleRefreshReadStarted.complete(Unit) + releaseStaleRefreshRead.await() + staleRefreshResponseReturning.complete(Unit) + unifiedGet(status = "pending", decision = null) + } + else -> error("unexpected extra approval.get") + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { pendingReadStarted.await() } + runtime.refreshExecApprovals() + withTimeout(2_000) { staleRefreshReadStarted.await() } + + releasePendingRead.complete(Unit) + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.resolvingDecision == null && + row.errorText == "The Gateway still shows this approval as pending. Review it before trying again." + } == true + } + + releaseStaleRefreshRead.complete(Unit) + withTimeout(2_000) { staleRefreshResponseReturning.await() } + delay(100) + + val finalRow = runtime.execApprovals.value.single() + assertNull(finalRow.resolvingDecision) + assertEquals( + "The Gateway still shows this approval as pending. Review it before trying again.", + finalRow.errorText, + ) + assertEquals(2, approvalReads.get()) + } + + @Test + fun legacyUnknownWriteUnlocksAfterReconnectProvesApprovalStillPending() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "exec.approval.resolve", "exec.approval.get" -> throw GatewayRequestOutcomeUnknown("disconnected") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + assertEquals( + "deny", + runtime.execApprovals.value + .single() + .resolvingDecision, + ) + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, legacyMethods) + val reconnectMethods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + reconnectMethods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "exec.approval.get" -> legacyGet() + "exec.approval.resolve" -> """{"ok":true}""" + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.resolvingDecision == null && + row.errorText == "The Gateway still shows this approval as pending. Review it before trying again." + } == true + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals( + listOf( + "exec.approval.list", + "exec.approval.get", + "exec.approval.get", + "exec.approval.resolve", + ), + reconnectMethods, + ) + } + + @Test + fun legacySuccessUsesNeutralWinnerAttribution() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + check(method == "exec.approval.resolve") + """{"ok":true}""" + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals("Gateway recorded approval once.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun resolutionEventWinsRaceAgainstLateLocalResponse() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + unifiedResolve(applied = true, status = "allowed", decision = "allow-once") + } + "approval.get" -> unifiedGet(status = "denied", decision = "deny") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + withTimeout(10_000) { resolveStarted.await() } + invokeApprovalEvent( + runtime, + "exec.approval.resolved", + """{"id":"approval-1","decision":"deny","resolvedBy":"other","ts":150}""", + ) + waitUntil { runtime.execApprovals.value.isEmpty() } + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + + releaseResolve.complete(Unit) + delay(100) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun legacyResolutionEventWinsBeforeLateResponseAndListFailure() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo selected"), + approvalSummary(id = "approval-2", commandText = "echo retained"), + ), + ) + val methods = mutableListOf() + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + """{"ok":true}""" + } + "exec.approval.list", "exec.approval.get" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "$method failed", + ), + ) + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + withTimeout(10_000) { resolveStarted.await() } + invokeApprovalEvent( + runtime, + "exec.approval.resolved", + """{"id":"approval-1","decision":"deny","resolvedBy":"other","ts":150,"request":{}}""", + ) + + assertEquals(listOf("approval-2"), runtime.execApprovals.value.map { it.id }) + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + assertEquals(listOf("exec.approval.resolve"), methods) + + releaseResolve.complete(Unit) + delay(100) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + + runtime.refreshExecApprovals() + waitUntil { runtime.execApprovalsErrorText.value != null && !runtime.execApprovalsRefreshing.value } + + assertEquals(listOf("exec.approval.resolve", "exec.approval.list"), methods) + assertEquals(listOf("approval-2"), runtime.execApprovals.value.map { it.id }) + } + + @Test + fun legacyAlreadyResolvedRejectionRetiresExactCardWithoutEvent() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo selected"), + approvalSummary(id = "approval-2", commandText = "echo retryable"), + ), + ) + val resolvedIds = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + check(method == "exec.approval.resolve") + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + val id = + request["id"] + ?.jsonPrimitive + ?.content + ?: error("missing approval id") + resolvedIds += id + val reason = if (id == "approval-1") "APPROVAL_ALREADY_RESOLVED" else "OTHER_REJECTION" + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "approval rejected", + details = gatewayErrorDetails(reason), + ), + ) + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + assertEquals("A prior response already resolved this approval.", runtime.execApprovalsNotice.value?.message) + assertTrue(runtime.execApprovalsNotice.value?.warning == true) + + runtime.resolveExecApproval("approval-2", "deny") + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.id == "approval-2" && + row.resolvingDecision == null && + row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + assertEquals(listOf("approval-1", "approval-2"), resolvedIds) + } + + @Test + fun legacyAlreadyResolvedRacingMethodsEpochBumpReconcilesWrite() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "approval rejected", + details = gatewayErrorDetails("APPROVAL_ALREADY_RESOLVED"), + ), + ) + } + "exec.approval.get" -> legacyGet() + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + withTimeout(10_000) { resolveStarted.await() } + // Replacement hello on the same stable endpoint: the epoch bump makes the + // already-resolved publish a no-op, leaving only the pending-write record. + invokeReplaceGatewayMethods(runtime, legacyMethods) + releaseResolve.complete(Unit) + + // The settled rejection must reconcile through current canonical state instead + // of freezing until a perfectly timed manual refresh. + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.resolvingDecision == null && + row.errorText == "The Gateway still shows this approval as pending. Review it before trying again." + } == true + } + + assertEquals(listOf("exec.approval.resolve", "exec.approval.get"), methods) + } + + @Test + fun terminalNoticeSurvivesRefreshUntilUserDismissal() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo losing"), + approvalSummary(id = "approval-2", commandText = "echo retained"), + ), + ) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + check(method == "approval.resolve") + unifiedResolve(applied = false, status = "denied", decision = "deny") + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + + val refreshMethods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + refreshMethods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-2","createdAtMs":101,"expiresAtMs":4000000000000}]""" + "approval.get" -> unifiedGet(status = "pending", decision = null, id = "approval-2") + else -> error("unexpected method $method") + } + } + runtime.refreshExecApprovals() + waitUntil { refreshMethods.contains("approval.get") && !runtime.execApprovalsRefreshing.value } + assertEquals(listOf("approval-2"), runtime.execApprovals.value.map { it.id }) + + // A refresh must not wipe an unacknowledged losing outcome; only the user (or a + // replacement terminal notice) clears the banner. + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + + runtime.dismissExecApprovalsNotice(requireNotNull(runtime.execApprovalsNotice.value)) + assertNull(runtime.execApprovalsNotice.value) + } + + @Test + fun unrelatedApprovalWriteKeepsUnacknowledgedTerminalNotice() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo losing"), + approvalSummary(id = "approval-2", commandText = "echo unrelated"), + ), + ) + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + check(method == "approval.resolve") + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + when (val id = request["id"]?.jsonPrimitive?.content) { + "approval-1" -> unifiedResolve(applied = false, status = "denied", decision = "deny") + "approval-2" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape(code = "UNAVAILABLE", message = "resolve failed"), + ) + else -> error("unexpected approval id $id") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + + runtime.resolveExecApproval("approval-2", "deny") + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.id == "approval-2" && + row.resolvingDecision == null && + row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + // Starting (and failing) a write for approval-2 must not clear the unacknowledged + // losing outcome for approval-1; only the user or a replacement terminal clears it. + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun staleDismissLeavesReplacementNoticeVisible() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo first"), + approvalSummary(id = "approval-2", commandText = "echo second"), + ), + ) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> unifiedResolve(applied = false, status = "denied", decision = "deny") + // Readback for the approval-2 resolved event: this terminal-notice publisher + // does not hold execApprovalsStateLock, the exact writer the atomic dismiss + // must not race. + "approval.get" -> unifiedGet(status = "denied", decision = "deny", id = "approval-2") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + val staleNotice = requireNotNull(runtime.execApprovalsNotice.value) + assertEquals("approval-1", staleNotice.approvalId) + + invokeApprovalEvent(runtime, "exec.approval.resolved", """{"id":"approval-2"}""") + waitUntil { runtime.execApprovals.value.isEmpty() } + val replacement = requireNotNull(runtime.execApprovalsNotice.value) + assertEquals("approval-2", replacement.approvalId) + + // compareAndSet semantics: a close tap captured for the first notice must leave + // the replacement untouched; only dismissing the rendered notice clears it. + runtime.dismissExecApprovalsNotice(staleNotice) + assertEquals(replacement, runtime.execApprovalsNotice.value) + + runtime.dismissExecApprovalsNotice(replacement) + assertNull(runtime.execApprovalsNotice.value) + } + + @Test + fun staleDismissCannotClearStructurallyEqualReplacementNotice() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> unifiedResolve(applied = false, status = "denied", decision = "deny") + "approval.get" -> unifiedGet(status = "pending", decision = null) + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + val staleNotice = requireNotNull(runtime.execApprovalsNotice.value) + + // The same approval id is re-requested and loses again: the replacement notice + // carries identical id/message/warning but is a distinct publication. + invokeApprovalEvent(runtime, "exec.approval.requested", """{"id":"approval-1"}""") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-1") } + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + val replacement = requireNotNull(runtime.execApprovalsNotice.value) + assertEquals(staleNotice.approvalId, replacement.approvalId) + assertEquals(staleNotice.message, replacement.message) + assertEquals(staleNotice.warning, replacement.warning) + assertNotEquals(staleNotice, replacement) + + // A close tap captured for the first banner must not clear the equal-looking + // replacement outcome the user has not acknowledged yet. + runtime.dismissExecApprovalsNotice(staleNotice) + assertEquals(replacement, runtime.execApprovalsNotice.value) + + runtime.dismissExecApprovalsNotice(replacement) + assertNull(runtime.execApprovalsNotice.value) + } + + @Test + fun oldGatewayUsesOnlyShippedExecMethods() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime( + runtime, + setOf("exec.approval.list", "exec.approval.get", "exec.approval.resolve"), + ) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "exec.approval.get" -> legacyGet() + "exec.approval.resolve" -> """{"ok":true}""" + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.allowedDecisions == listOf("allow-once", "deny") + } + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals( + listOf("exec.approval.list", "exec.approval.get", "exec.approval.resolve"), + methods, + ) + assertFalse(methods.any { it == "approval.get" || it == "approval.resolve" }) + } + + @Test + fun partialCanonicalCatalogCannotMixWithLegacyApprovalMethods() = + runBlocking { + val runtime = createTestRuntime() + val mixedMethods = legacyMethods + "approval.get" + seedConnectedRuntime(runtime, mixedMethods) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + else -> error("an inconsistent hello must not select approval RPC $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText == + "Could not load approval details. Refresh and try again." + } + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.let { row -> + row.resolvingDecision == null && row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + assertEquals(listOf("exec.approval.list"), methods) + } + + @Test + fun canonicalUnknownReadFailsClosedWithoutLegacyDowngrade() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, allApprovalMethods) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: approval.get", + ), + ) + "exec.approval.get" -> error("canonical hello must never downgrade") + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText == + "Could not load approval details. Refresh and try again." + } + + assertEquals(listOf("exec.approval.list", "approval.get"), methods) + } + + @Test + fun canonicalUnknownResolveFailsClosedWithoutLegacyDowngrade() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, allApprovalMethods) + seedApproval(runtime) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "approval.resolve" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: approval.resolve", + ), + ) + "exec.approval.resolve" -> error("canonical hello must never downgrade") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.let { row -> + row.resolvingDecision == null && row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + assertEquals(listOf("approval.resolve"), methods) + } + + @Test + fun staleCanonicalRejectionFromRetiredSocketCannotAffectReplacementCatalog() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val firstResolveStarted = CompletableDeferred() + val releaseFirstResolve = CompletableDeferred() + val methods = mutableListOf() + var unifiedResolveCalls = 0 + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "approval.resolve" -> { + unifiedResolveCalls += 1 + if (unifiedResolveCalls == 1) { + firstResolveStarted.complete(Unit) + releaseFirstResolve.await() + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: approval.resolve", + ), + ) + } + unifiedResolve(applied = true, status = "denied", decision = "deny") + } + "exec.approval.resolve" -> error("stale rejection must not trigger legacy fallback") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { firstResolveStarted.await() } + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + releaseFirstResolve.complete(Unit) + delay(100) + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals(listOf("approval.resolve", "approval.resolve"), methods) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.approval.runtime.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime( + runtime: NodeRuntime, + methods: Set, + ) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + invokeReplaceGatewayMethods(runtime, methods) + } + + private fun seedApproval(runtime: NodeRuntime) { + seedApprovals(runtime, listOf(approvalSummary())) + } + + private fun seedApprovals( + runtime: NodeRuntime, + approvals: List, + ) { + readField>>(runtime, "_execApprovals").value = + approvals + } + + private fun approvalSummary( + id: String = "approval-1", + commandText: String = "echo ok", + ): GatewayExecApprovalSummary = + GatewayExecApprovalSummary( + id = id, + commandText = verbatimText(commandText), + commandPreview = "echo", + warningText = null, + allowedDecisions = listOf("allow-once", "allow-always", "deny"), + host = "gateway", + nodeId = null, + agentId = "main", + createdAtMs = 100, + expiresAtMs = 4_000_000_000_000, + ) + + private suspend fun waitUntil(condition: () -> Boolean) { + // Generous ceiling for loaded CI runners; passing tests exit on first poll. + withTimeout(10_000) { + while (!condition()) delay(10) + } + } + + private fun invokeApprovalEvent( + runtime: NodeRuntime, + event: String, + payloadJson: String, + ) { + runtime.javaClass + .getDeclaredMethod("handleExecApprovalGatewayEvent", String::class.java, String::class.java) + .apply { isAccessible = true } + .invoke(runtime, event, payloadJson) + } + + private fun invokeClearOperatorState( + runtime: NodeRuntime, + retirePendingRuns: Boolean, + ) { + runtime.javaClass + .getDeclaredMethod("clearOperatorGatewayState", java.lang.Boolean.TYPE) + .apply { isAccessible = true } + .invoke(runtime, retirePendingRuns) + writeField(runtime, "operatorConnected", false) + } + + private fun invokeReplaceGatewayMethods( + runtime: NodeRuntime, + methods: Set, + ) { + runtime.javaClass + .getDeclaredMethod("replaceGatewayMethods", Set::class.java) + .apply { isAccessible = true } + .invoke(runtime, methods) + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + findField(target, name).set(target, value) + } + + private fun readField( + target: Any, + name: String, + ): T { + @Suppress("UNCHECKED_CAST") + return findField(target, name).get(target) as T + } + + private fun findField( + target: Any, + name: String, + ): Field { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + return type.getDeclaredField(name).apply { isAccessible = true } + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun unifiedResolve( + applied: Boolean, + status: String, + decision: String?, + id: String = "approval-1", + ): String = """{"applied":$applied,"approval":${approval(status, decision, id)}}""" + + private fun unifiedGet( + status: String, + decision: String?, + id: String = "approval-1", + ): String = """{"approval":${approval(status, decision, id)}}""" + + private fun approval( + status: String, + decision: String?, + id: String, + ): String { + val terminalFields = + if (status == "pending") { + "" + } else { + val reason = if (status == "expired") "timeout" else "user" + val decisionField = decision?.let { ",\"decision\":\"$it\"" }.orEmpty() + ",\"resolvedAtMs\":150,\"reason\":\"$reason\"$decisionField" + } + return """ + { + "id":${JsonPrimitive(id)}, + "urlPath":"/approve/approval-1", + "status":"$status", + "createdAtMs":100, + "expiresAtMs":4000000000000, + "presentation":{ + "kind":"exec", + "commandText":"echo ok", + "commandPreview":"echo", + "warningText":null, + "host":"gateway", + "nodeId":null, + "agentId":"main", + "allowedDecisions":["allow-once","allow-always","deny"] + }$terminalFields + } + """.trimIndent() + } + + private fun legacyGet(): String = + """ + { + "id":"approval-1", + "commandText":"echo ok", + "commandPreview":"echo", + "allowedDecisions":["allow-once","deny"], + "host":"gateway", + "nodeId":null, + "agentId":"main", + "expiresAtMs":4000000000000 + } + """.trimIndent() + + private fun gatewayErrorDetails(reason: String): GatewayErrorDetails = + GatewayErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = reason, + ) + + private val unifiedMethods = setOf("approval.get", "approval.resolve", "exec.approval.list") + private val legacyMethods = setOf("exec.approval.list", "exec.approval.get", "exec.approval.resolve") + private val allApprovalMethods = unifiedMethods + legacyMethods +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt b/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt new file mode 100644 index 0000000..431fec8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt @@ -0,0 +1,109 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayRegistryEntry +import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayFleetSelectionTest { + @Test + fun focusedGatewayIsExcludedButOtherEnabledGatewaysRemain() { + val entries = listOf(entry("alpha"), entry("beta"), entry("gamma")) + + assertEquals( + listOf("beta", "gamma"), + backgroundGatewayStableIds( + entries = entries, + connectedIds = listOf("alpha", "beta", "gamma", "beta", "forgotten"), + activeId = "alpha", + foreground = true, + ), + ) + assertEquals( + emptyList(), + backgroundGatewayStableIds( + entries = entries, + connectedIds = listOf("alpha", "beta"), + activeId = "alpha", + foreground = false, + ), + ) + } + + @Test + fun endpointGapRetainsEnabledSecondaryUntilItIsDisabled() { + val secondary = entry("bonjour|secondary") + + val duringGap = + backgroundGatewayFleetPlan( + entries = listOf(secondary), + connectedIds = listOf(secondary.stableId), + activeId = null, + foreground = true, + existingStableIds = listOf(secondary.stableId), + resolveEndpoint = { null }, + ) + + assertEquals(emptyList(), duringGap.disconnectStableIds) + assertEquals(emptyMap(), duringGap.resolvedEndpoints) + + val disabled = + backgroundGatewayFleetPlan( + entries = listOf(secondary), + connectedIds = emptyList(), + activeId = null, + foreground = true, + existingStableIds = listOf(secondary.stableId), + resolveEndpoint = { null }, + ) + + assertEquals(listOf(secondary.stableId), disabled.disconnectStableIds) + } + + @Test + fun manualRegistryTlsControlsEndpointAndControlPageOrigin() { + val endpoint = + manualGatewayEndpoint( + GatewayRegistryEntry( + stableId = "manual|gateway.example|443", + kind = GatewayRegistryEntryKind.MANUAL, + name = "Gateway", + host = " gateway.example ", + port = 443, + tls = true, + ), + ) + + assertTrue(endpoint?.tlsEnabled == true) + assertEquals("https://gateway.example:443", gatewayControlPageBaseUrl(requireNotNull(endpoint))) + } + + @Test + fun savingSameManualEndpointReplacesStaleTlsSetting() { + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 443, tlsEnabled = true) + val previous = + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = endpoint.name, + host = endpoint.host, + port = endpoint.port, + tls = false, + lastConnectedAtMs = 42L, + ) + + val updated = gatewayRegistryEntry(endpoint, previous) + + assertTrue(updated.tls) + assertEquals(42L, updated.lastConnectedAtMs) + } + + private fun entry(stableId: String) = + GatewayRegistryEntry( + stableId = stableId, + kind = GatewayRegistryEntryKind.DISCOVERED, + name = stableId, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt b/app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt new file mode 100644 index 0000000..bf34a01 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt @@ -0,0 +1,46 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GatewayLogTextTest { + @Test + fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() { + assertEquals( + "hindsight: Skipping retain", + sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"), + ) + } + + @Test + fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() { + assertEquals( + "hindsight: Skipping retain", + sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"), + ) + } + + @Test + fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() { + assertEquals( + "error and bold", + sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"), + ) + } + + @Test + fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() { + assertEquals( + """{"1":"hindsight: Skipping retain"}""", + sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""), + ) + } + + @Test + fun sanitizeGatewayLogTextKeepsPlainBracketedText() { + assertEquals( + "cache ttl [5m] expired", + sanitizeGatewayLogText("cache ttl [5m] expired"), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt b/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt new file mode 100644 index 0000000..8efe090 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt @@ -0,0 +1,194 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayNodeApprovalStateTest { + @Test + fun parsesGatewayNodeApprovalState() { + assertEquals(GatewayNodeApprovalState.Approved, parseGatewayNodeApprovalState("approved")) + assertEquals(GatewayNodeApprovalState.PendingApproval, parseGatewayNodeApprovalState("pending-approval")) + assertEquals(GatewayNodeApprovalState.PendingReapproval, parseGatewayNodeApprovalState("pending-reapproval")) + assertEquals(GatewayNodeApprovalState.Unapproved, parseGatewayNodeApprovalState("unapproved")) + assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState(null)) + assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState("future-state")) + } + + @Test + fun nodePairingFailuresRefreshNodeDeviceState() { + assertTrue( + nodeConnectFailureNeedsApprovalRefresh( + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + reason = "not-paired", + ), + ), + ), + ) + assertFalse( + nodeConnectFailureNeedsApprovalRefresh( + GatewaySession.ErrorShape( + code = "UNAUTHORIZED", + message = "token mismatch", + details = + GatewayErrorDetails( + code = "AUTH_TOKEN_MISMATCH", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ), + ), + ) + } + + @Test + fun parsesNodeListApprovalFields() { + val node = + parseGatewayNodeSummary( + Json.parseToJsonElement( + """ + { + "nodeId": "android-node", + "paired": true, + "connected": true, + "approvalState": "pending-approval", + "pendingRequestId": "request-1", + "caps": ["device"], + "commands": ["device.status"] + } + """.trimIndent(), + ), + ) + + requireNotNull(node) + assertEquals(GatewayNodeApprovalState.PendingApproval, node.approvalState) + assertEquals("request-1", node.pendingRequestId) + assertEquals(listOf("device"), node.capabilities) + assertEquals(listOf("device.status"), node.commands) + } + + @Test + fun parsesSplitNodeListShapeFromGateway() { + val root = + Json + .parseToJsonElement( + """ + { + "pending": [ + { + "nodeId": "pending-node", + "paired": false, + "connected": false, + "approvalState": "pending-approval", + "pendingRequestId": "request-pending" + } + ], + "paired": [ + { + "nodeId": "self", + "paired": true, + "connected": true, + "approvalState": "approved", + "caps": ["device"], + "commands": ["device.status"] + } + ] + } + """.trimIndent(), + ).jsonObject + + val nodes = parseGatewayNodeList(root) + + assertEquals(2, nodes.size) + assertEquals( + GatewayNodeCapabilityApproval.Approved, + currentNodeCapabilityApproval(nodes = nodes, selfNodeId = "self"), + ) + } + + @Test + fun treatsMissingNodeApprovalStateAsUnsupported() { + val node = + parseGatewayNodeSummary( + Json.parseToJsonElement("""{"nodeId":"android-node","paired":true,"connected":true}"""), + ) + + requireNotNull(node) + assertEquals(GatewayNodeApprovalState.Unsupported, node.approvalState) + assertEquals( + GatewayNodeCapabilityApproval.Unsupported, + currentNodeCapabilityApproval(nodes = listOf(node), selfNodeId = "android-node"), + ) + assertNull(node.pendingRequestId) + } + + @Test + fun resolvesCurrentPhoneNodeApprovalState() { + val nodes = + listOf( + GatewayNodeSummary( + id = "other", + displayName = null, + remoteIp = null, + version = null, + deviceFamily = null, + paired = true, + connected = false, + approvalState = GatewayNodeApprovalState.Approved, + pendingRequestId = null, + capabilities = emptyList(), + commands = emptyList(), + ), + GatewayNodeSummary( + id = "self", + displayName = null, + remoteIp = null, + version = null, + deviceFamily = null, + paired = true, + connected = true, + approvalState = GatewayNodeApprovalState.PendingApproval, + pendingRequestId = "request-self", + capabilities = emptyList(), + commands = emptyList(), + ), + ) + + assertEquals( + GatewayNodeCapabilityApproval.PendingApproval("request-self"), + currentNodeCapabilityApproval(nodes = nodes, selfNodeId = "self"), + ) + assertEquals( + GatewayNodeCapabilityApproval.Loading, + currentNodeCapabilityApproval(nodes = nodes, selfNodeId = "missing"), + ) + } + + @Test + fun ignoresStaleNodeApprovalRefreshResults() { + val guard = LatestGatewayRefreshGuard() + var approvalState = GatewayNodeApprovalState.Loading + val staleRefresh = guard.begin() + val currentRefresh = guard.begin() + + assertFalse(guard.publishIfCurrent(staleRefresh) { approvalState = GatewayNodeApprovalState.Approved }) + assertTrue( + guard.publishIfCurrent(currentRefresh) { approvalState = GatewayNodeApprovalState.PendingReapproval }, + ) + assertEquals(GatewayNodeApprovalState.PendingReapproval, approvalState) + } +} diff --git a/app/src/test/java/ai/openclaw/app/GatewayTalkSetupReadinessTest.kt b/app/src/test/java/ai/openclaw/app/GatewayTalkSetupReadinessTest.kt new file mode 100644 index 0000000..282ea18 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/GatewayTalkSetupReadinessTest.kt @@ -0,0 +1,297 @@ +package ai.openclaw.app + +import ai.openclaw.app.i18n.NativeText +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayTalkSetupReadinessTest { + @Test + fun targetTitlesStayNestedLocalizedPresentation() { + val targets = + listOf( + GatewayTalkSetupTarget.REALTIME_TALK to "Realtime Talk", + GatewayTalkSetupTarget.DICTATION to "Dictation", + ) + + for ((target, source) in targets) { + val title = NativeText.Resource(source = source, formatArgs = emptyList()) + assertEquals(title, target.title) + val issueCases = + listOf( + GatewayTalkSetupIssue.GroupMissing(target) to "Gateway did not return \${issue.target.title} setup", + GatewayTalkSetupIssue.NoProvider(target) to + "No \${issue.target.title} provider is configured on the Gateway", + GatewayTalkSetupIssue.MissingReadiness(target) to + "Gateway did not return \${issue.target.title} readiness", + GatewayTalkSetupIssue.ConfigureProvider(target) to + "Configure a \${issue.target.title} provider on the Gateway", + GatewayTalkSetupIssue.MissingActiveProvider(target) to + "Gateway did not identify the active \${issue.target.title} provider", + GatewayTalkSetupIssue.UnsupportedProvider(target) to + "Choose a supported \${issue.target.title} provider on the Gateway", + ) + for ((issue, template) in issueCases) { + assertEquals( + NativeText.Resource(source = template, formatArgs = listOf(title)), + gatewayTalkSetupIssueDescriptionText(issue), + ) + } + } + } + + @Test + fun gatewayTechnicalLabelsStayVerbatimInsideLocalizedPresentation() { + assertEquals( + NativeText.Resource( + source = "\${state.provider.label} via Gateway relay", + formatArgs = listOf(NativeText.Verbatim("Future Realtime")), + ), + gatewayTalkSetupDescriptionText( + GatewayTalkSetupState.Ready(GatewayTalkProvider(id = "future-provider", label = "Future Realtime")), + ), + ) + assertEquals( + NativeText.Resource( + source = "Gateway selected unknown provider \${issue.providerId}", + formatArgs = listOf(NativeText.Verbatim("future-provider")), + ), + gatewayTalkSetupIssueDescriptionText( + GatewayTalkSetupIssue.UnknownProvider( + target = GatewayTalkSetupTarget.REALTIME_TALK, + providerId = "future-provider", + ), + ), + ) + assertEquals( + NativeText.Resource( + source = "Configure \${issue.providerLabel} on the Gateway", + formatArgs = listOf(NativeText.Verbatim("Future Provider")), + ), + gatewayTalkSetupIssueDescriptionText( + GatewayTalkSetupIssue.ConfigureSelectedProvider(providerLabel = "Future Provider"), + ), + ) + } + + @Test + fun mixedProviderStatesRemainDistinct() { + val readiness = + parseGatewayTalkSetupReadiness( + catalog( + realtime = providerGroup(id = "openai", label = "OpenAI Realtime", configured = false), + transcription = providerGroup(id = "deepgram", label = "Deepgram", configured = true), + ), + ) + + val realtime = readiness.realtimeTalk as GatewayTalkSetupState.NeedsSetup + val dictation = readiness.dictation as GatewayTalkSetupState.Ready + assertEquals("OpenAI Realtime", realtime.provider?.label) + assertEquals("Deepgram", dictation.provider.label) + } + + @Test + fun browserOnlyModelsSkipAndroidRealtimeRelay() { + assertFalse(isAndroidRealtimeRelayModelSupported("gpt-live")) + assertFalse(isAndroidRealtimeRelayModelSupported(" GPT-LIVE-future ")) + assertTrue(isAndroidRealtimeRelayModelSupported("gpt-realtime-2.1")) + assertTrue(isAndroidRealtimeRelayModelSupported("gpt-liveness")) + assertTrue(isAndroidRealtimeRelayModelSupported(null)) + } + + @Test + fun activeProviderAliasSelectsCanonicalCatalogEntry() { + val readiness = + parseGatewayTalkSetupReadiness( + catalog( + realtime = providerGroup(id = "google", label = "Google Live", configured = true), + transcription = + providerGroup( + id = "openai", + label = "OpenAI Realtime Transcription", + configured = true, + activeProvider = "openai-realtime", + aliases = listOf("openai-realtime"), + ), + ), + ) + + val dictation = readiness.dictation as GatewayTalkSetupState.Ready + assertEquals("openai", dictation.provider.id) + } + + @Test + fun canonicalProviderIdWinsOverAnEarlierAliasCollision() { + val readiness = + parseGatewayTalkSetupReadiness( + json( + """ + { + "realtime": { + "ready": true, + "activeProvider": "google", + "providers": [ + {"id":"bridge","aliases":["google"],"label":"Bridge","configured":false}, + {"id":"google","label":"Google Live","configured":true} + ] + }, + "transcription": ${providerGroup(id = "deepgram", label = "Deepgram", configured = true)} + } + """.trimIndent(), + ), + ) + + val realtime = readiness.realtimeTalk as GatewayTalkSetupState.Ready + assertEquals("google", realtime.provider.id) + } + + @Test + fun missingActiveProviderStaysUnverifiedInsteadOfGuessingFromRowOrder() { + val readiness = + parseGatewayTalkSetupReadiness( + json( + """ + { + "realtime": { + "providers": [ + {"id":"google","label":"Google Live","configured":false}, + {"id":"openai","label":"OpenAI Realtime","configured":true} + ] + }, + "transcription": ${providerGroup(id = "deepgram", label = "Deepgram", configured = true)} + } + """.trimIndent(), + ), + ) + + assertTrue(readiness.realtimeTalk is GatewayTalkSetupState.Unverified) + assertTrue(!readiness.realtimeTalk.requiresSetup) + } + + @Test + fun authoritativeUnconfiguredProvidersRequireSetupWithoutAnActiveProvider() { + val readiness = + parseGatewayTalkSetupReadiness( + catalog( + realtime = + providerGroup( + id = "openai", + label = "OpenAI Realtime", + configured = false, + activeProvider = null, + ready = false, + ), + transcription = providerGroup(id = "deepgram", label = "Deepgram", configured = true), + ), + ) + + assertTrue(readiness.realtimeTalk is GatewayTalkSetupState.NeedsSetup) + assertTrue(readiness.realtimeTalk.requiresSetup) + } + + @Test + fun olderCatalogRowStateStaysUnverified() { + val readiness = + parseGatewayTalkSetupReadiness( + catalog( + realtime = + providerGroup( + id = "openai", + label = "OpenAI Realtime", + configured = false, + ready = null, + ), + transcription = providerGroup(id = "deepgram", label = "Deepgram", configured = true), + ), + ) + + assertTrue(readiness.realtimeTalk is GatewayTalkSetupState.Unverified) + assertTrue(!readiness.realtimeTalk.requiresSetup) + } + + @Test + fun unknownActiveProviderStaysUnverifiedInsteadOfBlockingStartup() { + val readiness = + parseGatewayTalkSetupReadiness( + catalog( + realtime = providerGroup(id = "google", label = "Google Live", configured = true, activeProvider = "future-alias"), + transcription = providerGroup(id = "deepgram", label = "Deepgram", configured = true), + ), + ) + + assertTrue(readiness.realtimeTalk is GatewayTalkSetupState.Unverified) + assertTrue(!readiness.realtimeTalk.requiresSetup) + } + + @Test + fun authoritativeUnknownActiveProviderRequiresSetup() { + val readiness = + parseGatewayTalkSetupReadiness( + catalog( + realtime = + providerGroup( + id = "google", + label = "Google Live", + configured = true, + activeProvider = "removed-provider", + ready = false, + ), + transcription = providerGroup(id = "deepgram", label = "Deepgram", configured = true), + ), + ) + + val realtime = readiness.realtimeTalk as GatewayTalkSetupState.NeedsSetup + assertEquals("Choose a supported Realtime Talk provider on the Gateway", gatewayTalkSetupDescription(realtime)) + assertTrue(readiness.realtimeTalk.requiresSetup) + } + + @Test + fun unknownActiveProviderWithEmptyRegistryStaysUnverified() { + val readiness = + parseGatewayTalkSetupReadiness( + json( + """ + { + "realtime": {"activeProvider":"custom-id","providers":[]}, + "transcription": ${providerGroup(id = "deepgram", label = "Deepgram", configured = true)} + } + """.trimIndent(), + ), + ) + + assertTrue(readiness.realtimeTalk is GatewayTalkSetupState.Unverified) + assertTrue(!readiness.realtimeTalk.requiresSetup) + } + + @Test + fun missingCatalogIsUnverifiedForBothActions() { + val readiness = parseGatewayTalkSetupReadiness(null) + + assertTrue(readiness.realtimeTalk is GatewayTalkSetupState.Unverified) + assertTrue(readiness.dictation is GatewayTalkSetupState.Unverified) + } + + private fun catalog( + realtime: String, + transcription: String, + ) = json("""{"realtime":$realtime,"transcription":$transcription}""") + + private fun providerGroup( + id: String, + label: String, + configured: Boolean, + activeProvider: String? = id, + aliases: List = emptyList(), + ready: Boolean? = configured, + ): String { + val active = activeProvider?.let { "\"activeProvider\":\"$it\"," }.orEmpty() + val readiness = ready?.let { "\"ready\":$it," }.orEmpty() + val aliasJson = aliases.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + return """{$readiness$active"providers":[{"id":"$id","label":"$label","configured":$configured,"aliases":$aliasJson}]}""" + } + + private fun json(value: String) = Json.parseToJsonElement(value).jsonObject +} diff --git a/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt b/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt new file mode 100644 index 0000000..b1c3737 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt @@ -0,0 +1,344 @@ +package ai.openclaw.app + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Looper +import androidx.lifecycle.SavedStateHandle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MainActivityLifecycleTest { + @Test + fun pendingIntentRouterUsesLatestIntentBeforeActivation() { + val router = MainActivityPendingIntentRouter() + val initial = Intent("initial") + val replacement = Intent("replacement") + val routed = mutableListOf() + + router.setInitialIntent(initial) + router.onNewIntent(replacement, routed::add) + + assertTrue(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + assertFalse(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + } + + @Test + fun pendingIntentRouterRoutesImmediatelyAfterActivation() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + val next = Intent("next") + + assertTrue(router.activate(routed::add)) + router.onNewIntent(next, routed::add) + router.setInitialIntent(Intent("ignored")) + + assertEquals(listOf(next), routed) + } + + @Test + fun pendingIntentRouterQueuesRapidColdStartShares() { + val router = MainActivityPendingIntentRouter() + val first = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "first") + val second = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "second") + val routed = mutableListOf() + + router.setInitialIntent(first) + assertTrue(router.onNewIntent(second, routed::add)) + + assertTrue(router.activate(routed::add)) + assertEquals(listOf(first, second), routed) + } + + @Test + fun pendingIntentRouterDiscardsOnlyRecreatedInitialIntent() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + + router.setInitialIntent(Intent("recreated")) + router.discardInitialIntent() + + assertTrue(router.activate(routed::add)) + assertTrue(routed.isEmpty()) + } + + @Test + fun pendingIntentRouterKeepsNewIntentAcrossRecreationGate() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + val replacement = Intent("replacement") + + router.setInitialIntent(Intent("recreated")) + router.onNewIntent(replacement, routed::add) + router.discardInitialIntent() + + assertTrue(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + } + + @Test + fun pendingIntentRouterRetainsShareOverflowUntilViewModelActivation() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + repeat(MAX_PENDING_CHAT_SHARES) { index -> + val share = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "share-$index") + if (index == 0) { + router.setInitialIntent(share) + } else { + assertTrue(router.onNewIntent(share, routed::add)) + } + } + + assertFalse( + router.onNewIntent( + Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "overflow"), + routed::add, + ), + ) + + assertTrue(router.activate(routed::add)) + assertEquals(MAX_PENDING_CHAT_SHARES, routed.size) + assertEquals(1, router.takeShareOverflowCount()) + assertEquals(0, router.takeShareOverflowCount()) + } + + @Test + fun initialIntentGateDistinguishesRecreationFromProcessRestoration() { + val retainedGate = MainActivityInitialIntentGate() + + assertTrue(retainedGate.claim()) + assertFalse(retainedGate.claim()) + assertTrue(MainActivityInitialIntentGate().claim()) + } + + @Test + fun blockedShareMimeResolutionSurvivesActivityRecreation() { + val app = RuntimeEnvironment.getApplication() as NodeApp + app.chatShareDraftQueue.clear() + val resolverEntered = CountDownLatch(1) + val releaseResolver = CountDownLatch(1) + ShadowContentResolver.registerProviderInternal( + "blocked-share", + BlockingMimeProvider(resolverEntered, releaseResolver), + ) + val sharedUri = Uri.parse("content://blocked-share/document") + val shareIntent = + Intent(Intent.ACTION_SEND) + .setType("*/*") + .putExtra(Intent.EXTRA_STREAM, sharedUri) + val controller = + Robolectric + .buildActivity(MainActivity::class.java) + .create() + .start() + .resume() + val activity = controller.get() + val prefs = + SecurePrefs( + app, + securePrefsOverride = + app.getSharedPreferences( + "share-recreation-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ), + ) + val viewModel = MainViewModel(app, prefs, SavedStateHandle()) + val expectedOwner = viewModel.captureChatShareOwner() + assertTrue(viewModel.claimInitialIntentRouting()) + val handleLaunchIntent = + MainActivity::class.java + .getDeclaredMethod("handleLaunchIntent", MainViewModel::class.java, Intent::class.java) + .apply { isAccessible = true } + + handleLaunchIntent.invoke(activity, viewModel, shareIntent) + assertTrue(resolverEntered.await(5, TimeUnit.SECONDS)) + + controller.pause().stop().destroy() + assertFalse(viewModel.claimInitialIntentRouting()) + releaseResolver.countDown() + + assertTrue(waitUntil { app.chatShareDraftQueue.size() == 1 }) + val draft = requireNotNull(app.chatShareDraftQueue.head.value) + assertEquals(listOf(sharedUri), draft.attachments.map(SharedAttachment::uri)) + assertEquals(expectedOwner, app.chatShareDraftQueue.ownerOf(draft.id)) + } + + @Test + fun runtimeStaysForegroundAcrossConfigurationRecreation() { + assertFalse(shouldNotifyRuntimeBackgrounded(isChangingConfigurations = true)) + assertTrue(shouldNotifyRuntimeBackgrounded(isChangingConfigurations = false)) + } + + @Test + fun topResumedPermissionHostRefreshesAuthorityAfterActivation() { + val events = mutableListOf() + + updateTopResumedPermissionHost( + isTopResumedActivity = true, + activate = { events += "activate" }, + deactivate = { events += "deactivate" }, + refreshPermissionSurface = { events += "refresh" }, + ) + updateTopResumedPermissionHost( + isTopResumedActivity = false, + activate = { events += "activate" }, + deactivate = { events += "deactivate" }, + refreshPermissionSurface = { events += "refresh" }, + ) + + assertEquals(listOf("activate", "refresh", "deactivate"), events) + } + + @Test + fun runtimeUiStarterWaitsForReadinessAndStartsOnce() { + val starter = MainActivityRuntimeUiStarter() + var attachCount = 0 + var serviceCount = 0 + + starter.onRuntimeInitialized( + ready = false, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + + assertEquals(1, attachCount) + assertEquals(1, serviceCount) + } + + @Test + fun runtimeUiStarterCompletesWithoutSideEffectsForScreenshotFixture() { + val starter = MainActivityRuntimeUiStarter() + var attachCount = 0 + var serviceCount = 0 + + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = false, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + + assertEquals(0, attachCount) + assertEquals(0, serviceCount) + } + + @Test + fun recreatedRuntimeUiStarterCannotRestartSuppressedServiceUntilExplicitResume() { + val app = RuntimeEnvironment.getApplication() + val appShadow = shadowOf(app) + NodeForegroundService.resume(app, startNow = false) + + try { + NodeForegroundService.stop(app) + assertEquals("ai.openclaw.app.action.STOP", appShadow.nextStartedService.action) + + repeat(2) { + MainActivityRuntimeUiStarter().onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = {}, + startNodeService = { NodeForegroundService.start(app) }, + ) + } + + assertNull(appShadow.nextStartedService) + + NodeForegroundService.resume(app, startNow = true) + assertEquals("ai.openclaw.app.action.RESUME", appShadow.nextStartedService.action) + } finally { + NodeForegroundService.resume(app, startNow = false) + } + } + + private fun waitUntil( + timeoutMillis: Long = 2_000, + predicate: () -> Boolean, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + if (predicate()) return true + Thread.sleep(10) + } + shadowOf(Looper.getMainLooper()).idle() + return predicate() + } + + private class BlockingMimeProvider( + private val entered: CountDownLatch, + private val release: CountDownLatch, + ) : ContentProvider() { + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri): String { + entered.countDown() + check(release.await(5, TimeUnit.SECONDS)) + return "application/pdf" + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + } +} diff --git a/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt b/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt new file mode 100644 index 0000000..523a904 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt @@ -0,0 +1,514 @@ +package ai.openclaw.app + +import ai.openclaw.app.chat.ChatComposerOwner +import ai.openclaw.app.gateway.GatewayRegistryEntry +import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import ai.openclaw.app.ui.chat.ChatComposerStateStore +import ai.openclaw.app.ui.chat.PendingAttachment +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Looper +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MainViewModelTest { + @After + fun resetNodeServiceStartSuppression() { + val app = RuntimeEnvironment.getApplication() + NodeForegroundService.resume(app, startNow = false) + (app as NodeApp).chatShareDraftQueue.clear() + val appShadow = shadowOf(app) + while (appShadow.nextStartedService != null) { + // Drain queued service intents so each test owns its lifecycle assertions. + } + } + + @Test + fun foregroundStartupRequiresForegroundAndCompletedOnboarding() { + assertFalse( + shouldStartRuntimeOnForeground( + foreground = false, + onboardingCompleted = true, + ), + ) + assertFalse( + shouldStartRuntimeOnForeground( + foreground = true, + onboardingCompleted = false, + ), + ) + assertFalse( + shouldStartRuntimeOnForeground( + foreground = false, + onboardingCompleted = false, + ), + ) + assertTrue( + shouldStartRuntimeOnForeground( + foreground = true, + onboardingCompleted = true, + ), + ) + } + + @Test + fun cronEditorDraftMemoryIsBoundedAndClearsOnlyItsOwningJob() { + val memory = CronEditorDraftMemory() + val first = draft("First") + val second = draft("Second") + + memory.set("job-a", first) + assertEquals(first, memory.get("job-a")) + assertNull(memory.get("job-b")) + + memory.set("job-b", second) + assertNull(memory.get("job-a")) + memory.clear("job-a") + assertEquals(second, memory.get("job-b")) + + memory.set("job-b", null) + assertNull(memory.get("job-b")) + } + + @Test + fun disconnectStopsStickyNodeServiceWithoutClearingSavedGateways() { + val (viewModel, prefs) = createViewModel() + val gateway = + GatewayRegistryEntry( + stableId = "manual|gateway.test|18789", + kind = GatewayRegistryEntryKind.MANUAL, + name = "gateway.test", + host = "gateway.test", + port = 18789, + ) + prefs.gatewayRegistry.upsert(gateway) + prefs.setOnboardingCompleted(true) + + viewModel.disconnect() + + assertNodeServiceStopRequested() + assertEquals(listOf(gateway), prefs.gatewayRegistry.entries.value) + + viewModel.resumeNodeServiceForConnection() + + assertNodeServiceResumeRequested() + } + + @Test + fun pairNewGatewayStopsStickyNodeServiceWithoutClearingSavedGateways() { + val (viewModel, prefs) = createViewModel() + val gateway = + GatewayRegistryEntry( + stableId = "manual|gateway.test|18789", + kind = GatewayRegistryEntryKind.MANUAL, + name = "gateway.test", + host = "gateway.test", + port = 18789, + ) + prefs.gatewayRegistry.upsert(gateway) + + viewModel.pairNewGateway() + + assertNodeServiceStopRequested() + assertEquals(listOf(gateway), prefs.gatewayRegistry.entries.value) + } + + @Test + fun assistantLaunchDraftCapturesItsProvisionalComposerOwner() { + val (viewModel, _) = createViewModel() + + viewModel.handleAssistantLaunch( + AssistantLaunchRequest( + source = "app_action", + prompt = "captured prompt", + autoSend = false, + ), + ) + + val draft = requireNotNull(viewModel.chatDraft.value) + val captured = requireNotNull(draft.owner) + assertEquals("captured prompt", draft.text) + assertNull( + claimChatDraftForOwner( + draft = draft, + owner = captured.copy(gatewayStableId = "another-gateway", agentId = "another-agent"), + mainSessionKey = "agent:another-agent:main", + ), + ) + } + + @Test + fun assistantAutoSendCapturesAndMigratesItsProvisionalComposerOwner() { + val (viewModel, _) = createViewModel() + + viewModel.handleAssistantLaunch( + AssistantLaunchRequest( + source = "app_action", + prompt = "send to the captured chat", + autoSend = true, + ), + ) + + val pending = requireNotNull(viewModel.pendingAssistantAutoSend.value) + val resolvedOwner = + pending.owner.copy( + agentId = "work", + sessionKey = "agent:work:device", + routingVerified = true, + ) + viewModel.resolveChatComposerOwnerAliases(to = resolvedOwner, mainSessionKey = resolvedOwner.sessionKey) + + assertEquals("send to the captured chat", viewModel.pendingAssistantAutoSend.value?.prompt) + assertEquals(resolvedOwner, viewModel.pendingAssistantAutoSend.value?.owner) + } + + @Test + fun mediaAuthorizationMigratesWithItsProvisionalComposerOwner() { + val (viewModel, _) = createViewModel() + val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false) + val resolved = ChatComposerOwner("gateway", "work", "agent:work:device") + val authorizationId = requireNotNull(viewModel.chatComposerState.beginMediaAcquisition(provisional)) + + viewModel.resolveChatComposerOwnerAliases(to = resolved, mainSessionKey = resolved.sessionKey) + + assertEquals( + 0, + viewModel.chatComposerState.addAuthorizedAttachments( + owner = resolved, + mediaAuthorizationId = authorizationId, + candidates = listOf(PendingAttachment("migrated", "photo.jpg", "image/jpeg", "YQ==")), + ), + ) + assertEquals( + 1, + viewModel.chatComposerState.attachments.value[resolved] + ?.size, + ) + } + + @Test + fun completedAssistantAutoSendClearsItsMigratedOperationButNotAReplacement() { + val original = + PendingAssistantAutoSend( + prompt = "send once", + owner = ChatComposerOwner("gateway", "main", "main"), + ) + val migrated = original.copy(owner = original.owner.copy(sessionKey = "agent:main:device")) + val replacement = PendingAssistantAutoSend(prompt = original.prompt, owner = migrated.owner) + + assertNull(clearCompletedAssistantAutoSend(migrated, original.id)) + assertEquals(replacement, clearCompletedAssistantAutoSend(replacement, original.id)) + } + + @Test + fun refusedAssistantPromptBecomesEditableWithoutOverwritingNewerText() { + assertEquals("send once", retainRefusedAssistantPrompt("send once", "")) + assertEquals("send once\n\nnewer edit", retainRefusedAssistantPrompt("send once", "newer edit")) + assertEquals("send once", retainRefusedAssistantPrompt("send once", "send once")) + } + + @Test + fun assistantAutoSendSharesTheManualComposerAdmissionGate() { + val owner = ChatComposerOwner("gateway", "main", "agent:main:device") + val state = ChatComposerStateStore() + + val sendId = requireNotNull(state.tryBeginTrackedSend(owner)) + assertNull(state.tryBeginTrackedSend(owner)) + state.finishTrackedSend(sendId) + assertNotNull(state.tryBeginTrackedSend(owner)) + } + + @Test + fun warmShareIntentsQueueOnceInArrivalOrderWithCapturedOwner() { + val (viewModel, _) = createViewModel(resolveShareMimeType = { "application/pdf" }) + val firstUri = Uri.parse("content://share/first") + val secondUri = Uri.parse("content://share/second") + val owner = viewModel.captureChatShareOwner() + + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(firstUri, "first"))) + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(secondUri, "second"))) + + assertTrue(waitUntil { viewModel.chatShareDrafts.value.size == 2 }) + val drafts = viewModel.chatShareDrafts.value + assertEquals(listOf("first", "second"), drafts.map(ChatShareDraft::text)) + assertEquals(listOf(firstUri, secondUri), drafts.map { draft -> draft.attachments.single().uri }) + assertTrue(drafts.all { draft -> viewModel.chatShareDraftTargetsOwner(draft.id, owner, owner.sessionKey) }) + } + + @Test + fun blockedShareReportsRetainedOverflowAndReleasesItsSlot() { + val resolverEntered = CountDownLatch(1) + val releaseResolver = CountDownLatch(1) + val blockedUri = Uri.parse("content://share/blocked") + val nextUri = Uri.parse("content://share/next") + val (viewModel, _) = + createViewModel( + resolveShareMimeType = { uri -> + if (uri == blockedUri) { + resolverEntered.countDown() + check(releaseResolver.await(5, TimeUnit.SECONDS)) + } + "application/pdf" + }, + shareLaunchCapacity = 1, + ) + + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(blockedUri, "blocked"))) + assertTrue(resolverEntered.await(5, TimeUnit.SECONDS)) + assertFalse(viewModel.handleShareLaunchIntent(shareIntent(nextUri, "overflow"))) + assertEquals(1L, viewModel.shareLaunchOverflowRevision.value) + + releaseResolver.countDown() + assertTrue(waitUntil { viewModel.chatShareDrafts.value.size == 1 }) + assertEquals(1, viewModel.takeShareLaunchOverflowCount()) + assertEquals(0, viewModel.takeShareLaunchOverflowCount()) + viewModel.reportShareLaunchOverflow(2) + viewModel.reportShareLaunchOverflow() + assertEquals(3L, viewModel.shareLaunchOverflowRevision.value) + assertEquals(3, viewModel.takeShareLaunchOverflowCount()) + + (RuntimeEnvironment.getApplication() as NodeApp).chatShareDraftQueue.clear() + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(nextUri, "next"))) + assertTrue( + waitUntil { + viewModel.chatShareDrafts.value + .singleOrNull() + ?.text == "next" + }, + ) + } + + @Test + fun gatewayAuthResetCleanupPurgesOnlyThatGatewaysComposerState() = + runBlocking { + val (viewModel, _) = createViewModel() + val removed = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-a", "main", "main") + val retained = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-b", "main", "main") + viewModel.chatComposerState.textDrafts[removed] = "private a" + viewModel.chatComposerState.textDrafts[retained] = "private b" + val removedAttachment = PendingAttachment("a", "a.txt", "text/plain", "YQ==") + val retainedAttachment = PendingAttachment("b", "b.txt", "text/plain", "Yg==") + viewModel.chatComposerState.addAttachments(removed, listOf(removedAttachment)) + viewModel.chatComposerState.addAttachments(retained, listOf(retainedAttachment)) + + viewModel.clearChatComposerGateway("gateway-a") + + assertEquals("", viewModel.chatComposerState.textDrafts[removed]) + assertEquals("private b", viewModel.chatComposerState.textDrafts[retained]) + assertEquals(null, viewModel.chatComposerState.attachments.value[removed]) + assertEquals(listOf(retainedAttachment), viewModel.chatComposerState.attachments.value[retained]) + } + + @Test + fun gatewayAuthResetRejectsMediaCompletionsCapturedByRetiredCredentials() = + runBlocking { + val (viewModel, _) = createViewModel() + val owner = ChatComposerOwner("gateway-a", "main", "main") + val authorizationId = requireNotNull(viewModel.chatComposerState.beginMediaAcquisition(owner)) + var imageLoaderCalled = false + + viewModel.clearChatComposerGateway("gateway-a") + + assertFalse(viewModel.chatComposerState.isMediaAcquisitionActive(authorizationId)) + assertNull( + viewModel.chatComposerState.addAuthorizedAttachments( + owner = owner, + mediaAuthorizationId = authorizationId, + candidates = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")), + ), + ) + viewModel.importChatComposerAttachments(owner, authorizationId, mainSessionKey = "main", expectedCount = 1) { + imageLoaderCalled = true + listOf(PendingAttachment("late-image", "late.jpg", "image/jpeg", "YQ==")) + } + assertFalse(imageLoaderCalled) + assertNull(viewModel.chatComposerState.attachments.value[owner]) + } + + @Test + fun deletedSessionCleanupPurgesItsMainAliasesWithoutTouchingSiblingOwners() = + runBlocking { + val (viewModel, _) = createViewModel() + val alias = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-a", "main", "main") + val canonical = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-a", "main", "agent:main:device") + val provisional = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-a", "placeholder", "main", routingVerified = false) + val sibling = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-a", "main", "agent:main:other") + val otherAgent = + ai.openclaw.app.chat + .ChatComposerOwner("gateway-a", "work", "agent:main:device") + val mediaAuthorizationId = requireNotNull(viewModel.chatComposerState.beginMediaAcquisition(canonical)) + listOf(alias, canonical, provisional, sibling, otherAgent).forEach { owner -> + viewModel.chatComposerState.textDrafts[owner] = owner.toString() + viewModel.chatComposerState.addAttachments( + owner, + listOf(PendingAttachment(owner.toString(), "draft.txt", "text/plain", "YQ==")), + ) + } + + viewModel.clearChatComposerSession( + gatewayStableId = "gateway-a", + agentId = "main", + sessionKey = "main", + mainSessionKey = "agent:main:device", + ) + + assertEquals("", viewModel.chatComposerState.textDrafts[alias]) + assertEquals("", viewModel.chatComposerState.textDrafts[canonical]) + assertEquals("", viewModel.chatComposerState.textDrafts[provisional]) + assertEquals(sibling.toString(), viewModel.chatComposerState.textDrafts[sibling]) + assertEquals(otherAgent.toString(), viewModel.chatComposerState.textDrafts[otherAgent]) + assertEquals(null, viewModel.chatComposerState.attachments.value[alias]) + assertEquals(null, viewModel.chatComposerState.attachments.value[canonical]) + assertEquals(null, viewModel.chatComposerState.attachments.value[provisional]) + assertEquals( + 1, + viewModel.chatComposerState.attachments.value[sibling] + ?.size, + ) + assertEquals( + 1, + viewModel.chatComposerState.attachments.value[otherAgent] + ?.size, + ) + assertFalse(viewModel.chatComposerState.isMediaAcquisitionActive(mediaAuthorizationId)) + assertNull( + viewModel.chatComposerState.addAuthorizedAttachments( + owner = canonical, + mediaAuthorizationId = mediaAuthorizationId, + candidates = listOf(PendingAttachment("late", "late.txt", "text/plain", "YQ==")), + ), + ) + } + + @Test + fun replyDraftRejectsACallbackCapturedForAnotherChat() { + val (viewModel, _) = createViewModel() + viewModel.handleAssistantLaunch( + AssistantLaunchRequest( + source = "app_action", + prompt = "initial", + autoSend = false, + ), + ) + val owner = requireNotNull(viewModel.chatDraft.value?.owner) + + viewModel.setChatReplyDraft("quoted", owner) + assertEquals("quoted", viewModel.chatDraft.value?.text) + + viewModel.setChatReplyDraft("stale", owner.copy(sessionKey = "agent:main:another")) + assertEquals("quoted", viewModel.chatDraft.value?.text) + } + + private fun assertNodeServiceStopRequested() { + val app = RuntimeEnvironment.getApplication() + val intent: Intent? = shadowOf(app).nextStartedService + assertNotNull(intent) + assertEquals(NodeForegroundService::class.java.name, intent?.component?.className) + assertEquals("ai.openclaw.app.action.STOP", intent?.action) + } + + private fun assertNodeServiceResumeRequested() { + val app = RuntimeEnvironment.getApplication() + val intent: Intent? = shadowOf(app).nextStartedService + assertNotNull(intent) + assertEquals(NodeForegroundService::class.java.name, intent?.component?.className) + assertEquals("ai.openclaw.app.action.RESUME", intent?.action) + } + + private fun createViewModel( + resolveShareMimeType: (Uri) -> String? = { null }, + shareLaunchCapacity: Int = MAX_PENDING_CHAT_SHARES, + ): Pair { + val app = RuntimeEnvironment.getApplication() as NodeApp + val prefs = + SecurePrefs( + app, + securePrefsOverride = + app.getSharedPreferences( + "main-view-model-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ), + ) + return ( + MainViewModel( + app = app, + prefs = prefs, + savedStateHandle = SavedStateHandle(), + resolveShareMimeType = resolveShareMimeType, + shareLaunchCapacity = shareLaunchCapacity, + ) to + prefs + ) + } + + private fun shareIntent( + uri: Uri, + text: String, + ): Intent = + Intent(Intent.ACTION_SEND) + .setType("*/*") + .putExtra(Intent.EXTRA_TEXT, text) + .putExtra(Intent.EXTRA_STREAM, uri) + + private fun waitUntil( + timeoutMillis: Long = 2_000, + predicate: () -> Boolean, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + if (predicate()) return true + Thread.sleep(10) + } + shadowOf(Looper.getMainLooper()).idle() + return predicate() + } + + private fun draft(name: String): CronEditorDraftState { + val edit = + GatewayCronJobEdit( + name = name, + description = "", + enabled = true, + deleteAfterRun = false, + schedule = GatewayCronScheduleEdit.At("2026-07-10T09:00:00Z"), + sessionTarget = "isolated", + wakeMode = "now", + payload = GatewayCronPayloadEdit.SystemEvent("Wake up"), + ) + return CronEditorDraftState( + baseline = edit, + edit = edit.copy(name = "$name draft"), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt b/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt new file mode 100644 index 0000000..c056541 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt @@ -0,0 +1,295 @@ +package ai.openclaw.app + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NodeForegroundServiceTest { + @After + fun resetNodeServiceStartSuppression() { + NodeForegroundService.resume(RuntimeEnvironment.getApplication(), startNow = false) + } + + @Test + fun stableNotificationStateReemitsWhenLocaleChanges() = + runBlocking { + val localeChanges = MutableStateFlow(0L) + val firstEmission = CompletableDeferred() + val emissions = mutableListOf>() + val collection = + launch(start = CoroutineStart.UNDISPATCHED) { + refreshNotificationOnLocaleChanges( + states = flowOf("stable"), + localeChanges = localeChanges, + ).take(2) + .collect { update -> + emissions += update + if (emissions.size == 1) firstEmission.complete(Unit) + } + } + + firstEmission.await() + localeChanges.value = 1L + collection.join() + + assertEquals( + listOf( + LocaleAwareNotificationState(state = "stable", localeRevision = 0L), + LocaleAwareNotificationState(state = "stable", localeRevision = 1L), + ), + emissions, + ) + } + + @Test + fun restoreStickyRuntimeCreatesAndActivatesMissingProcessRuntime() = + runBlocking { + val restoredRuntime = Any() + var created = false + var activated: Any? = null + + restoreStickyRuntime( + createRuntime = { + created = true + restoredRuntime + }, + disconnectRequested = { false }, + disconnectRuntime = {}, + activateRuntime = { + activated = it + true + }, + ) + + assertTrue(created) + assertSame(restoredRuntime, activated) + } + + @Test + fun restoreStickyRuntimeDoesNotCreateAfterDisconnectAlreadyWon() = + runBlocking { + var created = false + + restoreStickyRuntime( + createRuntime = { + created = true + Any() + }, + disconnectRequested = { true }, + disconnectRuntime = {}, + activateRuntime = { true }, + ) + + assertFalse(created) + } + + @Test + fun restoreStickyRuntimeHonorsDisconnectRequestedDuringCreation() = + runBlocking { + val restoredRuntime = Any() + var disconnectRequested = false + var disconnected: Any? = null + var activated = false + + restoreStickyRuntime( + createRuntime = { + disconnectRequested = true + restoredRuntime + }, + disconnectRequested = { disconnectRequested }, + disconnectRuntime = { disconnected = it }, + activateRuntime = { + activated = true + true + }, + ) + + assertSame(restoredRuntime, disconnected) + assertFalse(activated) + } + + @Test + fun restoreStickyRuntimeDisconnectsWhenActivationDeclinesOwnership() = + runBlocking { + val restoredRuntime = Any() + var disconnected: Any? = null + + restoreStickyRuntime( + createRuntime = { restoredRuntime }, + disconnectRequested = { false }, + disconnectRuntime = { disconnected = it }, + activateRuntime = { false }, + ) + + assertSame(restoredRuntime, disconnected) + } + + @Test + fun coldStopDoesNotCreateRuntime() { + val app = RuntimeEnvironment.getApplication() as NodeApp + assertNull(app.peekRuntime()) + val controller = Robolectric.buildService(NodeForegroundService::class.java).create() + + try { + val result = + controller + .get() + .onStartCommand( + Intent(app, NodeForegroundService::class.java) + .setAction("ai.openclaw.app.action.STOP"), + 0, + 1, + ) + + assertEquals(Service.START_NOT_STICKY, result) + assertNull(app.peekRuntime()) + + val secondResult = controller.get().onStartCommand(Intent(app, NodeForegroundService::class.java), 0, 2) + assertEquals(Service.START_NOT_STICKY, secondResult) + assertEquals(2, Shadows.shadowOf(controller.get()).stopSelfResultId) + assertNull(app.peekRuntime()) + } finally { + controller.destroy() + } + } + + @Test + fun explicitResumeAfterStopRestoresStickyServiceOwnership() { + val app = RuntimeEnvironment.getApplication() as NodeApp + val controller = Robolectric.buildService(NodeForegroundService::class.java).create() + + try { + val stopped = + controller + .get() + .onStartCommand( + Intent(app, NodeForegroundService::class.java) + .setAction("ai.openclaw.app.action.STOP"), + 0, + 1, + ) + val resumed = + controller + .get() + .onStartCommand( + Intent(app, NodeForegroundService::class.java) + .setAction("ai.openclaw.app.action.RESUME"), + 0, + 2, + ) + + assertEquals(Service.START_NOT_STICKY, stopped) + assertEquals(Service.START_STICKY, resumed) + } finally { + controller.destroy() + app.peekRuntime()?.disconnect() + } + } + + @Test + fun backgroundRuntimeStartsWithoutForegroundCapabilitiesOrMicRestore() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences("node-service-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + prefs.setVoiceMicEnabled(true) + val runtime = NodeRuntime(app, prefs, initialForeground = false) + + try { + assertFalse(runtime.isForeground.value) + assertFalse(prefs.voiceMicEnabled.value) + } finally { + runtime.disconnect() + } + } + + @Test + fun buildNotificationSetsLaunchIntent() { + val service = Robolectric.buildService(NodeForegroundService::class.java).get() + val notification = buildNotification(service) + + val pendingIntent = notification.contentIntent + assertNotNull(pendingIntent) + + val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent + assertNotNull(savedIntent) + assertEquals(MainActivity::class.java.name, savedIntent.component?.className) + + val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + assertEquals(expectedFlags, savedIntent.flags and expectedFlags) + } + + @Test + fun foregroundServiceTypes_addsOnlyActiveSensitiveTypes() { + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, + foregroundServiceTypes(VoiceCaptureMode.Off, backgroundLocationActive = false), + ) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + foregroundServiceTypes(VoiceCaptureMode.ManualMic, backgroundLocationActive = false), + ) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION, + foregroundServiceTypes(VoiceCaptureMode.TalkMode, backgroundLocationActive = true), + ) + } + + @Test + fun backgroundLocationNotificationSuffix_disclosesActiveAlwaysMode() { + assertEquals("", backgroundLocationNotificationSuffix(active = false)) + assertEquals(" · Location: Always", backgroundLocationNotificationSuffix(active = true)) + } + + @Test + fun voiceNotificationSuffixReflectsActiveCaptureMode() { + assertEquals("", voiceNotificationSuffix(VoiceCaptureMode.Off, false, false, false, false)) + assertEquals( + " · Mic: Listening", + voiceNotificationSuffix(VoiceCaptureMode.ManualMic, true, true, false, false), + ) + assertEquals( + " · Talk: Speaking", + voiceNotificationSuffix(VoiceCaptureMode.TalkMode, false, false, true, true), + ) + } + + private fun buildNotification(service: NodeForegroundService): Notification { + val method = + NodeForegroundService::class.java.getDeclaredMethod( + "buildNotification", + String::class.java, + String::class.java, + ) + method.isAccessible = true + return method.invoke(service, "Title", "Text") as Notification + } +} diff --git a/app/src/test/java/ai/openclaw/app/NodeRuntimeAgentSelectionTest.kt b/app/src/test/java/ai/openclaw/app/NodeRuntimeAgentSelectionTest.kt new file mode 100644 index 0000000..0d21e11 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/NodeRuntimeAgentSelectionTest.kt @@ -0,0 +1,30 @@ +package ai.openclaw.app + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NodeRuntimeAgentSelectionTest { + @Test + fun selectingAgentRebindsCanonicalMainSession() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ) + val runtime = NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + + runtime.selectChatAgent(" scout ") + + assertEquals("scout", resolveAgentIdFromMainSessionKey(runtime.mainSessionKey.value)) + assertEquals(runtime.mainSessionKey.value, runtime.chatSessionKey.value) + } +} diff --git a/app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt b/app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt new file mode 100644 index 0000000..27c344a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt @@ -0,0 +1,72 @@ +package ai.openclaw.app + +import ai.openclaw.app.wear.WearRealtimeAttemptOwner +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class NodeRuntimeWearRealtimeTalkTest { + @Test + fun `replacement during relay creation stops the stale session and rejects start`() = + runTest { + val owner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val startEntered = CompletableDeferred() + val releaseStart = CompletableDeferred() + val stoppedOwners = mutableListOf() + var currentOwner: WearRealtimeAttemptOwner? = owner + + val result = + async { + startWearRealtimeTalkWhileCurrent( + owner = owner, + isCurrent = { candidate -> currentOwner == candidate }, + start = { onSessionActivated -> + startEntered.complete(Unit) + releaseStart.await() + onSessionActivated() + true + }, + stop = { staleOwner -> stoppedOwners += staleOwner }, + ) + } + + startEntered.await() + currentOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + releaseStart.complete(Unit) + + assertFalse(result.await()) + assertEquals(listOf(owner), stoppedOwners) + } + + @Test + fun `cancellation after relay activation still stops the uncommitted session`() = + runTest { + val owner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val relayActivated = CompletableDeferred() + val stoppedOwners = mutableListOf() + + val result = + async { + startWearRealtimeTalkWhileCurrent( + owner = owner, + isCurrent = { true }, + start = { onSessionActivated -> + onSessionActivated() + relayActivated.complete(Unit) + awaitCancellation() + }, + stop = { staleOwner -> stoppedOwners += staleOwner }, + ) + } + + relayActivated.await() + result.cancelAndJoin() + + assertEquals(listOf(owner), stoppedOwners) + } +} diff --git a/app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt b/app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt new file mode 100644 index 0000000..31b46c3 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt @@ -0,0 +1,247 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDateTime +import java.time.ZoneId + +class NotificationForwardingPolicyTest { + @Test + fun parseLocalHourMinute_parsesValidValues() { + assertEquals(0, parseLocalHourMinute("00:00")) + assertEquals(23 * 60 + 59, parseLocalHourMinute("23:59")) + assertEquals(7 * 60 + 5, parseLocalHourMinute("07:05")) + } + + @Test + fun normalizeLocalHourMinute_acceptsStrict24HourDrafts() { + assertEquals("00:00", normalizeLocalHourMinute("00:00")) + assertEquals("23:59", normalizeLocalHourMinute("23:59")) + assertEquals("07:05", normalizeLocalHourMinute("07:05")) + } + + @Test + fun parseLocalHourMinute_rejectsInvalidValues() { + assertEquals(null, parseLocalHourMinute("")) + assertEquals(null, parseLocalHourMinute("24:00")) + assertEquals(null, parseLocalHourMinute("12:60")) + assertEquals(null, parseLocalHourMinute("abc")) + assertEquals(null, parseLocalHourMinute("7:05")) + assertEquals(null, parseLocalHourMinute("07:5")) + } + + @Test + fun normalizeLocalHourMinute_rejectsNonCanonicalDrafts() { + assertEquals(null, normalizeLocalHourMinute("")) + assertEquals(null, normalizeLocalHourMinute("7:05")) + assertEquals(null, normalizeLocalHourMinute("07:5")) + assertEquals(null, normalizeLocalHourMinute("24:00")) + assertEquals(null, normalizeLocalHourMinute("12:60")) + } + + @Test + fun allowsPackage_blocklistBlocksConfiguredPackages() { + val policy = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Blocklist, + packages = setOf("com.blocked.app"), + quietHoursEnabled = false, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + + assertFalse(policy.allowsPackage("com.blocked.app")) + assertTrue(policy.allowsPackage("com.allowed.app")) + } + + @Test + fun allowsPackage_allowlistOnlyAllowsConfiguredPackages() { + val policy = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Allowlist, + packages = setOf("com.allowed.app"), + quietHoursEnabled = false, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + + assertTrue(policy.allowsPackage("com.allowed.app")) + assertFalse(policy.allowsPackage("com.other.app")) + } + + @Test + fun allowsPackage_neverForwardsSelfPackageEvenInAllowlist() { + val policy = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Allowlist, + packages = setOf("ai.openclaw.app", "com.other.app"), + quietHoursEnabled = false, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + selfPackageName = "ai.openclaw.app", + ) + + assertFalse(policy.allowsPackage("ai.openclaw.app")) + assertTrue(policy.allowsPackage("com.other.app")) + } + + @Test + fun allowsPackage_neverForwardsNativeChannelPackages() { + val nativeChannelPackages = + setOf( + "com.discord", + "com.whatsapp", + "com.whatsapp.w4b", + "org.telegram.messenger", + "org.telegram.messenger.web", + "org.thunderdog.challegram", + "org.thoughtcrime.securesms", + ) + val policies = + NotificationPackageFilterMode.entries.map { mode -> + NotificationForwardingPolicy( + enabled = true, + mode = mode, + packages = + if (mode == NotificationPackageFilterMode.Allowlist) { + nativeChannelPackages + "com.other.app" + } else { + nativeChannelPackages + }, + quietHoursEnabled = false, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + } + + policies.forEach { policy -> + nativeChannelPackages.forEach { packageName -> + assertFalse("$packageName must be owned by its native channel", policy.allowsPackage(packageName)) + } + assertTrue(policy.allowsPackage("com.other.app")) + } + } + + @Test + fun isWithinQuietHours_handlesWindowCrossingMidnight() { + val policy = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Blocklist, + packages = emptySet(), + quietHoursEnabled = true, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + + val zone = ZoneId.of("UTC") + val at2330 = + LocalDateTime + .of(2024, 1, 6, 23, 30) + .atZone(zone) + .toInstant() + .toEpochMilli() + val at1200 = + LocalDateTime + .of(2024, 1, 6, 12, 0) + .atZone(zone) + .toInstant() + .toEpochMilli() + + assertTrue(policy.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone)) + assertFalse(policy.isWithinQuietHours(nowEpochMs = at1200, zoneId = zone)) + } + + @Test + fun isWithinQuietHours_sameStartEndMeansAlwaysQuiet() { + val policy = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Blocklist, + packages = emptySet(), + quietHoursEnabled = true, + quietStart = "00:00", + quietEnd = "00:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + + assertTrue(policy.isWithinQuietHours(nowEpochMs = 1_704_098_400_000L, zoneId = ZoneId.of("UTC"))) + } + + @Test + fun blocksEventsWhenDisabledOrQuietHoursOrRateLimited() { + val disabled = + NotificationForwardingPolicy( + enabled = false, + mode = NotificationPackageFilterMode.Blocklist, + packages = emptySet(), + quietHoursEnabled = false, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + assertFalse(disabled.enabled && disabled.allowsPackage("com.allowed.app")) + + val quiet = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Blocklist, + packages = emptySet(), + quietHoursEnabled = true, + quietStart = "22:00", + quietEnd = "07:00", + maxEventsPerMinute = 20, + sessionKey = null, + ) + val zone = ZoneId.of("UTC") + val at2330 = + LocalDateTime + .of(2024, 1, 6, 23, 30) + .atZone(zone) + .toInstant() + .toEpochMilli() + assertTrue(quiet.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone)) + + val limiter = NotificationBurstLimiter() + val minute = 1_704_098_400_000L + assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1)) + assertFalse(limiter.allow(nowEpochMs = minute + 500L, maxEventsPerMinute = 1)) + } + + @Test + fun burstLimiter_blocksEventsAboveLimitInSameMinute() { + val limiter = NotificationBurstLimiter() + val minute = 1_704_098_400_000L + + assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 2)) + assertTrue(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 2)) + assertFalse(limiter.allow(nowEpochMs = minute + 2_000L, maxEventsPerMinute = 2)) + } + + @Test + fun burstLimiter_resetsOnNextMinuteWindow() { + val limiter = NotificationBurstLimiter() + val minute = 1_704_098_400_000L + + assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1)) + assertFalse(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 1)) + assertTrue(limiter.allow(nowEpochMs = minute + 60_000L, maxEventsPerMinute = 1)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/NotificationNodeEventOutboxTest.kt b/app/src/test/java/ai/openclaw/app/NotificationNodeEventOutboxTest.kt new file mode 100644 index 0000000..b7d086e --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/NotificationNodeEventOutboxTest.kt @@ -0,0 +1,346 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.NodeEventSendOutcome +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationNodeEventOutboxTest { + @Test + fun deliverRetainsAcceptedEventsAcrossReconnectAndPreservesOrder() = + runBlocking { + val attempted = mutableListOf() + val delivered = Channel(Channel.UNLIMITED) + val firstBlockedAttempt = CompletableDeferred() + var connected = false + val outbox = + NotificationNodeEventOutbox(capacity = 2) { pending -> + attempted += pending.payloadJson.orEmpty() + if (!connected) { + firstBlockedAttempt.complete(Unit) + NodeEventSendOutcome.DISCONNECTED + } else { + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("first")) + withTimeout(1_000) { firstBlockedAttempt.await() } + outbox.enqueue(notificationEvent("second")) + + connected = true + outbox.onConnected() + + val received = + listOf( + withTimeout(1_000) { delivered.receive() }, + withTimeout(1_000) { delivered.receive() }, + ) + assertEquals(listOf("first", "second"), received) + assertEquals(listOf("first", "first", "second"), attempted) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun enqueueDropsOldestBufferedEventAtCapacity() = + runBlocking { + val delivered = Channel(Channel.UNLIMITED) + var connected = false + val outbox = + NotificationNodeEventOutbox( + capacity = 2, + isConnected = { connected }, + ) { pending -> + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + val deliveryJob = launch { outbox.deliver() } + + outbox.enqueue(notificationEvent("first")) + outbox.enqueue(notificationEvent("second")) + outbox.enqueue(notificationEvent("third")) + connected = true + outbox.onConnected() + + try { + assertEquals("second", withTimeout(1_000) { delivered.receive() }) + assertEquals("third", withTimeout(1_000) { delivered.receive() }) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun clearDropsCurrentAndBufferedEvents() = + runBlocking { + val firstAttempt = CompletableDeferred() + val delivered = Channel(Channel.UNLIMITED) + var connected = false + val outbox = + NotificationNodeEventOutbox { pending -> + if (!connected) { + firstAttempt.complete(Unit) + NodeEventSendOutcome.DISCONNECTED + } else { + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("first")) + withTimeout(1_000) { firstAttempt.await() } + outbox.enqueue(notificationEvent("second")) + outbox.clear() + connected = true + outbox.onConnected() + + assertEquals(null, withTimeoutOrNull(100) { delivered.receive() }) + outbox.enqueue(notificationEvent("third")) + assertEquals("third", withTimeout(1_000) { delivered.receive() }) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun ambiguousSendFailureIsNotRetried() = + runBlocking { + val failedAttempt = CompletableDeferred() + val delivered = Channel(Channel.UNLIMITED) + val attempted = mutableListOf() + val outbox = + NotificationNodeEventOutbox { pending -> + val payload = pending.payloadJson.orEmpty() + attempted += payload + if (payload == "failed") { + failedAttempt.complete(Unit) + NodeEventSendOutcome.FAILED + } else { + delivered.send(payload) + NodeEventSendOutcome.COMPLETED + } + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("failed")) + withTimeout(1_000) { failedAttempt.await() } + outbox.enqueue(notificationEvent("next")) + assertEquals("next", withTimeout(1_000) { delivered.receive() }) + assertEquals(listOf("failed", "next"), attempted) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun ambiguousSendFailureConsumesDeliverySlot() = + runBlocking { + val sleepStarted = CompletableDeferred() + val releaseSleep = CompletableDeferred() + val delivered = Channel(Channel.UNLIMITED) + var nowEpochMs = 1_000L + val outbox = + NotificationNodeEventOutbox( + deliveryIntervalMs = { 100L }, + nowEpochMs = { nowEpochMs }, + sleep = { delayMs -> + sleepStarted.complete(delayMs) + releaseSleep.await() + nowEpochMs += delayMs + }, + ) { pending -> + if (pending.payloadJson == "failed") { + NodeEventSendOutcome.FAILED + } else { + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("failed")) + outbox.enqueue(notificationEvent("next")) + assertEquals(100L, withTimeout(1_000) { sleepStarted.await() }) + assertEquals(null, withTimeoutOrNull(100) { delivered.receive() }) + releaseSleep.complete(Unit) + assertEquals("next", withTimeout(1_000) { delivered.receive() }) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun deliveryPacesQueuedEvents() = + runBlocking { + val sleepStarted = CompletableDeferred() + val releaseSleep = CompletableDeferred() + val delivered = Channel(Channel.UNLIMITED) + var nowEpochMs = 1_000L + val outbox = + NotificationNodeEventOutbox( + deliveryIntervalMs = { 100L }, + nowEpochMs = { nowEpochMs }, + sleep = { delayMs -> + sleepStarted.complete(delayMs) + releaseSleep.await() + nowEpochMs += delayMs + }, + ) { pending -> + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("first")) + outbox.enqueue(notificationEvent("second")) + assertEquals("first", withTimeout(1_000) { delivered.receive() }) + assertEquals(100L, withTimeout(1_000) { sleepStarted.await() }) + assertEquals(null, withTimeoutOrNull(100) { delivered.receive() }) + releaseSleep.complete(Unit) + assertEquals("second", withTimeout(1_000) { delivered.receive() }) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun clearInvalidatesRateWaitWithoutDelayingReplacement() = + runBlocking { + val sleepStarted = CompletableDeferred() + val releaseSleep = CompletableDeferred() + val delivered = Channel(Channel.UNLIMITED) + var nowEpochMs = 1_000L + val outbox = + NotificationNodeEventOutbox( + deliveryIntervalMs = { 100L }, + nowEpochMs = { nowEpochMs }, + sleep = { delayMs -> + sleepStarted.complete(Unit) + releaseSleep.await() + nowEpochMs += delayMs + }, + ) { pending -> + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("first")) + assertEquals("first", withTimeout(1_000) { delivered.receive() }) + outbox.enqueue(notificationEvent("stale")) + withTimeout(1_000) { sleepStarted.await() } + outbox.clear() + outbox.enqueue(notificationEvent("replacement")) + releaseSleep.complete(Unit) + + assertEquals("replacement", withTimeout(1_000) { delivered.receive() }) + assertEquals(null, withTimeoutOrNull(100) { delivered.receive() }) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun clearInvalidatesOnlyAnInFlightSend() = + runBlocking { + val firstSendStarted = CompletableDeferred() + val finishFirstSend = CompletableDeferred() + val delivered = Channel(Channel.UNLIMITED) + var invalidationCount = 0 + var firstSend = true + val outbox = + NotificationNodeEventOutbox( + invalidateConnection = { invalidationCount += 1 }, + ) { pending -> + if (firstSend) { + firstSend = false + firstSendStarted.complete(Unit) + finishFirstSend.await() + NodeEventSendOutcome.FAILED + } else { + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("stale")) + withTimeout(1_000) { firstSendStarted.await() } + outbox.clear() + outbox.enqueue(notificationEvent("replacement")) + finishFirstSend.complete(Unit) + + assertEquals("replacement", withTimeout(1_000) { delivered.receive() }) + assertEquals(1, invalidationCount) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun clearWithOnlyQueuedEventsDoesNotInvalidateConnection() = + runBlocking { + var invalidationCount = 0 + val outbox = + NotificationNodeEventOutbox( + isConnected = { false }, + invalidateConnection = { invalidationCount += 1 }, + ) { NodeEventSendOutcome.COMPLETED } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(notificationEvent("queued")) + outbox.clear() + assertEquals(0, invalidationCount) + } finally { + deliveryJob.cancelAndJoin() + } + } + + @Test + fun policyUpdateIsVisibleBeforeNewGenerationCanSend() = + runBlocking { + var authorized = true + val delivered = Channel(Channel.UNLIMITED) + val outbox = + NotificationNodeEventOutbox( + isAuthorized = { authorized }, + ) { pending -> + delivered.send(pending.payloadJson.orEmpty()) + NodeEventSendOutcome.COMPLETED + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.updatePolicy { authorized = false } + outbox.enqueue(notificationEvent("blocked")) + assertEquals(null, withTimeoutOrNull(100) { delivered.receive() }) + } finally { + deliveryJob.cancelAndJoin() + } + } + + private fun notificationEvent(payload: String) = + PendingNotificationNodeEvent( + event = "notifications.changed", + payloadJson = payload, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt b/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt new file mode 100644 index 0000000..be39801 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt @@ -0,0 +1,446 @@ +package ai.openclaw.app + +import android.Manifest +import android.app.Dialog +import android.content.pm.PackageManager +import androidx.activity.ComponentActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowDialog + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PermissionRequesterTest { + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun timedOutRequestCallbackDoesNotCompleteNextRequest() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val requests = FakePermissionRequests() + val requester = requester(activity(), requests) + + try { + val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) } + runCurrent() + advanceTimeBy(11) + runCurrent() + + assertTrue(first.isCompleted) + assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException) + assertEquals(listOf(Manifest.permission.CAMERA), requests[0].permissions) + + val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(listOf(Manifest.permission.CAMERA), requests[1].permissions) + + assertFalse(requests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to false))) + runCurrent() + + assertFalse(second.isCompleted) + + assertTrue(requests.deliver(requester, 1, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + + assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun repeatedTimedOutRequestsWithoutCallbacksDoNotBlockNextRequest() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val requests = FakePermissionRequests() + val requester = requester(activity(), requests) + + try { + repeat(4) { index -> + val timedOut = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) } + runCurrent() + advanceTimeBy(11) + runCurrent() + + assertTrue(timedOut.isCompleted) + assertTrue(timedOut.getCompletionExceptionOrNull() is TimeoutCancellationException) + assertEquals(listOf(Manifest.permission.CAMERA), requests[index].permissions) + } + + val recovered = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + + assertEquals(5, requests.size) + assertEquals(listOf(Manifest.permission.CAMERA), requests[4].permissions) + + assertTrue(requests.deliver(requester, 4, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + + assertEquals(mapOf(Manifest.permission.CAMERA to true), recovered.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cancelledRequestCallbackDoesNotCompleteNextRequest() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val requests = FakePermissionRequests() + val requester = requester(activity(), requests) + + try { + val cancelled = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + cancelled.cancelAndJoin() + + val recovered = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + + assertEquals(2, requests.size) + assertFalse(requests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to false))) + runCurrent() + assertFalse(recovered.isCompleted) + + assertTrue(requests.deliver(requester, 1, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), recovered.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun emptyPlatformCallbackTreatsRequestedPermissionsAsDenied() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val requests = FakePermissionRequests() + val requester = requester(activity(), requests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + + assertTrue( + requester.onRequestPermissionsResult( + requests[0].requestCode, + emptyArray(), + intArrayOf(), + ), + ) + runCurrent() + + cancelDialog(checkNotNull(ShadowDialog.getLatestDialog())) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to false), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun replacementActivityCompletesPendingRequestAndOwnsLaterPrompts() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(1, originalRequests.size) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + requester.deactivate(originalActivity) + requester.detach(originalActivity) + + assertTrue(originalRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), pending.await()) + + val replacementPrompt = + async { requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO), timeoutMs = 1_000) } + runCurrent() + assertEquals(1, replacementRequests.size) + replacementPrompt.cancelAndJoin() + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun requestWaitsForReplacementActivityAcrossRecreationGap() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + requester.deactivate(originalActivity) + requester.detach(originalActivity) + + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(0, originalRequests.size) + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + assertEquals(1, replacementRequests.size) + assertFalse(pending.isCompleted) + assertTrue(replacementRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun resumedEarlierTaskReclaimsPermissionPromptOwnership() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val firstActivity = activity() + val firstRequests = FakePermissionRequests() + val requester = requester(firstActivity, firstRequests) + val secondActivity = activity() + val secondRequests = FakePermissionRequests() + + try { + requester.deactivate(firstActivity) + requester.attach(secondActivity, secondRequests::request) + requester.activate(secondActivity) + requester.deactivate(secondActivity) + requester.activate(firstActivity) + + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + + assertEquals(1, firstRequests.size) + assertEquals(0, secondRequests.size) + pending.cancelAndJoin() + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun permanentDenialWaitsForReplacementActivity() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertTrue(originalRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to false))) + requester.deactivate(originalActivity) + requester.detach(originalActivity) + runCurrent() + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + val settingsDialog = checkNotNull(ShadowDialog.getLatestDialog()) + assertTrue(settingsDialog.isShowing) + assertFalse(pending.isCompleted) + + cancelDialog(settingsDialog) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to false), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun permanentDenialPromptMovesToNewActiveActivity() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertTrue(originalRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to false))) + runCurrent() + + val originalDialog = checkNotNull(ShadowDialog.getLatestDialog()) + assertTrue(originalDialog.isShowing) + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + val replacementDialog = checkNotNull(ShadowDialog.getLatestDialog()) + assertFalse(originalDialog.isShowing) + assertTrue(replacementDialog !== originalDialog) + assertTrue(replacementDialog.isShowing) + assertFalse(pending.isCompleted) + + cancelDialog(replacementDialog) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to false), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun rationaleHostLossRetriesOnReplacementActivity() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val rationaleActivity = rationaleActivity() + val rationaleRequests = FakePermissionRequests() + val requester = requester(rationaleActivity, rationaleRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(0, rationaleRequests.size) + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + assertEquals(0, rationaleRequests.size) + assertEquals(1, replacementRequests.size) + assertTrue(replacementRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun requestCodeAllocatorWrapsWithinLegacyRangeAndSkipsLiveCodes() { + val allocator = + PermissionRequestCodeAllocator(PermissionRequestCodeAllocator.LAST_PERMISSION_REQUEST_CODE) + + assertEquals(PermissionRequestCodeAllocator.LAST_PERMISSION_REQUEST_CODE, allocator.allocate { false }) + assertEquals( + PermissionRequestCodeAllocator.FIRST_PERMISSION_REQUEST_CODE + 1, + allocator.allocate { requestCode -> + requestCode == PermissionRequestCodeAllocator.FIRST_PERMISSION_REQUEST_CODE + }, + ) + } + + private fun activity(): ComponentActivity = + Robolectric + .buildActivity(ComponentActivity::class.java) + .setup() + .get() + + private fun rationaleActivity(): ComponentActivity = + Robolectric + .buildActivity(PermissionRationaleActivity::class.java) + .setup() + .get() + + private fun cancelDialog(dialog: Dialog) { + checkNotNull(shadowOf(dialog).onCancelListener).onCancel(dialog) + } + + private fun requester( + activity: ComponentActivity, + requests: FakePermissionRequests, + ): PermissionRequester = + PermissionRequester(activity.applicationContext).also { requester -> + requester.attach(activity, requests::request) + requester.activate(activity) + } +} + +class PermissionRationaleActivity : ComponentActivity() { + override fun shouldShowRequestPermissionRationale(permission: String): Boolean = true +} + +private class FakePermissionRequest( + val permissions: List, + val requestCode: Int, +) + +private class FakePermissionRequests { + private val requests = mutableListOf() + + val size: Int + get() = requests.size + + operator fun get(index: Int): FakePermissionRequest = requests[index] + + fun request( + permissions: Array, + requestCode: Int, + ) { + requests += FakePermissionRequest(permissions.toList(), requestCode) + } + + fun deliver( + requester: PermissionRequester, + index: Int, + result: Map, + ): Boolean { + val request = requests[index] + val grantResults = + request.permissions + .map { permission -> + if (result[permission] == true) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED + }.toIntArray() + return requester.onRequestPermissionsResult( + request.requestCode, + request.permissions.toTypedArray(), + grantResults, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/PhotoPermissionsTest.kt b/app/src/test/java/ai/openclaw/app/PhotoPermissionsTest.kt new file mode 100644 index 0000000..3b36580 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/PhotoPermissionsTest.kt @@ -0,0 +1,48 @@ +package ai.openclaw.app + +import android.Manifest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class PhotoPermissionsTest { + @Test + @Config(sdk = [34]) + fun api34RequestsFullAndSelectedPhotoPermissions() { + assertEquals( + listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, + ), + photoReadPermissionsForRequest(), + ) + } + + @Test + @Config(sdk = [34]) + fun api34TreatsSelectedPhotoPermissionAsPhotoAccess() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + + assertTrue(hasPhotoReadPermission(app)) + } + + @Test + @Config(sdk = [34]) + fun api34ReportsNoPhotoAccessWhenNeitherFullNorSelectedPermissionIsGranted() { + assertFalse(hasPhotoReadPermission(RuntimeEnvironment.getApplication())) + } + + @Test + @Config(sdk = [33]) + fun api33RequestsImagePermissionOnly() { + assertEquals(listOf(Manifest.permission.READ_MEDIA_IMAGES), photoReadPermissionsForRequest()) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ProviderModelCatalogRequestTest.kt b/app/src/test/java/ai/openclaw/app/ProviderModelCatalogRequestTest.kt new file mode 100644 index 0000000..c07421a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ProviderModelCatalogRequestTest.kt @@ -0,0 +1,60 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProviderModelCatalogRequestTest { + @Test + fun prefersEffectiveContextCapOverNativeWindow() { + val models = + parseGatewayModels( + Json + .parseToJsonElement( + """[{"id":"model","name":"Model","provider":"example","contextWindow":128000,"contextTokens":96000}]""", + ).jsonArray, + ) + + assertEquals(96_000L, models.single().contextTokens) + } + + @Test + fun reportsProviderConfigUnsupportedWithoutSubstitutingConfiguredView() = + runBlocking { + val requests = mutableListOf() + var actual: Throwable? = null + + try { + requestProviderModelConfig { paramsJson -> + requests += paramsJson + throw GatewayRequestRejected(GatewaySession.ErrorShape("INVALID_REQUEST", "unsupported view")) + } + } catch (err: Throwable) { + actual = err + } + + assertTrue(actual is ProviderModelConfigUnsupported) + assertEquals(listOf("""{"view":"provider-config"}"""), requests) + } + + @Test + fun preservesNonCompatibilityGatewayFailures() = + runBlocking { + val expected = GatewayRequestRejected(GatewaySession.ErrorShape("UNAVAILABLE", "gateway busy")) + var actual: Throwable? = null + + try { + requestProviderModelConfig { throw expected } + } catch (err: Throwable) { + actual = err + } + + assertSame(expected, actual) + } +} diff --git a/app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt b/app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt new file mode 100644 index 0000000..7942097 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt @@ -0,0 +1,193 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayRegistryEntry +import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class SecurePrefsNotificationForwardingTest { + private fun testPrefs(context: android.app.Application): SecurePrefs = + SecurePrefs( + context, + context.getSharedPreferences("notification-prefs-${UUID.randomUUID()}", Context.MODE_PRIVATE), + ) + + @Test + fun setNotificationForwardingQuietHours_rejectsInvalidDraftsWithoutMutatingStoredValues() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + + val prefs = testPrefs(context) + + assertTrue( + prefs.setNotificationForwardingQuietHours( + enabled = false, + start = "22:00", + end = "07:00", + ), + ) + + val originalStart = prefs.notificationForwardingQuietStart.value + val originalEnd = prefs.notificationForwardingQuietEnd.value + val originalEnabled = prefs.notificationForwardingQuietHoursEnabled.value + + assertFalse( + prefs.setNotificationForwardingQuietHours( + enabled = true, + start = "7:00", + end = "07:00", + ), + ) + + assertEquals(originalStart, prefs.notificationForwardingQuietStart.value) + assertEquals(originalEnd, prefs.notificationForwardingQuietEnd.value) + assertEquals(originalEnabled, prefs.notificationForwardingQuietHoursEnabled.value) + } + + @Test + fun setNotificationForwardingQuietHours_persistsValidDraftsAndEnabledState() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + + val prefs = testPrefs(context) + + assertTrue( + prefs.setNotificationForwardingQuietHours( + enabled = true, + start = "22:30", + end = "06:45", + ), + ) + + assertTrue(prefs.notificationForwardingQuietHoursEnabled.value) + assertEquals("22:30", prefs.notificationForwardingQuietStart.value) + assertEquals("06:45", prefs.notificationForwardingQuietEnd.value) + } + + @Test + fun setNotificationForwardingQuietHours_disablesWithoutRevalidatingDrafts() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + + val prefs = testPrefs(context) + assertTrue( + prefs.setNotificationForwardingQuietHours( + enabled = true, + start = "22:30", + end = "06:45", + ), + ) + + assertTrue( + prefs.setNotificationForwardingQuietHours( + enabled = false, + start = "7:00", + end = "06:45", + ), + ) + + assertFalse(prefs.notificationForwardingQuietHoursEnabled.value) + assertEquals("22:30", prefs.notificationForwardingQuietStart.value) + assertEquals("06:45", prefs.notificationForwardingQuietEnd.value) + } + + @Test + fun getNotificationForwardingPolicy_readsLatestQuietHoursImmediately() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + + val prefs = testPrefs(context) + assertTrue( + prefs.setNotificationForwardingQuietHours( + enabled = true, + start = "21:15", + end = "06:10", + ), + ) + + val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app") + + assertTrue(policy.quietHoursEnabled) + assertEquals("21:15", policy.quietStart) + assertEquals("06:10", policy.quietEnd) + } + + @Test + fun notificationForwarding_defaultsDisabledForSaferPosture() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + + val prefs = testPrefs(context) + val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app") + + assertFalse(prefs.notificationForwardingEnabled.value) + assertFalse(policy.enabled) + assertEquals(NotificationPackageFilterMode.Blocklist, policy.mode) + } + + @Test + fun getNotificationForwardingPolicy_blocksOwnedPackagesInAllowlistMode() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + + val prefs = testPrefs(context) + prefs.setNotificationForwardingMode(NotificationPackageFilterMode.Allowlist) + prefs.setNotificationForwardingPackages(listOf("ai.openclaw.app", "com.whatsapp", "com.other.app")) + + val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app") + + assertFalse(policy.allowsPackage("ai.openclaw.app")) + assertFalse(policy.allowsPackage("com.whatsapp")) + assertTrue(policy.allowsPackage("com.other.app")) + } + + @Test + fun notificationSessionKeyFollowsActiveGateway() { + val context = RuntimeEnvironment.getApplication() + context + .getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + val secure = context.getSharedPreferences("notification-gateways-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val prefs = SecurePrefs(context, secure) + val gatewayA = GatewayEndpoint.manual("a.example", 18789) + val gatewayB = GatewayEndpoint.manual("b.example", 18789) + listOf(gatewayA, gatewayB).forEach { endpoint -> + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = endpoint.name, + host = endpoint.host, + port = endpoint.port, + ), + ) + } + + prefs.gatewayRegistry.setActive(gatewayA.stableId) + prefs.setNotificationForwardingSessionKey("session-a") + prefs.gatewayRegistry.setActive(gatewayB.stableId) + prefs.setNotificationForwardingSessionKey("session-b") + + prefs.gatewayRegistry.setActive(gatewayA.stableId) + assertEquals("session-a", prefs.getNotificationForwardingPolicy("ai.openclaw.app").sessionKey) + prefs.gatewayRegistry.setActive(gatewayB.stableId) + assertEquals("session-b", prefs.getNotificationForwardingPolicy("ai.openclaw.app").sessionKey) + } +} diff --git a/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt new file mode 100644 index 0000000..cd5d05c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -0,0 +1,426 @@ +package ai.openclaw.app + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class SecurePrefsTest { + private fun testPrefs(context: android.app.Application): SecurePrefs = + SecurePrefs( + context, + context.getSharedPreferences("secure-prefs-test-${UUID.randomUUID()}", Context.MODE_PRIVATE), + ) + + @Test + fun backgroundSettingsResolutionRequiresBothPermissionLevels() { + assertEquals( + LocationMode.Always, + locationModeAfterBackgroundSettings(LocationMode.Off, foregroundGranted = true, backgroundGranted = true), + ) + assertEquals( + LocationMode.Off, + locationModeAfterBackgroundSettings(LocationMode.Off, foregroundGranted = true, backgroundGranted = false), + ) + assertEquals( + LocationMode.WhileUsing, + locationModeAfterBackgroundSettings(LocationMode.Always, foregroundGranted = true, backgroundGranted = false), + ) + } + + @Test + fun loadLocationMode_enforcesFlavorAvailabilityForAlwaysValue() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putString("location.enabledMode", "always") + .commit() + + val prefs = testPrefs(context) + + val expected = + if (SensitiveFeatureConfig.backgroundLocationEnabled) LocationMode.Always else LocationMode.WhileUsing + assertEquals(expected, prefs.locationMode.value) + assertEquals(expected.rawValue, plainPrefs.getString("location.enabledMode", null)) + } + + @Test + fun voiceMicEnabled_ignoresOldTalkEnabledKey() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putBoolean("talk.enabled", true) + .commit() + + val prefs = testPrefs(context) + + assertFalse(prefs.voiceMicEnabled.value) + assertFalse(plainPrefs.contains("voice.micEnabled")) + } + + @Test + fun setVoiceMicEnabled_persistsNewKeyOnly() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putBoolean("talk.enabled", false) + .commit() + val prefs = testPrefs(context) + + prefs.setVoiceMicEnabled(true) + + assertTrue(prefs.voiceMicEnabled.value) + assertTrue(plainPrefs.getBoolean("voice.micEnabled", false)) + assertFalse(plainPrefs.getBoolean("talk.enabled", false)) + } + + @Test + fun voiceWakeSettingsDefaultAndPersist() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + assertFalse(prefs.voiceWakeEnabled.value) + assertEquals(listOf("openclaw", "claude", "computer"), prefs.voiceWakeWords.value) + + prefs.setVoiceWakeEnabled(true) + prefs.setVoiceWakeWords(listOf(" hey claw ", "computer")) + + val restored = testPrefs(context) + assertTrue(restored.voiceWakeEnabled.value) + assertEquals(listOf("hey claw", "computer"), restored.voiceWakeWords.value) + } + + @Test + fun cameraAndAudioInputPreferencesDefaultAndPersist() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + assertEquals("front", prefs.preferredCameraFacing.value) + assertEquals(null, prefs.preferredAudioInputDevice.value) + + prefs.setPreferredCameraFacing("back") + prefs.setPreferredAudioInputDevice("7|usb%3A1|Desk+Mic") + + val restored = testPrefs(context) + assertEquals("back", restored.preferredCameraFacing.value) + assertEquals("7|usb%3A1|Desk+Mic", restored.preferredAudioInputDevice.value) + + restored.setPreferredCameraFacing("side") + restored.setPreferredAudioInputDevice(null) + assertEquals("front", restored.preferredCameraFacing.value) + assertEquals(null, restored.preferredAudioInputDevice.value) + } + + @Test + fun installedAppsSharing_defaultsOffAndPersistsDisclosureConsent() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + assertFalse(prefs.installedAppsSharingEnabled.value) + + prefs.grantInstalledAppsDisclosureConsent() + + assertTrue(prefs.installedAppsSharingEnabled.value) + assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false)) + assertEquals(1, plainPrefs.getInt("device.apps.prominentDisclosure.consentVersion", 0)) + assertTrue(testPrefs(context).installedAppsSharingEnabled.value) + } + + @Test + fun accessibilityControl_defaultsOffAndPersistsOptIn() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + assertFalse(prefs.accessibilityControlEnabled.value) + + prefs.setAccessibilityControlEnabled(true) + + assertTrue(prefs.accessibilityControlEnabled.value) + assertTrue(plainPrefs.getBoolean("mobileUi.accessibilityControl.enabled", false)) + assertTrue(testPrefs(context).accessibilityControlEnabled.value) + } + + @Test + fun installedAppsSharing_legacyOptInWithoutDisclosureRequiresReconsent() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putBoolean("device.apps.sharing.enabled", true) + .commit() + + val prefs = testPrefs(context) + + assertFalse(prefs.installedAppsSharingEnabled.value) + assertFalse(plainPrefs.getBoolean("device.apps.sharing.enabled", true)) + assertFalse(plainPrefs.contains("device.apps.prominentDisclosure.consentVersion")) + } + + @Test + fun installedAppsSharing_staleDisclosureVersionRequiresReconsent() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putBoolean("device.apps.sharing.enabled", true) + .putInt("device.apps.prominentDisclosure.consentVersion", 0) + .commit() + + val prefs = testPrefs(context) + + assertFalse(prefs.installedAppsSharingEnabled.value) + assertFalse(plainPrefs.getBoolean("device.apps.sharing.enabled", true)) + assertFalse(plainPrefs.contains("device.apps.prominentDisclosure.consentVersion")) + } + + @Test + fun installedAppsSharing_futureDisclosureVersionRequiresReconsent() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putBoolean("device.apps.sharing.enabled", true) + .putInt("device.apps.prominentDisclosure.consentVersion", 2) + .commit() + + val prefs = testPrefs(context) + + assertFalse(prefs.installedAppsSharingEnabled.value) + assertFalse(plainPrefs.getBoolean("device.apps.sharing.enabled", true)) + assertFalse(plainPrefs.contains("device.apps.prominentDisclosure.consentVersion")) + } + + @Test + fun installedAppsSharing_disablingRevokesConsent() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + prefs.grantInstalledAppsDisclosureConsent() + prefs.revokeInstalledAppsDisclosureConsent() + + assertFalse(prefs.installedAppsSharingEnabled.value) + assertFalse(plainPrefs.getBoolean("device.apps.sharing.enabled", true)) + assertFalse(plainPrefs.contains("device.apps.prominentDisclosure.consentVersion")) + } + + @Test + fun cameraSharing_defaultsOffAndPersistsOptIn() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + assertFalse(prefs.cameraEnabled.value) + assertFalse(plainPrefs.getBoolean("camera.enabled", true)) + + prefs.setCameraEnabled(true) + + assertTrue(prefs.cameraEnabled.value) + assertTrue(plainPrefs.getBoolean("camera.enabled", false)) + } + + @Test + fun cameraSharing_migratesExistingInstallsToPreviousDefault() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putString("node.instanceId", "existing-node") + .commit() + val prefs = testPrefs(context) + + assertTrue(prefs.cameraEnabled.value) + assertTrue(plainPrefs.getBoolean("camera.enabled", false)) + } + + @Test + fun appearanceThemeMode_defaultsDarkForExistingInstalls() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = testPrefs(context) + + assertEquals(AppearanceThemeMode.Dark, prefs.appearanceThemeMode.value) + assertFalse(plainPrefs.contains("appearance.themeMode")) + } + + @Test + fun setAppearanceThemeMode_persistsSelectedMode() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val securePrefs = context.getSharedPreferences("secure-prefs-test-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val prefs = SecurePrefs(context, securePrefs) + + prefs.setAppearanceThemeMode(AppearanceThemeMode.Light) + + assertEquals(AppearanceThemeMode.Light, prefs.appearanceThemeMode.value) + assertEquals("light", plainPrefs.getString("appearance.themeMode", null)) + assertEquals(AppearanceThemeMode.Light, SecurePrefs(context, securePrefs).appearanceThemeMode.value) + } + + @Test + fun gatewayCredentials_areIndependentAcrossGateways() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + + prefs.saveGatewayCredentials("gateway-a", token = " shared-token ", bootstrapToken = "bootstrap-token") + prefs.saveGatewayCredentials("gateway-b", password = "password-token") + + assertEquals(GatewayCredentials(token = "shared-token", bootstrapToken = "bootstrap-token"), prefs.loadGatewayCredentials("gateway-a")) + assertEquals(GatewayCredentials(password = "password-token"), prefs.loadGatewayCredentials("gateway-b")) + } + + @Test + fun clearGatewayCredentials_removesOnlyTargetGateway() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test.clear", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + + prefs.saveGatewayCredentials("gateway-a", token = "shared-token", bootstrapToken = "bootstrap-token") + prefs.saveGatewayCredentials("gateway-b", password = "password-token") + + prefs.clearGatewayCredentials("gateway-a") + + assertEquals(GatewayCredentials(), prefs.loadGatewayCredentials("gateway-a")) + assertEquals(GatewayCredentials(password = "password-token"), prefs.loadGatewayCredentials("gateway-b")) + } + + @Test + fun modelFavorites_togglePersistsPinOrder() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = SecurePrefs(context) + + prefs.toggleModelFavorite(" anthropic/claude-opus-4 ") + prefs.toggleModelFavorite("openai/gpt-5") + prefs.toggleModelFavorite("anthropic/claude-opus-4") + prefs.toggleModelFavorite("anthropic/claude-opus-4") + prefs.toggleModelFavorite(" ") + + assertEquals( + listOf("openai/gpt-5", "anthropic/claude-opus-4"), + prefs.modelFavorites.value, + ) + assertEquals(prefs.modelFavorites.value, SecurePrefs(context).modelFavorites.value) + } + + @Test + fun modelRecents_dedupesToFrontAndCapsAtFive() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = SecurePrefs(context) + + (1..6).forEach { index -> prefs.recordModelRecent("provider/model-$index") } + prefs.recordModelRecent(" provider/model-3 ") + prefs.recordModelRecent(" ") + + assertEquals( + listOf( + "provider/model-3", + "provider/model-6", + "provider/model-5", + "provider/model-4", + "provider/model-2", + ), + prefs.modelRecents.value, + ) + assertEquals(prefs.modelRecents.value, SecurePrefs(context).modelRecents.value) + } + + @Test + fun gatewayCustomHeaders_roundTripStaysScopedPerGateway() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test.headers", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + val stableId = "manual|gw.example.com|443" + + assertTrue(prefs.loadGatewayCustomHeaders(stableId).isEmpty()) + prefs.saveGatewayCustomHeaders( + stableId, + mapOf("CF-Access-Client-Id" to "client-id", "CF-Access-Client-Secret" to "client-secret"), + ) + assertEquals( + mapOf("CF-Access-Client-Id" to "client-id", "CF-Access-Client-Secret" to "client-secret"), + prefs.loadGatewayCustomHeaders(stableId), + ) + // Headers are per-gateway credentials; another endpoint never observes them. + assertTrue(prefs.loadGatewayCustomHeaders("manual|other.example.com|443").isEmpty()) + + prefs.saveGatewayCustomHeaders(stableId, emptyMap()) + assertTrue(prefs.loadGatewayCustomHeaders(stableId).isEmpty()) + assertFalse(securePrefs.contains("gateway.customHeaders.$stableId")) + } + + @Test + fun gatewayCustomHeaders_dropsReservedAndUnsafeEntries() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test.headers2", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + val stableId = "manual|gw.example.com|443" + + prefs.saveGatewayCustomHeaders( + stableId, + mapOf( + "Host" to "smuggled.example", + "Sec-WebSocket-Protocol" to "override", + "X Bad" to "space", + "X:Bad" to "colon", + "X-Bad-é" to "unicode", + "X-Split" to "a\r\nEvil: b", + "X-Allowed" to "yes", + ), + ) + assertEquals(mapOf("X-Allowed" to "yes"), prefs.loadGatewayCustomHeaders(stableId)) + } + + @Test + fun gatewayCustomHeaders_explicitClearRemovesOnlyCustomHeaderCredentials() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test.headers3", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + prefs.saveGatewayCustomHeaders("manual|one.example|443", mapOf("X-One" to "secret-one")) + prefs.saveGatewayCustomHeaders("manual|two.example|443", mapOf("X-Two" to "secret-two")) + prefs.putString("unrelated.secret", "keep") + + prefs.clearGatewayCustomHeaders("manual|one.example|443") + + assertTrue(prefs.loadGatewayCustomHeaders("manual|one.example|443").isEmpty()) + assertEquals(mapOf("X-Two" to "secret-two"), prefs.loadGatewayCustomHeaders("manual|two.example|443")) + assertEquals("keep", prefs.getString("unrelated.secret")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/SessionKeyTest.kt b/app/src/test/java/ai/openclaw/app/SessionKeyTest.kt new file mode 100644 index 0000000..12dccc0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/SessionKeyTest.kt @@ -0,0 +1,45 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionKeyTest { + @Test + fun buildNodeMainSessionKeyUsesStableDeviceScopedSuffix() { + val key = buildNodeMainSessionKey(deviceId = "1234567890abcdef", agentId = "ops") + + assertEquals("agent:ops:node-1234567890ab", key) + } + + @Test + fun buildAndroidAppSessionLabelIncludesDeviceDisplayName() { + assertEquals("OpenClaw App · 1234567890ab", buildAndroidAppSessionLabel(null, "1234567890abcdef")) + assertEquals( + "OpenClaw App · Pixel · 1234567890ab", + buildAndroidAppSessionLabel(" Pixel ", "1234567890abcdef"), + ) + } + + @Test + fun buildAndroidAppSessionLabelPreservesUtf16BoundariesAtDisplayNameLimit() { + val deviceId = "1234567890abcdef" + val splitPairPrefix = "a".repeat(95) + assertEquals( + "OpenClaw App · $splitPairPrefix · 1234567890ab", + buildAndroidAppSessionLabel("$splitPairPrefix😀tail", deviceId), + ) + + val completePairPrefix = "a".repeat(94) + assertEquals( + "OpenClaw App · $completePairPrefix😀 · 1234567890ab", + buildAndroidAppSessionLabel("$completePairPrefix😀tail", deviceId), + ) + } + + @Test + fun resolveAgentIdFromMainSessionKeyParsesCanonicalAgentKey() { + assertEquals("ops", resolveAgentIdFromMainSessionKey("agent:ops:main")) + assertNull(resolveAgentIdFromMainSessionKey("global")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt b/app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt new file mode 100644 index 0000000..ea452c1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/SessionObserverVisibilityTest.kt @@ -0,0 +1,129 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayMethod +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionObserverVisibilityTest { + @Test + fun foregroundTransitionsDeclareActualVisibilityOncePerSocket() = + runBlocking { + var visible = true + var firstSocketIsCurrent = true + var secondSocketIsCurrent = false + val requests = mutableListOf>() + val firstLease = + GatewaySession.RequestLease( + endpointStableId = "gateway", + isCurrentImpl = { firstSocketIsCurrent }, + ) { method, params, _ -> + requests.add(method to params) + "" + } + val secondLease = + GatewaySession.RequestLease( + endpointStableId = "gateway", + isCurrentImpl = { secondSocketIsCurrent }, + ) { method, params, _ -> + requests.add(method to params) + "" + } + var activeLease: GatewaySession.RequestLease? = firstLease + val observer = + SessionObserverVisibility( + isVisible = { visible }, + captureLease = { activeLease }, + ) + + observer.sync() + observer.sync() + visible = false + observer.sync() + observer.sync() + visible = true + observer.sync() + firstSocketIsCurrent = false + secondSocketIsCurrent = true + activeLease = secondLease + observer.sync() + observer.sync() + + assertEquals( + listOf( + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":false}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + ), + requests, + ) + } + + @Test + fun disconnectedObserverNeverCreatesARequestOrConnection() = + runBlocking { + var captureAttempts = 0 + var visibilityReads = 0 + val observer = + SessionObserverVisibility( + isVisible = { + visibilityReads += 1 + true + }, + captureLease = { + captureAttempts += 1 + null + }, + ) + + observer.sync() + + assertEquals(1, captureAttempts) + assertEquals(0, visibilityReads) + } + + @Test + fun lostBackgroundReplyCannotSuppressForegroundRecovery() = + runBlocking { + var visible = true + var loseBackgroundReply = true + val requests = mutableListOf>() + val lease = + GatewaySession.RequestLease(endpointStableId = "gateway") { method, params, _ -> + requests.add(method to params) + if (params == """{"visible":false}""" && loseBackgroundReply) { + loseBackgroundReply = false + throw IllegalStateException("visibility reply lost") + } + "" + } + val observer = + SessionObserverVisibility( + isVisible = { visible }, + captureLease = { lease }, + ) + + observer.sync() + visible = false + try { + observer.sync() + throw AssertionError("The lost background reply must be reported") + } catch (error: IllegalStateException) { + assertEquals("visibility reply lost", error.message) + } + visible = true + observer.sync() + observer.sync() + + assertEquals( + listOf( + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":false}""", + GatewayMethod.SessionsObserverVisibility.rawValue to """{"visible":true}""", + ), + requests, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt b/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt new file mode 100644 index 0000000..5082efd --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt @@ -0,0 +1,399 @@ +package ai.openclaw.app + +import android.content.ClipData +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ShareLaunchTest { + @Test + fun composesDistinctSubjectAndTextForReview() { + val parsed = + parseShare( + Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, "Article title") + .putExtra(Intent.EXTRA_TEXT, "https://example.com/article"), + ) + + requireNotNull(parsed) + assertEquals("Article title\n\nhttps://example.com/article", parsed.text) + assertEquals(emptyList(), parsed.attachments) + } + + @Test + fun keepsCaptionAndProviderBackedImage() { + val image = Uri.parse("content://photos/shared/1") + val parsed = + parseShare( + Intent(Intent.ACTION_SEND) + .setType("image/png") + .putExtra(Intent.EXTRA_TEXT, "What is in this image?") + .putExtra(Intent.EXTRA_STREAM, image), + ) + + requireNotNull(parsed) + assertEquals("What is in this image?", parsed.text) + assertEquals(listOf(image), parsed.attachments.map(SharedAttachment::uri)) + assertEquals(listOf(SharedAttachmentKind.Image), parsed.attachments.map(SharedAttachment::kind)) + } + + @Test + fun readsProviderBackedImageFromClipData() { + val image = Uri.parse("content://photos/shared/clip") + val parsed = + parseShare( + Intent(Intent.ACTION_SEND) + .setType("IMAGE/PNG") + .apply { + clipData = ClipData("shared", arrayOf("image/png"), ClipData.Item(image)) + }, + ) + + requireNotNull(parsed) + assertEquals(listOf(image), parsed.attachments.map(SharedAttachment::uri)) + } + + @Test + fun deduplicatesAndBoundsMultipleAttachmentsAcrossExtrasAndClipData() { + val images = (1..10).map { index -> Uri.parse("content://photos/shared/$index") } + val parsed = + parseShare( + Intent(Intent.ACTION_SEND_MULTIPLE) + .setType("image/jpeg") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(images)) + .apply { + clipData = ClipData("shared", arrayOf("image/jpeg"), ClipData.Item(images.first())) + }, + ) + + requireNotNull(parsed) + assertEquals(images.take(8), parsed.attachments.map(SharedAttachment::uri)) + assertEquals(2, parsed.droppedAttachmentCount) + } + + @Test + fun acceptsSingleAudioShareWhenProviderMimeIsUnknown() { + val audio = Uri.parse("content://media/shared/song") + val parsed = + parseShare( + Intent(Intent.ACTION_SEND) + .setType("audio/mpeg") + .putExtra(Intent.EXTRA_STREAM, audio), + ) + + requireNotNull(parsed) + assertEquals(listOf(audio), parsed.attachments.map(SharedAttachment::uri)) + assertEquals(listOf(SharedAttachmentKind.Audio), parsed.attachments.map(SharedAttachment::kind)) + } + + @Test + fun rejectsWildcardAudioWhenProviderMimeIsUnknown() { + val audio = Uri.parse("content://media/shared/unknown") + val parsed = + parseShare( + Intent(Intent.ACTION_SEND) + .setType("audio/*") + .putExtra(Intent.EXTRA_STREAM, audio), + ) + + requireNotNull(parsed) + assertEquals(emptyList(), parsed.attachments) + assertEquals(1, parsed.droppedAttachmentCount) + } + + @Test + fun acceptsMultipleAudioShares() { + val audio = (1..3).map { Uri.parse("content://media/shared/$it") } + val parsed = + parseShare( + Intent(Intent.ACTION_SEND_MULTIPLE) + .setType("audio/ogg") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(audio)), + ) + + requireNotNull(parsed) + assertEquals(audio, parsed.attachments.map(SharedAttachment::uri)) + assertTrue(parsed.attachments.all { it.kind == SharedAttachmentKind.Audio }) + } + + @Test + fun acceptsVideoShareFromProviderMimeType() { + val video = Uri.parse("content://media/shared/video") + val parsed = + parseShare( + intent = + Intent(Intent.ACTION_SEND) + .setType("video/*") + .putExtra(Intent.EXTRA_STREAM, video), + mimeTypes = mapOf(video to "video/mp4"), + ) + + requireNotNull(parsed) + assertEquals(SharedAttachmentKind.Video, parsed.attachments.single().kind) + assertEquals("video/mp4", parsed.attachments.single().mimeType) + assertTrue("video/*" in SHARED_ATTACHMENT_MIME_ALLOWLIST) + } + + @Test + fun acceptsCuratedDocumentShare() { + val document = Uri.parse("content://docs/shared/report") + val parsed = + parseShare( + Intent(Intent.ACTION_SEND) + .setType("application/pdf") + .putExtra(Intent.EXTRA_STREAM, document), + ) + + requireNotNull(parsed) + assertEquals(listOf(SharedAttachmentKind.Document), parsed.attachments.map(SharedAttachment::kind)) + assertEquals("application/pdf", parsed.attachments.single().mimeType) + } + + @Test + fun classifiesMixedBatchFromProviderMimeTypes() { + val image = Uri.parse("content://mixed/image") + val audio = Uri.parse("content://mixed/audio") + val document = Uri.parse("content://mixed/document") + val mimeTypes = + mapOf( + image to "image/png", + audio to "audio/mpeg", + document to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + val parsed = + parseShare( + intent = + Intent(Intent.ACTION_SEND_MULTIPLE) + .setType("*/*") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(image, audio, document)), + mimeTypes = mimeTypes, + ) + + requireNotNull(parsed) + assertEquals( + listOf(SharedAttachmentKind.Image, SharedAttachmentKind.Audio, SharedAttachmentKind.Document), + parsed.attachments.map(SharedAttachment::kind), + ) + } + + @Test + fun rejectsBlanketApplicationTypeUsingProviderMimeAndReportsDrop() { + val payload = Uri.parse("content://files/shared/blob") + val parsed = + parseShare( + intent = + Intent(Intent.ACTION_SEND) + .setType("application/pdf") + .putExtra(Intent.EXTRA_STREAM, payload), + mimeTypes = mapOf(payload to "application/octet-stream"), + ) + + requireNotNull(parsed) + assertEquals(emptyList(), parsed.attachments) + assertEquals(1, parsed.droppedAttachmentCount) + } + + @Test + fun unsupportedEntriesDoNotConsumeAttachmentCap() { + val unsupported = Uri.parse("content://mixed/unsupported") + val documents = (1..8).map { Uri.parse("content://mixed/document/$it") } + val mimeTypes = + buildMap { + put(unsupported, "application/octet-stream") + documents.forEach { document -> put(document, "application/pdf") } + } + val parsed = + parseShare( + intent = + Intent(Intent.ACTION_SEND_MULTIPLE) + .setType("*/*") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(listOf(unsupported) + documents)), + mimeTypes = mimeTypes, + ) + + requireNotNull(parsed) + assertEquals(documents, parsed.attachments.map(SharedAttachment::uri)) + assertEquals(1, parsed.droppedAttachmentCount) + } + + @Test + fun rejectsFileUrisAndEmptyOrUnrelatedIntents() { + assertNull( + parseShare( + Intent(Intent.ACTION_SEND) + .setType("image/jpeg") + .putExtra(Intent.EXTRA_STREAM, Uri.parse("file:///data/data/ai.openclaw.app/private.jpg")), + ), + ) + assertNull(parseShare(Intent(Intent.ACTION_SEND).setType("text/plain"))) + assertNull(parseShare(Intent(Intent.ACTION_VIEW))) + } + + @Test + fun rapidSharesKeepStableHeadUntilMatchingAcknowledgement() { + val queue = ChatShareDraftQueue(capacity = 2) + val owner = composerOwner("main", "agent:main:device") + val first = ChatShareDraft(id = 1, text = "first", attachments = emptyList(), droppedAttachmentCount = 0) + val second = ChatShareDraft(id = 2, text = "second", attachments = emptyList(), droppedAttachmentCount = 0) + + assertTrue(queue.enqueue(first, owner)) + assertTrue(queue.enqueue(second, owner)) + assertEquals(first, queue.head.value) + assertFalse(queue.acknowledgeHead(second.id, owner)) + assertEquals(first, queue.head.value) + + runBlocking { assertTrue(queue.withHeadLease(first.id, owner) {}) } + assertTrue(queue.acknowledgeHead(first.id, owner)) + assertEquals(second, queue.head.value) + runBlocking { assertTrue(queue.withHeadLease(second.id, owner) {}) } + assertTrue(queue.acknowledgeHead(second.id, owner)) + assertNull(queue.head.value) + } + + @Test + fun pendingShareQueueIsBoundedWithoutReplacingItsHead() { + val queue = ChatShareDraftQueue(capacity = 1) + val owner = composerOwner("main", "agent:main:device") + val first = ChatShareDraft(id = 1, text = "first", attachments = emptyList(), droppedAttachmentCount = 0) + val overflow = ChatShareDraft(id = 2, text = "overflow", attachments = emptyList(), droppedAttachmentCount = 0) + + assertTrue(queue.enqueue(first, owner)) + assertFalse(queue.enqueue(overflow, owner)) + assertEquals(1, queue.size()) + assertEquals(first, queue.head.value) + } + + @Test + fun anotherOwnersShareCanAdvanceWithoutRetargetingTheGlobalHead() = + runBlocking { + val queue = ChatShareDraftQueue(capacity = 2) + val ownerA = composerOwner("agent-a", "session-a") + val ownerB = composerOwner("agent-b", "session-b") + val first = ChatShareDraft(id = 1, text = "first", attachments = emptyList(), droppedAttachmentCount = 0) + val second = ChatShareDraft(id = 2, text = "second", attachments = emptyList(), droppedAttachmentCount = 0) + queue.enqueue(first, ownerA) + queue.enqueue(second, ownerB) + + assertEquals(first, queue.head.value) + assertTrue(queue.withHeadLease(second.id, ownerB) {}) + assertTrue(queue.acknowledgeHead(second.id, ownerB)) + assertEquals(first, queue.head.value) + assertTrue(queue.withHeadLease(first.id, ownerA) {}) + } + + @Test + fun overlappingActivityLoadersCannotCommitTheSameHead() = + runBlocking { + val queue = ChatShareDraftQueue(capacity = 2) + val first = ChatShareDraft(id = 1, text = "first", attachments = emptyList(), droppedAttachmentCount = 0) + val next = ChatShareDraft(id = 2, text = "second", attachments = emptyList(), droppedAttachmentCount = 0) + val owner = composerOwner("main", "agent:main:device") + queue.enqueue(first, owner) + queue.enqueue(next, owner) + val entered = CompletableDeferred() + val release = CompletableDeferred() + + val firstLoader = + async { + queue.withHeadLease(first.id, owner) { + entered.complete(Unit) + release.await() + assertTrue(queue.acknowledgeHead(first.id, owner)) + } + } + entered.await() + var staleLoaderRan = false + val staleLoader = + async { + queue.withHeadLease(first.id, owner) { + staleLoaderRan = true + } + } + release.complete(Unit) + + assertTrue(firstLoader.await()) + assertFalse(staleLoader.await()) + assertFalse(staleLoaderRan) + assertEquals(next, queue.head.value) + } + + @Test + fun claimedShareCannotRetargetAcrossComposerNavigation() = + runBlocking { + val queue = ChatShareDraftQueue(capacity = 1) + val share = ChatShareDraft(id = 1, text = "private", attachments = emptyList(), droppedAttachmentCount = 0) + val ownerA = composerOwner("agent-a", "session-a") + val ownerB = composerOwner("agent-b", "session-b") + val resolvedA = ownerA.copy(sessionKey = "agent:agent-a:device") + queue.enqueue(share, ownerA) + + assertTrue(queue.withHeadLease(share.id, ownerA) {}) + assertFalse(queue.withHeadLease(share.id, ownerB) {}) + assertFalse(queue.acknowledgeHead(share.id, ownerB)) + + queue.migrateOwner(ownerA, resolvedA) + assertTrue(queue.withHeadLease(share.id, resolvedA) {}) + assertTrue(queue.acknowledgeHead(share.id, resolvedA)) + } + + @Test + fun shareOwnerIsCapturedBeforeAnyLoaderRuns() = + runBlocking { + val queue = ChatShareDraftQueue(capacity = 1) + val share = ChatShareDraft(id = 1, text = "private", attachments = emptyList(), droppedAttachmentCount = 0) + val ownerA = composerOwner("agent-a", "session-a") + val ownerB = composerOwner("agent-b", "session-b") + + assertTrue(queue.enqueue(share, ownerA)) + assertEquals(ownerA, queue.ownerOf(share.id)) + assertFalse(queue.withHeadLease(share.id, ownerB) {}) + assertTrue(queue.withHeadLease(share.id, ownerA) {}) + } + + @Test + fun removingGatewaySharesKeepsOtherGatewayOwners() = + runBlocking { + val queue = ChatShareDraftQueue(capacity = 2) + val ownerA = composerOwner("agent-a", "session-a", gatewayStableId = "gateway-a") + val ownerB = composerOwner("agent-b", "session-b", gatewayStableId = "gateway-b") + val first = ChatShareDraft(id = 1, text = "private a", attachments = emptyList(), droppedAttachmentCount = 0) + val second = ChatShareDraft(id = 2, text = "private b", attachments = emptyList(), droppedAttachmentCount = 0) + queue.enqueue(first, ownerA) + queue.enqueue(second, ownerB) + + queue.removeOwners { it.gatewayStableId == "gateway-a" } + + assertEquals(listOf(second), queue.queued.value) + assertNull(queue.ownerOf(first.id)) + assertEquals(ownerB, queue.ownerOf(second.id)) + } + + private fun composerOwner( + agentId: String, + sessionKey: String, + gatewayStableId: String = "gateway", + ): ai.openclaw.app.chat.ChatComposerOwner = + ai.openclaw.app.chat.ChatComposerOwner( + gatewayStableId = gatewayStableId, + agentId = agentId, + sessionKey = sessionKey, + ) + + private fun parseShare( + intent: Intent, + mimeTypes: Map = emptyMap(), + ): ShareLaunchRequest? = parseShareLaunchIntent(intent) { uri -> mimeTypes[uri] } +} diff --git a/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt b/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt new file mode 100644 index 0000000..443d4b6 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt @@ -0,0 +1,224 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SkillManagementTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun searchResultsKeepOnlyIdentifiedSkills() { + val results = + parseClawHubSearchResults( + """{"results":[{"slug":" alpha ","displayName":"Alpha","summary":"Useful","version":"1.2.3"},{"slug":"missing-name"},{"displayName":"Missing slug"}]}""", + json, + ) + + assertEquals( + listOf( + GatewayClawHubSkillSummary( + slug = "alpha", + displayName = "Alpha", + summary = "Useful", + version = "1.2.3", + ), + ), + results, + ) + } + + @Test + fun detailBindsExactVersionAndPublisherIdentity() { + val review = + parseClawHubInstallReview( + """{"skill":{"displayName":"Alpha Skill","summary":"Reviewed metadata"},"latestVersion":{"version":"2.0.0"},"owner":{"displayName":"Alice","handle":"alice"}}""", + GatewayClawHubSkillSummary("alpha", "Alpha", null, null), + json, + ) + + assertEquals( + GatewayClawHubInstallReview( + slug = "@alice/alpha", + displayName = "Alpha Skill", + summary = "Reviewed metadata", + version = "2.0.0", + author = "Alice", + ), + review, + ) + } + + @Test + fun detailVersionWinsWhenSearchResultIsStale() { + val review = + parseClawHubInstallReview( + """{"skill":{"displayName":"Alpha"},"latestVersion":{"version":"2.0.0"},"owner":{"handle":"alice"}}""", + GatewayClawHubSkillSummary("alpha", "Alpha", null, "1.9.0"), + json, + ) + + assertEquals("2.0.0", review?.version) + } + + @Test + fun detailFailsClosedWithoutAnInstallableVersion() { + val review = + parseClawHubInstallReview( + """{"skill":{"displayName":"Alpha"},"owner":{"handle":"alice"}}""", + GatewayClawHubSkillSummary("alpha", "Alpha", null, null), + json, + ) + + assertNull(review) + } + + @Test + fun installParamsKeepRegistryAndTrustPolicyOnGateway() { + val params = json.parseToJsonElement(clawHubInstallParams("alpha", "1.2.3", acknowledgeRisk = true)).jsonObject + + assertEquals(setOf("source", "slug", "version", "acknowledgeClawHubRisk", "timeoutMs"), params.keys) + assertEquals("clawhub", params.getValue("source").jsonPrimitive.content) + assertEquals("alpha", params.getValue("slug").jsonPrimitive.content) + assertEquals("1.2.3", params.getValue("version").jsonPrimitive.content) + assertTrue(params.getValue("acknowledgeClawHubRisk").jsonPrimitive.boolean) + assertEquals(120_000, params.getValue("timeoutMs").jsonPrimitive.int) + } + + @Test + fun onlyStructuredReviewRequiredFailureOffersAcknowledgement() { + val rejection = + clawHubInstallRejection( + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "review required", + details = + GatewayErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + clawhubTrustCode = "clawhub_risk_acknowledgement_required", + clawhubWarning = "Scanner found elevated permissions.", + clawhubVersion = "1.2.3", + ), + ), + attemptedVersion = "1.2.3", + ) + + assertTrue(rejection.requiresAcknowledgement) + assertEquals("1.2.3", rejection.acknowledgeVersion) + assertEquals("Scanner found elevated permissions.", rejection.warning) + } + + @Test + fun changedGatewayVersionRequiresFreshReview() { + val rejection = + clawHubInstallRejection( + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "review required", + details = + GatewayErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + clawhubTrustCode = "clawhub_risk_acknowledgement_required", + clawhubWarning = "Scanner found elevated permissions.", + clawhubVersion = "1.2.4", + ), + ), + attemptedVersion = "1.2.3", + ) + + assertFalse(rejection.requiresAcknowledgement) + assertNull(rejection.acknowledgeVersion) + assertTrue(rejection.message.contains("different ClawHub release")) + } + + @Test + fun blockedFailureNeverOffersAcknowledgement() { + val rejection = + clawHubInstallRejection( + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "download blocked", + details = + GatewayErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + clawhubTrustCode = "clawhub_download_blocked", + clawhubWarning = "ClawHub marked this release malicious.", + clawhubVersion = "1.2.3", + ), + ), + attemptedVersion = "1.2.3", + ) + + assertFalse(rejection.requiresAcknowledgement) + assertNull(rejection.acknowledgeVersion) + } + + @Test + fun unknownInstallReadbackUsesClawHubProvenanceSlug() { + val skill = + GatewaySkillSummary( + skillKey = "custom-frontmatter-key", + name = "Custom display name", + description = null, + source = "openclaw-managed", + emoji = null, + disabled = false, + eligible = true, + blockedByAllowlist = false, + blockedByAgentFilter = false, + bundled = false, + missingCount = 0, + installCount = 0, + clawHubSlug = "registry-slug", + clawHubValid = true, + clawHubOwnerHandle = "registry-owner", + clawHubInstalledVersion = "1.2.3", + ) + + assertTrue(isClawHubSkillInstalled(listOf(skill), "registry-slug", "1.2.3")) + assertTrue(isClawHubSkillInstalled(listOf(skill), "registry-slug")) + assertTrue(isClawHubSkillInstalled(listOf(skill), "@registry-owner/registry-slug", "1.2.3")) + assertFalse(isClawHubSkillInstalled(listOf(skill), "@other-owner/registry-slug", "1.2.3")) + assertFalse(isClawHubSkillInstalled(listOf(skill), "registry-slug", "1.2.4")) + assertFalse(isClawHubSkillInstalled(listOf(skill.copy(clawHubValid = false)), "registry-slug", "1.2.3")) + assertFalse(isClawHubSkillInstalled(listOf(skill), "custom-frontmatter-key", "1.2.3")) + } + + @Test + fun ownerQualifiedInstallStaysActiveForBrowseSlug() { + assertTrue(isClawHubSkillOperationActive(setOf("@registry-owner/registry-slug"), "registry-slug")) + assertTrue( + isClawHubSkillOperationActive( + setOf("@registry-owner/registry-slug"), + "@registry-owner/registry-slug", + ), + ) + assertFalse( + isClawHubSkillOperationActive( + setOf("@other-owner/registry-slug"), + "@registry-owner/registry-slug", + ), + ) + } + + @Test + fun clawHubManagementRequiresEveryAdvertisedMethod() { + assertTrue(supportsClawHubSkillManagement(CLAWHUB_SKILL_GATEWAY_METHODS)) + assertFalse(supportsClawHubSkillManagement(CLAWHUB_SKILL_GATEWAY_METHODS - "skills.detail")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt b/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt new file mode 100644 index 0000000..820263c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/SkillWorkshopAgentScopeRuntimeTest.kt @@ -0,0 +1,285 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.i18n.NativeText +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SkillWorkshopAgentScopeRuntimeTest { + private val json = Json { ignoreUnknownKeys = true } + + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun gatewayActionsCarryClosedLocalizedPresentation() { + val cases = + listOf( + SkillWorkshopGatewayAction.Apply to Triple("apply", "applied", "Proposal applied."), + SkillWorkshopGatewayAction.Reject to Triple("reject", "rejected", "Proposal rejected."), + SkillWorkshopGatewayAction.Quarantine to Triple("quarantine", "quarantined", "Proposal quarantined."), + ) + + for ((action, expected) in cases) { + val (methodSuffix, expectedStatus, noticeSource) = expected + assertEquals(methodSuffix, action.methodSuffix) + assertEquals(expectedStatus, action.expectedStatus) + assertEquals(NativeText.Resource(noticeSource, emptyList()), action.notice) + assertEquals(NativeText.Resource(methodSuffix, emptyList()), action.verb) + assertEquals( + NativeText.Resource( + source = "Gateway returned status '\$statusLabel' after \${action.verb}.", + formatArgs = listOf(NativeText.Verbatim("future_status"), action.verb), + ), + skillWorkshopUnexpectedStatusText("future_status", action), + ) + assertEquals( + NativeText.Resource( + source = "Could not \${action.verb} Skill Workshop proposal.", + formatArgs = listOf(action.verb), + ), + skillWorkshopActionFailureText(action), + ) + } + } + + @Test + fun missingGatewayActionStatusUsesLocalizedUnknownFallback() { + assertEquals( + NativeText.Resource( + source = "Gateway returned status '\$statusLabel' after \${action.verb}.", + formatArgs = + listOf( + NativeText.Resource(source = "unknown", formatArgs = emptyList()), + SkillWorkshopGatewayAction.Apply.verb, + ), + ), + skillWorkshopUnexpectedStatusText(null, SkillWorkshopGatewayAction.Apply), + ) + } + + @Test + fun resetSkillWorkshopAgentScopeClearsRowsAndInFlightActionState() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + runtime.resetSkillWorkshopAgentScope("main") + readField>(runtime, "_skillWorkshopSummary").value = + GatewaySkillWorkshopSummary( + agentId = "main", + proposals = listOf(skillWorkshopProposal("main-proposal")), + ) + + runtime.resetSkillWorkshopAgentScope("ops") + + assertEquals("ops", runtime.skillWorkshopSummary.value.agentId) + assertEquals(emptyList(), runtime.skillWorkshopSummary.value.proposals) + assertFalse(runtime.skillWorkshopRefreshing.value) + assertNull(runtime.skillWorkshopErrorText.value) + assertNull(runtime.skillWorkshopNoticeText.value) + assertNull(runtime.skillWorkshopInspectingProposalId.value) + assertNull(runtime.skillWorkshopMutatingProposalId.value) + } + + @Test + fun inspectAndMutateDoNotStartForStaleSelectedAgentScope() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + runtime.resetSkillWorkshopAgentScope("main") + + runtime.inspectSkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops") + readField>>(runtime, "_operatorScopes").value = listOf("operator.admin") + waitUntil { runtime.operatorAdminScopeAvailable.value } + runtime.applySkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops") + Thread.sleep(100) + + assertEquals("main", runtime.skillWorkshopSummary.value.agentId) + assertNull(runtime.skillWorkshopInspectingProposalId.value) + assertNull(runtime.skillWorkshopMutatingProposalId.value) + assertNull(runtime.skillWorkshopErrorText.value) + assertNull(runtime.skillWorkshopNoticeText.value) + } + + @Test + fun busyProposalActionDoesNotInvalidateActiveRequestGenerations() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + readField>(runtime, "_skillWorkshopSummary").value = + GatewaySkillWorkshopSummary( + agentId = "main", + proposals = listOf(skillWorkshopProposal("proposal-1")), + ) + readField>>(runtime, "_operatorScopes").value = listOf("operator.admin") + waitUntil { runtime.operatorAdminScopeAvailable.value } + readField>(runtime, "_skillWorkshopMutatingProposalId").value = "proposal-1" + val mutationSeq = readField(runtime, "skillWorkshopMutationSeq").apply { set(41) } + val inspectSeq = readField(runtime, "skillWorkshopInspectSeq").apply { set(17) } + + runtime.applySkillWorkshopProposal(proposalId = "proposal-1", agentId = "main") + runtime.inspectSkillWorkshopProposal(proposalId = "proposal-1", agentId = "main") + Thread.sleep(100) + + assertEquals(41, mutationSeq.get()) + assertEquals(17, inspectSeq.get()) + assertEquals("proposal-1", runtime.skillWorkshopMutatingProposalId.value) + assertNull(runtime.skillWorkshopInspectingProposalId.value) + } + + @Test + fun proposalActionResultUsesGatewayReturnedStatusAndPreservesInspectedDetails() { + val runtime = createTestRuntime() + val supportFiles = + listOf(GatewaySkillWorkshopSupportFile(path = "references/proof.md", content = "proof")) + val previous = + skillWorkshopProposal("proposal-1") + .copy( + status = "pending", + content = "inspected markdown", + supportFiles = supportFiles, + ) + + val rejected = + parseSkillWorkshopActionResult( + runtime, + """ + { + "record": { + "id": "proposal-1", + "kind": "create", + "status": "rejected", + "title": "Rejected proposal", + "description": "Gateway action response", + "createdAt": "2026-07-08T00:00:00Z", + "updatedAt": "2026-07-09T00:00:00Z", + "scan": { "state": "clean" }, + "target": { "skillName": "Rejected Skill", "skillKey": "rejected-skill" } + } + } + """.trimIndent(), + previous, + ) + + assertEquals("rejected", rejected?.status) + assertEquals("2026-07-09T00:00:00Z", rejected?.updatedAt) + assertEquals("Rejected Skill", rejected?.skillName) + assertEquals("clean", rejected?.scanState) + assertEquals("inspected markdown", rejected?.content) + assertEquals(supportFiles, rejected?.supportFiles) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime(runtime: NodeRuntime) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + } + + private fun skillWorkshopProposal(id: String): GatewaySkillWorkshopProposal = + GatewaySkillWorkshopProposal( + id = id, + status = "pending", + kind = "create", + title = "Proposal $id", + skillKey = id, + skillName = "Proposal $id", + description = "desc", + createdAt = "2026-07-08T00:00:00Z", + updatedAt = "2026-07-08T00:00:00Z", + scanState = null, + ) + + private fun parseSkillWorkshopActionResult( + runtime: NodeRuntime, + payloadJson: String, + previous: GatewaySkillWorkshopProposal, + ): GatewaySkillWorkshopProposal? { + val method = + runtime.javaClass.getDeclaredMethod( + "parseSkillWorkshopProposalActionResult", + JsonObject::class.java, + GatewaySkillWorkshopProposal::class.java, + ) + method.isAccessible = true + @Suppress("UNCHECKED_CAST") + return method.invoke( + runtime, + json.parseToJsonElement(payloadJson).jsonObject, + previous, + ) as GatewaySkillWorkshopProposal? + } + + private fun waitUntil(condition: () -> Boolean) { + repeat(50) { + if (condition()) return + Thread.sleep(10) + } + error("Expected condition to become true") + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + field.set(target, value) + return + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun readField( + target: Any, + name: String, + ): T { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(target) as T + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } +} diff --git a/app/src/test/java/ai/openclaw/app/Utf16TextTest.kt b/app/src/test/java/ai/openclaw/app/Utf16TextTest.kt new file mode 100644 index 0000000..7574f7e --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/Utf16TextTest.kt @@ -0,0 +1,53 @@ +package ai.openclaw.app + +import ai.openclaw.app.ui.localizedInitial +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class Utf16TextTest { + @Test + fun firstGraphemeOrNullPreservesUserPerceivedCharacters() { + assertEquals("🧭", "🧭 Scout".firstGraphemeOrNull()) + assertEquals("🇺🇸", "🇺🇸 Scout".firstGraphemeOrNull()) + assertEquals("👩🏽‍💻", "👩🏽‍💻 Dev".firstGraphemeOrNull()) + assertEquals("A\u0308", "A\u0308lice".firstGraphemeOrNull()) + assertEquals("S", "Scout".firstGraphemeOrNull()) + assertNull("".firstGraphemeOrNull()) + } + + @Test + fun uppercaseFirstGraphemeOrNullPreservesUserPerceivedCharacters() { + assertEquals("🧭", "🧭 Scout".uppercaseFirstGraphemeOrNull()) + assertEquals("🇺🇸", "🇺🇸 Scout".uppercaseFirstGraphemeOrNull()) + assertEquals("👩🏽‍💻", "👩🏽‍💻 Dev".uppercaseFirstGraphemeOrNull()) + assertEquals("A\u0308", "a\u0308lice".uppercaseFirstGraphemeOrNull()) + assertEquals("S", "scout".uppercaseFirstGraphemeOrNull()) + assertEquals("ß", "ßcout".uppercaseFirstGraphemeOrNull()) + assertEquals("\uD801\uDC00", "\uD801\uDC28cout".uppercaseFirstGraphemeOrNull()) + assertNull("".uppercaseFirstGraphemeOrNull()) + } + + @Test + fun localizedInitialPreservesGraphemesAndLocale() { + assertEquals("🧭", localizedInitial("🧭 Scout", languageTag = "en", fallbackLocale = Locale.US)) + assertEquals("🇺🇸", localizedInitial("🇺🇸 Scout", languageTag = "en", fallbackLocale = Locale.US)) + assertEquals("👩🏽‍💻", localizedInitial("👩🏽‍💻 Dev", languageTag = "en", fallbackLocale = Locale.US)) + assertEquals("İ", localizedInitial("istanbul", languageTag = "tr", fallbackLocale = Locale.US)) + assertNull(localizedInitial("", languageTag = "en", fallbackLocale = Locale.US)) + } + + @Test + fun takeUtf16SafePreservesCodeUnitLimitWithoutSplittingSurrogatePairs() { + assertEquals("ab", "ab".takeUtf16Safe(2)) + assertEquals("ab", "abc".takeUtf16Safe(2)) + assertEquals("", "\uD83D\uDE00tail".takeUtf16Safe(1)) + assertEquals("\uD83D\uDE00", "\uD83D\uDE00tail".takeUtf16Safe(2)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt b/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt new file mode 100644 index 0000000..2a835c8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt @@ -0,0 +1,285 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import android.Manifest +import android.content.Context +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class VoiceWakeRuntimeTest { + @Test + fun disconnectedSaveDoesNotCreateLocalOverride() { + val runtime = createTestRuntime() + + runtime.setVoiceWakeWords(listOf("hey claw")) + + assertEquals(listOf("openclaw", "claude", "computer"), runtime.voiceWakeWords.value) + assertEquals("Connect to a Gateway to save wake words", runtime.voiceWakeWordsNoticeText.value) + assertFalse(runtime.voiceWakeWordsSaving.value) + } + + @Test + fun successfulSaveCommitsGatewayCanonicalWords() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + assertEquals("voicewake.set", method) + """{"triggers":[" gateway claw "]}""" + } + + runtime.setVoiceWakeWords(listOf("local draft")) + withTimeout(5_000) { + while (runtime.voiceWakeWordsSaving.value) delay(10) + } + + assertEquals(listOf("gateway claw"), runtime.voiceWakeWords.value) + assertEquals("Wake words saved", runtime.voiceWakeWordsNoticeText.value) + } + + @Test + fun failedGatewaySaveKeepsAuthoritativeWords() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, _, _ -> error("write rejected") } + + runtime.setVoiceWakeWords(listOf("local draft")) + withTimeout(5_000) { + while (runtime.voiceWakeWordsSaving.value) delay(10) + } + + assertEquals(listOf("openclaw", "claude", "computer"), runtime.voiceWakeWords.value) + assertEquals("Could not save wake words", runtime.voiceWakeWordsNoticeText.value) + } + + @Test + fun responseFromRetiredGatewayDoesNotPublish() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + val response = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, _, _ -> response.await() } + + runtime.setVoiceWakeWords(listOf("stale words")) + withTimeout(5_000) { + while (!runtime.voiceWakeWordsSaving.value) delay(10) + } + writeField(runtime, "gatewayDataGeneration", 1L) + response.complete("""{"triggers":["stale words"]}""") + withTimeout(5_000) { + while (runtime.voiceWakeWordsSaving.value) delay(10) + } + + assertEquals(listOf("openclaw", "claude", "computer"), runtime.voiceWakeWords.value) + assertEquals(null, runtime.voiceWakeWordsNoticeText.value) + } + + @Test + fun delayedSaveResponseCannotOverwriteNewerGatewayEvent() = + runBlocking { + val runtime = createTestRuntime() + val endpoint = seedConnectedRuntime(runtime) + val response = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, _, _ -> response.await() } + + runtime.setVoiceWakeWords(listOf("local draft")) + withTimeout(5_000) { + while (!runtime.voiceWakeWordsSaving.value) delay(10) + } + runtime.applyNodeVoiceWakeWords( + endpointStableId = endpoint.stableId, + payloadJson = """{"triggers":["newer gateway words"]}""", + isCurrentConnection = { true }, + ) + response.complete("""{"triggers":["local draft"]}""") + withTimeout(5_000) { + while (runtime.voiceWakeWordsSaving.value) delay(10) + } + + assertEquals(listOf("newer gateway words"), runtime.voiceWakeWords.value) + } + + @Test + fun nodeOnlyVoiceWakeEventPublishesForCurrentGateway() { + val runtime = createTestRuntime() + val endpoint = seedConnectedRuntime(runtime) + + runtime.applyNodeVoiceWakeWords( + endpointStableId = endpoint.stableId, + payloadJson = """{"triggers":[" node claw "]}""", + isCurrentConnection = { true }, + ) + + assertEquals(listOf("node claw"), runtime.voiceWakeWords.value) + } + + @Test + fun nodeOnlyVoiceWakeEventIgnoresRetiredConnection() { + val runtime = createTestRuntime() + val endpoint = seedConnectedRuntime(runtime) + + runtime.applyNodeVoiceWakeWords( + endpointStableId = endpoint.stableId, + payloadJson = """{"triggers":[" stale claw "]}""", + isCurrentConnection = { false }, + ) + + assertEquals(listOf("openclaw", "claude", "computer"), runtime.voiceWakeWords.value) + } + + @Test + fun gatewaySwitchClearsWordsAndBlocksSaveUntilRefresh() { + val runtime = createTestRuntime() + val firstEndpoint = seedConnectedRuntime(runtime) + runtime.applyNodeVoiceWakeWords( + endpointStableId = firstEndpoint.stableId, + payloadJson = """{"triggers":["gateway a"]}""", + isCurrentConnection = { true }, + ) + assertEquals(listOf("gateway a"), runtime.voiceWakeWords.value) + + val secondEndpoint = GatewayEndpoint.manual("127.0.0.2", 18789) + writeField(runtime, "connectedEndpoint", secondEndpoint) + invokeNoArg(runtime, "invalidateVoiceWakeWordsForGateway") + runtime.setVoiceWakeWords(listOf("stale overwrite")) + + assertEquals(listOf("openclaw", "claude", "computer"), runtime.voiceWakeWords.value) + assertEquals("Connect to a Gateway to save wake words", runtime.voiceWakeWordsNoticeText.value) + } + + @Test + fun capabilityRefreshTracksGatewayWakeWordReadiness() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val securePrefs = + app.getSharedPreferences( + "openclaw.node.voicewake.runtime.test.${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + prefs.setVoiceWakeEnabled(true) + val runtime = NodeRuntime(app, prefs, mode = NodeRuntimeMode.ScreenshotFixture) + val endpoint = GatewayEndpoint.manual("127.0.0.1", 18789) + writeField(runtime, "connectedEndpoint", endpoint) + + assertFalse(readField(runtime, "lastVoiceWakeCapabilityEnabled")) + writeField(runtime, "voiceWakeWordsGatewayStableId", endpoint.stableId) + readField(runtime, "scope").coroutineContext[Job]?.cancel() + invokeNoArg(runtime, "refreshVoiceWakeCapabilitySurfaceIfChanged") + + assertTrue(readField(runtime, "lastVoiceWakeCapabilityEnabled")) + } + + @Test + fun cameraAudioOwnershipBlocksVoiceNoteUntilRelease() { + val runtime = createTestRuntime() + + assertEquals(true, runtime.setCameraAudioCaptureActive(true)) + assertFalse(runtime.tryAcquireVoiceNoteMic()) + + assertEquals(true, runtime.setCameraAudioCaptureActive(false)) + assertEquals(true, runtime.tryAcquireVoiceNoteMic()) + runtime.releaseVoiceNoteMic() + } + + @Test + fun dictationAndVoiceNoteCannotShareTheMicrophone() { + val runtime = createTestRuntime() + + assertTrue(runtime.tryAcquireDictationMic()) + assertFalse(runtime.tryAcquireVoiceNoteMic()) + assertFalse(runtime.setCameraAudioCaptureActive(true)) + + runtime.releaseDictationMic() + assertTrue(runtime.tryAcquireVoiceNoteMic()) + assertFalse(runtime.tryAcquireDictationMic()) + + runtime.releaseVoiceNoteMic() + assertTrue(runtime.tryAcquireDictationMic()) + runtime.releaseDictationMic() + + assertTrue(runtime.setCameraAudioCaptureActive(true)) + assertFalse(runtime.tryAcquireDictationMic()) + assertTrue(runtime.setCameraAudioCaptureActive(false)) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.voicewake.runtime.test.${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime(runtime: NodeRuntime): GatewayEndpoint { + val endpoint = GatewayEndpoint.manual("127.0.0.1", 18789) + writeField(runtime, "connectedEndpoint", endpoint) + runtime.applyNodeVoiceWakeWords( + endpointStableId = endpoint.stableId, + payloadJson = """{"triggers":["openclaw","claude","computer"]}""", + isCurrentConnection = { true }, + ) + return endpoint + } + + private fun invokeNoArg( + target: Any, + name: String, + ) { + val method = target.javaClass.getDeclaredMethod(name) + method.isAccessible = true + method.invoke(target) + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + var type: Class<*>? = target.javaClass + while (type != null) { + val field = runCatching { type.getDeclaredField(name) }.getOrNull() + if (field != null) { + field.isAccessible = true + field.set(target, value) + return + } + type = type.superclass + } + error("missing field $name") + } + + @Suppress("UNCHECKED_CAST") + private fun readField( + target: Any, + name: String, + ): T { + var type: Class<*>? = target.javaClass + while (type != null) { + val field = runCatching { type.getDeclaredField(name) }.getOrNull() + if (field != null) { + field.isAccessible = true + return field.get(target) as T + } + type = type.superclass + } + error("missing field $name") + } +} diff --git a/app/src/test/java/ai/openclaw/app/WorkspaceFilesTest.kt b/app/src/test/java/ai/openclaw/app/WorkspaceFilesTest.kt new file mode 100644 index 0000000..485cda0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/WorkspaceFilesTest.kt @@ -0,0 +1,85 @@ +package ai.openclaw.app + +import ai.openclaw.app.ui.isWorkspaceDirectoryRequestInFlight +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkspaceFilesTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun tracksDirectoryRequestInFlightAcrossRefreshAndPagination() { + assertEquals(false, isWorkspaceDirectoryRequestInFlight(loading = false, loadingMore = false)) + assertEquals(true, isWorkspaceDirectoryRequestInFlight(loading = true, loadingMore = false)) + assertEquals(true, isWorkspaceDirectoryRequestInFlight(loading = false, loadingMore = true)) + assertEquals(true, isWorkspaceDirectoryRequestInFlight(loading = true, loadingMore = true)) + } + + @Test + fun parsesListingEntriesAndPagination() { + val payload = + """ + { + "agentId": "main", + "workspace": "/tmp/workspace", + "path": "src", + "parentPath": "", + "entries": [ + {"path": "src/util", "name": "util", "kind": "directory", "updatedAtMs": 1700000000000}, + {"path": "src/index.ts", "name": "index.ts", "kind": "file", "size": 42, "updatedAtMs": 1700000000123} + ], + "totalEntries": 12, + "offset": 0 + } + """.trimIndent() + + val listing = parseWorkspaceListing(json.parseToJsonElement(payload)) + + assertEquals("src", listing?.path) + assertEquals(12, listing?.totalEntries) + assertEquals(0, listing?.offset) + assertEquals(2, listing?.entries?.size) + val directory = listing?.entries?.first() + assertEquals(true, directory?.isDirectory) + assertNull(directory?.size) + val file = listing?.entries?.last() + assertEquals(false, file?.isDirectory) + assertEquals(42L, file?.size) + assertEquals(1_700_000_000_123L, file?.updatedAtMs) + } + + @Test + fun parsesTextAndImageFilePayloads() { + val text = + parseWorkspaceFile( + json.parseToJsonElement( + """{"agentId":"main","workspace":"/w","file":{"path":"notes.md","name":"notes.md","size":8,"updatedAtMs":1,"mimeType":"text/plain","encoding":"utf8","content":"# Notes\n"}}""", + ), + ) + assertEquals("notes.md", text?.name) + assertEquals(false, text?.isBase64) + assertEquals("# Notes\n", text?.content) + + val image = + parseWorkspaceFile( + json.parseToJsonElement( + """{"agentId":"main","workspace":"/w","file":{"path":"shot.png","name":"shot.png","size":3,"updatedAtMs":1,"mimeType":"image/png","encoding":"base64","content":"AAECAw=="}}""", + ), + ) + assertEquals(true, image?.isBase64) + assertTrue(image?.mimeType?.startsWith("image/") == true) + } + + @Test + fun rejectsPayloadsWithoutFileOrPath() { + assertNull(parseWorkspaceFile(Json.parseToJsonElement("""{"agentId":"main"}"""))) + assertNull( + parseWorkspaceFile( + Json.parseToJsonElement("""{"file":{"name":"x","size":1,"mimeType":"text/plain","encoding":"utf8","content":""}}"""), + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/BackgroundTaskTest.kt b/app/src/test/java/ai/openclaw/app/chat/BackgroundTaskTest.kt new file mode 100644 index 0000000..d48dda7 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/BackgroundTaskTest.kt @@ -0,0 +1,166 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.ui.chat.backgroundTasksEmptyStateVisible +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackgroundTaskTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parsesPromptAndOutputFromTaskDetails() { + val tasks = + parseBackgroundTasks( + json, + """{"tasks":[{"id":"task-1","taskId":"worker-1","status":"failed","runtime":"cli","title":"Index docs","startedAt":1000,"endedAt":"2026-07-16T09:00:00Z","error":"Command failed","prompt":"Index the docs"}]}""", + ) + + assertEquals(1, tasks.size) + assertEquals("Index docs", tasks.single().displayTitle) + assertEquals("Index the docs", tasks.single().prompt) + assertEquals("Command failed", tasks.single().output) + assertEquals(BackgroundTaskDisplayStatus.Failed, tasks.single().displayStatus) + assertFalse(tasks.single().isActive) + } + + @Test + fun parsesRunningBackgroundExecTask() { + val tasks = + parseBackgroundTasks( + json, + """{"tasks":[{"id":"task-exec","taskId":"task-exec","kind":"exec","status":"running","runtime":"cli","title":"CLI command","progressSummary":"Command running"}]}""", + ) + + assertEquals(1, tasks.size) + assertEquals("CLI command", tasks.single().displayTitle) + assertEquals("Command running", tasks.single().output) + assertTrue(tasks.single().isActive) + assertEquals(BackgroundTaskDisplayStatus.Running, tasks.single().displayStatus) + } + + @Test + fun listsActiveAndRecentTasksWithoutRequestingPrompts() = + runTest { + val calls = mutableListOf>() + val controller = + ChatController( + scope = backgroundScope, + json = json, + requestGateway = { method, params -> + calls += method to params + """{"tasks":[]}""" + }, + ) + + assertTrue(controller.listBackgroundTasks("main").isEmpty()) + assertEquals(listOf("tasks.list", "tasks.list"), calls.map { it.first }) + val statuses = + calls.map { (_, params) -> + json + .parseToJsonElement(params.orEmpty()) + .jsonObject["status"]!! + .jsonArray + .map { it.jsonPrimitive.content } + } + assertEquals(listOf("queued", "running"), statuses[0]) + assertEquals(listOf("completed", "failed", "cancelled", "timed_out"), statuses[1]) + assertNull(json.parseToJsonElement(calls[0].second.orEmpty()).jsonObject["prompt"]) + } + + @Test + fun requestsTaskDetailsByCanonicalLedgerId() = + runTest { + var requestedParams: String? = null + val controller = + ChatController( + scope = backgroundScope, + json = json, + requestGateway = { method, params -> + assertEquals("tasks.get", method) + requestedParams = params + """{"task":{"id":"ledger-1","taskId":"runtime-1","status":"completed","runtime":"cli"}}""" + }, + ) + + val task = controller.getBackgroundTask("ledger-1") + + assertEquals("ledger-1", task.id) + assertEquals( + "ledger-1", + json + .parseToJsonElement(requestedParams.orEmpty()) + .jsonObject["taskId"] + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun newestTaskSnapshotWinsDuplicateAndGroupsActiveFirst() { + val finished = sampleTask(id = "same", status = "completed", endedAtMs = 2000) + val running = sampleTask(id = "same", status = "running", endedAtMs = 3000) + val older = sampleTask(id = "older", status = "failed", endedAtMs = 1000) + + val merged = mergeBackgroundTasks(listOf(finished, older), listOf(running)) + + assertEquals(listOf("same", "older"), merged.map { it.id }) + assertTrue(merged.first().isActive) + } + + @Test + fun terminalSnapshotWinsTimestampTie() { + val running = sampleTask(id = "same", status = "running", endedAtMs = 2000) + val finished = sampleTask(id = "same", status = "completed", endedAtMs = 2000) + + val merged = mergeBackgroundTasks(listOf(running), listOf(finished)) + + assertEquals("completed", merged.single().status) + } + + @Test + fun finishedProtocolStatusesUseTheBinaryFailedPresentation() { + assertEquals( + BackgroundTaskDisplayStatus.Failed, + sampleTask(id = "cancelled", status = "cancelled", endedAtMs = 2000).displayStatus, + ) + assertEquals( + BackgroundTaskDisplayStatus.Failed, + sampleTask(id = "timed-out", status = "timed_out", endedAtMs = 2000).displayStatus, + ) + } + + @Test + fun emptyStateDoesNotMaskLoadFailure() { + assertTrue(backgroundTasksEmptyStateVisible(loading = false, error = null, taskCount = 0)) + assertFalse(backgroundTasksEmptyStateVisible(loading = false, error = "offline", taskCount = 0)) + } + + private fun sampleTask( + id: String, + status: String, + endedAtMs: Long?, + ) = BackgroundTask( + id = id, + status = status, + runtime = "cli", + title = id, + agentId = "main", + childSessionKey = null, + createdAtMs = 100, + updatedAtMs = endedAtMs, + startedAtMs = 500, + endedAtMs = endedAtMs, + progress = null, + terminal = null, + error = null, + prompt = null, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt new file mode 100644 index 0000000..164a8ce --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt @@ -0,0 +1,884 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import androidx.room.Room +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class ChatControllerBranchCoordinationTest { + private val json = Json { ignoreUnknownKeys = true } + private val database = + Room + .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ClientStateDatabase::class.java) + .build() + private val outbox = RoomChatCommandOutbox(database) + private val controllerScopes = mutableListOf() + + @After + fun tearDown() { + controllerScopes.forEach { it.cancel() } + database.close() + } + + private fun controller( + gateway: ScriptedGateway, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + ): ChatController { + val controllerScope = CoroutineScope(SupervisorJob() + dispatcher) + controllerScopes += controllerScope + return ChatController( + scope = controllerScope, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope("gateway-a", 1) }, + commandOutbox = outbox, + ) + } + + private suspend fun enqueue(text: String = "queued"): ChatOutboxItem = + ( + outbox.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = text, + thinkingLevel = "off", + nowMs = System.currentTimeMillis(), + ownerAgentId = "main", + ) as ChatOutboxEnqueueResult.Queued + ).item + + private suspend fun ChatController.awaitOutboxRestore() { + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { outboxPresentationRestored.first { it } } + } + } + + @Test + fun unconfirmedOutboxBlocksRewindForkAndBranchSwitch() = + runTest { + enqueue() + assertNull( + outbox.beginSessionMutation( + "gateway-a", + ChatOutboxScope("main", "main"), + nowMs = 100, + ), + ) + val gateway = ScriptedGateway(json) + val controller = controller(gateway) + runCurrent() + controller.awaitOutboxRestore() + + assertNull(controller.rewindSessionAtEntryResult("main", "entry-a")) + assertNull(controller.forkSessionAtEntry("main", "entry-a")) + assertFalse(controller.switchSessionBranch("main", "leaf-b")) + assertTrue( + gateway.calls.toString(), + gateway.calls.none { it.method in setOf("sessions.rewind", "sessions.fork", "sessions.branches.switch") }, + ) + } + + @Test + fun rewindParksAnEnqueueThatRacesInsideTheMutationLease() = + runTest { + val gateway = ScriptedGateway(json) + val rewindStarted = CompletableDeferred() + val releaseRewind = CompletableDeferred() + gateway.respond("sessions.rewind") { + rewindStarted.complete(Unit) + releaseRewind.await() + """{"editorText":"restored"}""" + } + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "before", 1, entryId = "leaf-after")), + ), + ) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-after","headline":"After rewind","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + runCurrent() + controller.awaitOutboxRestore() + + val rewind = async { controller.rewindSessionAtEntryResult("main", "entry-a") } + rewindStarted.await() + val racing = enqueue("racing") + releaseRewind.complete(Unit) + + val result = rewind.await() + assertNotNull(result) + assertEquals("restored", result?.editorText) + val parked = outbox.load("gateway-a").single() + assertEquals(racing.id, parked.id) + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(1, outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.epoch) + } + + @Test + fun ambiguousRewindBlocksDeliveryUntilAuthoritativeHistoryReconcilesTheBranch() = + runTest { + val gateway = ScriptedGateway(json) + val historyCalls = AtomicInteger() + val retryHistoryStarted = CompletableDeferred() + val releaseRetryHistory = CompletableDeferred() + gateway.respond("sessions.rewind") { throw GatewayRequestOutcomeUnknown("response lost") } + gateway.respond("chat.history") { + when (historyCalls.incrementAndGet()) { + 1, 2 -> throw IllegalStateException("history temporarily unavailable") + else -> { + retryHistoryStarted.complete(Unit) + releaseRetryHistory.await() + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "authoritative rewind", 2, entryId = "leaf-rewound")), + ) + } + } + } + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-rewound","headline":"Rewound","messageCount":1,"active":true}]}""", + ) + gateway.respondChatSend("started") + val controller = controller(gateway, StandardTestDispatcher(testScheduler)) + runCurrent() + controller.awaitOutboxRestore() + controller.handleGatewayEvent("health", null) + runCurrent() + assertTrue(controller.healthOk.value) + + assertNull(controller.rewindSessionAtEntryResult("main", "entry-a")) + advanceUntilIdle() + retryHistoryStarted.await() + assertTrue(controller.sendMessageAwaitAcceptance("queued after rewind", "off", emptyList())) + + val state = outbox.branchState("gateway-a", ChatOutboxScope("main", "main")) + assertTrue(state?.needsReconciliation == true) + assertNull(state?.switchPendingSinceMs) + assertEquals(0, gateway.callCount("chat.send")) + assertTrue(controller.messages.value.isEmpty()) + + releaseRetryHistory.complete(Unit) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (gateway.callCount("chat.send") == 0) { + runCurrent() + kotlinx.coroutines.delay(10) + } + } + } + + assertFalse(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true) + assertEquals(1, gateway.callCount("chat.send")) + assertEquals( + "authoritative rewind", + controller.messages.value + .first() + .content + .single() + .text, + ) + } + + @Test + fun transientBranchListFailureRetriesReconciliationAndDeliversQueuedInput() = + runTest { + val gateway = ScriptedGateway(json) + val listCalls = AtomicInteger() + val firstListFailure = CompletableDeferred() + gateway.respond("sessions.rewind") { throw GatewayRequestOutcomeUnknown("response lost") } + gateway.respond("chat.history") { + if (gateway.callCount("chat.history") == 1) throw IllegalStateException("history temporarily unavailable") + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "authoritative rewind", 2, entryId = "leaf-rewound")), + ) + } + gateway.respond("sessions.branches.list") { + if (listCalls.incrementAndGet() == 1) { + firstListFailure.complete(Unit) + throw IllegalStateException("branches temporarily unavailable") + } + """{"branches":[{"leafEntryId":"leaf-rewound","headline":"Rewound","messageCount":1,"active":true}]}""" + } + gateway.respondChatSend("started") + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.handleGatewayEvent("health", null) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.healthOk.first { it } } + } + + assertNull(controller.rewindSessionAtEntryResult("main", "entry-a")) + assertTrue(controller.sendMessageAwaitAcceptance("queued after rewind", "off", emptyList())) + firstListFailure.await() + assertEquals(0, gateway.callCount("chat.send")) + + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (gateway.callCount("chat.send") == 0) kotlinx.coroutines.delay(10) + } + } + + assertTrue(listCalls.get() >= 2) + assertFalse(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true) + } + + @Test + fun expiredMutationLeaseReconcilesBeforeStartingTheNextAction() = + runTest { + assertNotNull(outbox.beginSessionMutation("gateway-a", ChatOutboxScope("main", "main"), nowMs = 1)) + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.rewind", """{"editorText":"recovered"}""") + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "current", 1, entryId = "leaf-current")), + ), + ) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-current","headline":"Current","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + controller.awaitOutboxRestore() + + val result = controller.rewindSessionAtEntryResult("main", "entry-a") + + assertEquals("recovered", result?.editorText) + assertTrue(gateway.callCount("sessions.branches.list") >= 2) + assertFalse(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true) + } + + @Test + fun rewindCanFinalizeAnEmptyTranscriptRoot() = + runTest { + val branchScope = ChatOutboxScope("main", "main") + val initial = requireNotNull(outbox.branchState("gateway-a", branchScope)) + assertTrue(outbox.updateLastActiveLeafEntryId("gateway-a", branchScope, "leaf-old", initial.epoch, initial.revision)) + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.rewind", """{"editorText":null}""") + gateway.respondWith("chat.history", historyResponse(sessionId = "session-main", messages = emptyList())) + gateway.respondWith("sessions.branches.list", """{"branches":[]}""") + val controller = controller(gateway) + controller.awaitOutboxRestore() + + assertNotNull(controller.rewindSessionAtEntryResult("main", "leaf-old")) + + val finalized = outbox.branchState("gateway-a", branchScope) + assertEquals(1, finalized?.epoch) + assertNull(finalized?.lastActiveLeafEntryId) + assertFalse(finalized?.needsReconciliation == true) + } + + @Test + fun cancellingRewindDoesNotStrandTheDurableMutationLease() = + runTest { + val gateway = ScriptedGateway(json) + val rewindStarted = CompletableDeferred() + gateway.respond("sessions.rewind") { + rewindStarted.complete(Unit) + CompletableDeferred().await() + "{}" + } + val controller = controller(gateway) + controller.awaitOutboxRestore() + val rewind = async { controller.rewindSessionAtEntryResult("main", "entry-a") } + rewindStarted.await() + + rewind.cancel() + rewind.join() + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation != true) { + kotlinx.coroutines.delay(10) + } + } + } + + val state = outbox.branchState("gateway-a", ChatOutboxScope("main", "main")) + assertTrue(state?.needsReconciliation == true) + assertNull(state?.switchPendingSinceMs) + } + + @Test + fun rewindInvalidatesAHistoryResponseStartedBeforeTheMutation() = + runTest { + val gateway = ScriptedGateway(json) + val historyCalls = AtomicInteger() + val oldHistoryStarted = CompletableDeferred() + val releaseOldHistory = CompletableDeferred() + gateway.respond("chat.history") { + if (historyCalls.incrementAndGet() == 1) { + oldHistoryStarted.complete(Unit) + releaseOldHistory.await() + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "stale", 1, entryId = "leaf-stale")), + ) + } else { + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "rewound", 2, entryId = "leaf-new")), + ) + } + } + gateway.respondWith("sessions.rewind", """{"editorText":"rewound draft"}""") + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-new","headline":"Rewound","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.load("main") + oldHistoryStarted.await() + + val rewind = controller.rewindSessionAtEntryResult("main", "entry-a") + assertNotNull(rewind) + releaseOldHistory.complete(Unit) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + controller.messages.first { messages -> + messages + .singleOrNull() + ?.content + ?.singleOrNull() + ?.text == "rewound" + } + } + } + + assertEquals( + "rewound", + controller.messages.value + .single() + .content + .single() + .text, + ) + } + + @Test + fun branchRefreshPreservesTheLastGoodListOnFailure() = + runTest { + val gateway = ScriptedGateway(json) + var fail = false + gateway.respond("sessions.branches.list") { + if (fail) throw IllegalStateException("offline") + """{"branches":[ + {"leafEntryId":"leaf-a","headline":"Current","messageCount":2,"active":true}, + {"leafEntryId":"leaf-b","headline":"Earlier","messageCount":1,"active":false} + ]}""" + } + val controller = controller(gateway) + runCurrent() + controller.awaitOutboxRestore() + + assertTrue(controller.refreshSessionBranches()) + val cached = controller.sessionBranches.value + fail = true + assertFalse(controller.refreshSessionBranches()) + assertEquals(cached, controller.sessionBranches.value) + } + + @Test + fun bootstrapReconcilesTheCapturedBranchStateBeforeAdvancingTheTip() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "hello", 1, entryId = "leaf-live")), + ), + ) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-live","headline":"Current","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + controller.awaitOutboxRestore() + + controller.load("main") + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.sessionBranches.first { it.isNotEmpty() } } + } + + val state = outbox.branchState("gateway-a", ChatOutboxScope("main", "main")) + assertEquals("leaf-live", state?.lastActiveLeafEntryId) + assertFalse(state?.needsReconciliation == true) + } + + @Test + fun staleBranchSwitchCompletionCannotOverrideNewerNavigation() = + runTest { + val gateway = ScriptedGateway(json) + val switchStarted = CompletableDeferred() + val releaseSwitch = CompletableDeferred() + gateway.respond("sessions.branches.switch") { + switchStarted.complete(Unit) + releaseSwitch.await() + "{}" + } + gateway.respondWith("chat.history", historyResponse("other", emptyList())) + gateway.respondWith("sessions.branches.list", """{"branches":[]}""") + val controller = controller(gateway) + runCurrent() + controller.awaitOutboxRestore() + + val switching = async { controller.switchSessionBranch("main", "leaf-b") } + switchStarted.await() + controller.switchSession("agent:main:other") + releaseSwitch.complete(Unit) + + assertFalse(switching.await()) + assertEquals("agent:main:other", controller.sessionKey.value) + assertFalse(controller.sessionBranchSwitching.value) + } + + @Test + fun secondBranchSwitchDoesNotInvalidateTheActiveSwitch() = + runTest { + val gateway = ScriptedGateway(json) + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + gateway.respond("sessions.branches.switch") { + firstStarted.complete(Unit) + releaseFirst.await() + "{}" + } + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "branch", 1, entryId = "leaf-b")), + ), + ) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-b","headline":"Selected","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + controller.awaitOutboxRestore() + + val first = async { controller.switchSessionBranch("main", "leaf-b") } + firstStarted.await() + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"main","agentId":"main"}""", + ) + assertFalse(controller.switchSessionBranch("main", "leaf-c")) + releaseFirst.complete(Unit) + + assertTrue(first.await()) + assertFalse(controller.sessionBranchSwitching.value) + assertEquals(1, gateway.callCount("sessions.branches.switch")) + } + + @Test + fun localBranchEventAfterConfirmationDoesNotInvalidateTheActionRefresh() = + runTest { + val gateway = ScriptedGateway(json) + val historyStarted = CompletableDeferred() + val releaseHistory = CompletableDeferred() + gateway.respondWith("sessions.branches.switch", "{}") + gateway.respond("chat.history") { + historyStarted.complete(Unit) + releaseHistory.await() + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "selected", 1, entryId = "leaf-b")), + ) + } + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-b","headline":"Selected","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + controller.awaitOutboxRestore() + + val switching = async { controller.switchSessionBranch("main", "leaf-b") } + historyStarted.await() + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"main","agentId":"main"}""", + ) + releaseHistory.complete(Unit) + + assertTrue(switching.await()) + assertFalse(controller.sessionBranchSwitching.value) + } + + @Test + fun matchingSecondClientBranchMutationDuringOurLeaseReconcilesToItsWinningLeaf() = + runTest { + val gateway = ScriptedGateway(json) + val localHistoryStarted = CompletableDeferred() + val releaseLocalHistory = CompletableDeferred() + var historyRequests = 0 + var branchRequests = 0 + gateway.respondWith("sessions.branches.switch", "{}") + gateway.respond("chat.history") { + when (++historyRequests) { + 1 -> { + localHistoryStarted.complete(Unit) + releaseLocalHistory.await() + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "local", 1, entryId = "leaf-local")), + ) + } + else -> + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "winner", 2, entryId = "leaf-winner")), + ) + } + } + gateway.respond("sessions.branches.list") { + val leaf = if (++branchRequests == 1) "leaf-local" else "leaf-winner" + """{"branches":[{"leafEntryId":"$leaf","headline":"Current","messageCount":1,"active":true}]}""" + } + val controller = controller(gateway) + controller.awaitOutboxRestore() + + val switching = async { controller.switchSessionBranch("main", "leaf-local") } + localHistoryStarted.await() + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"main","agentId":"main"}""", + ) + releaseLocalHistory.complete(Unit) + + assertTrue(switching.await()) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.lastActiveLeafEntryId != "leaf-winner") { + kotlinx.coroutines.delay(10) + } + } + } + assertFalse(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true) + assertTrue(gateway.callCount("chat.history") >= 2) + } + + @Test + fun remoteBranchEventIsNotDiscardedWhileForkUsesAnEntryGate() = + runTest { + val gateway = ScriptedGateway(json) + val forkStarted = CompletableDeferred() + val releaseFork = CompletableDeferred() + gateway.respond("sessions.fork") { + forkStarted.complete(Unit) + releaseFork.await() + """{"sessionKey":"agent:main:forked"}""" + } + val controller = controller(gateway) + controller.awaitOutboxRestore() + val fork = async { controller.forkSessionAtEntry("main", "entry-a") } + forkStarted.await() + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"main","agentId":"main"}""", + ) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation != true) { + kotlinx.coroutines.delay(10) + } + } + } + releaseFork.complete(Unit) + + assertNotNull(fork.await()) + assertTrue(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true) + } + + @Test + fun failedEventHistoryRefreshStillSchedulesReconciliationAndDelivery() = + runTest { + val branchScope = ChatOutboxScope("main", "main") + val initial = requireNotNull(outbox.branchState("gateway-a", branchScope)) + assertTrue(outbox.updateLastActiveLeafEntryId("gateway-a", branchScope, "leaf-current", initial.epoch, initial.revision)) + val gateway = ScriptedGateway(json) + var historyRequests = 0 + val branchesEntered = CompletableDeferred() + val releaseBranches = CompletableDeferred() + gateway.respond("chat.history") { + if (++historyRequests == 1) throw IllegalStateException("history temporarily unavailable") + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "current", 1, entryId = "leaf-current")), + ) + } + gateway.respond("sessions.branches.list") { + branchesEntered.complete(Unit) + releaseBranches.await() + """{"branches":[{"leafEntryId":"leaf-current","headline":"Current","messageCount":1,"active":true}]}""" + } + gateway.respondChatSend("started") + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.handleGatewayEvent("health", null) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.healthOk.first { it } } + } + enqueue("deliver after recovery") + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"main","agentId":"main"}""", + ) + branchesEntered.await() + releaseBranches.complete(Unit) + + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (gateway.callCount("chat.send") == 0) { + kotlinx.coroutines.delay(10) + } + } + } + assertFalse(outbox.branchState("gateway-a", branchScope)?.needsReconciliation == true) + } + + @Test + fun backgroundMutationRefreshesTheSessionDrawerBeforeBranchHandlingReturns() = + runTest { + val gateway = ScriptedGateway(json) + val changed = AtomicBoolean(false) + gateway.respond("sessions.list") { + val label = if (changed.get()) "After rewind" else "Before rewind" + """{"sessions":[{"key":"agent:main:background","label":"$label"}]}""" + } + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.refreshSessions() + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + controller.sessions.first { sessions -> sessions.singleOrNull()?.label == "Before rewind" } + } + } + + changed.set(true) + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"rewind","sessionKey":"agent:main:background","agentId":"main"}""", + ) + + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + controller.sessions.first { sessions -> sessions.singleOrNull()?.label == "After rewind" } + } + } + } + + @Test + fun remoteBackgroundBranchChangeDemotesThatSessionsDurableScope() = + runTest { + val backgroundKey = "agent:main:background" + val backgroundScope = ChatOutboxScope(backgroundKey, "main") + val initial = requireNotNull(outbox.branchState("gateway-a", backgroundScope)) + assertTrue(outbox.updateLastActiveLeafEntryId("gateway-a", backgroundScope, "leaf-old", initial.epoch, initial.revision)) + outbox.enqueue( + gatewayId = "gateway-a", + sessionKey = backgroundKey, + text = "background message", + thinkingLevel = "off", + nowMs = System.currentTimeMillis(), + ownerAgentId = "main", + ) + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "background", + messages = listOf(ReplayHistoryMessage("user", "old", 1, entryId = "leaf-old")), + ), + ) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-old","headline":"Old","messageCount":1,"active":true}]}""", + ) + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.load(backgroundKey) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.sessionBranches.first { it.isNotEmpty() } } + } + controller.switchSession("main") + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "background", + messages = listOf(ReplayHistoryMessage("user", "new", 2, entryId = "leaf-new")), + ), + ) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[ + {"leafEntryId":"leaf-old","headline":"Old","messageCount":1,"active":false}, + {"leafEntryId":"leaf-new","headline":"New","messageCount":1,"active":true} + ]}""", + ) + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"$backgroundKey","agentId":"main"}""", + ) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (outbox.load("gateway-a").single().status != ChatOutboxStatus.Failed) { + kotlinx.coroutines.delay(10) + } + } + } + + assertEquals(ChatOutboxStatus.Failed, outbox.load("gateway-a").single().status) + } + + @Test + fun directSendQueuesWithoutDispatchWhileRemoteBranchReconciliationIsPending() = + runTest { + val gateway = ScriptedGateway(json) + val remoteChange = AtomicBoolean(false) + val releaseBranches = CompletableDeferred() + gateway.respond("chat.history") { + historyResponse( + sessionId = "main", + messages = + listOf( + ReplayHistoryMessage( + "user", + if (remoteChange.get()) "new" else "old", + 1, + entryId = if (remoteChange.get()) "leaf-new" else "leaf-old", + ), + ), + ) + } + gateway.respond("sessions.branches.list") { + if (remoteChange.get()) releaseBranches.await() + val leaf = if (remoteChange.get()) "leaf-new" else "leaf-old" + """{"branches":[{"leafEntryId":"$leaf","headline":"Current","messageCount":1,"active":true}]}""" + } + gateway.respondChatSend("started") + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.load("main") + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.sessionBranches.first { it.isNotEmpty() } } + } + controller.handleGatewayEvent("health", null) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.healthOk.first { it } } + } + + remoteChange.set(true) + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"branch-switch","sessionKey":"main","agentId":"main"}""", + ) + assertTrue(controller.sendMessageAwaitAcceptance("queued during reconcile", "off", emptyList())) + + assertEquals(0, gateway.callCount("chat.send")) + releaseBranches.complete(Unit) + } + + @Test + fun reconcileOwnerDrainsRequestsQueuedDuringAnActivePass() = + runTest { + val branchScope = ChatOutboxScope("main", "main") + val initial = requireNotNull(outbox.branchState("gateway-a", branchScope)) + assertTrue(outbox.updateLastActiveLeafEntryId("gateway-a", branchScope, "leaf-current", initial.epoch, initial.revision)) + enqueue("first queued") + assertTrue(outbox.demoteSessionMutationToReconciliation("gateway-a", branchScope, lease = null)) + + val gateway = ScriptedGateway(json) + val branchesEntered = CompletableDeferred() + val releaseBranches = CompletableDeferred() + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "main", + messages = listOf(ReplayHistoryMessage("user", "current", 1, entryId = "leaf-current")), + ), + ) + gateway.respond("sessions.branches.list") { + branchesEntered.complete(Unit) + releaseBranches.await() + """{"branches":[{"leafEntryId":"leaf-current","headline":"Current","messageCount":1,"active":true}]}""" + } + gateway.respondChatSend("started") + gateway.respond("chat.history") { + val idempotencyKey = gateway.lastRunId?.let { "$it:user" } + historyResponse( + sessionId = "main", + messages = + listOf( + ReplayHistoryMessage( + "user", + "current", + 1, + idempotencyKey = idempotencyKey, + entryId = "leaf-current", + ), + ), + ) + } + val controller = controller(gateway) + controller.awaitOutboxRestore() + controller.handleGatewayEvent("health", null) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { controller.healthOk.first { it } } + } + branchesEntered.await() + + assertTrue(controller.sendMessageAwaitAcceptance("second queued", "off", emptyList())) + releaseBranches.complete(Unit) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (outbox.branchState("gateway-a", branchScope)?.needsReconciliation != false) { + kotlinx.coroutines.delay(10) + } + } + } + + assertEquals(2, outbox.load("gateway-a").size) + assertTrue(outbox.load("gateway-a").none { it.status == ChatOutboxStatus.Failed }) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt new file mode 100644 index 0000000..7067964 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt @@ -0,0 +1,928 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerCommandControlsTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parseChatCommandsKeepsTextAliasesAndArgumentFlag() { + val commands = + parseChatCommands( + json, + """ + { + "commands": [ + { + "name": "new", + "description": "Start a fresh chat", + "category": "session", + "textAliases": ["/new", "/reset"], + "acceptsArgs": false + }, + { + "name": "/model", + "description": "Switch models", + "category": "options", + "textAliases": ["model", "/model"], + "acceptsArgs": true + } + ] + } + """.trimIndent(), + ) + + assertEquals(2, commands.size) + assertEquals("new", commands[0].name) + assertEquals(listOf("/new", "/reset"), commands[0].textAliases) + assertEquals(false, commands[0].acceptsArgs) + assertEquals("model", commands[1].name) + assertEquals(listOf("/model"), commands[1].textAliases) + assertEquals(true, commands[1].acceptsArgs) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun healthEventRefreshesCommandsAfterReconnect() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "chat.metadata" -> + """ + { + "commands": [ + { + "name": "model", + "description": "Switch models", + "textAliases": ["/model"], + "acceptsArgs": true + } + ] + } + """.trimIndent() + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals( + listOf("/model"), + controller.commands.value + .single() + .textAliases, + ) + + controller.onDisconnected("gateway closed") + assertEquals(emptyList(), controller.commands.value) + + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals( + listOf("/model"), + controller.commands.value + .single() + .textAliases, + ) + assertEquals(2, requests.count { it.first == "chat.metadata" }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun commandListScopesToActiveAgentAndRefreshesAfterAgentSwitch() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "chat.metadata" -> + if (paramsJson.orEmpty().contains("\"agentId\":\"ops\"")) { + """ + { + "commands": [ + { + "name": "ops", + "description": "Ops command", + "textAliases": ["/ops"], + "acceptsArgs": false + } + ] + } + """.trimIndent() + } else { + """ + { + "commands": [ + { + "name": "main", + "description": "Main command", + "textAliases": ["/main"], + "acceptsArgs": false + } + ] + } + """.trimIndent() + } + "chat.history" -> """{"sessionId":"loaded-session","messages":[]}""" + "health" -> "{}" + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals( + listOf("/main"), + controller.commands.value + .single() + .textAliases, + ) + + controller.switchSession("agent:ops:dashboard:parent") + advanceUntilIdle() + assertEquals( + listOf("/ops"), + controller.commands.value + .single() + .textAliases, + ) + + val commandRequests = requests.filter { it.first == "chat.metadata" } + assertTrue(commandRequests.any { it.second.orEmpty().contains("\"agentId\":\"main\"") }) + assertTrue(commandRequests.any { it.second.orEmpty().contains("\"agentId\":\"ops\"") }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun delayedCommandListFromPreviousGatewayCannotReplaceCurrentCommands() = + runTest { + var cacheScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val gatewayAResponse = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> error("gateway-bound request expected") }, + requestGatewayForGateway = { gatewayId, method, _ -> + require(method == "chat.metadata") + if (gatewayId == "gateway-a") { + gatewayAResponse.await() + } else { + commandResponse("gateway-b") + } + }, + cacheScope = { cacheScope }, + ) + + controller.refreshCommands() + runCurrent() + cacheScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.refreshCommands() + runCurrent() + assertEquals( + "gateway-b", + controller.commands.value + .single() + .name, + ) + + gatewayAResponse.complete(commandResponse("gateway-a")) + advanceUntilIdle() + + assertEquals( + "gateway-b", + controller.commands.value + .single() + .name, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatCreatesWriteScopedSessionAndReloadsHistory() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + + assertTrue(controller.startNewChatAwait()) + + val create = requests.first { it.first == "sessions.create" } + assertTrue(create.second.orEmpty().contains("\"agentId\":\"main\"")) + assertTrue(create.second.orEmpty().contains("\"parentSessionKey\":\"main\"")) + assertTrue(create.second.orEmpty().contains("\"emitCommandHooks\":true")) + assertTrue(create.second.orEmpty().contains("\"succeedsParent\":false")) + assertTrue(create.second.orEmpty().contains("\"label\":\"New chat\"")) + assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value) + assertEquals("fresh-session", controller.sessionId.value) + assertTrue(requests.any { it.first == "chat.history" }) + assertTrue(requests.any { it.first == "sessions.list" }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatRetriesWithoutParentLifecycleAgainstOlderGateway() = + runTest { + val requests = mutableListOf>() + var createCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> { + createCalls += 1 + if (createCalls == 1) { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = + "invalid sessions.create params: at root: unexpected property 'succeedsParent'", + ), + ) + } + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + } + "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + + assertTrue(controller.startNewChatAwait()) + + val creates = requests.filter { it.first == "sessions.create" } + assertEquals(2, creates.size) + assertTrue(creates[0].second.orEmpty().contains("\"succeedsParent\":false")) + assertEquals(false, creates[1].second.orEmpty().contains("\"succeedsParent\"")) + assertEquals(false, creates[1].second.orEmpty().contains("\"parentSessionKey\"")) + assertEquals(false, creates[1].second.orEmpty().contains("\"emitCommandHooks\"")) + assertTrue(creates[1].second.orEmpty().contains("\"agentId\":\"main\"")) + assertTrue(creates[1].second.orEmpty().contains("\"label\":\"New chat\"")) + assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatInWorktreeIncludesWorktreeFlag() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:worktree"}""" + "chat.history" -> """{"sessionId":"worktree-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + + assertTrue(controller.startNewChatAwait(worktree = true)) + + val create = requests.first { it.first == "sessions.create" } + assertTrue(create.second.orEmpty().contains("\"worktree\":true")) + } + + @Test + fun sessionMutationsSendGatewayContractsAndRefresh() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.list" -> """{"sessions":[]}""" + "sessions.delete" -> """{"deleted":true}""" + else -> "{}" + } + }, + ) + + controller.patchSession( + key = "main", + ownerAgentId = "owner-a", + clearLabel = true, + clearCategory = true, + pinned = true, + archived = false, + unread = true, + ) + controller.deleteSession("main", ownerAgentId = "main") + + val patch = requests.first { it.first == "sessions.patch" }.second.orEmpty() + assertTrue(patch.contains("\"key\":\"main\"")) + assertTrue(patch.contains("\"agentId\":\"owner-a\"")) + assertTrue(patch.contains("\"label\":null")) + assertTrue(patch.contains("\"category\":null")) + assertTrue(patch.contains("\"pinned\":true")) + assertTrue(patch.contains("\"archived\":false")) + assertTrue(patch.contains("\"unread\":true")) + + val delete = requests.first { it.first == "sessions.delete" }.second.orEmpty() + assertTrue(delete.contains("\"key\":\"main\"")) + assertTrue(delete.contains("\"deleteTranscript\":true")) + assertEquals(2, requests.count { it.first == "sessions.list" }) + } + + @Test + fun renameSessionGroupPatchesEveryMemberIncludingArchivedOnlyOnes() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.list" -> + if (paramsJson.orEmpty().contains("\"archived\":true")) { + """{"sessions":[{"key":"agent:main:active","category":"Work"},{"key":"agent:main:archived","category":" Work "}]}""" + } else { + """{"sessions":[{"key":"agent:main:active","category":"Work"},{"key":"agent:main:other","category":"Play"}]}""" + } + else -> "{}" + } + }, + ) + + controller.renameSessionGroup(from = "Work", to = "Focus") + + // Membership enumeration sends the explicit high bound (absent limit is + // capped at 100 rows server-side) across active + archived rows. + val lists = requests.filter { it.first == "sessions.list" }.map { it.second.orEmpty() } + assertEquals(2, lists.count { it.contains("\"limit\":10000") }) + assertEquals(1, lists.count { it.contains("\"archived\":true") }) + + val patches = requests.filter { it.first == "sessions.patch" }.map { it.second.orEmpty() } + assertEquals(2, patches.size) + assertTrue(patches.any { it.contains("\"key\":\"agent:main:active\"") && it.contains("\"category\":\"Focus\"") }) + assertTrue(patches.any { it.contains("\"key\":\"agent:main:archived\"") && it.contains("\"category\":\"Focus\"") }) + // The session list refreshes (windowed) after the fan-out. + assertTrue(lists.last().contains("\"limit\"")) + } + + @Test + fun dissolveSessionGroupClearsCategoriesBestEffort() = + runTest { + val requests = mutableListOf>() + var patchCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.list" -> + if (paramsJson.orEmpty().contains("\"archived\":true")) { + """{"sessions":[{"key":"agent:main:archived","category":"Work"}]}""" + } else { + """{"sessions":[{"key":"agent:main:a","category":"Work"},{"key":"agent:main:b","category":"Work"}]}""" + } + "sessions.patch" -> { + patchCount += 1 + if (patchCount == 1) throw RuntimeException("offline") else "{}" + } + else -> "{}" + } + }, + ) + + controller.dissolveSessionGroup("Work") + + // One failed member patch must not abandon the remaining members. + val patches = requests.filter { it.first == "sessions.patch" }.map { it.second.orEmpty() } + assertEquals(3, patches.size) + assertTrue(patches.all { it.contains("\"category\":null") }) + assertEquals("offline", controller.errorText.value) + } + + @Test + fun forkSessionReturnsCreatedKeyAndRefreshesActiveSessions() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> """{"session":{"key":"agent:main:forked"}}""" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + + val key = controller.forkSession("main") + + assertEquals("agent:main:forked", key) + val create = requests.first { it.first == "sessions.create" }.second.orEmpty() + assertTrue(create.contains("\"parentSessionKey\":\"main\"")) + assertTrue(create.contains("\"fork\":true")) + // The active unqualified parent keeps the captured default-agent owner. + assertTrue(create.contains("\"agentId\":\"main\"")) + + // Agent-qualified parents keep the fork under the parent's agent. + controller.forkSession("agent:ops:dashboard:abc") + val scopedCreate = requests.last { it.first == "sessions.create" }.second.orEmpty() + assertTrue(scopedCreate.contains("\"parentSessionKey\":\"agent:ops:dashboard:abc\"")) + assertTrue(scopedCreate.contains("\"agentId\":\"ops\"")) + + // Unqualified list rows carry their captured owner through a later default-agent change. + controller.forkSession("custom", ownerAgentId = "owner-a") + val capturedOwnerCreate = requests.last { it.first == "sessions.create" }.second.orEmpty() + assertTrue(capturedOwnerCreate.contains("\"parentSessionKey\":\"custom\"")) + assertTrue(capturedOwnerCreate.contains("\"agentId\":\"owner-a\"")) + assertTrue(requests.any { it.first == "sessions.list" }) + assertEquals( + false, + requests + .last { it.first == "sessions.list" } + .second + .orEmpty() + .contains("\"archived\""), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun archivedSessionListAndOpenUnreadSessionUsePatchContracts() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","unread":true}]}""" + else -> "{}" + } + }, + ) + + controller.refreshSessions(archived = true) + advanceUntilIdle() + assertTrue( + requests + .first { it.first == "sessions.list" } + .second + .orEmpty() + .contains("\"archived\":true"), + ) + + controller.switchSession("main") + advanceUntilIdle() + controller.switchSession("main") + advanceUntilIdle() + + val patch = requests.single { it.first == "sessions.patch" }.second.orEmpty() + assertTrue(patch.contains("\"key\":\"main\"")) + assertTrue(patch.contains("\"unread\":false")) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun sessionEventsApplyExplicitLabelAndCategoryClears() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","label":"Named","category":"Work"}]}""" + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + assertEquals( + "Work", + controller.sessions.value + .single() + .category, + ) + + // Another client cleared the group and name; the gateway sends explicit nulls. + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"main","session":{"key":"main","agentId":"main","label":null,"category":null}}""", + ) + advanceUntilIdle() + val merged = controller.sessions.value.single() + assertEquals(null, merged.label) + assertEquals(null, merged.category) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun failedReadAcknowledgementUnlatchesForRetry() = + runTest { + val requests = mutableListOf>() + var failPatches = true + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.patch" -> if (failPatches) throw RuntimeException("offline") else "{}" + "sessions.list" -> """{"sessions":[{"key":"main","unread":true}]}""" + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + controller.switchSession("main") + advanceUntilIdle() + assertEquals(1, requests.count { it.first == "sessions.patch" }) + + // The failed acknowledgement unlatched; the next unread snapshot retries. + failPatches = false + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true}}""", + ) + advanceUntilIdle() + assertEquals(2, requests.count { it.first == "sessions.patch" }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun archivingOrDeletingTheOpenSessionFallsBackToMain() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.list" -> """{"sessions":[{"key":"agent:main:side"}]}""" + "sessions.delete" -> """{"deleted":true}""" + else -> "{}" + } + }, + ) + + controller.switchSession("agent:main:side") + advanceUntilIdle() + assertEquals("agent:main:side", controller.sessionKey.value) + + controller.patchSession(key = "agent:main:side", archived = true) + advanceUntilIdle() + assertEquals("main", controller.sessionKey.value) + + controller.switchSession("agent:main:side") + advanceUntilIdle() + controller.deleteSession("agent:main:side") + advanceUntilIdle() + assertEquals("main", controller.sessionKey.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun openSessionReacknowledgesUnreadOncePerEpisode() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","unread":false}]}""" + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + controller.switchSession("main") + advanceUntilIdle() + assertEquals(0, requests.count { it.first == "sessions.patch" }) + + // A run completes while the session stays open: the gateway flags it unread again. + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true}}""", + ) + advanceUntilIdle() + assertEquals(1, requests.count { it.first == "sessions.patch" }) + + // Server-confirmed read resets the episode; a stale duplicate must not re-patch. + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":false}}""", + ) + advanceUntilIdle() + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"main","session":{"key":"main","agentId":"main","unread":true}}""", + ) + advanceUntilIdle() + assertEquals(2, requests.count { it.first == "sessions.patch" }) + } + + @Test + fun startNewChatWithoutLoadedParentCreatesFirstSession() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:first"}""" + "chat.history" -> """{"sessionId":"first-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.startNewChatAwait()) + + val create = requests.first { it.first == "sessions.create" } + assertTrue(create.second.orEmpty().contains("\"agentId\":\"main\"")) + assertEquals(false, create.second.orEmpty().contains("\"parentSessionKey\"")) + assertEquals(false, create.second.orEmpty().contains("\"emitCommandHooks\"")) + assertEquals("agent:main:dashboard:first", controller.sessionKey.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatUsesNextAvailableNewChatLabel() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:fresh-3"}""" + "chat.history" -> """{"sessionId":"fresh-session-3","messages":[]}""" + "health" -> "{}" + "sessions.list" -> + """ + { + "sessions": [ + {"key":"agent:main:dashboard:fresh","displayName":"New chat"}, + {"key":"agent:main:dashboard:fresh-2","displayName":"New chat 2"} + ] + } + """.trimIndent() + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.refreshSessions() + advanceUntilIdle() + + assertTrue(controller.startNewChatAwait()) + + val create = requests.first { it.first == "sessions.create" } + assertTrue(create.second.orEmpty().contains("\"label\":\"New chat 3\"")) + assertEquals("agent:main:dashboard:fresh-3", controller.sessionKey.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatScopesCreateToActiveAgentSession() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> """{"ok":true,"key":"agent:ops:dashboard:fresh"}""" + "chat.history" -> """{"sessionId":"ops-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + + controller.switchSession("agent:ops:dashboard:parent") + advanceUntilIdle() + + assertTrue(controller.startNewChatAwait()) + + val create = requests.first { it.first == "sessions.create" } + assertTrue(create.second.orEmpty().contains("\"agentId\":\"ops\"")) + assertTrue(create.second.orEmpty().contains("\"parentSessionKey\":\"agent:ops:dashboard:parent\"")) + assertEquals("agent:ops:dashboard:fresh", controller.sessionKey.value) + } + + @Test + fun bareNewSlashCommandUsesGatewayChatCommandPath() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "chat.send" -> """{"runId":"run-new"}""" + "health" -> "{}" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("/new", "off", emptyList())) + + val send = requests.single { it.first == "chat.send" } + assertTrue(send.second.orEmpty().contains("\"message\":\"/new\"")) + assertTrue(requests.none { it.first == "sessions.create" }) + } + + @Test + fun startNewChatRejectsWhileRunPending() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "chat.send" -> """{"runId":"run-1"}""" + "health" -> "{}" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("hello", "off", emptyList())) + assertEquals(1, controller.pendingRunCount.value) + assertEquals(false, controller.startNewChatAwait()) + assertTrue(requests.none { it.first == "sessions.create" }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatRejectsDuplicateCreateWhileFirstRequestIsPending() = + runTest { + val requests = mutableListOf>() + val createEntered = CompletableDeferred() + val releaseCreate = CompletableDeferred() + var createCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> { + createCount += 1 + createEntered.complete(Unit) + releaseCreate.await() + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + } + "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + val first = async { controller.startNewChatAwait() } + createEntered.await() + + val second = async { controller.startNewChatAwait() } + advanceUntilIdle() + releaseCreate.complete(Unit) + + assertTrue(first.await()) + assertEquals(false, second.await()) + assertEquals(1, createCount) + assertEquals(1, requests.count { it.first == "sessions.create" }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatIgnoresStaleCreateResponseAfterSessionSwitch() = + runTest { + val requests = mutableListOf>() + lateinit var controller: ChatController + controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> { + controller.switchSession("agent:main:dashboard:other") + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + } + "chat.history" -> """{"sessionId":"other-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + assertEquals(false, controller.startNewChatAwait()) + advanceUntilIdle() + assertEquals("agent:main:dashboard:other", controller.sessionKey.value) + assertEquals("other-session", controller.sessionId.value) + assertTrue(requests.any { it.first == "sessions.create" }) + } + + private fun commandResponse(name: String): String = """{"commands":[{"name":"$name","textAliases":["/$name"],"acceptsArgs":false}]}""" +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt new file mode 100644 index 0000000..e46fd69 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt @@ -0,0 +1,397 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ChatControllerMessageIdentityTest { + @Test + fun reconcileMessageIdsKeepsCanonicalEntryIdentityFromReload() { + val previous = + ChatMessage( + id = "stable-compose-id", + role = "user", + content = listOf(ChatMessageContent(text = "hello")), + timestampMs = 10, + entryId = "old-entry", + ) + val incoming = previous.copy(id = "temporary-id", entryId = "canonical-entry") + + val reconciled = reconcileMessageIds(listOf(previous), listOf(incoming)).single() + + assertEquals("stable-compose-id", reconciled.id) + assertEquals("canonical-entry", reconciled.entryId) + } + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parseChatMessageContentsReadsGatewayStringContent() { + val obj = + json + .parseToJsonElement( + """ + {"role":"user","content":"Hello","idempotencyKey":"run-1:user"} + """.trimIndent(), + ).jsonObject + + val content = parseChatMessageContents(obj) + + assertEquals(listOf(ChatMessageContent(type = "text", text = "Hello")), content) + } + + @Test + fun parseChatMessageContentsFallsBackToTopLevelText() { + val obj = + json + .parseToJsonElement( + """ + {"role":"assistant","text":"Hi there"} + """.trimIndent(), + ).jsonObject + + val content = parseChatMessageContents(obj) + + assertEquals(listOf(ChatMessageContent(type = "text", text = "Hi there")), content) + } + + @Test + fun managedImagesParticipateInMessageIdentity() { + fun message(artifactId: String) = + ChatMessage( + id = artifactId, + role = "assistant", + content = + listOf( + ChatMessageContent( + type = "image", + artifactId = artifactId, + url = "/api/chat/media/outgoing/main/$artifactId/full", + mimeType = "image/png", + ), + ), + timestampMs = 1, + ) + + assertNotEquals( + messageIdentityKey(message("artifact_managed_image_11111111-1111-4111-8111-111111111111")), + messageIdentityKey(message("artifact_managed_image_22222222-2222-4222-8222-222222222222")), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun liveHistoryDropsInternalRoleRows() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "chat.history") { + """ + { + "messages": [ + { "role": "user", "content": "hello" }, + { "role": "toolResult", "content": "private tool output" }, + { "role": "internal", "text": "private reasoning" }, + { "role": "custom", "content": "visible plugin notice" }, + { "role": "Assistant", "content": "reply" } + ] + } + """.trimIndent() + } else { + "{}" + } + }, + ) + + controller.load("main") + advanceUntilIdle() + + assertEquals(listOf("user", "custom", "assistant"), controller.messages.value.map { it.role }) + assertEquals( + listOf("hello", "visible plugin notice", "reply"), + controller.messages.value.map { it.content.single().text }, + ) + } + + @Test + fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "msg-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id }) + } + + @Test + fun reconcileMessageIdsLeavesNewMessagesUntouched() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "new reply")), + timestampMs = 3000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals("msg-1", reconciled[0].id) + assertEquals("new-2", reconciled[1].id) + assertNotEquals(reconciled[0].id, reconciled[1].id) + } + + @Test + fun reconcileMessageIdsPreservesOptimisticVoiceNoteDuration() { + val previous = + ChatMessage( + id = "local-user", + role = "user", + content = + listOf( + ChatMessageContent(type = "text", text = "See attached."), + ChatMessageContent(type = "audio", mimeType = "audio/mp4", fileName = "voice-note.m4a", durationMs = 4_321L), + ), + timestampMs = 1_000L, + idempotencyKey = "run:user", + ) + val incoming = + previous.copy( + id = "gateway-user", + content = previous.content.map { it.copy(durationMs = null) }, + ) + + val reconciled = reconcileMessageIds(previous = listOf(previous), incoming = listOf(incoming)).single() + + assertEquals("local-user", reconciled.id) + assertEquals(4_321L, reconciled.content[1].durationMs) + } + + @Test + fun reconcileMessageIdsPreservesMultipleVoiceNoteDurationsInOrder() { + val previous = + ChatMessage( + id = "local-user", + role = "user", + content = + listOf( + ChatMessageContent(type = "text", text = "See attached."), + ChatMessageContent(type = "audio", mimeType = "audio/mp4", fileName = "first.m4a", durationMs = 1_000L), + ChatMessageContent(type = "audio", mimeType = "audio/mp4", fileName = "second.m4a", durationMs = 2_000L), + ), + timestampMs = 1_000L, + idempotencyKey = "run:user", + ) + val incoming = + previous.copy( + id = "gateway-user", + content = + listOf( + ChatMessageContent(type = "text", text = "See attached."), + ChatMessageContent(type = "audio", mimeType = "audio/x-m4a", fileName = "stored-a.m4a"), + ChatMessageContent(type = "audio", mimeType = "audio/x-m4a", fileName = "stored-b.m4a"), + ), + ) + + val reconciled = reconcileMessageIds(previous = listOf(previous), incoming = listOf(incoming)).single() + + assertEquals(listOf(1_000L, 2_000L), reconciled.content.drop(1).map { it.durationMs }) + } + + @Test + fun mergeOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")), + timestampMs = 1000L, + ) + val assistant = + ChatMessage( + id = "remote-assistant", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "Received.")), + timestampMs = 2000L, + ) + + val merged = mergeOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic)) + + assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id }) + } + + @Test + fun retainUnmatchedOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")), + timestampMs = 1000L, + ) + val assistant = + ChatMessage( + id = "remote-assistant", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "Received.")), + timestampMs = 2000L, + ) + + val retained = retainUnmatchedOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic)) + + assertEquals(listOf("local-user"), retained.map { it.id }) + } + + @Test + fun retainUnmatchedOptimisticMessagesDropsGatewayPersistedUserTurn() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + idempotencyKey = "run-1:user", + ) + val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 500L) + + val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic)) + + assertEquals(emptyList(), retained.map { it.id }) + } + + @Test + fun retainUnmatchedOptimisticMessagesKeepsDistinctIdempotencyKey() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + idempotencyKey = "run-2:user", + ) + val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L, idempotencyKey = "run-1:user") + + val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic)) + + assertEquals(listOf("local-user"), retained.map { it.id }) + } + + @Test + fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() { + val user = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ) + val remoteUser = user.copy(id = "remote-user") + + val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(user)) + + assertEquals(listOf("remote-user"), merged.map { it.id }) + } + + @Test + fun mergeOptimisticMessagesDoesNotDuplicateGatewayPersistedUserTurnWithDifferentTimestamp() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ) + val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L) + + val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic)) + + assertEquals(listOf("remote-user"), merged.map { it.id }) + } + + @Test + fun mergeOptimisticMessagesKeepsRepeatedOptimisticTurnWhenHistoryOnlyHasOneMatch() { + val first = + ChatMessage( + id = "local-user-1", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ) + val second = first.copy(id = "local-user-2", timestampMs = 1100L) + val remoteUser = first.copy(id = "remote-user", timestampMs = 2000L) + + val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(first, second)) + + assertEquals(listOf("local-user-2", "remote-user"), merged.map { it.id }) + } + + @Test + fun mergeOptimisticMessagesDoesNotConsumeOlderIdenticalHistoryTurn() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "ok")), + timestampMs = 2000L, + ) + val oldHistoryUser = optimistic.copy(id = "remote-old-user", timestampMs = 1000L) + + val merged = mergeOptimisticMessages(incoming = listOf(oldHistoryUser), optimistic = listOf(optimistic)) + + assertEquals(listOf("remote-old-user", "local-user"), merged.map { it.id }) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt new file mode 100644 index 0000000..3c59239 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt @@ -0,0 +1,1363 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerModelSelectionTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun successfulSelectionRecordsRecentAndUpdatesSelectedModel() = + runTest { + val requests = mutableListOf>() + val recents = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + "{}" + }, + recordModelRecent = recents::add, + ) + + assertTrue(controller.setSessionModelAwait("main", " anthropic/claude-opus-4 ")) + + assertEquals(listOf("anthropic/claude-opus-4"), recents) + assertEquals("anthropic/claude-opus-4", controller.selectedModelRef.value) + assertEquals( + "sessions.patch" to "{\"key\":\"main\",\"agentId\":\"main\",\"model\":\"anthropic/claude-opus-4\"}", + requests.single(), + ) + } + + @Test + fun successfulSelectionAppliesGatewayThinkingLevelsAndEffectiveLevel() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val acceptedThinking = (params["thinkingLevel"] as? JsonPrimitive)?.content ?: "max" + """ + { + "resolved": { + "modelProvider": "anthropic", + "model": "claude-sonnet-5", + "thinkingLevel": "$acceptedThinking", + "thinkingLevels": [ + {"id": "off", "label": "off"}, + {"id": "minimal", "label": "minimal"}, + {"id": "low", "label": "low"}, + {"id": "medium", "label": "medium"}, + {"id": "high", "label": "high"}, + {"id": "xhigh", "label": "xhigh"}, + {"id": "adaptive", "label": "adaptive"}, + {"id": "max", "label": "max"} + ] + } + } + """.trimIndent() + }, + ) + + assertTrue(controller.setSessionModelAwait("main", "anthropic/claude-sonnet-5")) + + assertTrue(controller.thinkingLevelSelection.value.isGatewayProvided) + assertEquals( + listOf("off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"), + controller.thinkingLevelSelection.value.options + .map { it.id }, + ) + assertEquals("max", controller.thinkingLevel.value) + + controller.setThinkingLevel("ultra") + assertEquals("max", controller.thinkingLevel.value) + controller.setThinkingLevel("adaptive") + assertEquals("adaptive", controller.thinkingLevel.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun existingSessionPreservesEffectiveLevelOmittedFromAdvertisedOptions() = + runTest { + val sentThinkingLevels = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "sessions.list" -> + """ + { + "sessions": [ + { + "key": "main", + "modelProvider": "openai", + "model": "gpt-5.6-luna", + "thinkingLevel": "ultra", + "thinkingLevels": [ + {"id": "off", "label": "off"}, + {"id": "high", "label": "high"}, + {"id": "xhigh", "label": "xhigh"}, + {"id": "max", "label": "max"} + ] + } + ] + } + """.trimIndent() + "chat.send" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-ok","status":"ok"}""" + } + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + + assertEquals( + listOf("off", "high", "xhigh", "max"), + controller + .thinkingLevelSelection + .value + .options + .map { it.id }, + ) + assertEquals("ultra", controller.thinkingLevel.value) + controller.handleGatewayEvent("health", null) + assertTrue( + controller.sendMessageAwaitAcceptance( + message = "preserve effective reasoning", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ), + ) + assertEquals(listOf("ultra"), sentThinkingLevels) + } + + @Test + fun failedSelectionDoesNotRecordRecentOrUpdateSelectedModel() = + runTest { + val recents = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> error("patch failed") }, + recordModelRecent = recents::add, + ) + + assertFalse(controller.setSessionModelAwait("main", "openai/gpt-5")) + + assertEquals(emptyList(), recents) + assertNull(controller.selectedModelRef.value) + assertEquals("patch failed", controller.errorText.value) + } + + @Test + fun successfulDefaultSelectionDoesNotRecordRecent() = + runTest { + val requests = mutableListOf() + val recents = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, paramsJson -> + requests += paramsJson + "{}" + }, + recordModelRecent = recents::add, + ) + + assertTrue(controller.setSessionModelAwait("main", null)) + + assertEquals(emptyList(), recents) + assertEquals("{\"key\":\"main\",\"agentId\":\"main\",\"model\":null}", requests.single()) + } + + @Test + fun immediateSendWaitsForPendingModelSelection() = + runTest { + val patchStarted = CompletableDeferred() + val releasePatch = CompletableDeferred() + val requests = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + requests += method + when (method) { + "sessions.patch" -> { + patchStarted.complete(Unit) + releasePatch.await() + "{}" + } + "chat.send" -> """{"runId":"run-ok","status":"ok"}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + controller.setSessionModel("main", "openai/gpt-5") + patchStarted.await() + val send = + async { + controller.sendMessageAwaitAcceptance( + message = "hello", + thinkingLevel = "off", + attachments = emptyList(), + ) + } + yield() + + assertEquals(listOf("sessions.patch"), requests.filter { it == "sessions.patch" || it == "chat.send" }) + + releasePatch.complete(Unit) + assertTrue(send.await()) + assertEquals( + listOf("sessions.patch", "chat.send"), + requests.filter { it == "sessions.patch" || it == "chat.send" }, + ) + } + + @Test + fun thinkingPatchAndSendFollowPendingModelOnSharedSettingsLane() = + runTest { + val modelPatchStarted = CompletableDeferred() + val releaseModelPatch = CompletableDeferred() + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + modelPatchStarted.complete(Unit) + releaseModelPatch.await() + """{"resolved":{"thinkingLevel":"high","thinkingLevels":[{"id":"off","label":"off"},{"id":"high","label":"high"},{"id":"ultra","label":"ultra"}]}}""" + } else { + """{"resolved":{"thinkingLevel":"ultra"}}""" + } + } + "chat.send" -> """{"runId":"run-ok","status":"ok"}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + controller.setSessionModel("main", "openai/gpt-5.6-sol") + modelPatchStarted.await() + controller.setThinkingLevel("ultra") + val send = + async { + controller.sendMessageAwaitAcceptance( + message = "hello", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ) + } + yield() + + assertEquals( + listOf("sessions.patch"), + requests.map { it.first }.filter { it == "sessions.patch" || it == "chat.send" }, + ) + releaseModelPatch.complete(Unit) + assertTrue(send.await()) + assertEquals("ultra", controller.thinkingLevel.value) + assertEquals( + listOf("sessions.patch", "sessions.patch", "chat.send"), + requests.map { it.first }.filter { it == "sessions.patch" || it == "chat.send" }, + ) + val thinkingPatch = requests.first { (method, params) -> method == "sessions.patch" && "thinkingLevel" in params.orEmpty() } + assertEquals( + "ultra", + ((json.parseToJsonElement(thinkingPatch.second.orEmpty()) as JsonObject)["thinkingLevel"] as JsonPrimitive) + .content, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun failedThinkingPatchRollsBackToModelAcceptedLevelWithoutSessionRow() = + runTest { + val modelPatchStarted = CompletableDeferred() + val releaseModelPatch = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + if (method != "sessions.patch") { + "{}" + } else { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + modelPatchStarted.complete(Unit) + releaseModelPatch.await() + """{"resolved":{"thinkingLevel":"high","thinkingLevels":[{"id":"off","label":"off"},{"id":"high","label":"high"},{"id":"ultra","label":"ultra"}]}}""" + } else { + error("thinking rejected") + } + } + }, + ) + + controller.setSessionModel("main", "openai/gpt-5.6-sol") + modelPatchStarted.await() + controller.setThinkingLevel("ultra") + releaseModelPatch.complete(Unit) + advanceUntilIdle() + + assertEquals("high", controller.thinkingLevel.value) + assertEquals("thinking rejected", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun thinkingRollbackStateIsScopedToGatewayConnection() = + runTest { + var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + cacheScope = { gatewayScope }, + requestGateway = { method, _ -> + when { + method == "sessions.list" -> + """{"sessions":[{"key":"main","thinkingLevel":"off"}]}""" + method == "sessions.patch" && gatewayScope.gatewayId == "gateway-a" -> + """{"resolved":{"thinkingLevel":"medium"}}""" + method == "sessions.patch" -> error("thinking rejected") + else -> "{}" + } + }, + ) + + controller.setThinkingLevel("medium") + advanceUntilIdle() + assertEquals("medium", controller.thinkingLevel.value) + + gatewayScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.refreshSessions() + advanceUntilIdle() + assertEquals("off", controller.thinkingLevel.value) + + controller.setThinkingLevel("high") + advanceUntilIdle() + + assertEquals("off", controller.thinkingLevel.value) + assertEquals("thinking rejected", controller.errorText.value) + } + + @Test + fun settingsPatchUsesCapturedGatewayConnectionScope() = + runTest { + val capturedScopes = mutableListOf() + val gatewayScope = ChatCacheScope(gatewayId = " gateway-a ", connectionGeneration = 7) + val normalizedScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 7) + val controller = + ChatController( + scope = this, + json = json, + cacheScope = { gatewayScope }, + requestGateway = { _, _ -> error("unscoped request") }, + captureSettingsRequestLease = { scope -> + scope ?: error("missing scope") + GatewaySession.RequestLease(scope.gatewayId) { _, _, _ -> + capturedScopes += scope + "{}" + } + }, + ) + + assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) + + assertEquals(listOf(normalizedScope), capturedScopes) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleGatewayThinkingFailureDoesNotReplaceCurrentError() = + runTest { + val oldPatchStarted = CompletableDeferred() + val releaseOldPatch = CompletableDeferred() + var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + cacheScope = { gatewayScope }, + requestGateway = { method, paramsJson -> + if (method != "sessions.patch") { + "{}" + } else { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as? JsonPrimitive)?.content + if (level == "medium") { + oldPatchStarted.complete(Unit) + releaseOldPatch.await() + error("old gateway failure") + } + error("current gateway failure") + } + }, + ) + + controller.setThinkingLevel("medium") + oldPatchStarted.await() + + gatewayScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.setThinkingLevel("high") + assertEquals("current gateway failure", controller.errorText.value) + + releaseOldPatch.complete(Unit) + advanceUntilIdle() + assertEquals("current gateway failure", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleGatewayModelFailureDoesNotReplaceCurrentError() = + runTest { + val oldPatchStarted = CompletableDeferred() + val releaseOldPatch = CompletableDeferred() + var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + cacheScope = { gatewayScope }, + requestGateway = { method, paramsJson -> + if (method != "sessions.patch") { + "{}" + } else { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + oldPatchStarted.complete(Unit) + releaseOldPatch.await() + error("old gateway failure") + } + error("current gateway failure") + } + }, + ) + + controller.setSessionModel("main", "openai/gpt-old") + oldPatchStarted.await() + + gatewayScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.setThinkingLevel("high") + assertEquals("current gateway failure", controller.errorText.value) + + releaseOldPatch.complete(Unit) + advanceUntilIdle() + assertEquals("current gateway failure", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun queuedMutationDoesNotCrossGatewayConnection() = + runTest { + val oldModelPatchStarted = CompletableDeferred() + val releaseOldModelPatch = CompletableDeferred() + val patchedThinkingLevels = mutableListOf() + var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + cacheScope = { gatewayScope }, + requestGateway = { method, paramsJson -> + if (method != "sessions.patch") { + "{}" + } else { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + oldModelPatchStarted.complete(Unit) + releaseOldModelPatch.await() + "{}" + } else { + val level = (params["thinkingLevel"] as JsonPrimitive).content + patchedThinkingLevels += level + """{"resolved":{"thinkingLevel":"$level"}}""" + } + } + }, + ) + + controller.setSessionModel("main", "openai/gpt-old") + oldModelPatchStarted.await() + controller.setThinkingLevel("high") + + gatewayScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.setThinkingLevel("max") + assertEquals(listOf("max"), patchedThinkingLevels) + + releaseOldModelPatch.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("max"), patchedThinkingLevels) + assertEquals("max", controller.thinkingLevel.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun failedThinkingPatchUsesRefreshedAuthoritativeLevel() = + runTest { + var sessionLevel = "off" + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","thinkingLevel":"$sessionLevel"}]}""" + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as JsonPrimitive).content + if (level == "max") error("rejected") + """{"resolved":{"thinkingLevel":"$level"}}""" + } + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + controller.setThinkingLevel("medium") + advanceUntilIdle() + + sessionLevel = "high" + controller.refreshSessions() + advanceUntilIdle() + assertEquals("high", controller.thinkingLevel.value) + + controller.setThinkingLevel("max") + advanceUntilIdle() + assertEquals("high", controller.thinkingLevel.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sessionsRefreshRetriesWhenThinkingPatchOverlapsResponse() = + runTest { + val firstListStarted = CompletableDeferred() + val releaseFirstList = CompletableDeferred() + val thinkingPatchStarted = CompletableDeferred() + val releaseThinkingPatch = CompletableDeferred() + var listRequests = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> { + listRequests += 1 + if (listRequests == 1) { + firstListStarted.complete(Unit) + releaseFirstList.await() + } + """{"sessions":[{"key":"main","thinkingLevel":"high"}]}""" + } + "sessions.patch" -> { + thinkingPatchStarted.complete(Unit) + releaseThinkingPatch.await() + error("rejected") + } + else -> "{}" + } + }, + ) + + controller.refreshSessions() + firstListStarted.await() + controller.setThinkingLevel("max") + thinkingPatchStarted.await() + + releaseFirstList.complete(Unit) + yield() + assertEquals("max", controller.thinkingLevel.value) + assertEquals(1, listRequests) + + releaseThinkingPatch.complete(Unit) + advanceUntilIdle() + + assertEquals(2, listRequests) + assertEquals("high", controller.thinkingLevel.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sessionsRefreshDoesNotWaitForSettingsOnPreviousGateway() = + runTest { + val oldPatchStarted = CompletableDeferred() + val releaseOldPatch = CompletableDeferred() + val newListFinished = CompletableDeferred() + var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + cacheScope = { gatewayScope }, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> { + newListFinished.complete(Unit) + """{"sessions":[{"key":"main","thinkingLevel":"high"}]}""" + } + else -> "{}" + } + }, + requestGatewayForGateway = { gatewayId, method, _ -> + if (gatewayId == "gateway-a" && method == "sessions.patch") { + oldPatchStarted.complete(Unit) + releaseOldPatch.await() + } + "{}" + }, + ) + + controller.setThinkingLevel("max") + oldPatchStarted.await() + + gatewayScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.refreshSessions() + yield() + + assertTrue(newListFinished.isCompleted) + assertEquals("high", controller.thinkingLevel.value) + + releaseOldPatch.complete(Unit) + advanceUntilIdle() + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun twoFailedQueuedThinkingPatchesWithoutSessionRowRestoreConfirmedLevel() = + runTest { + val firstPatchStarted = CompletableDeferred() + val releaseFirstPatch = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + if (method != "sessions.patch") { + "{}" + } else { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as JsonPrimitive).content + if (level == "medium") { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + } + error("rejected") + } + }, + ) + + controller.setThinkingLevel("medium") + firstPatchStarted.await() + controller.setThinkingLevel("high") + releaseFirstPatch.complete(Unit) + advanceUntilIdle() + + assertEquals("off", controller.thinkingLevel.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun failedLatestThinkingPatchRestoresOlderAcceptedOptionsWithoutSessionRow() = + runTest { + val firstPatchStarted = CompletableDeferred() + val releaseFirstPatch = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + if (method != "sessions.patch") { + "{}" + } else { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as JsonPrimitive).content + if (level == "medium") { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + """ + {"resolved":{"thinkingLevel":"medium","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"medium","label":"medium"} + ]}} + """.trimIndent() + } else { + error("rejected") + } + } + }, + ) + + controller.setThinkingLevel("medium") + firstPatchStarted.await() + controller.setThinkingLevel("high") + releaseFirstPatch.complete(Unit) + advanceUntilIdle() + + assertEquals("medium", controller.thinkingLevel.value) + assertTrue(controller.thinkingLevelSelection.value.isGatewayProvided) + assertEquals( + listOf("off", "medium"), + controller.thinkingLevelSelection.value.options + .map { it.id }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun failedThinkingPatchPreservesGatewayOptionsWithoutSessionRow() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """ + {"resolved":{"thinkingLevel":"off","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"high","label":"high"} + ]}} + """.trimIndent() + } else { + error("rejected") + } + } + else -> "{}" + } + }, + ) + + assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) + controller.setThinkingLevel("high") + advanceUntilIdle() + + assertEquals("off", controller.thinkingLevel.value) + assertTrue(controller.thinkingLevelSelection.value.isGatewayProvided) + assertEquals( + listOf("off", "high"), + controller.thinkingLevelSelection.value.options + .map { it.id }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun modelPatchPreservesAcceptedOptionsWhenResolutionOmitsThem() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "sessions.list" -> + """ + {"sessions":[{"key":"main","thinkingLevel":"off","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"ultra","label":"ultra"} + ]}]} + """.trimIndent() + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol","thinkingLevel":"off"}}""" + } else { + error("rejected") + } + } + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) + controller.setThinkingLevel("ultra") + advanceUntilIdle() + + assertEquals("off", controller.thinkingLevel.value) + assertTrue(controller.thinkingLevelSelection.value.isGatewayProvided) + assertEquals( + listOf("off", "ultra"), + controller.thinkingLevelSelection.value.options + .map { it.id }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun modelPatchUpdatesAcceptedOptionsWhenResolutionOmitsLevel() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "sessions.list" -> + """ + {"sessions":[{"key":"main","thinkingLevel":"off","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"high","label":"high"} + ]}]} + """.trimIndent() + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """ + {"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"max","label":"max"} + ]}} + """.trimIndent() + } else { + error("rejected") + } + } + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) + controller.setThinkingLevel("max") + advanceUntilIdle() + + assertEquals("off", controller.thinkingLevel.value) + assertTrue(controller.thinkingLevelSelection.value.isGatewayProvided) + assertEquals( + listOf("off", "max"), + controller.thinkingLevelSelection.value.options + .map { it.id }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun modelPatchPreservesAcceptedThinkingWhenResolutionOmitsThinkingMetadata() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "sessions.list" -> + """ + {"sessions":[{"key":"main","thinkingLevel":"off","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"ultra","label":"ultra"} + ]}]} + """.trimIndent() + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol"}}""" + } else { + """ + {"resolved":{"thinkingLevel":"ultra","thinkingLevels":[ + {"id":"off","label":"off"},{"id":"ultra","label":"ultra"} + ]}} + """.trimIndent() + } + } + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + controller.setThinkingLevel("ultra") + advanceUntilIdle() + assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) + + assertEquals("ultra", controller.thinkingLevel.value) + assertTrue(controller.thinkingLevelSelection.value.isGatewayProvided) + assertEquals( + listOf("off", "ultra"), + controller.thinkingLevelSelection.value.options + .map { it.id }, + ) + } + + @Test + fun olderThinkingCompletionDoesNotReplaceNewerQueuedIntent() = + runTest { + val firstPatchStarted = CompletableDeferred() + val releaseFirstPatch = CompletableDeferred() + val secondPatchStarted = CompletableDeferred() + val releaseSecondPatch = CompletableDeferred() + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.patch" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + when ((params["thinkingLevel"] as? JsonPrimitive)?.content) { + "high" -> { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + } + "ultra" -> { + secondPatchStarted.complete(Unit) + releaseSecondPatch.await() + } + } + "{}" + } + "chat.send" -> """{"runId":"run-ok","status":"ok"}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + controller.setThinkingLevel("high") + firstPatchStarted.await() + controller.setThinkingLevel("ultra") + releaseFirstPatch.complete(Unit) + secondPatchStarted.await() + + assertEquals("ultra", controller.thinkingLevel.value) + val send = + async { + controller.sendMessageAwaitAcceptance( + message = "hello", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ) + } + releaseSecondPatch.complete(Unit) + assertTrue(send.await()) + val sendParams = requests.first { it.first == "chat.send" }.second.orEmpty() + assertEquals( + "ultra", + ((json.parseToJsonElement(sendParams) as JsonObject)["thinking"] as JsonPrimitive).content, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun repeatedThinkingValueStillUsesLatestRequestIdentity() = + runTest { + val firstPatchStarted = CompletableDeferred() + val releaseFirstPatch = CompletableDeferred() + var patchIndex = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method != "sessions.patch") { + "{}" + } else { + patchIndex += 1 + when (patchIndex) { + 1 -> { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + """{"resolved":{"thinkingLevel":"medium"}}""" + } + 2 -> """{"resolved":{"thinkingLevel":"ultra"}}""" + else -> """{"resolved":{"thinkingLevel":"max"}}""" + } + } + }, + ) + + controller.setThinkingLevel("high") + firstPatchStarted.await() + controller.setThinkingLevel("ultra") + controller.setThinkingLevel("high") + releaseFirstPatch.complete(Unit) + advanceUntilIdle() + + assertEquals(3, patchIndex) + assertEquals("max", controller.thinkingLevel.value) + } + + @Test + fun immediateSendStopsWhenPendingModelSelectionFails() = + runTest { + val patchStarted = CompletableDeferred() + val releasePatch = CompletableDeferred() + val requests = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + requests += method + when (method) { + "sessions.patch" -> { + patchStarted.complete(Unit) + releasePatch.await() + error("patch failed") + } + "chat.send" -> """{"runId":"run-unexpected","status":"ok"}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + + controller.setSessionModel("main", "openai/gpt-5") + patchStarted.await() + val send = + async { + controller.sendMessageAwaitAcceptance( + message = "hello", + thinkingLevel = "off", + attachments = emptyList(), + ) + } + yield() + + releasePatch.complete(Unit) + assertFalse(send.await()) + assertEquals("patch failed", controller.errorText.value) + assertFalse("chat.send" in requests) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleHistoryDoesNotOverwriteAcceptedModelSelection() = + runTest { + val historyStarted = CompletableDeferred() + val releaseHistory = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "chat.history" -> { + historyStarted.complete(Unit) + releaseHistory.await() + """{"messages":[],"sessionInfo":{"key":"main","modelProvider":"anthropic","model":"claude-opus-4"}}""" + } + "sessions.list" -> """{"sessions":[]}""" + "chat.metadata" -> """{"commands":[],"models":[]}""" + else -> "{}" + } + }, + ) + + controller.load("main") + historyStarted.await() + assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5")) + + releaseHistory.complete(Unit) + advanceUntilIdle() + + assertEquals("openai/gpt-5", controller.selectedModelRef.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun historyHydratesSelectedModelAndAgentScopedCatalog() = + runTest { + val requests = mutableListOf>() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "chat.history" -> + """ + { + "sessionId": "session-ops", + "messages": [], + "sessionInfo": { + "key": "agent:ops:main", + "modelProvider": "anthropic", + "model": "claude-opus-4" + } + } + """.trimIndent() + "chat.metadata" -> + """ + { + "commands": [], + "models": [ + { + "id": "claude-opus-4", + "name": "Claude Opus 4", + "provider": "anthropic", + "available": true, + "input": ["text"] + } + ] + } + """.trimIndent() + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + + controller.load("agent:ops:main") + advanceUntilIdle() + + assertEquals("anthropic/claude-opus-4", controller.selectedModelRef.value) + assertEquals( + "claude-opus-4", + controller.modelCatalog.value + .single() + .id, + ) + val metadataRequest = requests.single { it.first == "chat.metadata" } + assertTrue(metadataRequest.second.orEmpty().contains("\"agentId\":\"ops\"")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun emptyModelCatalogIsRetriedOnNextHealthEvent() = + runTest { + var metadataRequests = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "chat.metadata" -> { + metadataRequests += 1 + if (metadataRequests == 1) { + """{"commands":[{"name":"new","textAliases":["/new"]}],"models":[]}""" + } else { + """{"commands":[{"name":"new","textAliases":["/new"]}],"models":[{"id":"gpt-5","provider":"openai","input":["text"]}]}""" + } + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + assertTrue(controller.modelCatalog.value.isEmpty()) + + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(2, metadataRequests) + assertEquals( + "gpt-5", + controller.modelCatalog.value + .single() + .id, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun validEmptyModelCatalogStopsAfterOneRetry() = + runTest { + var metadataRequests = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "chat.metadata") { + metadataRequests += 1 + """{"commands":[],"models":[]}""" + } else { + "{}" + } + }, + ) + + repeat(3) { + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + } + + assertEquals(2, metadataRequests) + assertTrue(controller.modelCatalog.value.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unsupportedReasoningSendsOffWithoutChangingStoredLevelAndRestoresAfterFlip() = + runTest { + val sentThinkingLevels = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "chat.send" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-${sentThinkingLevels.size}","status":"ok"}""" + } + "chat.history" -> """{"messages":[],"sessionInfo":{"key":"main"}}""" + "sessions.list" -> """{"sessions":[]}""" + // Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata. + "chat.metadata" -> + """ + { + "commands": [], + "models": [ + {"id": "plain", "name": "plain", "provider": "openai", "available": true, "input": ["text"], "reasoning": false}, + {"id": "reasoning", "name": "reasoning", "provider": "openai", "available": true, "input": ["text"], "reasoning": true} + ] + } + """.trimIndent() + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + controller.setThinkingLevel("high") + assertTrue(controller.setSessionModelAwait("main", "openai/plain")) + + assertTrue( + controller.sendMessageAwaitAcceptance( + message = "plain model", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ), + ) + assertEquals(listOf("off"), sentThinkingLevels) + assertEquals("high", controller.thinkingLevel.value) + + assertTrue(controller.setSessionModelAwait("main", "openai/reasoning")) + assertTrue( + controller.sendMessageAwaitAcceptance( + message = "reasoning restored", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ), + ) + assertEquals(listOf("off", "high"), sentThinkingLevels) + assertEquals("high", controller.thinkingLevel.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun advertisedThinkingLevelsOverrideCatalogReasoningFlagForSend() = + runTest { + val sentThinkingLevels = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "chat.metadata" -> + """ + { + "commands": [], + "models": [ + { + "id": "reasoner", + "name": "Reasoner", + "provider": "synthetic", + "available": true, + "input": ["text"], + "reasoning": false + } + ] + } + """.trimIndent() + "chat.history" -> """{"messages":[],"sessionInfo":{"key":"main"}}""" + "sessions.list" -> """{"sessions":[]}""" + "sessions.patch" -> + """ + { + "resolved": { + "modelProvider": "synthetic", + "model": "reasoner", + "thinkingLevel": "max", + "thinkingLevels": [ + {"id": "off", "label": "off"}, + {"id": "max", "label": "max"} + ] + } + } + """.trimIndent() + "chat.send" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-ok","status":"ok"}""" + } + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + + assertTrue(controller.setSessionModelAwait("main", "synthetic/reasoner")) + assertTrue( + controller.sendMessageAwaitAcceptance( + message = "use the advertised level", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ), + ) + + assertEquals(listOf("max"), sentThinkingLevels) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt new file mode 100644 index 0000000..47c1ac8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt @@ -0,0 +1,3353 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayRequestNotEnqueued +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID + +private data class DeliveredSend( + val key: String, + val message: String, + val sessionKey: String, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatControllerOutboxTest { + private val json = Json { ignoreUnknownKeys = true } + + private class LoadGate( + var remainingLoads: Int, + val entered: CompletableDeferred, + val release: CompletableDeferred, + ) + + /** In-memory stand-in for the Room outbox; Room persistence itself is covered by [RoomChatCommandOutboxTest]. */ + private class FakeCommandOutbox( + private val capacity: Int = OUTBOX_MAX_QUEUED, + ) : ChatCommandOutbox { + val rows = LinkedHashMap() + val admittedIds = linkedSetOf() + val attachmentBytes = mutableMapOf>() + val gatewayIds = mutableMapOf() + val deletedSessions = mutableListOf() + var recoveryGate: CompletableDeferred? = null + var recoveryFailure: Throwable? = null + var failedStatusUpdateFailure: Throwable? = null + var acceptedStatusUpdateFailure: Throwable? = null + var queuedStatusUpdateFailure: Throwable? = null + var sendingStatusUpdateFailure: Throwable? = null + var pinSessionKeyFailure: Throwable? = null + var enqueueGate: CompletableDeferred? = null + var claimGate: CompletableDeferred? = null + var deleteFailure: Throwable? = null + var beforeDeleteIfQueued: (() -> Unit)? = null + var deleteOnFailedStatus = false + var loadGate: LoadGate? = null + var onStatusUpdated: ((ChatOutboxStatus) -> Unit)? = null + private var nextCreatedAt = 0L + + fun seed( + item: ChatOutboxItem, + gatewayId: String = "gateway-test", + ) { + rows[item.id] = item + gatewayIds[item.id] = gatewayId + nextCreatedAt = maxOf(nextCreatedAt, item.createdAtMs + 1) + } + + override suspend fun load(gatewayId: String): List { + loadGate?.let { gate -> + if (gate.remainingLoads == 0) { + loadGate = null + gate.entered.complete(Unit) + gate.release.await() + } else { + gate.remainingLoads -= 1 + } + } + return rows.values + .filter { gatewayIds[it.id] == gatewayId } + .sortedWith(compareBy({ it.createdAtMs }, { it.id })) + } + + override suspend fun wasAdmitted(id: String): Boolean = id in rows || id in admittedIds + + override suspend fun enqueue( + gatewayId: String, + sessionKey: String, + text: String, + thinkingLevel: String, + nowMs: Long, + attachments: List, + gatedEpoch: Long?, + ownerAgentId: String, + idempotencyKey: String?, + ): ChatOutboxEnqueueResult { + enqueueGate?.await() + if (gatewayIds.values.count { it == gatewayId } >= capacity) return ChatOutboxEnqueueResult.QueueFull + val commandBytes = attachments.sumOf { it.bytes.size.toLong() } + if (!outboxCommandAttachmentsWithinByteLimits(attachments)) return ChatOutboxEnqueueResult.AttachmentsTooLarge + val queuedBytes = attachmentBytes.values.sumOf { list -> list.sumOf { it.size.toLong() } } + if (commandBytes > 0 && queuedBytes + commandBytes > OUTBOX_MAX_GATEWAY_ATTACHMENT_BYTES) { + return ChatOutboxEnqueueResult.StorageFull + } + val createdAt = maxOf(nowMs, nextCreatedAt) + nextCreatedAt = createdAt + 1 + val id = idempotencyKey ?: UUID.randomUUID().toString() + if (idempotencyKey != null) admittedIds += id + val item = + ChatOutboxItem( + id = id, + sessionKey = sessionKey, + text = text, + thinkingLevel = thinkingLevel, + createdAtMs = createdAt, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + gatedEpoch = gatedEpoch, + ownerAgentId = ownerAgentId, + attachments = + attachments.mapIndexed { index, payload -> + ChatOutboxAttachment( + id = "$id-$index", + type = payload.type, + mimeType = payload.mimeType, + fileName = payload.fileName, + durationMs = payload.durationMs, + byteLength = payload.bytes.size.toLong(), + ) + }, + ) + rows[item.id] = item + attachmentBytes[item.id] = attachments.map { it.bytes } + gatewayIds[item.id] = gatewayId + return ChatOutboxEnqueueResult.Queued(item) + } + + override suspend fun loadAttachments(id: String): List { + val item = rows[id] ?: return emptyList() + val bytes = attachmentBytes[id].orEmpty() + return item.attachments.mapIndexed { index, attachment -> + LoadedOutboxAttachment(attachment = attachment, bytes = bytes[index]) + } + } + + override suspend fun claimForSending( + id: String, + retryCount: Int, + lastError: String?, + ): Int { + claimGate?.await() + sendingStatusUpdateFailure?.let { throw it } + val current = rows[id] ?: return 0 + if (current.status != ChatOutboxStatus.Queued) return 0 + rows[id] = current.copy(status = ChatOutboxStatus.Sending, retryCount = retryCount, lastError = lastError) + onStatusUpdated?.invoke(ChatOutboxStatus.Sending) + return 1 + } + + override suspend fun pinSessionKey( + id: String, + sessionKey: String, + ) { + pinSessionKeyFailure?.let { throw it } + val current = rows[id] ?: return + rows[id] = current.copy(sessionKey = sessionKey) + } + + override suspend fun confirmDelivered(ids: Set): Int { + var removed = 0 + for (id in ids) { + if (rows.remove(id) != null) { + attachmentBytes.remove(id) + gatewayIds.remove(id) + removed += 1 + } + } + return removed + } + + override suspend fun updateStatus( + id: String, + status: ChatOutboxStatus, + retryCount: Int, + lastError: String?, + ): Int { + if (status == ChatOutboxStatus.Failed && deleteOnFailedStatus) { + rows.remove(id) + gatewayIds.remove(id) + return 0 + } + if (status == ChatOutboxStatus.Failed) failedStatusUpdateFailure?.let { throw it } + if (status == ChatOutboxStatus.Accepted) acceptedStatusUpdateFailure?.let { throw it } + if (status == ChatOutboxStatus.Queued) queuedStatusUpdateFailure?.let { throw it } + if (status == ChatOutboxStatus.Sending) sendingStatusUpdateFailure?.let { throw it } + val current = rows[id] ?: return 0 + rows[id] = current.copy(status = status, retryCount = retryCount, lastError = lastError) + onStatusUpdated?.invoke(status) + return 1 + } + + override suspend fun requeueForRetry( + gatewayId: String, + id: String, + nowMs: Long, + gatedEpoch: Long?, + ownerAgentId: String?, + ): Int { + val current = rows[id] ?: return 0 + if (gatewayIds[id] != gatewayId || current.status != ChatOutboxStatus.Failed) return 0 + var createdAt = maxOf(nowMs, nextCreatedAt) + rows[id] = + current.copy( + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + createdAtMs = createdAt, + gatedEpoch = gatedEpoch, + ownerAgentId = current.ownerAgentId ?: ownerAgentId, + ) + // Mirror the Room store: queued same-session successors follow the retried row. + val successors = + rows.values + .filter { + it.id != id && + gatewayIds[it.id] == gatewayId && + it.sessionKey == current.sessionKey && + it.createdAtMs > current.createdAtMs && + it.status == ChatOutboxStatus.Queued + }.sortedBy { it.createdAtMs } + for (successor in successors) { + createdAt += 1 + rows[successor.id] = successor.copy(createdAtMs = createdAt) + } + nextCreatedAt = createdAt + 1 + return 1 + } + + override suspend fun delete(id: String) { + deleteFailure?.let { throw it } + rows.remove(id) + attachmentBytes.remove(id) + gatewayIds.remove(id) + } + + override suspend fun deleteIfQueued(id: String): Boolean { + deleteFailure?.let { throw it } + beforeDeleteIfQueued?.invoke() + val current = rows[id] ?: return false + if (current.status != ChatOutboxStatus.Queued) return false + rows.remove(id) + attachmentBytes.remove(id) + gatewayIds.remove(id) + admittedIds.remove(id) + return true + } + + override suspend fun deleteForSession( + gatewayId: String, + sessionKey: String, + ownerAgentId: String, + ) { + deletedSessions += sessionKey + val ids = + rows.values + .filter { gatewayIds[it.id] == gatewayId && it.sessionKey == sessionKey && it.ownerAgentId == ownerAgentId } + .map { it.id } + ids.forEach { + rows.remove(it) + attachmentBytes.remove(it) + gatewayIds.remove(it) + } + } + + override suspend fun clearGateway(gatewayId: String) { + val ids = gatewayIds.filterValues { it == gatewayId }.keys.toList() + ids.forEach { + rows.remove(it) + attachmentBytes.remove(it) + gatewayIds.remove(it) + } + } + + override suspend fun failSendingAfterRestart() { + recoveryGate?.await() + recoveryFailure?.let { throw it } + for ((id, item) in rows) { + if (item.status == ChatOutboxStatus.Sending) { + rows[id] = item.copy(status = ChatOutboxStatus.Failed, lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR) + } + } + } + + override suspend fun expireStale( + gatewayId: String, + nowMs: Long, + ) { + for ((id, item) in rows) { + if (gatewayIds[id] != gatewayId || item.createdAtMs > nowMs - OUTBOX_EXPIRY_MS) continue + if (item.status == ChatOutboxStatus.Queued) { + rows[id] = item.copy(status = ChatOutboxStatus.Failed, lastError = OUTBOX_EXPIRED_ERROR) + } else if (item.status == ChatOutboxStatus.Accepted) { + rows[id] = item.copy(status = ChatOutboxStatus.Failed, lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR) + } + } + } + } + + /** + * Toggleable gateway seam: records chat.send idempotency keys and echoes them as run ids. + * Sends that returned an acknowledgement are echoed into chat.history as `:user` rows + * plus an assistant reply, mirroring how the real gateway persists delivered turns; sends + * that threw after dispatch are not echoed (their persistence is genuinely unknown). + */ + private inner class FakeGateway { + var online = false + var sendFailureBeforeDispatch: Throwable? = null + var sendFailureAfterDispatch: Throwable? = null + var sendGate: CompletableDeferred? = null + var settingsPatchStarted: CompletableDeferred? = null + var settingsPatchGate: CompletableDeferred? = null + val settingsPatchFailures = mutableListOf() + var sendResponse: (idempotencyKey: String) -> String = { key -> """{"runId":"$key","status":"started"}""" } + val sentIdempotencyKeys = mutableListOf() + val sentMessages = mutableListOf() + val sentSessionKeys = mutableListOf() + val sentAgentIds = mutableListOf() + val sentThinkingLevels = mutableListOf() + val sentAttachmentFileNames = mutableListOf>() + val historyAgentIds = mutableListOf() + var echoDeliveredSendsInHistory = true + private val deliveredSends = mutableListOf() + var historyMessagesJson = "[]" + val historyMessagesByAgent = mutableMapOf() + var metadataModelsJson = "[]" + + suspend fun request( + method: String, + paramsJson: String?, + ): String { + if (!online) throw IllegalStateException("offline") + return when (method) { + "chat.send" -> { + sendFailureBeforeDispatch?.let { throw it } + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val key = (params["idempotencyKey"] as JsonPrimitive).content + val message = (params["message"] as JsonPrimitive).content + val sessionKey = (params["sessionKey"] as JsonPrimitive).content + val agentId = (params["agentId"] as JsonPrimitive).content + sentIdempotencyKeys += key + sentMessages += message + sentSessionKeys += sessionKey + sentAgentIds += agentId + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + sentAttachmentFileNames += + (params["attachments"] as? JsonArray) + ?.mapNotNull { ((it as? JsonObject)?.get("fileName") as? JsonPrimitive)?.content } + .orEmpty() + sendFailureAfterDispatch?.let { throw it } + sendGate?.await() + val response = sendResponse(key) + // Terminal failures never persist a turn; every other returned ack means the gateway + // accepted the dispatch and the turn becomes visible in canonical history. + val status = + runCatching { (json.parseToJsonElement(response) as? JsonObject)?.get("status") as? JsonPrimitive } + .getOrNull() + ?.content + if (status != "timeout" && status != "error") { + deliveredSends += DeliveredSend(key = key, message = message, sessionKey = sessionKey) + } + response + } + "chat.history" -> { + val params = + runCatching { + json.parseToJsonElement(paramsJson.orEmpty()) as? JsonObject + }.getOrNull() + val requestedKey = (params?.get("sessionKey") as? JsonPrimitive)?.content + val requestedAgentId = (params?.get("agentId") as? JsonPrimitive)?.content + historyAgentIds += requestedAgentId + val echoed = + if (echoDeliveredSendsInHistory) { + deliveredSends + .filter { requestedKey == null || it.sessionKey == requestedKey } + .flatMapIndexed { index, send -> + listOf( + """{"role":"user","content":"${send.message}","timestamp":${100 + index * 2},"idempotencyKey":"${send.key}:user"}""", + """{"role":"assistant","content":"reply","timestamp":${101 + index * 2},"idempotencyKey":"${send.key}:assistant"}""", + ) + } + } else { + emptyList() + } + val explicitJson = requestedAgentId?.let(historyMessagesByAgent::get) ?: historyMessagesJson + val explicit = (json.parseToJsonElement(explicitJson) as JsonArray).map { it.toString() } + """{"sessionId":"session-1","messages":[${(explicit + echoed).joinToString(",")}]}""" + } + "chat.metadata" -> """{"commands":[],"models":$metadataModelsJson}""" + "sessions.patch" -> { + settingsPatchStarted?.complete(Unit) + settingsPatchGate?.await() + if (settingsPatchFailures.isNotEmpty()) { + settingsPatchFailures.removeAt(0)?.let { throw it } + } + "{}" + } + else -> "{}" + } + } + } + + private fun controller( + scope: CoroutineScope, + gateway: FakeGateway, + outbox: ChatCommandOutbox, + ): ChatController = + ChatController( + scope = scope, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { "main" }, + commandOutbox = outbox, + ) + + @Test + fun enqueueWhileOfflineShowsQueuedRowAndSurvivesControllerRecreation() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val first = controller(this, gateway, outbox) + first.load("main") + advanceUntilIdle() + assertFalse(first.healthOk.value) + + val accepted = first.sendMessageAwaitAcceptance(message = "offline hello", thinkingLevel = "off", attachments = emptyList()) + + assertTrue(accepted) + val queuedRow = first.outboxItems.value.single() + assertEquals("offline hello", queuedRow.text) + assertEquals(ChatOutboxStatus.Queued, queuedRow.status) + + // Recreated controller (fresh process analog) republishes the durable row. + val second = controller(this, gateway, outbox) + advanceUntilIdle() + assertEquals(listOf("offline hello"), second.outboxItems.value.map { it.text }) + } + + @Test + fun reconnectFlushesQueuedCommandsInOrderWithRowIdsAsIdempotencyKeys() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("agent:main:main") + advanceUntilIdle() + + chat.sendMessageAwaitAcceptance(message = "one", thinkingLevel = "high", attachments = emptyList()) + chat.sendMessageAwaitAcceptance(message = "two", thinkingLevel = "off", attachments = emptyList()) + chat.sendMessageAwaitAcceptance(message = "three", thinkingLevel = "off", attachments = emptyList()) + val queuedIds = chat.outboxItems.value.map { it.id } + assertEquals(3, queuedIds.size) + // A later selector change must not rewrite the thinking level of already-queued sends. + chat.setThinkingLevel("low") + + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("one", "two", "three"), gateway.sentMessages) + assertEquals(queuedIds, gateway.sentIdempotencyKeys) + assertEquals(listOf("agent:main:main", "agent:main:main", "agent:main:main"), gateway.sentSessionKeys) + assertEquals(listOf("high", "off", "off"), gateway.sentThinkingLevels) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun reconnectFlushWaitsForPendingSessionSettings() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + assertTrue(chat.sendMessageAwaitAcceptance(message = "queued", thinkingLevel = "high", attachments = emptyList())) + + gateway.online = true + gateway.settingsPatchStarted = CompletableDeferred() + gateway.settingsPatchGate = CompletableDeferred() + chat.setSessionModel("main", "openai/gpt-5.6-sol") + gateway.settingsPatchStarted?.await() + chat.handleGatewayEvent("health", null) + runCurrent() + + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals( + ChatOutboxStatus.Queued, + chat.outboxItems.value + .single() + .status, + ) + + gateway.settingsPatchGate?.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("queued"), gateway.sentMessages) + } + + @Test + fun reconnectFlushWaitsForQueuedRowsOwnerAfterVisibleOwnerChanges() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("custom", ownerAgentId = "agent-a") + advanceUntilIdle() + assertTrue(chat.sendMessageAwaitAcceptance(message = "owned by agent a", thinkingLevel = "off", attachments = emptyList())) + + gateway.online = true + gateway.settingsPatchStarted = CompletableDeferred() + gateway.settingsPatchGate = CompletableDeferred() + chat.setSessionModel("custom", "openai/gpt-5.6-sol") + gateway.settingsPatchStarted?.await() + + chat.switchSession("custom", ownerAgentId = "agent-b") + runCurrent() + chat.handleGatewayEvent("health", null) + runCurrent() + + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals( + ChatOutboxStatus.Queued, + chat.outboxItems.value + .single() + .status, + ) + + gateway.settingsPatchGate?.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("owned by agent a"), gateway.sentMessages) + assertEquals(listOf("agent-a"), gateway.sentAgentIds) + } + + @Test + fun reconnectFlushResumesAfterNewerPendingSessionSettingSucceeds() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + assertTrue(chat.sendMessageAwaitAcceptance(message = "queued", thinkingLevel = "high", attachments = emptyList())) + + gateway.online = true + gateway.settingsPatchStarted = CompletableDeferred() + gateway.settingsPatchGate = CompletableDeferred() + gateway.settingsPatchFailures += IllegalStateException("first patch rejected") + gateway.settingsPatchFailures += null + chat.setSessionModel("main", "anthropic/claude-fable-5") + gateway.settingsPatchStarted?.await() + chat.setSessionModel("main", "openai/gpt-5.6-sol") + chat.handleGatewayEvent("health", null) + runCurrent() + + assertTrue(gateway.sentMessages.isEmpty()) + gateway.settingsPatchGate?.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf("queued"), gateway.sentMessages) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun knownAdmissionAcksWithRunIdsRemoveRows() = + runTest { + for (status in listOf("started", "in_flight", "ok")) { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = status, + thinkingLevel = "off", + attachments = emptyList(), + ) + + gateway.online = true + gateway.sendResponse = { key -> """{"runId":"$key","status":"$status"}""" } + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf(status), gateway.sentMessages) + assertTrue(chat.outboxItems.value.isEmpty()) + } + } + + @Test + fun failedAcceptedPersistenceRearmsRecoveryBeforeYoungerRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "accepted", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "younger", + thinkingLevel = "off", + attachments = emptyList(), + ) + + // The acknowledged transition to accepted cannot be made durable; the flush must stop + // before younger rows instead of advancing past an ambiguous head still marked sending. + gateway.echoDeliveredSendsInHistory = false + outbox.acceptedStatusUpdateFailure = IllegalStateException("storage unavailable") + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("accepted"), gateway.sentMessages) + assertFalse(chat.healthOk.value) + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .first { it.text == "accepted" } + .status, + ) + assertEquals( + ChatOutboxStatus.Queued, + outbox.rows.values + .first { it.text == "younger" } + .status, + ) + + outbox.acceptedStatusUpdateFailure = null + chat.handleGatewayEvent("health", null) + // Bounded advance: enough for recovery, the flush, and its reconcile passes, but before + // the pending-run timeout would park the still-unproven younger send. + advanceTimeBy(5_000) + runCurrent() + + // The re-armed recovery sweep parks the interrupted head for review and the younger row + // proceeds; the parked head no longer blocks the session. + assertEquals(listOf("accepted", "younger"), gateway.sentMessages) + val parked = outbox.rows.values.first { it.text == "accepted" } + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, parked.lastError) + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .first { it.text == "younger" } + .status, + ) + } + + @Test + fun reconnectGatesActiveSessionThinkingAndFailsOpenForOtherSessions() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val now = System.currentTimeMillis() + // Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata, + // so hydrate first (empty queue) and seed the rows afterwards; the flush loop re-reads + // the outbox on each health transition. + gateway.metadataModelsJson = + """[{"id":"plain","name":"Plain","provider":"openai","available":true,"input":["text"],"reasoning":false}]""" + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + outbox.seed( + ChatOutboxItem( + id = "active", + sessionKey = "main", + text = "active session", + thinkingLevel = "high", + createdAtMs = now, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = "retry manually", + ownerAgentId = "main", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "other", + sessionKey = "other-session", + text = "unknown session", + thinkingLevel = "medium", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + assertTrue(chat.setSessionModelAwait("main", "openai/plain")) + // Drop health via a transport failure mid-flush: unlike a disconnect this keeps the + // hydrated catalog, which is the state where the flush gate has data to act on. + gateway.sendFailureBeforeDispatch = GatewayRequestNotEnqueued("gateway send failed") + chat.retryOutboxCommand("active") + advanceUntilIdle() + assertFalse(chat.healthOk.value) + + gateway.sendFailureBeforeDispatch = null + chat.handleGatewayEvent("health", null) + advanceTimeBy(1_000) + runCurrent() + + // retryOutboxCommand refreshes the active row's createdAt, so the untouched + // unknown-session row flushes first in createdAt order. + assertEquals(listOf("unknown session", "active session"), gateway.sentMessages) + assertEquals(listOf("medium", "off"), gateway.sentThinkingLevels) + assertFalse(outbox.rows.containsKey("active")) + assertEquals(ChatOutboxStatus.Accepted, outbox.rows.getValue("other").status) + } + + @Test + fun mainAliasRowsFlushToCanonicalMainSessionAfterHello() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "queued pre-hello", thinkingLevel = "off", attachments = emptyList()) + val queuedRow = chat.outboxItems.value.single() + assertEquals("main", queuedRow.sessionKey) + + // Gateway hello announces the canonical main session key, then health recovers. + gateway.online = true + chat.applyMainSessionKey("agent:main:main") + advanceUntilIdle() + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("agent:main:main"), gateway.sentSessionKeys) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun queuedRowsStayWithTheirGatewayAcrossSwitchAndFlushAfterSwitchBack() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var activeScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1L) + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { activeScope }, + currentDefaultAgentId = { "main" }, + commandOutbox = outbox, + ) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "gateway A queued", thinkingLevel = "off", attachments = emptyList()) + val queuedId = + chat.outboxItems.value + .single() + .id + + activeScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2L) + chat.onGatewayScopeChanging() + chat.onDisconnected("Offline") + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertTrue(gateway.sentMessages.isEmpty()) + assertTrue(chat.outboxItems.value.isEmpty()) + assertEquals(listOf(queuedId), outbox.load("gateway-a").map { it.id }) + assertTrue(outbox.load("gateway-b").isEmpty()) + + activeScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 3L) + chat.onGatewayScopeChanging() + chat.onDisconnected("Offline") + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(listOf("gateway A queued"), gateway.sentMessages) + assertTrue(outbox.load("gateway-a").isEmpty()) + } + + @Test + fun terminalFailureAcksFailUnconfirmedWithoutReplay() = + runTest { + val responses = + listOf<(String) -> String>( + { key -> """{"runId":"$key","status":"error"}""" }, + { key -> """{"runId":"$key","status":"timeout"}""" }, + { _ -> """{"status":"error"}""" }, + { _ -> """{"status":"timeout"}""" }, + ) + + for ((index, response) in responses.withIndex()) { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "terminal-$index", thinkingLevel = "off", attachments = emptyList()) + + gateway.online = true + gateway.sendResponse = response + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + val failed = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, failed.status) + assertEquals(0, failed.retryCount) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, failed.lastError) + assertEquals(1, gateway.sentMessages.size) + assertTrue(chat.healthOk.value) + + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(1, gateway.sentMessages.size) + } + } + + @Test + fun acknowledgedFailureKeepsGatewayOnlineAndFlushesLaterRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "fails", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "continues", + thinkingLevel = "off", + attachments = emptyList(), + ) + + gateway.online = true + gateway.sendResponse = { key -> + if (gateway.sentMessages.size == 1) { + """{"runId":"$key","status":"error"}""" + } else { + """{"runId":"$key","status":"started"}""" + } + } + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(chat.healthOk.value) + assertEquals(listOf("fails", "continues"), gateway.sentMessages) + val failed = chat.outboxItems.value.single() + assertEquals("fails", failed.text) + assertEquals(ChatOutboxStatus.Failed, failed.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, failed.lastError) + } + + @Test + fun failedFailurePersistenceStopsBeforeYoungerRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "ambiguous", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "younger", + thinkingLevel = "off", + attachments = emptyList(), + ) + + outbox.failedStatusUpdateFailure = IllegalStateException("storage unavailable") + gateway.online = true + gateway.sendResponse = { key -> """{"runId":"$key","status":"error"}""" } + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("ambiguous"), gateway.sentMessages) + assertFalse(chat.healthOk.value) + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .first { it.text == "ambiguous" } + .status, + ) + assertEquals( + ChatOutboxStatus.Queued, + outbox.rows.values + .first { it.text == "younger" } + .status, + ) + + outbox.failedStatusUpdateFailure = null + gateway.sendResponse = { key -> """{"runId":"$key","status":"started"}""" } + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("ambiguous", "younger"), gateway.sentMessages) + val recovered = chat.outboxItems.value.single() + assertEquals("ambiguous", recovered.text) + assertEquals(ChatOutboxStatus.Failed, recovered.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, recovered.lastError) + + val restarted = controller(this, gateway, outbox) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(listOf("ambiguous", "younger"), gateway.sentMessages) + assertEquals( + ChatOutboxStatus.Failed, + restarted.outboxItems.value + .single() + .status, + ) + } + + @Test + fun failedClaimPersistenceStopsBeforeDispatch() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "older", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "younger", + thinkingLevel = "off", + attachments = emptyList(), + ) + + outbox.sendingStatusUpdateFailure = IllegalStateException("storage unavailable") + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(gateway.sentMessages.isEmpty()) + assertFalse(chat.healthOk.value) + assertEquals( + listOf(ChatOutboxStatus.Queued, ChatOutboxStatus.Queued), + outbox.rows.values.map { it.status }, + ) + + outbox.sendingStatusUpdateFailure = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("older", "younger"), gateway.sentMessages) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun failedNotDispatchedPersistenceRearmsRecoveryBeforeYoungerRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "older", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "younger", + thinkingLevel = "off", + attachments = emptyList(), + ) + + outbox.queuedStatusUpdateFailure = IllegalStateException("storage unavailable") + gateway.online = true + gateway.sendFailureBeforeDispatch = GatewayRequestNotEnqueued("gateway send failed") + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(gateway.sentMessages.isEmpty()) + assertFalse(chat.healthOk.value) + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .first { it.text == "older" } + .status, + ) + assertEquals( + ChatOutboxStatus.Queued, + outbox.rows.values + .first { it.text == "younger" } + .status, + ) + + outbox.queuedStatusUpdateFailure = null + gateway.sendFailureBeforeDispatch = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("younger"), gateway.sentMessages) + val recovered = chat.outboxItems.value.single() + assertEquals("older", recovered.text) + assertEquals(ChatOutboxStatus.Failed, recovered.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, recovered.lastError) + } + + @Test + fun transmittedGatewayRejectionNeverReplaysUntilExplicitRetryAcrossRestart() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val processJob = SupervisorJob() + val processScope = CoroutineScope(coroutineContext + processJob) + val first = controller(processScope, gateway, outbox) + first.load("main") + advanceUntilIdle() + first.sendMessageAwaitAcceptance(message = "manual retry only", thinkingLevel = "off", attachments = emptyList()) + + gateway.online = true + gateway.sendFailureAfterDispatch = + GatewayRequestRejected(GatewaySession.ErrorShape(code = "UNAVAILABLE", message = "cached run failed")) + first.handleGatewayEvent("health", null) + advanceUntilIdle() + + val ambiguous = first.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, ambiguous.status) + assertEquals(0, ambiguous.retryCount) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, ambiguous.lastError) + assertEquals(1, gateway.sentMessages.size) + assertTrue(first.healthOk.value) + processJob.cancel() + + gateway.sendFailureAfterDispatch = null + val restarted = controller(this, gateway, outbox) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(1, gateway.sentMessages.size) + assertEquals( + ChatOutboxStatus.Failed, + restarted.outboxItems.value + .single() + .status, + ) + + restarted.retryOutboxCommand(ambiguous.id) + advanceUntilIdle() + assertEquals(listOf(ambiguous.id, ambiguous.id), gateway.sentIdempotencyKeys) + assertTrue(restarted.outboxItems.value.isEmpty()) + } + + @Test + fun failedKnownOwnerRowNeverSendsUntilExplicitRetry() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "migrated-ambiguous", + sessionKey = "main", + text = "possibly delivered before upgrade", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR, + ownerAgentId = "main", + ), + ) + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals( + ChatOutboxStatus.Failed, + chat.outboxItems.value + .single() + .status, + ) + + chat.retryOutboxCommand("migrated-ambiguous") + advanceUntilIdle() + assertEquals(listOf("migrated-ambiguous"), gateway.sentIdempotencyKeys) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun notDispatchedKeepsRowQueuedForNextReconnectInsteadOfBurningAttempts() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "survives drops", thinkingLevel = "off", attachments = emptyList()) + + gateway.online = true + gateway.sendFailureBeforeDispatch = GatewayRequestNotEnqueued("gateway send failed") + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + // The frame never entered the socket queue, so reconnect may retry it automatically. + val row = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Queued, row.status) + assertEquals(0, row.retryCount) + assertTrue(gateway.sentMessages.isEmpty()) + assertFalse(chat.healthOk.value) + + gateway.sendFailureBeforeDispatch = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("survives drops"), gateway.sentMessages) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun deletedUnknownOutcomeStillStopsBeforeYoungerRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "older", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "younger", + thinkingLevel = "off", + attachments = emptyList(), + ) + + outbox.deleteOnFailedStatus = true + gateway.online = true + gateway.sendFailureAfterDispatch = GatewayRequestOutcomeUnknown("ack lost") + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("older"), gateway.sentMessages) + assertFalse(chat.healthOk.value) + assertEquals(listOf("younger"), chat.outboxItems.value.map { it.text }) + + outbox.deleteOnFailedStatus = false + gateway.sendFailureAfterDispatch = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("older", "younger"), gateway.sentMessages) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun healthFlushRequestDuringActiveFlushIsDrainedAfterRelease() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "ambiguous", + thinkingLevel = "off", + attachments = emptyList(), + ) + chat.sendMessageAwaitAcceptance( + message = "younger", + thinkingLevel = "off", + attachments = emptyList(), + ) + + val finalPublishEntered = CompletableDeferred() + val releaseFinalPublish = CompletableDeferred() + outbox.onStatusUpdated = { status -> + if (status == ChatOutboxStatus.Failed) { + outbox.onStatusUpdated = null + // The first load republishes Failed; the second is the owning flush's finally block. + outbox.loadGate = + LoadGate( + remainingLoads = 1, + entered = finalPublishEntered, + release = releaseFinalPublish, + ) + } + } + gateway.online = true + gateway.sendFailureAfterDispatch = GatewayRequestOutcomeUnknown("ack lost") + chat.handleGatewayEvent("health", null) + runCurrent() + finalPublishEntered.await() + + assertEquals(listOf("ambiguous"), gateway.sentMessages) + assertFalse(chat.healthOk.value) + gateway.sendFailureAfterDispatch = null + chat.handleGatewayEvent("health", null) + runCurrent() + assertEquals(listOf("ambiguous"), gateway.sentMessages) + + releaseFinalPublish.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf("ambiguous", "younger"), gateway.sentMessages) + val failed = chat.outboxItems.value.single() + assertEquals("ambiguous", failed.text) + assertEquals(ChatOutboxStatus.Failed, failed.status) + } + + @Test + fun droppedAckFailsUnconfirmedAndNeverReplaysUntilExplicitRetry() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val first = controller(this, gateway, outbox) + first.load("main") + advanceUntilIdle() + first.sendMessageAwaitAcceptance(message = "send once", thinkingLevel = "off", attachments = emptyList()) + + gateway.online = true + gateway.sendFailureAfterDispatch = GatewayRequestOutcomeUnknown("ack lost") + first.handleGatewayEvent("health", null) + advanceUntilIdle() + + val ambiguous = first.outboxItems.value.single() + assertEquals(listOf("send once"), gateway.sentMessages) + assertFalse(first.healthOk.value) + + gateway.sendFailureAfterDispatch = null + first.handleGatewayEvent("health", null) + first.handleGatewayEvent("health", null) + advanceUntilIdle() + // Reconnect must not replay an ambiguous row; only the explicit retry below may dispatch it. + assertEquals(1, gateway.sentMessages.size) + assertEquals(ChatOutboxStatus.Failed, ambiguous.status) + assertEquals(0, ambiguous.retryCount) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, ambiguous.lastError) + + val restarted = controller(this, gateway, outbox) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(1, gateway.sentMessages.size) + assertEquals( + ChatOutboxStatus.Failed, + restarted.outboxItems.value + .single() + .status, + ) + + restarted.retryOutboxCommand(ambiguous.id) + advanceUntilIdle() + assertEquals(listOf(ambiguous.id, ambiguous.id), gateway.sentIdempotencyKeys) + assertTrue(restarted.outboxItems.value.isEmpty()) + } + + @Test + fun runIdOnlyAckFailsUnconfirmed() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance( + message = "missing status", + thinkingLevel = "off", + attachments = emptyList(), + ) + + gateway.online = true + gateway.sendResponse = { key -> """{"runId":"$key"}""" } + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("missing status"), gateway.sentMessages) + val failed = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, failed.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, failed.lastError) + assertTrue(chat.healthOk.value) + } + + @Test + fun unknownOrMalformedAckFailsUnconfirmed() = + runTest { + val responses = + listOf<(String) -> String>( + { key -> """{"runId":"$key","status":"mystery"}""" }, + { key -> """{"runId":"$key","status":"accepted"}""" }, + { _ -> """{"status":"accepted"}""" }, + { _ -> """{"status":"started"}""" }, + { _ -> """{"status":"in_flight"}""" }, + { key -> """{"runId":"$key","status":42}""" }, + { key -> """{"runId":"$key","status":null}""" }, + { key -> """{"runId":"$key","status":" "}""" }, + { _ -> "not-json" }, + ) + + for ((index, response) in responses.withIndex()) { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "unknown-$index", thinkingLevel = "off", attachments = emptyList()) + gateway.online = true + gateway.sendResponse = response + + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + val failed = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, failed.status) + assertEquals(0, failed.retryCount) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, failed.lastError) + assertEquals(1, gateway.sentMessages.size) + assertTrue(chat.healthOk.value) + + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(1, gateway.sentMessages.size) + } + } + + @Test + fun terminalSuccessAckWithoutRunIdFailsUnconfirmed() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "completed ack", thinkingLevel = "off", attachments = emptyList()) + + gateway.online = true + gateway.sendResponse = { _ -> """{"status":"ok"}""" } + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("completed ack"), gateway.sentMessages) + val failed = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, failed.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, failed.lastError) + assertTrue(chat.healthOk.value) + } + + @Test + fun retryResetsFailedRowAndFlushesImmediately() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "failed-row", + sessionKey = "main", + text = "try me again", + thinkingLevel = "off", + // Recent timestamp: the startup/flush expiry sweep must not expire this row. + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Failed, + retryCount = 2, + lastError = "boom", + ownerAgentId = "main", + ), + ) + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + assertTrue(chat.healthOk.value) + val seededRow = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, seededRow.status) + + chat.retryOutboxCommand("failed-row") + advanceUntilIdle() + + assertEquals(listOf("failed-row"), gateway.sentIdempotencyKeys) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun deleteRemovesQueuedRow() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "delete me", thinkingLevel = "off", attachments = emptyList()) + val queuedRow = chat.outboxItems.value.single() + val id = queuedRow.id + + chat.deleteOutboxCommand(id) + advanceUntilIdle() + + assertTrue(chat.outboxItems.value.isEmpty()) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun flushBuildsTheRequestIdentityFromTheCurrentReplacementRowId() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "replacement-client-id", + sessionKey = "main", + text = "retry on the selected branch", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("replacement-client-id"), gateway.sentIdempotencyKeys) + } + + @Test + fun queueFullRefusalSurfacesErrorWithoutQueueing() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox(capacity = 1) + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + assertTrue(chat.sendMessageAwaitAcceptance(message = "fits", thinkingLevel = "off", attachments = emptyList())) + + val accepted = chat.sendMessageAwaitAcceptance(message = "overflow", thinkingLevel = "off", attachments = emptyList()) + + assertFalse(accepted) + assertEquals(1, outbox.rows.size) + val errorText = chat.errorText.value.orEmpty() + assertTrue(errorText.contains("full")) + } + + @Test + fun sendingRowsBecomeDeliveryUnconfirmedOnControllerStartup() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "interrupted", + sessionKey = "main", + text = "crashed mid-send", + thinkingLevel = "off", + // Recent timestamp: startup recovery must surface this row before any retry decision. + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Sending, + retryCount = 1, + lastError = "socket closed", + ownerAgentId = "main", + ), + ) + + val chat = controller(this, gateway, outbox) + advanceUntilIdle() + + val recovered = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, recovered.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, recovered.lastError) + assertEquals(1, recovered.retryCount) + } + + @Test + fun startupRecoveryFinishesBeforeAHealthFlushCanClaimQueuedRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val recoveryGate = CompletableDeferred() + outbox.recoveryGate = recoveryGate + val now = System.currentTimeMillis() + outbox.seed( + ChatOutboxItem( + id = "interrupted", + sessionKey = "main", + text = "already dispatched", + thinkingLevel = "off", + createdAtMs = now, + status = ChatOutboxStatus.Sending, + retryCount = 1, + lastError = null, + ownerAgentId = "main", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "queued", + sessionKey = "main", + text = "send after recovery", + thinkingLevel = "off", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.handleGatewayEvent("health", null) + runCurrent() + + try { + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals(ChatOutboxStatus.Sending, outbox.rows.getValue("interrupted").status) + assertEquals(ChatOutboxStatus.Queued, outbox.rows.getValue("queued").status) + } finally { + // Never strand the controller's child job if a pre-release assertion fails. + recoveryGate.complete(Unit) + } + advanceUntilIdle() + + assertEquals(listOf("send after recovery"), gateway.sentMessages) + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue("interrupted").status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, outbox.rows.getValue("interrupted").lastError) + assertFalse(outbox.rows.containsKey("queued")) + } + + @Test + fun startupRecoveryFailureBlocksFlushUntilRecoveryCanBeRetried() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val now = System.currentTimeMillis() + outbox.seed( + ChatOutboxItem( + id = "interrupted", + sessionKey = "main", + text = "possibly delivered", + thinkingLevel = "off", + createdAtMs = now, + status = ChatOutboxStatus.Sending, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "queued", + sessionKey = "main", + text = "younger queued work", + thinkingLevel = "off", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + outbox.recoveryFailure = IllegalStateException("database unavailable") + val chat = controller(this, gateway, outbox) + + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertFalse(chat.healthOk.value) + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals(ChatOutboxStatus.Sending, outbox.rows.getValue("interrupted").status) + assertEquals(ChatOutboxStatus.Queued, outbox.rows.getValue("queued").status) + + outbox.recoveryFailure = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue("interrupted").status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, outbox.rows.getValue("interrupted").lastError) + assertEquals(listOf("younger queued work"), gateway.sentMessages) + assertFalse(outbox.rows.containsKey("queued")) + } + + @Test + fun cancellationLeavesTheClaimForStartupRecoveryInsteadOfReplaying() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val processJob = SupervisorJob() + val processScope = CoroutineScope(coroutineContext + processJob) + val first = controller(processScope, gateway, outbox) + first.load("main") + advanceUntilIdle() + first.sendMessageAwaitAcceptance(message = "interrupted send", thinkingLevel = "off", attachments = emptyList()) + + gateway.online = true + gateway.sendFailureAfterDispatch = CancellationException("process stopping") + first.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("interrupted send"), gateway.sentMessages) + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .single() + .status, + ) + processJob.cancel() + + gateway.sendFailureAfterDispatch = null + val restarted = controller(this, gateway, outbox) + advanceUntilIdle() + + val recovered = restarted.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, recovered.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, recovered.lastError) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(1, gateway.sentMessages.size) + } + + @Test + fun staleQueuedRowsExpireToFailedInsteadOfSendingOnReconnect() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "stale", + sessionKey = "main", + text = "two days old", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis() - OUTBOX_EXPIRY_MS, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(gateway.sentIdempotencyKeys.isEmpty()) + val expired = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, expired.status) + assertEquals(OUTBOX_EXPIRED_ERROR, expired.lastError) + + // Retrying an expired row refreshes its createdAt, so the flush sweep cannot + // immediately re-expire it and the send actually happens. + chat.retryOutboxCommand("stale") + advanceUntilIdle() + assertEquals(listOf("stale"), gateway.sentIdempotencyKeys) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun sessionDeleteEventPurgesThatSessionsOutboxRows() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "doomed-session-row", + sessionKey = "agent:old:main", + text = "orphaned", + thinkingLevel = "off", + createdAtMs = 5, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "old", + ), + ) + val chat = controller(this, gateway, outbox) + advanceUntilIdle() + + chat.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"agent:old:main"}""", + ) + advanceUntilIdle() + + assertEquals(listOf("agent:old:main"), outbox.deletedSessions) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun offlineAttachmentSendsQueueDurablyWithByteRecovery() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + + val imageBytes = byteArrayOf(1, 2, 3, 4) + val voiceBytes = byteArrayOf(9, 8, 7) + val accepted = + chat.sendMessageAwaitAcceptance( + message = "with media", + thinkingLevel = "off", + attachments = + listOf( + OutgoingAttachment( + type = "image", + mimeType = "image/jpeg", + fileName = "a.jpg", + base64 = + java.util.Base64 + .getEncoder() + .encodeToString(imageBytes), + ), + OutgoingAttachment( + type = "audio", + mimeType = "audio/mp4", + fileName = "note.m4a", + base64 = + java.util.Base64 + .getEncoder() + .encodeToString(voiceBytes), + durationMs = 1200L, + ), + ), + ) + + assertTrue(accepted) + val queued = chat.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Queued, queued.status) + assertEquals(listOf("a.jpg", "note.m4a"), queued.attachments.map { it.fileName }) + assertEquals(1200L, queued.attachments[1].durationMs) + // Exact bytes survive the round trip into durable storage. + val loaded = outbox.loadAttachments(queued.id) + assertTrue(imageBytes.contentEquals(loaded[0].bytes)) + assertTrue(voiceBytes.contentEquals(loaded[1].bytes)) + + // Reconnect flushes the attachment payload with the captured metadata. + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(listOf(listOf("a.jpg", "note.m4a")), gateway.sentAttachmentFileNames) + assertTrue(chat.outboxItems.value.isEmpty()) + assertTrue(outbox.attachmentBytes.isEmpty()) + } + + @Test + fun historyProofRetiresRowAndTheCanonicalCopyIsTheOnlyBubble() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + + chat.sendMessageAwaitAcceptance(message = "queued text", thinkingLevel = "off", attachments = emptyList()) + val queuedRow = chat.outboxItems.value.single() + val queuedId = queuedRow.id + + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(chat.outboxItems.value.isEmpty()) + val userCopies = chat.messages.value.filter { message -> message.content.any { it.text == "queued text" } } + assertEquals(1, userCopies.size) + assertEquals("$queuedId:user", userCopies.single().idempotencyKey) + } + + @Test + fun acceptedRowSurvivesUntilCanonicalHistoryConfirmsIt() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + + chat.sendMessageAwaitAcceptance(message = "await proof", thinkingLevel = "off", attachments = emptyList()) + val queuedId = + chat.outboxItems.value + .single() + .id + + // The gateway acks the send, but history lags: the durable row must not be deleted on + // the ACK alone, or a gateway crash before the transcript write would lose the message. + gateway.echoDeliveredSendsInHistory = false + gateway.online = true + chat.handleGatewayEvent("health", null) + runCurrent() + + assertEquals(listOf(queuedId), gateway.sentIdempotencyKeys) + assertEquals(ChatOutboxStatus.Accepted, outbox.rows.getValue(queuedId).status) + + // Canonical history catches up and retires the row. + gateway.echoDeliveredSendsInHistory = true + chat.refresh() + advanceUntilIdle() + assertFalse(outbox.rows.containsKey(queuedId)) + } + + @Test + fun healthySendsAreJournaledBeforeDispatchAndRetiredByHistoryProof() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + assertTrue(chat.healthOk.value) + + gateway.echoDeliveredSendsInHistory = false + val accepted = chat.sendMessageAwaitAcceptance(message = "healthy send", thinkingLevel = "off", attachments = emptyList()) + runCurrent() + + assertTrue(accepted) + // The dispatch used the durable row id as its idempotency key, and the row survives the + // started ACK: only canonical history proof may retire it. + val row = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Accepted, row.status) + assertEquals(listOf(row.id), gateway.sentIdempotencyKeys) + assertEquals(1, chat.messages.value.count { it.idempotencyKey == "${row.id}:user" }) + + gateway.echoDeliveredSendsInHistory = true + chat.refresh() + advanceUntilIdle() + assertTrue(outbox.rows.isEmpty()) + assertEquals(1, chat.messages.value.count { it.idempotencyKey == "${row.id}:user" }) + } + + @Test + fun processDeathDuringHealthyDispatchLeavesTheClaimForStartupRecovery() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val processJob = SupervisorJob() + val processScope = CoroutineScope(coroutineContext + processJob) + val first = controller(processScope, gateway, outbox) + gateway.online = true + first.load("main") + advanceUntilIdle() + + gateway.sendFailureAfterDispatch = CancellationException("process died mid-send") + runCatching { first.sendMessageAwaitAcceptance(message = "died in flight", thinkingLevel = "off", attachments = emptyList()) } + processJob.cancel() + + // The row keeps its 'sending' claim; the next process surfaces it as delivery-unconfirmed + // instead of silently replaying a possibly delivered dispatch. + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .single() + .status, + ) + gateway.sendFailureAfterDispatch = null + gateway.echoDeliveredSendsInHistory = false + val restarted = controller(this, gateway, outbox) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + val recovered = restarted.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, recovered.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, recovered.lastError) + assertEquals(1, gateway.sentMessages.size) + } + + @Test + fun restartOrphanedAcceptedRowIsRetiredByHistoryProofWithoutResending() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val processJob = SupervisorJob() + val processScope = CoroutineScope(coroutineContext + processJob) + val first = controller(processScope, gateway, outbox) + first.load("main") + advanceUntilIdle() + first.sendMessageAwaitAcceptance(message = "acked then killed", thinkingLevel = "off", attachments = emptyList()) + + // Flush accepts the row, then the process dies before history could confirm it. + gateway.echoDeliveredSendsInHistory = false + gateway.online = true + first.handleGatewayEvent("health", null) + runCurrent() + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + processJob.cancel() + + // The next process proves the turn against canonical history and retires the row + // without a second dispatch, even though the ACK was never locally processed further. + gateway.echoDeliveredSendsInHistory = true + val restarted = controller(this, gateway, outbox) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + assertTrue(outbox.rows.isEmpty()) + assertEquals(1, gateway.sentIdempotencyKeys.size) + } + + @Test + fun restartOrphanedAcceptedRowWithoutHistoryProofParksForManualReview() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val processJob = SupervisorJob() + val processScope = CoroutineScope(coroutineContext + processJob) + val first = controller(processScope, gateway, outbox) + first.load("main") + advanceUntilIdle() + first.sendMessageAwaitAcceptance(message = "acked but lost", thinkingLevel = "off", attachments = emptyList()) + + gateway.echoDeliveredSendsInHistory = false + gateway.online = true + first.handleGatewayEvent("health", null) + runCurrent() + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + processJob.cancel() + + // The gateway lost the turn (crash between ACK and transcript write): an idle history + // without the row's key parks it for explicit review instead of auto-retrying. + val restarted = controller(this, gateway, outbox) + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + val parked = restarted.outboxItems.value.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, parked.lastError) + assertEquals(1, gateway.sentIdempotencyKeys.size) + + // Explicit retry reuses the same idempotency key. + restarted.retryOutboxCommand(parked.id) + advanceUntilIdle() + assertEquals(listOf(parked.id, parked.id), gateway.sentIdempotencyKeys) + } + + @Test + fun preHelloMainRowsArePinnedAtFirstDispatchAndNeverRetarget() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + chat.sendMessageAwaitAcceptance(message = "pinned input", thinkingLevel = "off", attachments = emptyList()) + assertEquals( + "main", + outbox.rows.values + .single() + .sessionKey, + ) + + // First dispatch resolves the alias against the hello-announced main session and pins it. + gateway.echoDeliveredSendsInHistory = false + gateway.sendFailureAfterDispatch = GatewayRequestOutcomeUnknown("ack lost") + gateway.online = true + chat.applyMainSessionKey("agent:main:main") + advanceUntilIdle() + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + val parked = outbox.rows.values.single() + assertEquals("agent:main:main", parked.sessionKey) + assertEquals(ChatOutboxStatus.Failed, parked.status) + + // A later default-agent change must not redirect the captured input on retry. + gateway.sendFailureAfterDispatch = null + chat.applyMainSessionKey("agent:other:main") + advanceUntilIdle() + chat.retryOutboxCommand(parked.id) + advanceUntilIdle() + assertEquals(listOf("agent:main:main", "agent:main:main"), gateway.sentSessionKeys) + assertEquals(listOf("main", "main"), gateway.sentAgentIds) + } + + @Test + fun preHelloAliasParksWhenCanonicalSessionBelongsToAnotherAgent() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + assertTrue(chat.sendMessageAwaitAcceptance(message = "do not retarget", thinkingLevel = "off", attachments = emptyList())) + + gateway.online = true + chat.applyMainSessionKey("agent:work:main") + advanceUntilIdle() + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(gateway.sentMessages.isEmpty()) + val parked = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_OWNER_CHANGED_ERROR, parked.lastError) + } + + @Test + fun gatedCommandRowsParkAcrossReconnectAndSendOnlyOnExplicitRetry() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var generation = 1L + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = generation) }, + currentDefaultAgentId = { "main" }, + commandOutbox = outbox, + ) + chat.load("main") + advanceUntilIdle() + + // A slash command captured offline is connection-gated to the epoch that captured it. + chat.sendMessageAwaitAcceptance(message = "/clear", thinkingLevel = "off", attachments = emptyList()) + val row = outbox.rows.values.single() + assertEquals(1L, row.gatedEpoch) + + // Reconnecting bumps the connection epoch, so the command parks instead of replaying. + generation = 2L + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + val parked = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_CONNECTION_CHANGED_ERROR, parked.lastError) + assertTrue(gateway.sentMessages.isEmpty()) + + // An explicit retry while connected re-arms the row for the live epoch and sends it. + chat.retryOutboxCommand(parked.id) + advanceUntilIdle() + assertEquals(listOf("/clear"), gateway.sentMessages) + assertTrue(chat.outboxItems.value.isEmpty()) + } + + @Test + fun directSlashSendParksWhenReconnectLandsBeforeDispatch() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var generation = 1L + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = generation) }, + currentDefaultAgentId = { "main" }, + commandOutbox = outbox, + ) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + // Hold the direct dispatch at its durable claim, then reconnect underneath it. The + // command was captured under epoch 1 and must not auto-send on the new connection. + outbox.claimGate = CompletableDeferred() + var accepted: Boolean? = null + val send = + launch { + accepted = chat.sendMessageAwaitAcceptance(message = "/clear", thinkingLevel = "off", attachments = emptyList()) + } + runCurrent() + generation = 2L + outbox.claimGate?.complete(Unit) + send.join() + advanceUntilIdle() + + assertEquals(true, accepted) + assertTrue(gateway.sentMessages.isEmpty()) + val parked = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_CONNECTION_CHANGED_ERROR, parked.lastError) + } + + @Test + fun sendClaimedAcrossSessionRoundTripRestoresRunIntoRevisitedChat() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + gateway.echoDeliveredSendsInHistory = false + chat.load("agent:main:main") + advanceUntilIdle() + + outbox.claimGate = CompletableDeferred() + var accepted: Boolean? = null + val send = + launch { + accepted = + chat.sendMessageForOwnerAwaitAcceptance( + message = "captured main turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = + ChatComposerOwner( + gatewayStableId = "gateway-test", + agentId = "main", + sessionKey = "agent:main:main", + ), + ) + } + runCurrent() + + // The final owner values match the captured values, but this is a new UI generation. + chat.switchSession("agent:other:main") + chat.switchSession("agent:main:main") + outbox.claimGate?.complete(Unit) + send.join() + runCurrent() + + assertEquals(true, accepted) + assertEquals(listOf("agent:main:main"), gateway.sentSessionKeys) + assertTrue(chat.messages.value.any { message -> message.content.any { it.text == "captured main turn" } }) + assertEquals(1, chat.pendingRunCount.value) + assertNull(chat.errorText.value) + } + + @Test + fun projectedSendAcrossSessionRoundTripIsReprojectedAfterAck() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + gateway.echoDeliveredSendsInHistory = false + gateway.sendGate = CompletableDeferred() + chat.load("agent:main:main") + advanceUntilIdle() + + val send = + async { + chat.sendMessageForOwnerAwaitAcceptance( + message = "already projected turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = + ChatComposerOwner( + gatewayStableId = "gateway-test", + agentId = "main", + sessionKey = "agent:main:main", + ), + ) + } + runCurrent() + assertEquals(1, chat.pendingRunCount.value) + + chat.switchSession("agent:other:main") + chat.switchSession("agent:main:main") + assertEquals(1, chat.pendingRunCount.value) + gateway.sendGate?.complete(Unit) + assertTrue(send.await()) + + assertTrue(chat.messages.value.any { message -> message.content.any { it.text == "already projected turn" } }) + assertEquals(1, chat.pendingRunCount.value) + } + + @Test + fun ackReceivedInAnotherChatRestoresPendingRunWhenOwnerReturns() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + gateway.sendGate = CompletableDeferred() + chat.load("agent:main:main") + advanceUntilIdle() + + val send = + async { + chat.sendMessageForOwnerAwaitAcceptance( + message = "return after ack", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = + ChatComposerOwner( + gatewayStableId = "gateway-test", + agentId = "main", + sessionKey = "agent:main:main", + ), + ) + } + runCurrent() + chat.switchSession("agent:other:main") + gateway.sendGate?.complete(Unit) + assertTrue(send.await()) + assertEquals(0, chat.pendingRunCount.value) + + chat.switchSession("agent:main:main") + + assertEquals(1, chat.pendingRunCount.value) + assertTrue(chat.messages.value.any { message -> message.content.any { it.text == "return after ack" } }) + } + + @Test + fun hiddenAcceptedRunParksAfterItsReconciliationDeadline() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + gateway.sendGate = CompletableDeferred() + chat.load("agent:main:main") + advanceUntilIdle() + + val send = + async { + chat.sendMessageForOwnerAwaitAcceptance( + message = "hidden accepted turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = ChatComposerOwner("gateway-test", "main", "agent:main:main"), + ) + } + runCurrent() + chat.switchSession("agent:other:main") + gateway.sendGate?.complete(Unit) + assertTrue(send.await()) + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + + advanceTimeBy(120_001) + runCurrent() + + assertEquals( + ChatOutboxStatus.Failed, + outbox.rows.values + .single() + .status, + ) + chat.switchSession("agent:main:main") + assertEquals(0, chat.pendingRunCount.value) + assertTrue(chat.messages.value.none { message -> message.content.any { it.text == "hidden accepted turn" } }) + } + + @Test + fun visibleAcceptedRunGetsADeadlineWhenItsOwnerIsHidden() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + gateway.echoDeliveredSendsInHistory = false + chat.load("agent:main:main") + advanceUntilIdle() + + assertTrue( + chat.sendMessageForOwnerAwaitAcceptance( + message = "visible then hidden turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = ChatComposerOwner("gateway-test", "main", "agent:main:main"), + ), + ) + assertEquals(1, chat.pendingRunCount.value) + + chat.switchSession("agent:other:main") + advanceTimeBy(120_001) + runCurrent() + + assertEquals( + ChatOutboxStatus.Failed, + outbox.rows.values + .single() + .status, + ) + chat.switchSession("agent:main:main") + assertEquals(0, chat.pendingRunCount.value) + } + + @Test + fun restartReplayKeepsCapturedDefaultAgentForUnscopedSession() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId = "main" + val first = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + commandOutbox = outbox, + ) + first.load("custom") + advanceUntilIdle() + + assertTrue(first.sendMessageAwaitAcceptance(message = "owned offline", thinkingLevel = "off", attachments = emptyList())) + assertEquals( + "main", + outbox.rows.values + .single() + .ownerAgentId, + ) + + defaultAgentId = "other" + val restarted = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 2L) }, + currentDefaultAgentId = { defaultAgentId }, + commandOutbox = outbox, + ) + gateway.online = true + restarted.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("custom"), gateway.sentSessionKeys) + assertEquals(listOf("main"), gateway.sentAgentIds) + } + + @Test + fun unscopedSendWaitsForVerifiedDefaultAgent() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId: String? = null + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + commandOutbox = outbox, + ) + chat.load("custom") + advanceUntilIdle() + + assertFalse(chat.sendMessageAwaitAcceptance(message = "unknown owner", thinkingLevel = "off", attachments = emptyList())) + assertTrue(outbox.rows.isEmpty()) + + defaultAgentId = "work" + assertTrue(chat.sendMessageAwaitAcceptance(message = "verified owner", thinkingLevel = "off", attachments = emptyList())) + assertEquals( + "work", + outbox.rows.values + .single() + .ownerAgentId, + ) + } + + @Test + fun unscopedOfflineSendKeepsTheLastVerifiedOwnerOnTheSameGateway() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId: String? = "work" + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 2L) }, + currentDefaultAgentId = { defaultAgentId }, + commandOutbox = outbox, + ) + chat.load("custom") + advanceUntilIdle() + + defaultAgentId = null + chat.onDefaultAgentChanged(null) + chat.onDisconnected("offline") + assertTrue(chat.sendMessageAwaitAcceptance("offline work", "off", emptyList())) + + assertEquals( + "work", + outbox.rows.values + .single() + .ownerAgentId, + ) + } + + @Test + fun flushedRunProjectionAndEventsStayWithCapturedOwner() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId: String? = "other" + var defaultAgentRevision = 0L + outbox.seed( + ChatOutboxItem( + id = "owner-a-row", + sessionKey = "custom", + text = "owner A turn", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "owner-a", + ), + ) + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + commandOutbox = outbox, + ) + gateway.online = true + gateway.echoDeliveredSendsInHistory = false + chat.load("custom") + advanceTimeBy(1_000) + runCurrent() + + chat.handleGatewayEvent("health", null) + runCurrent() + + assertEquals(listOf("owner-a"), gateway.sentAgentIds) + assertEquals(0, chat.pendingRunCount.value) + assertTrue(chat.messages.value.none { message -> message.content.any { it.text == "owner A turn" } }) + chat.handleGatewayEvent( + "chat", + """{"sessionKey":"custom","runId":"owner-a-row","state":"delta","message":{"role":"assistant","content":[{"type":"text","text":"private A stream"}]}}""", + ) + assertNull(chat.streamingAssistantText.value) + + chat.switchSession("agent:other:main") + defaultAgentId = "owner-a" + defaultAgentRevision += 1 + chat.switchSession("custom") + + assertEquals(1, chat.pendingRunCount.value) + assertTrue(chat.messages.value.any { message -> message.content.any { it.text == "owner A turn" } }) + } + + @Test + fun currentHistoryCannotConfirmAnotherOwnersUnscopedRow() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + outbox.seed( + ChatOutboxItem( + id = "owner-a-accepted", + sessionKey = "custom", + text = "owner A turn", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Accepted, + retryCount = 0, + lastError = null, + ownerAgentId = "owner-a", + ), + ) + gateway.historyMessagesByAgent["owner-b"] = + """[{"role":"user","content":"wrong owner proof","timestamp":1,"idempotencyKey":"owner-a-accepted:user"}]""" + gateway.historyMessagesByAgent["owner-a"] = "[]" + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { "owner-b" }, + commandOutbox = outbox, + ) + gateway.online = true + + chat.load("custom") + advanceUntilIdle() + + assertTrue("owner-b" in gateway.historyAgentIds) + assertTrue("owner-a" in gateway.historyAgentIds) + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue("owner-a-accepted").status) + } + + @Test + fun retryDoesNotInferOwnerForMigratedUnscopedRow() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId = "original" + val row = + ChatOutboxItem( + id = "migrated-row", + sessionKey = "custom", + text = "migrated input", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = OUTBOX_OWNER_CHANGED_ERROR, + ownerAgentId = null, + ) + outbox.seed(row) + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + commandOutbox = outbox, + ) + chat.load("custom") + advanceUntilIdle() + defaultAgentId = "replacement" + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + chat.retryOutboxCommand(row.id) + advanceUntilIdle() + + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue(row.id).status) + assertNull(outbox.rows.getValue(row.id).ownerAgentId) + } + + @Test + fun sameOwnerHistoryReloadKeepsSuspendedSendProjection() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("agent:main:main") + advanceUntilIdle() + + outbox.claimGate = CompletableDeferred() + var accepted: Boolean? = null + val send = + launch { + accepted = + chat.sendMessageForOwnerAwaitAcceptance( + message = "same owner turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = + ChatComposerOwner( + gatewayStableId = "gateway-test", + agentId = "main", + sessionKey = "agent:main:main", + ), + ) + } + runCurrent() + + // A missing sessions.create key reloads the current parent. It is a history generation, + // not a composer-owner change, so the suspended send still owns this UI. + assertTrue(chat.startNewChatAwait()) + outbox.claimGate?.complete(Unit) + send.join() + + assertEquals(true, accepted) + assertEquals(listOf("agent:main:main"), gateway.sentSessionKeys) + assertTrue(chat.messages.value.any { message -> message.content.any { it.text == "same owner turn" } }) + assertEquals(1, chat.pendingRunCount.value) + } + + @Test + fun defaultAgentRoundTripDuringAdmissionRejectsBeforeDispatch() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId: String? = "main" + var defaultAgentRevision = 0L + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + commandOutbox = outbox, + ) + gateway.online = true + outbox.enqueueGate = CompletableDeferred() + chat.load("custom") + advanceUntilIdle() + + var accepted: Boolean? = null + val send = + launch { + accepted = + chat.sendMessageForOwnerAwaitAcceptance( + message = "old agent turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = ChatComposerOwner(gatewayStableId = "gateway-test", agentId = "main", sessionKey = "custom"), + ) + } + runCurrent() + + defaultAgentId = "other" + defaultAgentRevision += 1 + defaultAgentId = "main" + defaultAgentRevision += 1 + outbox.enqueueGate?.complete(Unit) + send.join() + + assertEquals(false, accepted) + assertTrue(gateway.sentSessionKeys.isEmpty()) + assertTrue(outbox.rows.isEmpty()) + assertTrue(chat.messages.value.none { message -> message.content.any { it.text == "old agent turn" } }) + assertEquals(0, chat.pendingRunCount.value) + } + + @Test + fun claimedRowStillOwnsInputWhenComposerOwnerChangesDuringAdmission() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId: String? = "main" + var defaultAgentRevision = 0L + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + commandOutbox = outbox, + ) + gateway.online = true + outbox.enqueueGate = CompletableDeferred() + chat.load("custom") + advanceUntilIdle() + + var accepted: Boolean? = null + val send = + launch { + accepted = + chat.sendMessageForOwnerAwaitAcceptance( + message = "flush-owned turn", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = ChatComposerOwner("gateway-test", "main", "custom"), + ) + } + runCurrent() + defaultAgentId = "other" + defaultAgentRevision += 1 + outbox.beforeDeleteIfQueued = { + val row = outbox.rows.values.single() + outbox.rows[row.id] = row.copy(status = ChatOutboxStatus.Sending) + } + outbox.enqueueGate?.complete(Unit) + send.join() + + assertEquals(true, accepted) + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .single() + .status, + ) + assertTrue(gateway.sentMessages.isEmpty()) + } + + @Test + fun defaultAgentChangeAfterAdmissionStillDispatchesToCapturedOwner() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + var defaultAgentId: String? = "main" + var defaultAgentRevision = 0L + val chat = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = 1L) }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + commandOutbox = outbox, + ) + gateway.online = true + outbox.claimGate = CompletableDeferred() + chat.load("custom") + advanceUntilIdle() + + val send = + async { + chat.sendMessageForOwnerAwaitAcceptance( + message = "captured owner", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = ChatComposerOwner(gatewayStableId = "gateway-test", agentId = "main", sessionKey = "custom"), + ) + } + runCurrent() + + defaultAgentId = "other" + defaultAgentRevision += 1 + outbox.claimGate?.complete(Unit) + assertTrue(send.await()) + + assertEquals(listOf("custom"), gateway.sentSessionKeys) + assertEquals(listOf("main"), gateway.sentAgentIds) + assertEquals(0, chat.pendingRunCount.value) + } + + @Test + fun oldNotEnqueuedRequestDoesNotPoisonNewConnectionHealth() = + runTest { + val outbox = FakeCommandOutbox() + val sendStarted = CompletableDeferred() + val sendGate = CompletableDeferred() + var generation = 1L + var sendRequestCount = 0 + val chat = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + if (method == "chat.send") { + sendRequestCount += 1 + if (sendRequestCount == 1) { + sendStarted.complete(Unit) + sendGate.await() + throw GatewayRequestNotEnqueued("old connection closed") + } + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val runId = (params["idempotencyKey"] as JsonPrimitive).content + """{"runId":"$runId","status":"started"}""" + } else { + "{}" + } + }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-test", connectionGeneration = generation) }, + currentDefaultAgentId = { "main" }, + commandOutbox = outbox, + ) + chat.handleGatewayEvent("health", null) + + val accepted = + async { + chat.sendMessageAwaitAcceptance( + message = "survive reconnect", + thinkingLevel = "off", + attachments = emptyList(), + ) + } + sendStarted.await() + generation = 2L + chat.handleGatewayEvent("health", null) + sendGate.complete(Unit) + + assertTrue(accepted.await()) + assertTrue(chat.healthOk.value) + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + assertEquals(2, sendRequestCount) + assertEquals(1, chat.pendingRunCount.value) + } + + @Test + fun acceptedRowAckedUnderDifferentRunIdStaysOwnedWhileTheRunIsLive() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + // The gateway acknowledges the send under a run id that differs from the row's + // idempotency key; local ownership transfers to that id while the row keeps its own. + gateway.sendResponse = { _ -> """{"runId":"gw-run-777","status":"started"}""" } + gateway.echoDeliveredSendsInHistory = false + chat.load("main") + advanceUntilIdle() + + val accepted = chat.sendMessageAwaitAcceptance(message = "slow turn", thinkingLevel = "off", attachments = emptyList()) + advanceTimeBy(1_000) + assertTrue(accepted) + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + + // A follow-up send must see the accepted head as live-owned: it dispatches directly, + // and the reconciliation sweep must not park the head while its run is in flight. + val followUp = chat.sendMessageAwaitAcceptance(message = "second", thinkingLevel = "off", attachments = emptyList()) + advanceTimeBy(10_000) + assertTrue(followUp) + assertEquals(listOf("slow turn", "second"), gateway.sentMessages) + assertTrue(outbox.rows.values.none { it.status == ChatOutboxStatus.Failed }) + } + + @Test + fun flushedSendAckedUnderDifferentRunIdResolvesWithTheLiveRun() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.sendResponse = { _ -> """{"runId":"gw-run-9","status":"started"}""" } + gateway.echoDeliveredSendsInHistory = false + chat.load("main") + advanceUntilIdle() + + // Captured offline, delivered by the reconnect flush under a divergent acked run id. + chat.sendMessageAwaitAcceptance(message = "queued turn", thinkingLevel = "off", attachments = emptyList()) + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceTimeBy(5_000) + assertEquals(listOf("queued turn"), gateway.sentMessages) + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + + // The run completes under the acknowledged id and its turn becomes visible in + // canonical history. The adopted send must resolve with the live run: without the + // ownership transfer the row-id pending run times out and surfaces a spurious error + // for a turn that was delivered. + gateway.echoDeliveredSendsInHistory = true + chat.handleGatewayEvent("chat", chatTerminalPayload("main", "gw-run-9", seq = 1, state = "final", assistantText = "done")) + advanceTimeBy(130_000) + assertEquals(0, chat.pendingRunCount.value) + assertTrue(outbox.rows.isEmpty()) + assertNull(chat.errorText.value) + } + + @Test + fun failedSessionPinKeepsTheRowQueuedInsteadOfDispatching() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + outbox.seed( + ChatOutboxItem( + id = "alias-row", + sessionKey = "main", + text = "captured pre-hello", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + chat.load("main") + chat.applyMainSessionKey("agent:main:main") + advanceUntilIdle() + + // The durable pin is the only record of the alias resolution; if it cannot persist, + // dispatching anyway would let a retry after a default change target another session. + outbox.pinSessionKeyFailure = IllegalStateException("storage unavailable") + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertTrue(gateway.sentMessages.isEmpty()) + assertEquals(ChatOutboxStatus.Queued, outbox.rows.getValue("alias-row").status) + assertFalse(chat.healthOk.value) + + // Storage recovers; the next health transition pins and delivers exactly once. + outbox.pinSessionKeyFailure = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(listOf("agent:main:main"), gateway.sentSessionKeys) + assertTrue(outbox.rows.values.none { it.sessionKey == "main" }) + } + + @Test + fun reconcileParkWriteFailureFailsClosedThenParksAfterRecovery() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.echoDeliveredSendsInHistory = false + outbox.seed( + ChatOutboxItem( + id = "orphan-row", + sessionKey = "main", + text = "ambiguous send", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Accepted, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + chat.load("main") + advanceUntilIdle() + + // Two sightings without proof want to park the row, but the write fails: health drops + // instead of the reconciler claiming a change it never persisted. + outbox.failedStatusUpdateFailure = IllegalStateException("storage unavailable") + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(ChatOutboxStatus.Accepted, outbox.rows.getValue("orphan-row").status) + assertFalse(chat.healthOk.value) + + // Storage recovers; the next pass parks the orphan for manual review. + outbox.failedStatusUpdateFailure = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + val parked = outbox.rows.getValue("orphan-row") + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, parked.lastError) + } + + @Test + fun staleGatedParkFailureFailsClosedInsteadOfSpinning() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + outbox.seed( + ChatOutboxItem( + id = "stale-command", + sessionKey = "main", + text = "/clear", + thinkingLevel = "off", + createdAtMs = System.currentTimeMillis(), + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + gatedEpoch = 5L, + ownerAgentId = "main", + ), + ) + chat.load("main") + advanceUntilIdle() + + // The park write fails; the flush must drop health and stop instead of reloading the + // same stale row forever on a healthy connection. + outbox.failedStatusUpdateFailure = IllegalStateException("storage unavailable") + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertFalse(chat.healthOk.value) + assertEquals(ChatOutboxStatus.Queued, outbox.rows.getValue("stale-command").status) + assertTrue(gateway.sentMessages.isEmpty()) + + // Storage recovers; the next health transition parks the stale command for review. + outbox.failedStatusUpdateFailure = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + val parked = outbox.rows.getValue("stale-command") + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_CONNECTION_CHANGED_ERROR, parked.lastError) + assertTrue(gateway.sentMessages.isEmpty()) + } + + @Test + fun orphanedAcceptedHeadBlocksItsSessionUntilReconciliationParksIt() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val now = System.currentTimeMillis() + outbox.seed( + ChatOutboxItem( + id = "ambiguous-a", + sessionKey = "agent:a:main", + text = "unresolved head", + thinkingLevel = "off", + createdAtMs = now, + status = ChatOutboxStatus.Accepted, + retryCount = 0, + lastError = null, + ownerAgentId = "a", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "queued-a", + sessionKey = "agent:a:main", + text = "blocked successor", + thinkingLevel = "off", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "a", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "queued-b", + sessionKey = "agent:b:main", + text = "independent session", + thinkingLevel = "off", + createdAtMs = now + 2, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "b", + ), + ) + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + // The unproven accepted head held its session while the unrelated session flowed first; + // once reconciliation parked it for review, the released successor followed. Full virtual + // idle also reaches both hidden runs' proof deadlines, so their accepted rows park too. + assertEquals(listOf("independent session", "blocked successor"), gateway.sentMessages) + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue("ambiguous-a").status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, outbox.rows.getValue("ambiguous-a").lastError) + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue("queued-a").status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, outbox.rows.getValue("queued-a").lastError) + assertEquals(ChatOutboxStatus.Failed, outbox.rows.getValue("queued-b").status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, outbox.rows.getValue("queued-b").lastError) + } + + @Test + fun unconfirmedTimeoutParksTheAcceptedRowForReview() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + // The gateway accepts the dispatch but its turn never reaches canonical history. + gateway.echoDeliveredSendsInHistory = false + chat.sendMessageAwaitAcceptance(message = "never confirmed", thinkingLevel = "off", attachments = emptyList()) + runCurrent() + assertEquals( + ChatOutboxStatus.Accepted, + outbox.rows.values + .single() + .status, + ) + + // Run ownership expires without proof; the row surfaces for manual review. + advanceUntilIdle() + val parked = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, parked.lastError) + } + + @Test + fun callerCancellationAfterTheClaimDoesNotStrandTheDirectSend() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + // The UI scope dies (screen leaves composition) while the dispatch is suspended on the + // gateway response; the controller-owned dispatch must still settle the claimed row. + val gate = CompletableDeferred() + gateway.sendGate = gate + val callerJob = SupervisorJob() + val caller = CoroutineScope(coroutineContext + callerJob) + caller.launch { + chat.sendMessageAwaitAcceptance(message = "survives caller death", thinkingLevel = "off", attachments = emptyList()) + } + runCurrent() + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .single() + .status, + ) + callerJob.cancel() + gate.complete(Unit) + advanceUntilIdle() + + // Delivered exactly once and retired by canonical history proof; nothing stranded. + assertEquals(listOf("survives caller death"), gateway.sentMessages) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun directSendClaimFailureHandsDeliveryToTheFlushLane() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + // The durable claim itself cannot be persisted; the admitted row must not be reported + // as sent-with-no-owner. The flush lane takes over and fails closed on the same error. + outbox.sendingStatusUpdateFailure = IllegalStateException("storage unavailable") + val accepted = chat.sendMessageAwaitAcceptance(message = "owned by flush", thinkingLevel = "off", attachments = emptyList()) + advanceUntilIdle() + assertTrue(accepted) + assertEquals( + ChatOutboxStatus.Queued, + outbox.rows.values + .single() + .status, + ) + assertTrue(gateway.sentMessages.isEmpty()) + assertFalse(chat.healthOk.value) + + // Storage recovers; the next health transition delivers the queued row exactly once. + outbox.sendingStatusUpdateFailure = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(listOf("owned by flush"), gateway.sentMessages) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun directSendPersistenceFailureRearmsRecoveryInsteadOfStrandingSending() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + // The acknowledged transition to accepted cannot be made durable mid-direct-send. + gateway.echoDeliveredSendsInHistory = false + outbox.acceptedStatusUpdateFailure = IllegalStateException("storage unavailable") + chat.sendMessageAwaitAcceptance(message = "stranded claim", thinkingLevel = "off", attachments = emptyList()) + runCurrent() + assertEquals( + ChatOutboxStatus.Sending, + outbox.rows.values + .single() + .status, + ) + assertFalse(chat.healthOk.value) + + // The re-armed recovery sweep parks the row on the next health transition, so the + // session is not blocked forever by a claim with no user action available. + outbox.acceptedStatusUpdateFailure = null + chat.handleGatewayEvent("health", null) + advanceTimeBy(5_000) + runCurrent() + val parked = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, parked.lastError) + } + + @Test + fun notEnqueuedDirectSendKeepsTheJournaledRowQueuedForReconnect() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + assertTrue(chat.healthOk.value) + + // The frame never enters the socket queue mid-direct-send; the durable copy must stay + // queued for reconnect instead of being deleted with only the volatile draft left. + gateway.sendFailureBeforeDispatch = GatewayRequestNotEnqueued("gateway send failed") + val accepted = chat.sendMessageAwaitAcceptance(message = "survives direct drop", thinkingLevel = "off", attachments = emptyList()) + assertTrue(accepted) + val row = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Queued, row.status) + assertFalse(chat.healthOk.value) + assertTrue(gateway.sentMessages.isEmpty()) + + gateway.sendFailureBeforeDispatch = null + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + assertEquals(listOf("survives direct drop"), gateway.sentMessages) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun directDispatchWaitsForStartupRecoveryBeforeClaimingItsRow() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val recoveryGate = CompletableDeferred() + outbox.recoveryGate = recoveryGate + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + runCurrent() + chat.setThinkingLevel("off") + + chat.sendMessage(message = "waits for recovery", thinkingLevel = "off", attachments = emptyList()) + runCurrent() + try { + // The row is journaled but must not be claimed 'sending' while the unscoped recovery + // sweep is pending, or the sweep would park this live dispatch as unconfirmed. + assertTrue(gateway.sentMessages.isEmpty()) + val row = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Queued, row.status) + } finally { + recoveryGate.complete(Unit) + } + advanceUntilIdle() + assertEquals(listOf("waits for recovery"), gateway.sentMessages) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun ambiguousDirectSendKeepsTheComposerClearBecauseTheRowOwnsTheInput() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + gateway.sendFailureAfterDispatch = IllegalStateException("transport wedged") + val accepted = chat.sendMessageAwaitAcceptance(message = "kept by the row", thinkingLevel = "off", attachments = emptyList()) + + // The dispatch outcome is unknown, so the journaled row parks for review and owns the + // input; a false return would restore a duplicate draft into the composer. + assertTrue(accepted) + val parked = outbox.rows.values.single() + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, parked.lastError) + assertEquals(1, gateway.sentMessages.size) + } + + @Test + fun historyProofOnABlockedHeadReleasesItsQueuedSuccessorInTheSameFlush() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val now = System.currentTimeMillis() + outbox.seed( + ChatOutboxItem( + id = "head", + sessionKey = "main", + text = "delivered before restart", + thinkingLevel = "off", + createdAtMs = now, + status = ChatOutboxStatus.Accepted, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "tail", + sessionKey = "main", + text = "blocked successor", + thinkingLevel = "off", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + // Canonical history already carries the head's turn from the previous process. + gateway.historyMessagesJson = + """[{"role":"user","content":"delivered before restart","timestamp":5,"idempotencyKey":"head:user"},""" + + """{"role":"assistant","content":"r","timestamp":6,"idempotencyKey":"head:assistant"}]""" + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + // Confirming the head must restart the drain so the released successor actually sends. + assertEquals(listOf("blocked successor"), gateway.sentMessages) + assertFalse(outbox.rows.containsKey("head")) + assertFalse(outbox.rows.containsKey("tail")) + } + + @Test + fun retryingAnUnconfirmedHeadWhileOfflineKeepsItAheadOfQueuedSuccessors() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val now = System.currentTimeMillis() + outbox.seed( + ChatOutboxItem( + id = "head", + sessionKey = "main", + text = "ambiguous head", + thinkingLevel = "off", + createdAtMs = now, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR, + ownerAgentId = "main", + ), + ) + outbox.seed( + ChatOutboxItem( + id = "tail", + sessionKey = "main", + text = "younger successor", + thinkingLevel = "off", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ), + ) + val chat = controller(this, gateway, outbox) + chat.load("main") + advanceUntilIdle() + + // Retry while still offline: the head re-queues ahead of its still-queued successor, so + // the reconnect flush cannot deliver younger turns before the turn the user retried. + chat.retryOutboxCommand("head") + advanceUntilIdle() + gateway.online = true + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(listOf("ambiguous head", "younger successor"), gateway.sentMessages) + assertTrue(outbox.rows.isEmpty()) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt new file mode 100644 index 0000000..157e500 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt @@ -0,0 +1,158 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerPlanStreamTest { + private val json = Json { ignoreUnknownKeys = true } + + private data class StartedRun( + val controller: ChatController, + val gateway: ScriptedGateway, + val runId: String, + ) + + private suspend fun TestScope.startRun(): StartedRun { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = ChatController(scope = this, json = json, requestGateway = gateway::request) + controller.handleGatewayEvent("health", null) + assertTrue(controller.sendMessageAwaitAcceptance("make a plan", "off", emptyList())) + return StartedRun(controller = controller, gateway = gateway, runId = requireNotNull(gateway.lastRunId)) + } + + private fun planPayload( + runId: String, + data: String, + ): String = """{"sessionKey":"main","runId":"$runId","seq":1,"ts":10,"stream":"plan","data":$data}""" + + @Test + fun typedStepsAreParsedAndMalformedEntriesAreDropped() = + runTest { + val (controller, _, runId) = startRun() + + controller.handleGatewayEvent( + "agent", + planPayload( + runId, + """{"phase":"update","steps":[{"step":" Inspect ","status":"completed"},{"step":"Patch","status":"in_progress"},{"step":"Test","status":"pending"},{"step":" ","status":"pending"},{"step":"Unknown","status":"blocked"},{"step":42,"status":"pending"},42]}""", + ), + ) + + assertEquals( + listOf( + ChatPlanStep(step = "Inspect", status = ChatPlanStepStatus.Completed), + ChatPlanStep(step = "Patch", status = ChatPlanStepStatus.InProgress), + ChatPlanStep(step = "Test", status = ChatPlanStepStatus.Pending), + ), + controller.planSteps.value, + ) + } + + @Test + fun legacyStringStepsBecomePending() = + runTest { + val (controller, _, runId) = startRun() + + controller.handleGatewayEvent( + "agent", + planPayload(runId, """{"phase":"update","steps":[" First "," ","Second"]}"""), + ) + + assertEquals( + listOf( + ChatPlanStep(step = "First", status = ChatPlanStepStatus.Pending), + ChatPlanStep(step = "Second", status = ChatPlanStepStatus.Pending), + ), + controller.planSteps.value, + ) + } + + @Test + fun laterSnapshotReplacesEarlierSnapshot() = + runTest { + val (controller, _, runId) = startRun() + + controller.handleGatewayEvent( + "agent", + planPayload(runId, """{"phase":"update","steps":[{"step":"First","status":"in_progress"},{"step":"Second","status":"pending"}]}"""), + ) + controller.handleGatewayEvent( + "agent", + planPayload(runId, """{"phase":"update","steps":[{"step":"Replacement","status":"completed"}]}"""), + ) + + assertEquals( + listOf(ChatPlanStep(step = "Replacement", status = ChatPlanStepStatus.Completed)), + controller.planSteps.value, + ) + } + + @Test + fun emptyOrExplanationOnlySnapshotClearsPlan() = + runTest { + val (controller, _, runId) = startRun() + val populated = """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}""" + + controller.handleGatewayEvent("agent", planPayload(runId, populated)) + controller.handleGatewayEvent("agent", planPayload(runId, """{"phase":"update","steps":[]}""")) + assertTrue(controller.planSteps.value.isEmpty()) + + controller.handleGatewayEvent("agent", planPayload(runId, populated)) + controller.handleGatewayEvent("agent", planPayload(runId, """{"phase":"update","explanation":"Revising"}""")) + assertTrue(controller.planSteps.value.isEmpty()) + } + + @Test + fun terminalRunClearsPlan() = + runTest { + val (controller, gateway, runId) = startRun() + controller.handleGatewayEvent( + "agent", + planPayload(runId, """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}"""), + ) + gateway.respondWith("chat.history", historyResponse(sessionId = "session-1", messages = emptyList())) + + controller.handleGatewayEvent("chat", chatTerminalPayload("main", runId, seq = 2)) + + assertTrue(controller.planSteps.value.isEmpty()) + } + + @Test + fun terminalEventForAnotherRunPreservesActivePlan() = + runTest { + val (controller, gateway, runId) = startRun() + val expected = listOf(ChatPlanStep(step = "Active", status = ChatPlanStepStatus.InProgress)) + controller.handleGatewayEvent( + "agent", + planPayload(runId, """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}"""), + ) + gateway.respondWith("chat.history", historyResponse(sessionId = "session-1", messages = emptyList())) + + controller.handleGatewayEvent("chat", chatTerminalPayload("main", "other-run", seq = 2)) + + assertEquals(expected, controller.planSteps.value) + } + + @Test + fun wrongRunCannotReplaceCurrentPlan() = + runTest { + val (controller, _, runId) = startRun() + val expected = listOf(ChatPlanStep(step = "Owned", status = ChatPlanStepStatus.InProgress)) + controller.handleGatewayEvent( + "agent", + planPayload(runId, """{"phase":"update","steps":[{"step":"Owned","status":"in_progress"}]}"""), + ) + + controller.handleGatewayEvent( + "agent", + planPayload("other-run", """{"phase":"update","steps":[{"step":"Foreign","status":"completed"}]}"""), + ) + + assertEquals(expected, controller.planSteps.value) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt new file mode 100644 index 0000000..e036db3 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt @@ -0,0 +1,1857 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Reconnect recovery scenarios: after a gateway disconnect, the next health event + * refetches chat.history and re-adopts the run the gateway still reports in flight + * (`inFlightRun`), matching the reconnect snapshot contract the TUI consumes. + */ +class ChatControllerReconnectRestoreTest { + private val json = Json { ignoreUnknownKeys = true } + + // The controller runs on backgroundScope: while a restored run stays in flight the + // pending-run watchdog keeps re-arming, so its timer must be cancelled by runTest + // instead of counting as an uncompleted test coroutine. + private fun TestScope.newController(gateway: ScriptedGateway): ChatController = ChatController(scope = backgroundScope, json = json, requestGateway = gateway::request) + + private fun TestScope.newScopedController(gateway: ScriptedGateway): ChatController = + ChatController( + scope = backgroundScope, + json = json, + requestGateway = gateway::request, + requestGatewayForGateway = { _, method, paramsJson -> gateway.request(method, paramsJson) }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, + ) + + private val userTurn = ReplayHistoryMessage("user", "keep working", 1_000) + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun connectedRefreshUpsertsDeviceSessionBeforeLoadingHistory() = + runTest { + val sessionKey = "agent:main:node-device" + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.describe", """{"session":null}""") + gateway.respondWith("sessions.patch", """{"ok":true,"key":"$sessionKey"}""") + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + + controller.load("agent:main:custom") + runCurrent() + gateway.calls.clear() + controller.prepareAndSelectMainSessionKey(sessionKey) + controller.onGatewayConnected(MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device")) + runCurrent() + + val describeIndex = gateway.calls.indexOfFirst { it.method == "sessions.describe" } + val patchIndex = gateway.calls.indexOfFirst { it.method == "sessions.patch" } + val historyIndex = gateway.calls.indexOfFirst { it.method == "chat.history" } + assertTrue(describeIndex >= 0) + assertTrue(patchIndex > describeIndex) + assertTrue(historyIndex > patchIndex) + assertEquals(sessionKey, controller.sessionKey.value) + val patchParams = json.parseToJsonElement(gateway.calls[patchIndex].paramsJson.orEmpty()).jsonObject + assertEquals(sessionKey, patchParams["key"]?.jsonPrimitive?.content) + assertEquals("OpenClaw App · Pixel · device", patchParams["label"]?.jsonPrimitive?.content) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun connectedRefreshContinuesWhenSessionAdoptionFails() = + runTest { + val sessionKey = "agent:main:node-device" + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.describe", """{"session":null}""") + gateway.respond("sessions.patch") { error("patch unavailable") } + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + + controller.prepareMainSessionKey(sessionKey) + controller.onGatewayConnected(MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device")) + runCurrent() + + assertEquals(1, gateway.callCount("sessions.patch")) + assertEquals(1, gateway.callCount("chat.history")) + assertEquals(sessionKey, controller.sessionKey.value) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun connectedRefreshLabelsExistingSessionWithoutRecreatingIt() = + runTest { + val sessionKey = "agent:main:node-device" + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.describe", """{"session":{"key":"$sessionKey"}}""") + gateway.respondWith("sessions.patch", """{"ok":true,"key":"$sessionKey"}""") + gateway.respondWith("chat.history", historyResponse("existing-session", listOf(userTurn))) + val controller = newScopedController(gateway) + + controller.prepareMainSessionKey(sessionKey) + controller.onGatewayConnected(MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device")) + runCurrent() + + assertEquals(0, gateway.callCount("sessions.create")) + val patchIndex = gateway.calls.indexOfFirst { it.method == "sessions.patch" } + val historyIndex = gateway.calls.indexOfFirst { it.method == "chat.history" } + assertTrue(patchIndex >= 0) + assertTrue(historyIndex > patchIndex) + val patchParams = json.parseToJsonElement(gateway.calls[patchIndex].paramsJson.orEmpty()).jsonObject + assertEquals(sessionKey, patchParams["key"]?.jsonPrimitive?.content) + assertEquals("OpenClaw App · Pixel · device", patchParams["label"]?.jsonPrimitive?.content) + assertEquals(listOf("keep working"), controller.messages.value.map { it.content.first().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun agentSelectionAcknowledgesUnreadDeviceSession() = + runTest { + val sessionKey = "agent:main:node-device" + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.describe", + """{"session":{"key":"$sessionKey","label":"OpenClaw App · Pixel · device"}}""", + ) + gateway.respondWith("sessions.patch", """{"ok":true,"key":"$sessionKey"}""") + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"patch","sessionKey":"$sessionKey","session":{"key":"$sessionKey","unread":true}}""", + ) + + controller.prepareAndSelectMainSessionKey(sessionKey) + controller.onGatewayConnected(MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device")) + runCurrent() + + val patchParams = + gateway.calls + .first { it.method == "sessions.patch" } + .paramsJson + .orEmpty() + assertTrue(patchParams.contains("\"key\":\"$sessionKey\"")) + assertTrue(patchParams.contains("\"unread\":false")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectRevalidatesWithoutOverwritingExistingLabel() = + runTest { + val sessionKey = "agent:main:node-device" + val gateway = ScriptedGateway(json) + var storedLabel: String? = null + gateway.respond("sessions.describe") { + storedLabel?.let { """{"session":{"key":"$sessionKey","label":"$it"}}""" } + ?: """{"session":null}""" + } + gateway.respond("sessions.patch") { paramsJson -> + storedLabel = + json + .parseToJsonElement(paramsJson.orEmpty()) + .jsonObject["label"] + ?.jsonPrimitive + ?.content + """{"ok":true,"key":"$sessionKey"}""" + } + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + val binding = MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device") + + controller.prepareMainSessionKey(sessionKey) + controller.onGatewayConnected(binding) + runCurrent() + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected(binding) + runCurrent() + + assertEquals(1, gateway.callCount("sessions.patch")) + assertEquals(2, gateway.callCount("sessions.describe")) + assertEquals(2, gateway.callCount("chat.history")) + + storedLabel = "My Android session" + controller.onGatewayConnected(binding.copy(label = "OpenClaw App · Renamed · device")) + runCurrent() + + assertEquals(1, gateway.callCount("sessions.patch")) + assertEquals(3, gateway.callCount("sessions.describe")) + assertEquals(3, gateway.callCount("chat.history")) + assertEquals("My Android session", storedLabel) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun agentSwitchWaitsForTheLatestSessionAdoption() = + runTest { + val firstDescribe = CompletableDeferred() + val gateway = ScriptedGateway(json) + gateway.respond("sessions.describe") { paramsJson -> + val key = + json + .parseToJsonElement(paramsJson.orEmpty()) + .jsonObject["key"] + ?.jsonPrimitive + ?.content + if (key == "agent:first:node-device") firstDescribe.await() else """{"session":null}""" + } + gateway.respond("sessions.patch") { paramsJson -> + val key = + json + .parseToJsonElement(paramsJson.orEmpty()) + .jsonObject["key"] + ?.jsonPrimitive + ?.content + """{"ok":true,"key":"$key"}""" + } + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + + controller.prepareAndSelectMainSessionKey("agent:first:node-device") + controller.onGatewayConnected(MainSessionBinding("agent:first:node-device", "OpenClaw App · Pixel · device")) + runCurrent() + controller.prepareAndSelectMainSessionKey("agent:second:node-device") + controller.onGatewayConnected(MainSessionBinding("agent:second:node-device", "OpenClaw App · Pixel · device")) + controller.refresh() + runCurrent() + + val patchCalls = gateway.calls.withIndex().filter { it.value.method == "sessions.patch" } + val patchIndex = patchCalls.single().index + val historyCalls = gateway.calls.withIndex().filter { it.value.method == "chat.history" } + val patchParams = + patchCalls + .single() + .value + .paramsJson + .orEmpty() + val patchedKey = + json + .parseToJsonElement(patchParams) + .jsonObject["key"] + ?.jsonPrimitive + ?.content + assertEquals("agent:second:node-device", patchedKey) + assertTrue(historyCalls.isNotEmpty()) + assertTrue(historyCalls.all { it.index > patchIndex }) + assertTrue(historyCalls.all { gateway.sessionKeyOf(it.value.paramsJson) == "agent:second:node-device" }) + assertEquals("agent:second:node-device", controller.sessionKey.value) + + // The cancelled response must remain inert even if its server-side work completes later. + firstDescribe.complete("""{"session":null}""") + runCurrent() + assertEquals(1, gateway.callCount("sessions.patch")) + assertTrue(gateway.calls.none { it.method == "chat.history" && gateway.sessionKeyOf(it.paramsJson) == "agent:first:node-device" }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectRecoveryWaitsForSessionReadiness() = + runTest { + val sessionKey = "agent:main:node-device" + val reconnectDescribe = CompletableDeferred() + var reconnecting = false + val gateway = ScriptedGateway(json) + gateway.respond("sessions.describe") { + if (reconnecting) { + reconnectDescribe.await() + } else { + """{"session":{"key":"$sessionKey","label":"OpenClaw App · Pixel · device"}}""" + } + } + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + val binding = MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device") + + controller.prepareMainSessionKey(sessionKey) + controller.onGatewayConnected(binding) + runCurrent() + val historyCallsBeforeReconnect = gateway.callCount("chat.history") + controller.onDisconnected("Reconnecting…") + reconnecting = true + controller.onGatewayConnected(binding) + controller.handleGatewayEvent("tick", null) + runCurrent() + + assertEquals(historyCallsBeforeReconnect, gateway.callCount("chat.history")) + reconnectDescribe.complete( + """{"session":{"key":"$sessionKey","label":"OpenClaw App · Pixel · device"}}""", + ) + runCurrent() + assertTrue(gateway.callCount("chat.history") > historyCallsBeforeReconnect) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectCancelsStaleAdoptionAndRetriesOnTheNewTransport() = + runTest { + val sessionKey = "agent:main:node-device" + val staleDescribe = CompletableDeferred() + var describeCalls = 0 + val gateway = ScriptedGateway(json) + gateway.respond("sessions.describe") { + describeCalls += 1 + if (describeCalls == 1) { + staleDescribe.await() + } else { + """{"session":{"key":"$sessionKey","label":"OpenClaw App · Pixel · device"}}""" + } + } + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + val binding = MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device") + + controller.prepareMainSessionKey(sessionKey) + controller.onGatewayConnected(binding) + runCurrent() + assertEquals(1, describeCalls) + + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected(binding) + runCurrent() + + assertEquals(2, describeCalls) + assertEquals(1, gateway.callCount("chat.history")) + assertEquals(sessionKey, controller.sessionKey.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectUpsertsSessionDeletedWhileDisconnected() = + runTest { + val sessionKey = "agent:main:node-device" + val gateway = ScriptedGateway(json) + var sessionExists = false + gateway.respond("sessions.describe") { + if (sessionExists) { + """{"session":{"key":"$sessionKey","label":"OpenClaw App · Pixel · device"}}""" + } else { + """{"session":null}""" + } + } + gateway.respond("sessions.patch") { + sessionExists = true + """{"ok":true,"key":"$sessionKey"}""" + } + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newScopedController(gateway) + val binding = MainSessionBinding(sessionKey, "OpenClaw App · Pixel · device") + + controller.prepareMainSessionKey(sessionKey) + controller.onGatewayConnected(binding) + runCurrent() + sessionExists = false + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected(binding) + runCurrent() + + assertEquals(2, gateway.callCount("sessions.describe")) + assertEquals(2, gateway.callCount("sessions.patch")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectAdoptsInFlightRunAndConsumesLiveEvents() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", listOf(userTurn))) + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertEquals(0, controller.pendingRunCount.value) + + controller.onDisconnected("Reconnecting…") + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "partial reply"), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("partial reply", controller.streamingAssistantText.value) + assertEquals(1, controller.messages.value.size) + + // The adopted run keeps consuming live deltas and its terminal event. + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", "run-active", 5, " more", "partial reply more"), + ) + assertEquals("partial reply more", controller.streamingAssistantText.value) + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(userTurn, ReplayHistoryMessage("assistant", "partial reply more", 2_000)), + ), + ) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", "run-active", seq = 6, assistantText = "partial reply more"), + ) + runCurrent() + + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + assertEquals(2, controller.messages.value.size) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectRestoresInFlightPlanSnapshot() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-active" to "working", + inFlightPlan = + ChatPlanSnapshot( + steps = + listOf( + ChatPlanStep("Inspect", ChatPlanStepStatus.Completed), + ChatPlanStep("Reconnect", ChatPlanStepStatus.InProgress), + ), + explanation = "Restore checklist", + ), + ), + ) + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected() + runCurrent() + + assertEquals( + listOf( + ChatPlanStep("Inspect", ChatPlanStepStatus.Completed), + ChatPlanStep("Reconnect", ChatPlanStepStatus.InProgress), + ), + controller.planSteps.value, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun historyPlanReconciliationContract() = + runTest { + val retainedSteps = listOf(ChatPlanStep("Retained", ChatPlanStepStatus.InProgress)) + + data class Case( + val name: String, + val history: String, + val expectedSteps: List, + val staleAfterLivePlan: Boolean = false, + val snapshotForNewLiveRun: ChatPlanSnapshot? = null, + val gatewayScopeChange: Boolean = false, + ) + + val cases = + listOf( + Case( + name = "replace", + history = + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-retained" to "working", + inFlightPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("Replacement", ChatPlanStepStatus.Completed)), + ), + ), + expectedSteps = listOf(ChatPlanStep("Replacement", ChatPlanStepStatus.Completed)), + ), + Case( + name = "legacy-preserve", + history = + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-retained" to "working", + ), + expectedSteps = retainedSteps, + ), + Case( + name = "superseded", + history = + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-next" to "next", + inFlightPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("Next run", ChatPlanStepStatus.InProgress)), + ), + ), + expectedSteps = listOf(ChatPlanStep("Next run", ChatPlanStepStatus.InProgress)), + ), + Case( + name = "active-preserve", + history = + historyResponse( + "session-1", + emptyList(), + hasActiveRun = true, + activeRunIds = listOf("run-retained"), + ), + expectedSteps = retainedSteps, + ), + Case( + name = "terminal-clear", + history = + historyResponse( + "session-1", + emptyList(), + hasActiveRun = false, + activeRunIds = emptyList(), + ), + expectedSteps = emptyList(), + ), + Case( + name = "no-evidence-preserve", + history = + historyResponse( + "session-1", + emptyList(), + hasActiveRun = null, + activeRunIds = null, + ), + expectedSteps = retainedSteps, + ), + Case( + name = "stale-response-does-not-clobber-newer-live-plan", + history = + historyResponse( + "session-1", + emptyList(), + hasActiveRun = false, + activeRunIds = emptyList(), + ), + expectedSteps = listOf(ChatPlanStep("New live plan", ChatPlanStepStatus.InProgress)), + staleAfterLivePlan = true, + ), + Case( + name = "stale-previous-run-snapshot-does-not-clobber-newer-live-plan", + history = + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-previous" to "stale", + inFlightPlan = ChatPlanSnapshot(steps = emptyList()), + ), + expectedSteps = listOf(ChatPlanStep("New live plan", ChatPlanStepStatus.InProgress)), + staleAfterLivePlan = true, + ), + Case( + name = "snapshot-for-newer-owned-run-is-accepted", + history = historyResponse("session-1", emptyList()), + expectedSteps = listOf(ChatPlanStep("Matching snapshot", ChatPlanStepStatus.Completed)), + staleAfterLivePlan = true, + snapshotForNewLiveRun = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("Matching snapshot", ChatPlanStepStatus.Completed)), + ), + ), + Case( + name = "explicit-empty-clears", + history = + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-retained" to "working", + inFlightPlan = ChatPlanSnapshot(steps = emptyList()), + ), + expectedSteps = emptyList(), + ), + Case( + name = "gateway-scope-change-clears", + history = historyResponse("session-1", emptyList()), + expectedSteps = emptyList(), + gatewayScopeChange = true, + ), + ) + + for (testCase in cases) { + val gateway = ScriptedGateway(json) + if (testCase.staleAfterLivePlan) { + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + } else { + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + emptyList(), + inFlightRun = "run-retained" to "working", + inFlightPlan = ChatPlanSnapshot(steps = retainedSteps), + ), + ) + } + val controller = newController(gateway) + controller.load("main") + runCurrent() + + if (testCase.staleAfterLivePlan) { + val historyStarted = CompletableDeferred() + val releaseHistory = CompletableDeferred() + gateway.respond("chat.history") { + historyStarted.complete(Unit) + releaseHistory.await() + } + gateway.respondChatSend(status = "started") + controller.refresh() + runCurrent() + historyStarted.await() + assertTrue(controller.sendMessageAwaitAcceptance("new work", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"$runId","seq":1,"ts":10,"stream":"plan","data":{"phase":"update","steps":[{"step":"New live plan","status":"in_progress"}]}}""", + ) + releaseHistory.complete( + testCase.snapshotForNewLiveRun?.let { plan -> + historyResponse( + "session-1", + emptyList(), + inFlightRun = runId to "matching", + inFlightPlan = plan, + ) + } ?: testCase.history, + ) + runCurrent() + assertEquals(testCase.name, 1, controller.pendingRunCount.value) + } else if (testCase.gatewayScopeChange) { + controller.onGatewayScopeChanging() + runCurrent() + } else { + gateway.respondWith("chat.history", testCase.history) + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected() + runCurrent() + } + + assertEquals(testCase.name, testCase.expectedSteps, controller.planSteps.value) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectWithoutInFlightRunStaysClean() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", listOf(userTurn))) + val controller = newController(gateway) + controller.load("main") + runCurrent() + val historyCallsAfterLoad = gateway.callCount("chat.history") + val metadataCallsAfterLoad = gateway.callCount("chat.metadata") + + controller.onDisconnected("Offline") + controller.onGatewayConnected() + runCurrent() + + // Reconnect refetched history once and restored nothing. + assertEquals(historyCallsAfterLoad + 1, gateway.callCount("chat.history")) + assertEquals(metadataCallsAfterLoad + 1, gateway.callCount("chat.metadata")) + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + assertTrue(controller.healthOk.value) + assertEquals(1, controller.messages.value.size) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectStaysUnhealthyUntilRecoveryHistoryApplies() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + val recoveryHistory = CompletableDeferred() + gateway.respond("chat.history") { recoveryHistory.await() } + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected() + runCurrent() + + assertFalse(controller.healthOk.value) + val healthCallsDuringRecovery = gateway.callCount("health") + val historyCallsDuringRecovery = gateway.callCount("chat.history") + controller.handleGatewayEvent("tick", null) + runCurrent() + assertFalse(controller.healthOk.value) + assertEquals(healthCallsDuringRecovery, gateway.callCount("health")) + assertEquals(historyCallsDuringRecovery + 1, gateway.callCount("chat.history")) + + recoveryHistory.complete(historyResponse("session-1", emptyList())) + runCurrent() + assertTrue(controller.healthOk.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun newerSameGenerationHistoryRequestCompletesReconnectHealth() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "working"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + val reconnectHistoryStarted = CompletableDeferred() + val releaseReconnectHistory = CompletableDeferred() + var recoveryHistoryCalls = 0 + gateway.respond("chat.history") { + recoveryHistoryCalls += 1 + if (recoveryHistoryCalls == 1) { + reconnectHistoryStarted.complete(Unit) + releaseReconnectHistory.await() + } else { + historyResponse( + "session-1", + listOf(userTurn, ReplayHistoryMessage("assistant", "done", 2_000)), + ) + } + } + + controller.onDisconnected("Reconnecting…") + controller.onGatewayConnected() + runCurrent() + reconnectHistoryStarted.await() + assertFalse(controller.healthOk.value) + + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", "run-active", seq = 2, assistantText = "done"), + ) + runCurrent() + + assertTrue(controller.healthOk.value) + assertEquals(listOf("keep working", "done"), controller.messages.value.map { it.content.single().text }) + + releaseReconnectHistory.complete(historyResponse("session-1", emptyList())) + runCurrent() + assertTrue(controller.healthOk.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun recoveredPendingRunRefreshesHistoryBeforeTimingOut() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "working"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertEquals(1, controller.pendingRunCount.value) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"run-active","seq":2,"ts":10,"stream":"tool","data":{"phase":"start","name":"exec","toolCallId":"tool-1"}}""", + ) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(userTurn, ReplayHistoryMessage("assistant", "completed while offline", 2_000)), + ), + ) + advanceTimeBy(120_000) + runCurrent() + + assertEquals(0, controller.pendingRunCount.value) + assertEquals( + listOf("keep working", "completed while offline"), + controller.messages.value.map { it.content.single().text }, + ) + assertNull(controller.errorText.value) + assertNull(controller.streamingAssistantText.value) + assertTrue(controller.pendingToolCalls.value.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun recoveredPendingRunStopsWatchdogWhenRefreshFails() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "working"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertEquals(1, controller.pendingRunCount.value) + + gateway.respond("chat.history") { error("history unavailable") } + advanceTimeBy(120_000) + runCurrent() + + assertEquals(2, gateway.callCount("chat.history")) + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + + advanceTimeBy(120_000) + runCurrent() + assertEquals(2, gateway.callCount("chat.history")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun newerRecoverySnapshotCanSupersedePendingRunWatchdogRefresh() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "working"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + val watchdogRefreshStarted = CompletableDeferred() + val releaseWatchdogRefresh = CompletableDeferred() + val newerRefreshStarted = CompletableDeferred() + val releaseNewerRefresh = CompletableDeferred() + var refreshCalls = 0 + gateway.respond("chat.history") { + refreshCalls += 1 + if (refreshCalls == 1) { + watchdogRefreshStarted.complete(Unit) + releaseWatchdogRefresh.await() + } else { + newerRefreshStarted.complete(Unit) + releaseNewerRefresh.await() + } + } + + advanceTimeBy(120_000) + runCurrent() + watchdogRefreshStarted.await() + controller.refresh() + runCurrent() + newerRefreshStarted.await() + releaseWatchdogRefresh.complete( + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "stale working"), + ) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("working", controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + + releaseNewerRefresh.complete( + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "still working"), + ) + runCurrent() + + assertEquals(3, gateway.callCount("chat.history")) + assertEquals(1, controller.pendingRunCount.value) + assertEquals("still working", controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun explicitRefreshClearsPriorHistoryError() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + gateway.respond("chat.history") { error("history unavailable") } + controller.refresh() + runCurrent() + assertEquals("history unavailable", controller.errorText.value) + + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + controller.refresh() + assertNull(controller.errorText.value) + runCurrent() + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun disconnectInvalidatesLateHistoryError() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + val pendingHistory = CompletableDeferred() + gateway.respond("chat.history") { pendingHistory.await() } + controller.refresh() + runCurrent() + controller.onDisconnected("Reconnecting…") + pendingHistory.completeExceptionally(IllegalStateException("socket closed")) + runCurrent() + assertNull(controller.errorText.value) + + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + controller.onGatewayConnected() + assertNull(controller.errorText.value) + runCurrent() + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun disconnectInvalidatesOlderHistorySnapshotBeforeOwnershipRestore() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertTrue(controller.sendMessageAwaitAcceptance("keep ownership", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + + val staleHistory = CompletableDeferred() + gateway.respond("chat.history") { staleHistory.await() } + controller.refresh() + runCurrent() + controller.onDisconnected("Reconnecting…") + staleHistory.complete(historyResponse("session-1", emptyList())) + runCurrent() + + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = runId to "working"), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("working", controller.streamingAssistantText.value) + assertEquals(listOf("keep ownership"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun disconnectAfterGatewayAcceptancePreservesSendWhenAckIsLost() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + val sendStarted = CompletableDeferred() + val releaseSend = CompletableDeferred() + gateway.respond("chat.send") { + sendStarted.complete(Unit) + releaseSend.await() + } + val sendResult = async { controller.sendMessageAwaitAcceptance("accepted before drop", "off", emptyList()) } + sendStarted.await() + val runId = + json + .parseToJsonElement(requireNotNull(gateway.calls.last { it.method == "chat.send" }.paramsJson)) + .jsonObject + .getValue("idempotencyKey") + .jsonPrimitive + .content + + controller.onDisconnected("Reconnecting…") + releaseSend.completeExceptionally(GatewayRequestOutcomeUnknown("socket closed before ACK")) + assertTrue(sendResult.await()) + assertEquals(listOf("accepted before drop"), controller.messages.value.map { it.content.single().text }) + assertNull(controller.errorText.value) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "accepted before drop", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "completed once", 2_000), + ), + ), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(0, controller.pendingRunCount.value) + assertEquals( + listOf("accepted before drop", "completed once"), + controller.messages.value.map { it.content.single().text }, + ) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun lostAckAdoptsCanonicalRunWhilePreservingClientHistoryIdentity() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + gateway.respond("chat.send") { throw GatewayRequestOutcomeUnknown("ACK lost") } + var clientRunId: String? = null + var recoveryHistoryCalls = 0 + gateway.respond("chat.history") { + recoveryHistoryCalls += 1 + clientRunId = + json + .parseToJsonElement(requireNotNull(gateway.calls.last { it.method == "chat.send" }.paramsJson)) + .jsonObject + .getValue("idempotencyKey") + .jsonPrimitive + .content + if (recoveryHistoryCalls == 1) { + historyResponse("session-1", emptyList()) + } else { + historyResponse( + "session-1", + listOf(ReplayHistoryMessage("user", "canonical recovery", 1_000, idempotencyKey = "$clientRunId:user")), + inFlightRun = "canonical-run" to "working", + ) + } + } + assertTrue(controller.sendMessageAwaitAcceptance("canonical recovery", "off", emptyList())) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + + advanceTimeBy(750) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("working", controller.streamingAssistantText.value) + assertEquals( + "$clientRunId:user", + controller.messages.value + .single { it.role == "user" } + .idempotencyKey, + ) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", "canonical-run", 1, " now", "working now"), + ) + assertEquals("working now", controller.streamingAssistantText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun repeatedReconnectsDoNotDuplicateRunOrRows() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "partial"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertEquals(1, controller.pendingRunCount.value) + + repeat(2) { + controller.onDisconnected("Reconnecting…") + assertEquals(0, controller.pendingRunCount.value) + controller.onGatewayConnected() + runCurrent() + } + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("partial", controller.streamingAssistantText.value) + assertEquals(1, controller.messages.value.size) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectKeepsOptimisticUserWhileHistoryPersistenceLags() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("survive reconnect", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + controller.onDisconnected("Reconnecting…") + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = runId to "working"), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("working", controller.streamingAssistantText.value) + assertEquals(listOf("survive reconnect"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectStaleSnapshotCannotReplaceDisconnectedLocalRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("local work", "off", emptyList())) + val localRunId = requireNotNull(gateway.lastRunId) + controller.onDisconnected("Reconnecting…") + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(ReplayHistoryMessage("user", "local work", 1_000, idempotencyKey = "$localRunId:user")), + inFlightRun = "run-stale" to "old text", + ), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", localRunId, 1, "ours", "ours"), + ) + assertEquals("ours", controller.streamingAssistantText.value) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"run-stale","seq":2,"stream":"assistant","data":{"text":"stale agent"}}""", + ) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"run-stale","seq":3,"ts":10,"stream":"tool","data":{"phase":"start","name":"exec","toolCallId":"stale-tool"}}""", + ) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", "run-stale", seq = 4, state = "error"), + ) + assertEquals(1, controller.pendingRunCount.value) + assertEquals("ours", controller.streamingAssistantText.value) + assertTrue(controller.pendingToolCalls.value.isEmpty()) + assertNull(controller.errorText.value) + assertEquals(listOf("local work"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectRetiresPersistedLocalRunBeforeAdoptingOtherRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("local work", "off", emptyList())) + val localRunId = requireNotNull(gateway.lastRunId) + controller.onDisconnected("Reconnecting…") + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "local work", 1_000, idempotencyKey = "$localRunId:user"), + ReplayHistoryMessage("assistant", "local done", 2_000), + ), + inFlightRun = "run-other" to "other working", + ), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("other working", controller.streamingAssistantText.value) + assertEquals(listOf("local work", "local done"), controller.messages.value.map { it.content.single().text }) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", localRunId, 1, "stale", "stale local"), + ) + assertEquals("other working", controller.streamingAssistantText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectReplacesPreviouslyAdoptedRunWithAuthoritativeSnapshotRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = "run-a" to "old work"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertEquals(1, controller.pendingRunCount.value) + assertEquals("old work", controller.streamingAssistantText.value) + + controller.onDisconnected("Reconnecting…") + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = "run-b" to "current work"), + ) + controller.onGatewayConnected() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("current work", controller.streamingAssistantText.value) + controller.handleGatewayEvent("chat", chatDeltaPayload("main", "run-a", 1, " stale", "old work stale")) + assertEquals("current work", controller.streamingAssistantText.value) + controller.handleGatewayEvent("chat", chatDeltaPayload("main", "run-b", 1, " now", "current work now")) + assertEquals("current work now", controller.streamingAssistantText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun seqGapKeepsOptimisticUserWhileHistoryPersistenceLags() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("survive gap", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = runId to "working"), + ) + controller.handleGatewayEvent("seqGap", null) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("working", controller.streamingAssistantText.value) + assertEquals(listOf("survive gap"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sameSessionRefreshKeepsOptimisticRunOwnership() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("survive refresh", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = runId to "working"), + ) + controller.refresh() + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("working", controller.streamingAssistantText.value) + assertEquals(listOf("survive refresh"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sameSessionRefreshClearsTransientUiForResolvedRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "partial"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"run-active","seq":2,"ts":10,"stream":"tool","data":{"phase":"start","name":"exec","toolCallId":"tool-1"}}""", + ) + assertEquals("partial", controller.streamingAssistantText.value) + assertEquals(1, controller.pendingToolCalls.value.size) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(userTurn, ReplayHistoryMessage("assistant", "complete", 2_000)), + ), + ) + controller.refresh() + runCurrent() + + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + assertTrue(controller.pendingToolCalls.value.isEmpty()) + assertEquals(listOf("keep working", "complete"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun seqGapMissingRunClearsPendingButKeepsOptimisticUser() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("finished during gap", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + controller.handleGatewayEvent("seqGap", null) + runCurrent() + + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + assertEquals(listOf("finished during gap"), controller.messages.value.map { it.content.single().text }) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "finished during gap", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "done", 2_000), + ), + ), + ) + advanceTimeBy(750) + runCurrent() + + assertEquals(listOf("finished during gap", "done"), controller.messages.value.map { it.content.single().text }) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun recoveryRetriesWhenUserPersistsBeforeAssistantReply() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("await reply", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + val persistedUser = ReplayHistoryMessage("user", "await reply", 1_000, idempotencyKey = "$runId:user") + gateway.respondWith("chat.history", historyResponse("session-1", listOf(persistedUser))) + controller.handleGatewayEvent("seqGap", null) + runCurrent() + assertEquals(listOf("await reply"), controller.messages.value.map { it.content.single().text }) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(persistedUser, ReplayHistoryMessage("assistant", "done", 2_000)), + ), + ) + advanceTimeBy(750) + runCurrent() + + assertEquals(listOf("await reply", "done"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun recoveryPerformsFinalRefreshWhenAssistantPersistsAfterFirstRetry() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("late reply", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + val persistedUser = ReplayHistoryMessage("user", "late reply", 1_000, idempotencyKey = "$runId:user") + var historyCall = 0 + gateway.respond("chat.history") { + historyCall += 1 + historyResponse( + "session-1", + if (historyCall < 3) { + listOf(persistedUser) + } else { + listOf(persistedUser, ReplayHistoryMessage("assistant", "eventually done", 2_000)) + }, + ) + } + + controller.handleGatewayEvent("seqGap", null) + runCurrent() + advanceTimeBy(750) + runCurrent() + assertEquals(listOf("late reply"), controller.messages.value.map { it.content.single().text }) + + advanceTimeBy(119_250) + runCurrent() + assertEquals(listOf("late reply", "eventually done"), controller.messages.value.map { it.content.single().text }) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun newerRunReconciliationKeepsOlderUnresolvedReply() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("first", "off", emptyList())) + val firstRunId = requireNotNull(gateway.lastRunId) + val firstUser = ReplayHistoryMessage("user", "first", 1_000, idempotencyKey = "$firstRunId:user") + gateway.respondWith("chat.history", historyResponse("session-1", listOf(firstUser))) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", firstRunId, seq = 2, assistantText = "first done"), + ) + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("second", "off", emptyList())) + val secondRunId = requireNotNull(gateway.lastRunId) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", secondRunId, 1, "new", "second working"), + ) + val secondUser = ReplayHistoryMessage("user", "second", 2_000, idempotencyKey = "$secondRunId:user") + val secondReply = ReplayHistoryMessage("assistant", "second done", 3_000) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(firstUser, secondUser, secondReply)), + ) + controller.handleGatewayEvent("chat", chatTerminalPayload("main", firstRunId, seq = 3, state = "error")) + runCurrent() + assertEquals("second working", controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", secondRunId, seq = 2, assistantText = "second done"), + ) + runCurrent() + assertEquals(listOf("first", "second", "second done"), controller.messages.value.map { it.content.single().text }) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf( + firstUser, + ReplayHistoryMessage("assistant", "first done", 1_500), + secondUser, + secondReply, + ), + ), + ) + advanceTimeBy(750) + runCurrent() + + assertEquals( + listOf("first", "first done", "second", "second done"), + controller.messages.value.map { it.content.single().text }, + ) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun newerRefreshCarriesUnresolvedReplyReconciliation() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("carry reply", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + val persistedUser = ReplayHistoryMessage("user", "carry reply", 1_000, idempotencyKey = "$runId:user") + var historyCall = 0 + gateway.respond("chat.history") { + historyCall += 1 + historyResponse( + "session-1", + if (historyCall < 4) { + listOf(persistedUser) + } else { + listOf(persistedUser, ReplayHistoryMessage("assistant", "carried done", 2_000)) + }, + ) + } + + controller.handleGatewayEvent("seqGap", null) + runCurrent() + advanceTimeBy(750) + runCurrent() + controller.refresh() + runCurrent() + advanceTimeBy(750) + runCurrent() + + assertEquals(listOf("carry reply", "carried done"), controller.messages.value.map { it.content.single().text }) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun successfulRecoveryRetryClearsHistoryError() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("recover error", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + var historyCall = 0 + gateway.respond("chat.history") { + historyCall += 1 + if (historyCall == 1) { + error("history unavailable") + } + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "recover error", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "recovered", 2_000), + ), + ) + } + + controller.handleGatewayEvent("seqGap", null) + runCurrent() + assertEquals("history unavailable", controller.errorText.value) + advanceTimeBy(750) + runCurrent() + + assertNull(controller.errorText.value) + assertEquals(listOf("recover error", "recovered"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectFailureStillExpiresUnconfirmedUser() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("never persisted", "off", emptyList())) + controller.onDisconnected("Reconnecting…") + gateway.respond("chat.history") { error("history unavailable") } + controller.onGatewayConnected() + runCurrent() + + assertEquals(listOf("never persisted"), controller.messages.value.map { it.content.single().text }) + + advanceTimeBy(120_000) + runCurrent() + + assertTrue(controller.messages.value.isEmpty()) + assertEquals("Timed out waiting for a reply; try again or refresh.", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun lateTerminalAfterTimeoutRefreshesHistoryWithoutClearingNewerRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("slow first", "off", emptyList())) + val firstRunId = requireNotNull(gateway.lastRunId) + advanceTimeBy(120_000) + runCurrent() + assertEquals("Timed out waiting for a reply; try again or refresh.", controller.errorText.value) + + assertTrue(controller.sendMessageAwaitAcceptance("newer work", "off", emptyList())) + val secondRunId = requireNotNull(gateway.lastRunId) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", secondRunId, 1, "new", "new reply"), + ) + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "slow first", 1_000, idempotencyKey = "$firstRunId:user"), + ReplayHistoryMessage("assistant", "slow done", 2_000), + ), + ), + ) + + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", firstRunId, seq = 2, assistantText = "slow done"), + ) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("new reply", controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + assertEquals( + listOf("slow first", "slow done", "newer work"), + controller.messages.value.map { it.content.single().text }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleRecoveryCompletionCannotCancelNewerReconciliation() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertTrue(controller.sendMessageAwaitAcceptance("ordered recovery", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + + val firstRecoveryStarted = CompletableDeferred() + val releaseFirstRecovery = CompletableDeferred() + var recoveryCall = 0 + gateway.respond("chat.history") { + recoveryCall += 1 + when (recoveryCall) { + 1 -> { + firstRecoveryStarted.complete(Unit) + releaseFirstRecovery.await() + } + 2 -> historyResponse("session-1", emptyList()) + else -> + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "ordered recovery", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "done", 2_000), + ), + ) + } + } + + controller.handleGatewayEvent("seqGap", null) + runCurrent() + firstRecoveryStarted.await() + controller.handleGatewayEvent("seqGap", null) + runCurrent() + releaseFirstRecovery.complete(historyResponse("session-1", emptyList())) + runCurrent() + advanceTimeBy(750) + runCurrent() + + assertEquals(listOf("ordered recovery", "done"), controller.messages.value.map { it.content.single().text }) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun olderSameGenerationRetryCannotOverwriteTerminalHistory() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertTrue(controller.sendMessageAwaitAcceptance("ordered result", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + controller.handleGatewayEvent("seqGap", null) + runCurrent() + + val retryStarted = CompletableDeferred() + val releaseRetry = CompletableDeferred() + var historyCall = 0 + gateway.respond("chat.history") { + historyCall += 1 + if (historyCall == 1) { + retryStarted.complete(Unit) + releaseRetry.await() + } else { + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "ordered result", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "done", 2_000), + ), + ) + } + } + advanceTimeBy(750) + runCurrent() + retryStarted.await() + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", runId, seq = 2, assistantText = "done"), + ) + runCurrent() + releaseRetry.complete(historyResponse("session-1", emptyList())) + runCurrent() + + assertEquals(listOf("ordered result", "done"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun newerSameGenerationHistoryCompletionSuppressesOlderFailureAndClearsLoading() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertTrue(controller.sendMessageAwaitAcceptance("ordered loading", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + + val recoveryStarted = CompletableDeferred() + val releaseRecovery = CompletableDeferred() + var historyCall = 0 + gateway.respond("chat.history") { + historyCall += 1 + if (historyCall == 1) { + recoveryStarted.complete(Unit) + releaseRecovery.await() + } else { + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "ordered loading", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "done", 2_000), + ), + ) + } + } + + controller.handleGatewayEvent("seqGap", null) + runCurrent() + recoveryStarted.await() + assertTrue(controller.historyLoading.value) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", runId, seq = 2, assistantText = "done"), + ) + runCurrent() + + assertFalse(controller.historyLoading.value) + assertEquals(listOf("ordered loading", "done"), controller.messages.value.map { it.content.single().text }) + + releaseRecovery.completeExceptionally(IllegalStateException("older history failed")) + runCurrent() + assertFalse(controller.historyLoading.value) + assertEquals(listOf("ordered loading", "done"), controller.messages.value.map { it.content.single().text }) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun seqGapStaleSnapshotCannotReplaceLocallyOwnedRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.load("main") + runCurrent() + + assertTrue(controller.sendMessageAwaitAcceptance("new work", "off", emptyList())) + val localRunId = requireNotNull(gateway.lastRunId) + gateway.respondWith( + "chat.history", + historyResponse("session-1", emptyList(), inFlightRun = "run-stale" to "old text"), + ) + controller.handleGatewayEvent("seqGap", null) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", localRunId, 1, "ours", "ours"), + ) + assertEquals("ours", controller.streamingAssistantText.value) + assertEquals(listOf("new work"), controller.messages.value.map { it.content.single().text }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun seqGapRefetchesHistoryAndRestoresInFlightRun() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "chat.history", + historyResponse("session-1", listOf(userTurn), inFlightRun = "run-active" to "still going"), + ) + val controller = newController(gateway) + controller.load("main") + runCurrent() + assertEquals(1, controller.pendingRunCount.value) + + controller.handleGatewayEvent("seqGap", null) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals("still going", controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + assertEquals(1, controller.messages.value.size) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt new file mode 100644 index 0000000..14ee48d --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt @@ -0,0 +1,203 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayRequestNotEnqueued +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerSessionActionsTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun controller( + scope: kotlinx.coroutines.CoroutineScope, + gateway: ScriptedGateway, + ): ChatController = + ChatController( + scope = scope, + json = json, + requestGateway = gateway::request, + ) + + private fun ScriptedGateway.respondWithBranchHistory() { + respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "hello", 1, entryId = "entry-user")), + ), + ) + respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"entry-user","headline":"Current work","messageCount":1,"updatedAt":"2026-07-20T12:00:00Z","active":true}]}""", + ) + } + + @Test + fun rewindReturnsEditorTextAndValidAttachmentsAndIssuesAgentScopedParams() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.rewind", + """{"editorText":"restore me","editorAttachments":[{"mimeType":"image/png","data":"aW1hZ2U="},{"mimeType":"image/jpeg","data":"%%%"}]}""", + ) + gateway.respondWithBranchHistory() + val controller = controller(this, gateway) + + assertEquals( + SessionRewindResult( + editorText = "restore me", + editorAttachments = listOf(SessionEditorAttachment(mimeType = "image/png", data = "aW1hZ2U=")), + ), + controller.rewindSessionAtEntryResult("main", "entry-user"), + ) + + val params = json.parseToJsonElement(gateway.calls.first { it.method == "sessions.rewind" }.paramsJson!!).jsonObject + assertEquals("main", params.getValue("sessionKey").jsonPrimitive.content) + assertEquals("main", params.getValue("agentId").jsonPrimitive.content) + assertEquals("entry-user", params.getValue("entryId").jsonPrimitive.content) + } + + @Test + fun forkReturnsCreatedKeyEditorTextAndAttachments() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.fork", + """{"sessionKey":"agent:main:forked","editorText":"continue here","editorAttachments":[{"mimeType":"image/webp","data":"Zm9yaw=="}]}""", + ) + val controller = controller(this, gateway) + + assertEquals( + SessionForkResult( + sessionKey = "agent:main:forked", + editorText = "continue here", + editorAttachments = listOf(SessionEditorAttachment(mimeType = "image/webp", data = "Zm9yaw==")), + ), + controller.forkSessionAtEntry("main", "entry-user"), + ) + + val params = json.parseToJsonElement(gateway.calls.single { it.method == "sessions.fork" }.paramsJson!!).jsonObject + assertEquals("main", params.getValue("agentId").jsonPrimitive.content) + assertEquals("entry-user", params.getValue("entryId").jsonPrimitive.content) + } + + @Test + fun branchesListParsesAllFields() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-a","headline":"Earlier idea","messageCount":4,"updatedAt":"2026-07-20T12:00:00Z","active":false}]}""", + ) + val controller = controller(this, gateway) + + assertEquals( + listOf(SessionBranch("leaf-a", "Earlier idea", 4, "2026-07-20T12:00:00Z", active = false)), + controller.listSessionBranches("main"), + ) + val params = json.parseToJsonElement(gateway.calls.single().paramsJson!!).jsonObject + assertEquals("main", params.getValue("agentId").jsonPrimitive.content) + } + + @Test + fun switchReturnsTrueAndRefreshesHistoryAndBranches() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.branches.switch", "{}") + gateway.respondWithBranchHistory() + val controller = controller(this, gateway) + + assertTrue(controller.switchSessionBranch("main", "leaf-other")) + assertEquals(1, gateway.callCount("sessions.branches.switch")) + assertEquals(1, gateway.callCount("chat.history")) + assertEquals(1, gateway.callCount("sessions.branches.list")) + } + + @Test + fun switchFailureReturnsFalseAndSurfacesError() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.branches.switch") { throw IllegalStateException("run active") } + val controller = controller(this, gateway) + + assertFalse(controller.switchSessionBranch("main", "leaf-other")) + assertEquals("run active", controller.errorText.value) + } + + @Test + fun listFailureReturnsNullAndSurfacesError() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.branches.list") { throw IllegalStateException("offline") } + val controller = controller(this, gateway) + + assertNull(controller.listSessionBranches("main")) + assertEquals("offline", controller.errorText.value) + } + + @Test + fun malformedBranchesResponseRetainsTheLastKnownBranchState() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-a","headline":"Known","messageCount":1,"active":true}]}""", + ) + val controller = controller(this, gateway) + + assertTrue(controller.refreshSessionBranches()) + val known = controller.sessionBranches.value + gateway.respondWith("sessions.branches.list", """{"branches":{}}""") + + assertFalse(controller.refreshSessionBranches()) + assertEquals(known, controller.sessionBranches.value) + } + + @Test + fun nullBranchTimestampRemainsAValidOptionalField() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.branches.list", + """{"branches":[{"leafEntryId":"leaf-a","headline":"Known","messageCount":1,"updatedAt":null,"active":true}]}""", + ) + val controller = controller(this, gateway) + + assertEquals( + listOf(SessionBranch("leaf-a", "Known", 1, updatedAt = null, active = true)), + controller.listSessionBranches("main"), + ) + } + + @Test + fun definitiveRewindFailureReloadsTheCurrentTranscript() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.rewind") { throw GatewayRequestNotEnqueued("rejected") } + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("user", "authoritative", 1, entryId = "entry-current")), + ), + ) + val controller = controller(this, gateway) + + assertNull(controller.rewindSessionAtEntryResult("main", "entry-old")) + assertEquals(1, gateway.callCount("chat.history")) + assertEquals( + "authoritative", + controller.messages.value + .single() + .content + .single() + .text, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt new file mode 100644 index 0000000..8051653 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt @@ -0,0 +1,251 @@ +package ai.openclaw.app.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerSessionPolicyTest { + @Test + fun applyMainSessionKeyMovesCurrentSessionWhenStillOnDefault() { + val state = + applyMainSessionKey( + currentSessionKey = "main", + appliedMainSessionKey = "main", + nextMainSessionKey = "agent:ops:node-device", + ) + + assertEquals("agent:ops:node-device", state.currentSessionKey) + assertEquals("agent:ops:node-device", state.appliedMainSessionKey) + } + + @Test + fun applyMainSessionKeyKeepsUserSelectedSession() { + val state = + applyMainSessionKey( + currentSessionKey = "custom", + appliedMainSessionKey = "agent:ops:node-old", + nextMainSessionKey = "agent:ops:node-new", + ) + + assertEquals("custom", state.currentSessionKey) + assertEquals("agent:ops:node-new", state.appliedMainSessionKey) + } + + @Test + fun staleHistoryLoadCannotApplyAfterSessionSwitch() { + assertTrue( + isCurrentHistoryLoad( + requestedSessionKey = "agent:one", + currentSessionKey = "agent:one", + requestGeneration = 2, + activeGeneration = 2, + ), + ) + assertFalse( + isCurrentHistoryLoad( + requestedSessionKey = "agent:old", + currentSessionKey = "agent:new", + requestGeneration = 1, + activeGeneration = 2, + ), + ) + assertFalse( + isCurrentHistoryLoad( + requestedSessionKey = "agent:new", + currentSessionKey = "agent:new", + requestGeneration = 1, + activeGeneration = 2, + ), + ) + } + + @Test + fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() { + val existing = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 1L, + displayName = "Phone", + totalTokens = 41_000L, + totalTokensFresh = true, + contextTokens = 100_000L, + ) + val next = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 2L, + displayName = "Phone renamed", + hasContextUsageMetadata = false, + ) + + val merged = mergeChatSessionEntry(existing, next) + + assertEquals("agent:main:phone", merged.key) + assertEquals(2L, merged.updatedAtMs) + assertEquals("Phone renamed", merged.displayName) + assertEquals(null, merged.totalTokens) + assertEquals(null, merged.totalTokensFresh) + assertEquals(null, merged.contextTokens) + assertFalse(merged.hasContextUsageMetadata) + } + + @Test + fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() { + val existing = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 1L, + displayName = "Phone", + totalTokens = 41_000L, + totalTokensFresh = true, + contextTokens = 100_000L, + ) + val next = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 2L, + displayName = "Phone renamed", + totalTokensFresh = false, + contextTokens = 120_000L, + ) + + val merged = + mergeChatSessionEntry( + existing = existing, + next = next, + preserveExistingContextUsageWithoutTotal = true, + ) + + assertEquals(2L, merged.updatedAtMs) + assertEquals("Phone renamed", merged.displayName) + assertEquals(41_000L, merged.totalTokens) + assertEquals(true, merged.totalTokensFresh) + assertEquals(120_000L, merged.contextTokens) + assertTrue(merged.hasContextUsageMetadata) + } + + @Test + fun sessionMergeAppliesExplicitStaleUsageMetadata() { + val existing = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 1L, + totalTokens = 41_000L, + totalTokensFresh = true, + contextTokens = 100_000L, + ) + val next = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 2L, + totalTokens = 82_000L, + totalTokensFresh = false, + contextTokens = 100_000L, + ) + + val merged = mergeChatSessionEntry(existing, next) + + assertEquals(82_000L, merged.totalTokens) + assertEquals(false, merged.totalTokensFresh) + assertEquals(100_000L, merged.contextTokens) + assertTrue(merged.hasContextUsageMetadata) + } + + @Test + fun sessionMergePreservesMissingSessionListMetadata() { + val existing = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 1L, + displayName = "Phone", + label = "Daily", + category = "Work", + pinned = true, + archived = false, + unread = true, + lastReadAt = 10L, + lastActivityAt = 20L, + ) + val next = ChatSessionEntry(key = "agent:main:phone", updatedAtMs = 2L) + + val merged = mergeChatSessionEntry(existing, next) + + assertEquals("Daily", merged.label) + assertEquals("Work", merged.category) + assertEquals(true, merged.pinned) + assertEquals(false, merged.archived) + assertEquals(true, merged.unread) + assertEquals(10L, merged.lastReadAt) + assertEquals(20L, merged.lastActivityAt) + } + + @Test + fun sessionMergeReplacesRunMetadataAsOneSnapshot() { + val existing = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 1L, + status = "done", + startedAt = 100L, + endedAt = 200L, + runtimeMs = 100L, + outputTokens = 12L, + ) + val running = + ChatSessionEntry( + key = "agent:main:phone", + updatedAtMs = 2L, + status = "running", + startedAt = 300L, + hasRunMetadata = true, + ) + + val merged = mergeChatSessionEntry(existing, running) + + assertEquals("running", merged.status) + assertEquals(300L, merged.startedAt) + assertEquals(null, merged.endedAt) + assertEquals(null, merged.runtimeMs) + assertEquals(null, merged.outputTokens) + } + + @Test + fun activeRunSelectionPrefersAdvertisedOverlapThenDeterministicLocalThenAdvertised() { + assertEquals( + "local-b", + resolvePreferredActiveRunId( + localRunIds = listOf("local-a", "local-b"), + advertisedRunIds = listOf("server", "local-b", "local-a"), + ), + ) + assertEquals( + "local-a", + resolvePreferredActiveRunId( + localRunIds = listOf("local-b", "local-a"), + advertisedRunIds = listOf("server"), + ), + ) + assertEquals("server", resolvePreferredActiveRunId(emptyList(), listOf("server", "later"))) + } + + @Test + fun activeRunCountIncludesBooleanFallbackWithoutAnId() { + assertEquals( + 1, + resolveSelectedActiveRunCount( + localRunIds = emptyList(), + advertisedRunIds = emptyList(), + hasAdvertisedRun = true, + ), + ) + assertEquals( + 3, + resolveSelectedActiveRunCount( + localRunIds = listOf("local", "overlap"), + advertisedRunIds = listOf("overlap", "server"), + hasAdvertisedRun = true, + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt new file mode 100644 index 0000000..49c21bc --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt @@ -0,0 +1,177 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatControllerSessionSearchTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun TestScope.newController(gateway: ScriptedGateway): ChatController = ChatController(scope = this, json = json, requestGateway = gateway::request) + + private fun sessionRowJson( + key: String, + updatedAt: Long, + displayName: String? = null, + archived: Boolean = false, + ) = buildJsonObject { + put("key", JsonPrimitive(key)) + put("updatedAt", JsonPrimitive(updatedAt)) + if (displayName != null) put("displayName", JsonPrimitive(displayName)) + if (archived) put("archived", JsonPrimitive(true)) + } + + private fun sessionsListJson(vararg rows: kotlinx.serialization.json.JsonObject): String = buildJsonObject { put("sessions", JsonArray(rows.toList())) }.toString() + + private fun paramField( + paramsJson: String?, + field: String, + ): String? = + paramsJson + ?.let { json.parseToJsonElement(it).jsonObject[field] } + ?.jsonPrimitive + ?.content + + @Test + fun filterSessionEntriesMatchesDisplayNameLabelAndKey() { + val sessions = + listOf( + ChatSessionEntry(key = "agent:main:topic-a", updatedAtMs = 2, displayName = "Trip planning"), + ChatSessionEntry(key = "agent:main:topic-b", updatedAtMs = 1, displayName = "Groceries"), + ChatSessionEntry(key = "agent:main:trip-notes", updatedAtMs = 3, displayName = "Notes"), + ) + assertEquals( + listOf("agent:main:topic-a", "agent:main:trip-notes"), + filterSessionEntries(sessions, "TRIP").map { it.key }, + ) + assertEquals(sessions, filterSessionEntries(sessions, " ")) + } + + @Test + fun fetchSessionListSendsSearchAndArchivedParams() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.list") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()).jsonObject + if (params["archived"]?.jsonPrimitive?.content == "true") { + sessionsListJson(sessionRowJson(key = "agent:main:old", updatedAt = 10, archived = true)) + } else { + sessionsListJson(sessionRowJson(key = "agent:main:topic-a", updatedAt = 100)) + } + } + val controller = newController(gateway) + + val archivedRows = controller.fetchSessionList(search = null, archived = true) + assertEquals(listOf("agent:main:old"), archivedRows.map { it.key }) + assertTrue(archivedRows.single().archived == true) + + controller.fetchSessionList(search = " trip ", archived = false) + val searchCall = gateway.calls.last { it.method == "sessions.list" } + assertEquals("trip", paramField(searchCall.paramsJson, "search")) + assertEquals("200", paramField(searchCall.paramsJson, "limit")) + } + + @Test + fun fetchSessionListFallsBackToLocalFilterWhenOffline() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.list") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()).jsonObject + if ("search" in params || "archived" in params) error("offline") + sessionsListJson( + sessionRowJson(key = "agent:main:topic-a", updatedAt = 2, displayName = "Trip planning"), + sessionRowJson(key = "agent:main:topic-b", updatedAt = 1, displayName = "Groceries"), + ) + } + val controller = newController(gateway) + controller.refreshSessions() + advanceUntilIdle() + + val filtered = controller.fetchSessionList(search = "trip", archived = false) + assertEquals(listOf("agent:main:topic-a"), filtered.map { it.key }) + // Archived rows exist only server-side, so offline archived search is empty. + assertTrue(controller.fetchSessionList(search = null, archived = true).isEmpty()) + } + + @Test + fun fetchSessionListDoesNotGuessMainWhileDefaultOwnerIsUnknown() = + runTest { + val gateway = ScriptedGateway(json) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + currentDefaultAgentId = { null }, + ) + + assertTrue(controller.fetchSessionList(search = "trip", archived = false).isEmpty()) + assertTrue(controller.fetchSessionList(search = null, archived = true).isEmpty()) + assertTrue(gateway.calls.isEmpty()) + } + + @Test + fun fetchSessionListRethrowsCancellationInsteadOfFallingBack() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.list") { _ -> throw CancellationException("superseded") } + val controller = newController(gateway) + + try { + controller.fetchSessionList(search = "trip", archived = false) + fail("expected CancellationException to propagate") + } catch (_: CancellationException) { + // A superseded search must cancel, not repaint stale fallback rows. + } + } + + @Test + fun fetchSessionListDropsAResponseAfterOwnerOrGatewayChanges() = + runTest { + var defaultAgentId = "agent-a" + var defaultAgentRevision = 1L + var cacheScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val requestStarted = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val gateway = ScriptedGateway(json) + gateway.respond("sessions.list") { + requestStarted.complete(Unit) + releaseResponse.await() + } + val controller = + ChatController( + scope = this, + json = json, + requestGateway = gateway::request, + cacheScope = { cacheScope }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + ) + + val pending = async { controller.fetchSessionList(search = "trip", archived = false) } + runCurrent() + requestStarted.await() + defaultAgentId = "agent-b" + defaultAgentRevision += 1 + cacheScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + releaseResponse.complete(sessionsListJson(sessionRowJson(key = "agent:agent-a:old", updatedAt = 10))) + + assertTrue(pending.await().isEmpty()) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt new file mode 100644 index 0000000..caf165d --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt @@ -0,0 +1,534 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Deterministic streaming replay scenarios: a ScriptedGateway replays scripted + * chat.event/chat.history sequences into ChatController under virtual time. + */ +class ChatControllerStreamReplayTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun TestScope.newController(gateway: ScriptedGateway): ChatController = ChatController(scope = this, json = json, requestGateway = gateway::request) + + private fun transcript(controller: ChatController): List> = + controller.messages.value.map { message -> + val text = + message.content + .firstOrNull { it.type == "text" } + ?.text + message.role to text + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cleanRunStreamsV3AndV4DeltasThenConvergesToHistoryWithoutDuplicates() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("Hello there", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + assertEquals(1, controller.pendingRunCount.value) + val optimisticUserId = + controller.messages.value + .single { it.role == "user" } + .id + + // V3 deltas carry the accumulated message without v4's deltaText field. + controller.handleGatewayEvent("chat", chatDeltaPayload("main", runId, 1, null, "Str")) + assertEquals("Str", controller.streamingAssistantText.value) + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", runId, 2, "eamed reply.", "Streamed reply."), + ) + assertEquals("Streamed reply.", controller.streamingAssistantText.value) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-1", + messages = + listOf( + ReplayHistoryMessage("user", "Hello there", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "Streamed reply.", 2_000), + ), + ), + ) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", runId, seq = 3, assistantText = "Streamed reply."), + ) + advanceUntilIdle() + + assertEquals( + listOf("user" to "Hello there", "assistant" to "Streamed reply."), + transcript(controller), + ) + // Gateway copy replaces the optimistic echo in place: same row identity, no duplicate. + assertEquals( + optimisticUserId, + controller.messages.value + .single { it.role == "user" } + .id, + ) + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.streamingAssistantText.value) + assertNull(controller.errorText.value) + assertEquals("session-1", controller.sessionId.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun duplicateDeltaAndTerminalDeliveryProducesNoDuplicateRows() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("dedupe me", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + + val delta = chatDeltaPayload("main", runId, 1, "Only once.", "Only once.") + controller.handleGatewayEvent("chat", delta) + controller.handleGatewayEvent("chat", delta) + assertEquals("Only once.", controller.streamingAssistantText.value) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-1", + messages = + listOf( + ReplayHistoryMessage("user", "dedupe me", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "Only once.", 2_000), + ), + ), + ) + val terminal = chatTerminalPayload("main", runId, seq = 2, assistantText = "Only once.") + controller.handleGatewayEvent("chat", terminal) + advanceUntilIdle() + val idsAfterFirstTerminal = controller.messages.value.map { it.id } + + // Once ownership resolves, redelivered terminal events are ignored. + controller.handleGatewayEvent("chat", terminal) + advanceUntilIdle() + + assertEquals(1, gateway.callCount("chat.history")) + assertEquals( + listOf("user" to "dedupe me", "assistant" to "Only once."), + transcript(controller), + ) + // Row identities stay stable across the duplicate refresh. + assertEquals(idsAfterFirstTerminal, controller.messages.value.map { it.id }) + assertEquals(0, controller.pendingRunCount.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun optimisticAckTimeoutDiscardsUserEchoUnderVirtualTime() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("never answered", "off", emptyList())) + assertEquals(1, controller.pendingRunCount.value) + + // One virtual millisecond before the 120s ack deadline nothing changes. + advanceTimeBy(119_999) + runCurrent() + assertEquals(1, controller.pendingRunCount.value) + assertTrue(transcript(controller).contains("user" to "never answered")) + assertNull(controller.errorText.value) + + advanceTimeBy(1) + runCurrent() + assertEquals(0, controller.pendingRunCount.value) + assertFalse(transcript(controller).contains("user" to "never answered")) + assertEquals("Timed out waiting for a reply; try again or refresh.", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun failedTerminalKeepsAcceptedUserUntilHistoryConfirmsIt() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("failed send", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + controller.handleGatewayEvent("chat", chatTerminalPayload("main", runId, seq = 1, state = "error")) + runCurrent() + + assertEquals(0, controller.pendingRunCount.value) + assertTrue(transcript(controller).contains("user" to "failed send")) + assertEquals("Chat failed", controller.errorText.value) + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(ReplayHistoryMessage("user", "failed send", 1_000, idempotencyKey = "$runId:user")), + ), + ) + advanceTimeBy(750) + runCurrent() + assertEquals(listOf("user" to "failed send"), transcript(controller)) + assertEquals("Chat failed", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun messageLessSuccessfulTerminalResolvesAfterUserPersists() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("no output", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(ReplayHistoryMessage("user", "no output", 1_000, idempotencyKey = "$runId:user")), + ), + ) + controller.handleGatewayEvent("chat", chatTerminalPayload("main", runId, seq = 1)) + runCurrent() + + assertEquals(listOf("user" to "no output"), transcript(controller)) + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.errorText.value) + + advanceTimeBy(120_000) + runCurrent() + assertEquals(listOf("user" to "no output"), transcript(controller)) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectMidRunClearsTransientStateAndHistoryConverges() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("survive reconnect", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + val optimisticUserId = + controller.messages.value + .single { it.role == "user" } + .id + + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", runId, 1, "partial ans", "partial ans"), + ) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"$runId","seq":2,"ts":10,"stream":"tool","data":{"phase":"start","name":"exec","toolCallId":"tool-1"}}""", + ) + assertEquals("partial ans", controller.streamingAssistantText.value) + assertEquals(1, controller.pendingToolCalls.value.size) + + controller.onDisconnected("connection lost") + assertNull(controller.streamingAssistantText.value) + assertEquals(0, controller.pendingRunCount.value) + assertTrue(controller.pendingToolCalls.value.isEmpty()) + assertNull(controller.sessionId.value) + assertFalse(controller.healthOk.value) + // The local echo stays rendered until the next history load resolves it. + assertTrue(transcript(controller).contains("user" to "survive reconnect")) + + controller.handleGatewayEvent("health", null) + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-1", + messages = + listOf( + ReplayHistoryMessage("user", "survive reconnect", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", "Recovered reply.", 2_000), + ), + ), + ) + controller.refresh() + advanceUntilIdle() + + assertEquals( + listOf("user" to "survive reconnect", "assistant" to "Recovered reply."), + transcript(controller), + ) + assertEquals( + optimisticUserId, + controller.messages.value + .single { it.role == "user" } + .id, + ) + assertEquals("session-1", controller.sessionId.value) + + // Disconnect cancelled the 120s ack timer: the converged transcript must not decay. + advanceTimeBy(120_000) + runCurrent() + assertNull(controller.errorText.value) + assertEquals(2, controller.messages.value.size) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleHistoryResponseIsDroppedByGenerationTracking() = + runTest { + val gateway = ScriptedGateway(json) + val controller = newController(gateway) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("assistant", "main transcript", 1_000)), + ), + ) + controller.load("main") + advanceUntilIdle() + assertEquals(listOf("assistant" to "main transcript"), transcript(controller)) + + // Gate the next "main" history fetch so its response arrives after a session switch. + val staleMainGate = CompletableDeferred() + gateway.respond("chat.history") { paramsJson -> + when (gateway.sessionKeyOf(paramsJson)) { + "other" -> + historyResponse( + sessionId = "session-other", + messages = listOf(ReplayHistoryMessage("assistant", "other transcript", 3_000)), + ) + else -> { + staleMainGate.await() + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("assistant", "stale main row", 9_000)), + ) + } + } + } + + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", runId = "external-run", seq = 1), + ) + runCurrent() // history refetch for "main" is now suspended on the gate + assertEquals(2, gateway.callCount("chat.history")) + + controller.switchSession("other") + advanceUntilIdle() + assertEquals(listOf("assistant" to "other transcript"), transcript(controller)) + assertEquals("session-other", controller.sessionId.value) + + staleMainGate.complete(Unit) + advanceUntilIdle() + + // The stale "main" response resolved after the switch and must be dropped. + assertEquals(listOf("assistant" to "other transcript"), transcript(controller)) + assertEquals("session-other", controller.sessionId.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun loadOfCurrentLiveSessionDoesNotRefreshOrMarkHistoryLoading() = + runTest { + val gateway = ScriptedGateway(json) + val controller = newController(gateway) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("assistant", "main transcript", 1_000)), + ), + ) + controller.load("main") + advanceUntilIdle() + val historyCallsAfterLiveLoad = gateway.callCount("chat.history") + assertFalse(controller.historyLoading.value) + assertEquals(listOf("assistant" to "main transcript"), transcript(controller)) + + controller.load("main") + + assertEquals(historyCallsAfterLiveLoad, gateway.callCount("chat.history")) + assertFalse(controller.historyLoading.value) + assertEquals(listOf("assistant" to "main transcript"), transcript(controller)) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun explicitRefreshFetchesAfterSameSessionLoadGate() = + runTest { + val gateway = ScriptedGateway(json) + val controller = newController(gateway) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("assistant", "main transcript", 1_000)), + ), + ) + controller.load("main") + advanceUntilIdle() + val historyCallsAfterLiveLoad = gateway.callCount("chat.history") + + controller.load("main") + assertEquals(historyCallsAfterLiveLoad, gateway.callCount("chat.history")) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("assistant", "refreshed transcript", 2_000)), + ), + ) + controller.refresh() + advanceUntilIdle() + + assertEquals(historyCallsAfterLiveLoad + 1, gateway.callCount("chat.history")) + assertEquals(listOf("assistant" to "refreshed transcript"), transcript(controller)) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun loadOfCurrentUnhealthyLiveSessionRefreshesToRecoverHealth() = + runTest { + val gateway = ScriptedGateway(json) + val controller = newController(gateway) + gateway.respond("health") { error("gateway down") } + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-main", + messages = listOf(ReplayHistoryMessage("assistant", "main transcript", 1_000)), + ), + ) + + controller.load("main") + advanceUntilIdle() + assertFalse(controller.healthOk.value) + assertFalse(controller.historyLoading.value) + + controller.load("main") + + assertTrue(controller.historyLoading.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unknownTerminalRefreshesIdleTranscript() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse("session-1", emptyList())) + val controller = newController(gateway) + controller.load("main") + runCurrent() + + gateway.respondWith( + "chat.history", + historyResponse( + "session-1", + listOf(ReplayHistoryMessage("assistant", "from another client", 2_000)), + ), + ) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", "external-run", seq = 1, assistantText = "from another client"), + ) + runCurrent() + + assertEquals(listOf("assistant" to "from another client"), transcript(controller)) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun markdownFixtureStreamsByteIdenticalAndConvergesLosslessly() = + runTest { + val fixture = + checkNotNull(javaClass.getResourceAsStream("/chat/markdown_stream_fixture.md")) { + "missing markdown stream fixture resource" + }.readBytes().toString(Charsets.UTF_8) + val fixtureBytes = fixture.toByteArray(Charsets.UTF_8) + + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + val controller = newController(gateway) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("render markdown shapes", "off", emptyList())) + val runId = requireNotNull(gateway.lastRunId) + + // Odd chunk size on purpose so boundaries fall inside words, escapes, and emoji. + val chunks = chunkPreservingCodePoints(fixture, chunkSize = 47) + assertTrue("fixture should stream in many chunks", chunks.size > 10) + var accumulated = "" + for ((index, chunk) in chunks.withIndex()) { + accumulated += chunk + controller.handleGatewayEvent( + "chat", + chatDeltaPayload("main", runId, index + 1, chunk, accumulated), + ) + assertEquals(accumulated, controller.streamingAssistantText.value) + } + + val streamed = requireNotNull(controller.streamingAssistantText.value) + assertArrayEquals(fixtureBytes, streamed.toByteArray(Charsets.UTF_8)) + + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-md", + messages = + listOf( + ReplayHistoryMessage("user", "render markdown shapes", 1_000, idempotencyKey = "$runId:user"), + ReplayHistoryMessage("assistant", fixture, 2_000), + ), + ), + ) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", runId, seq = chunks.size + 1, assistantText = fixture), + ) + advanceUntilIdle() + + val confirmed = + controller.messages.value + .single { it.role == "assistant" } + .content + .single { it.type == "text" } + .text + assertArrayEquals(fixtureBytes, requireNotNull(confirmed).toByteArray(Charsets.UTF_8)) + assertNull(controller.streamingAssistantText.value) + assertEquals(0, controller.pendingRunCount.value) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerSwarmProgressTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSwarmProgressTest.kt new file mode 100644 index 0000000..895cf10 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerSwarmProgressTest.kt @@ -0,0 +1,206 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerSwarmProgressTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun disabledSwarmDoesNotFetchChildSessions() = + runTest { + val methods = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + methods += method + when (method) { + "chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":false}""" + else -> "{}" + } + }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, + ) + + controller.refreshCommands() + advanceUntilIdle() + + assertTrue("sessions.list" !in methods) + assertTrue(controller.swarmGroups.value.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun swarmChildLifecycleStillUpdatesCanonicalSessionProjection() = + runTest { + val child = + """ + { + "key":"agent:main:child", + "parentSessionKey":"main", + "swarmGroupId":"swarm:main:turn-1", + "status":"running" + } + """.trimIndent() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}""" + "sessions.list" -> """{"sessions":[$child],"totalCount":1,"hasMore":false}""" + else -> "{}" + } + }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, + currentDefaultAgentId = { "main" }, + ) + + controller.refreshSessions() + controller.refreshCommands() + advanceUntilIdle() + assertEquals( + "running", + controller.sessions.value + .single() + .status, + ) + + controller.handleGatewayEvent( + "sessions.changed", + """ + { + "reason":"run-progress", + "session":{ + "key":"agent:main:child", + "parentSessionKey":"main", + "swarmGroupId":"swarm:main:turn-1", + "status":"done" + } + } + """.trimIndent(), + ) + runCurrent() + + assertEquals( + "done", + controller.sessions.value + .single() + .status, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun delayedSwarmRefreshCannotDispatchOnAReplacementGateway() = + runTest { + val listGateways = mutableListOf() + var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + requestGatewayForGateway = { gatewayId, method, _ -> + when (method) { + "chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}""" + "sessions.list" -> { + listGateways += gatewayId + """{"sessions":[],"totalCount":0,"hasMore":false}""" + } + else -> "{}" + } + }, + cacheScope = { currentScope }, + ) + + controller.refreshCommands() + advanceUntilIdle() + listGateways.clear() + + controller.handleGatewayEvent( + "sessions.changed", + """ + { + "reason":"create", + "session":{ + "key":"agent:main:child", + "parentSessionKey":"main", + "swarmGroupId":"swarm:main:turn-1", + "status":"running" + } + } + """.trimIndent(), + ) + runCurrent() + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + advanceTimeBy(250) + runCurrent() + + assertTrue(listGateways.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun oldGatewaySwarmResponseCannotPopulateTheNewGateway() = + runTest { + val listStarted = CompletableDeferred() + val listGate = CompletableDeferred() + var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + requestGatewayForGateway = { gatewayId, method, _ -> + when (method) { + "chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}""" + "sessions.list" -> { + check(gatewayId == "gateway-a") + listStarted.complete(Unit) + listGate.await() + """ + { + "sessions":[{ + "key":"agent:main:child", + "parentSessionKey":"main", + "swarmGroupId":"swarm:main:turn-1", + "status":"running" + }], + "totalCount":1, + "hasMore":false + } + """.trimIndent() + } + else -> "{}" + } + }, + cacheScope = { currentScope }, + ) + + controller.refreshCommands() + runCurrent() + listStarted.await() + + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + assertTrue(controller.swarmGroups.value.isEmpty()) + + listGate.complete(Unit) + advanceUntilIdle() + + assertTrue(controller.swarmGroups.value.isEmpty()) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt new file mode 100644 index 0000000..62a0e10 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt @@ -0,0 +1,422 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayRequestNotEnqueued +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerTerminalAckTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun composerOwnerMustMatchBeforeSendAdmission() = + runTest { + val requestedMethods = mutableListOf() + var defaultAgentId: String? = "main" + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + requestedMethods += method + """{"runId":"run-started","status":"started"}""" + }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, + currentDefaultAgentId = { defaultAgentId }, + ) + controller.handleGatewayEvent("health", null) + val ambiguousOwner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + assertFalse(controller.canSendForOwner(ambiguousOwner)) + assertFalse( + controller.sendMessageForOwnerAwaitAcceptance( + message = "unbound main alias", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = ambiguousOwner, + ), + ) + controller.prepareMainSessionKey("agent:main:node-test") + controller.handleGatewayEvent("health", null) + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:node-test") + assertTrue(controller.canSendForOwner(owner)) + assertFalse(controller.canSendForOwner(owner.copy(gatewayStableId = "gateway-b"))) + + assertFalse( + controller.sendMessageForOwnerAwaitAcceptance( + message = "wrong gateway", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = owner.copy(gatewayStableId = "gateway-b"), + ), + ) + assertFalse( + controller.sendMessageForOwnerAwaitAcceptance( + message = "wrong session", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = owner.copy(sessionKey = "agent:other:main", agentId = "other"), + ), + ) + assertTrue( + controller.sendMessageForOwnerAwaitAcceptance( + message = "correct owner", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = owner, + ), + ) + assertEquals(1, requestedMethods.count { it == "chat.send" }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun composerOwnerIsRecheckedAfterPendingSettingsComplete() = + runTest { + val settingsStarted = CompletableDeferred() + val settingsGate = CompletableDeferred() + var defaultAgentId: String? = "main" + var sendCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.patch" -> { + settingsStarted.complete(Unit) + settingsGate.await() + "{}" + } + "chat.send" -> { + sendCount += 1 + """{"runId":"run-started","status":"started"}""" + } + else -> "{}" + } + }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, + currentDefaultAgentId = { defaultAgentId }, + ) + controller.prepareMainSessionKey("agent:main:node-test") + controller.handleGatewayEvent("health", null) + controller.setThinkingLevel("high") + settingsStarted.await() + + val accepted = + async { + controller.sendMessageForOwnerAwaitAcceptance( + message = "stale after settings", + thinkingLevel = "high", + attachments = emptyList(), + expectedOwner = + ChatComposerOwner( + gatewayStableId = "gateway-a", + agentId = "main", + sessionKey = "agent:main:node-test", + ), + ) + } + runCurrent() + controller.switchSession("agent:other:main") + settingsGate.complete(Unit) + + assertFalse(accepted.await()) + assertEquals(0, sendCount) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unjournaledNotEnqueuedSendRemainsRejectedAfterOwnerChange() = + runTest { + val requestGate = CompletableDeferred() + var defaultAgentId: String? = "main" + var defaultAgentRevision = 0L + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "chat.send") { + requestGate.await() + throw GatewayRequestNotEnqueued("not enqueued") + } + "{}" + }, + cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + ) + controller.prepareMainSessionKey("agent:main:node-test") + controller.handleGatewayEvent("health", null) + + val accepted = + async { + controller.sendMessageForOwnerAwaitAcceptance( + message = "keep my draft", + thinkingLevel = "off", + attachments = emptyList(), + expectedOwner = + ChatComposerOwner( + gatewayStableId = "gateway-a", + agentId = "main", + sessionKey = "agent:main:node-test", + ), + ) + } + runCurrent() + controller.switchSession("agent:other:main") + requestGate.complete(Unit) + + assertFalse(accepted.await()) + assertEquals(0, controller.pendingRunCount.value) + assertTrue(controller.messages.value.none { message -> message.content.any { it.text == "keep my draft" } }) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() = + runTest { + var requestedMethod: String? = null + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + requestedMethod = method + """{"runId":"run-timeout","status":"timeout"}""" + }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + val accepted = + controller.sendMessageAwaitAcceptance( + message = "message that times out before start", + thinkingLevel = "off", + attachments = emptyList(), + ) + + assertFalse(accepted) + assertEquals("chat.send", requestedMethod) + assertEquals(0, controller.pendingRunCount.value) + assertEquals("Chat failed before the run started; try again.", controller.errorText.value) + assertFalse(controller.messages.value.hasUserText("message that times out before start")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + val accepted = + controller.sendMessageAwaitAcceptance( + message = "message that started", + thinkingLevel = "off", + attachments = emptyList(), + ) + + assertTrue(accepted) + assertEquals(1, controller.pendingRunCount.value) + assertNull(controller.errorText.value) + assertTrue(controller.messages.value.hasUserText("message that started")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun canonicalAckRunIdPreservesClientHistoryIdentity() = + runTest { + var clientRunId: String? = null + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "chat.send" -> { + clientRunId = + requireNotNull(paramsJson) + .let(json::parseToJsonElement) + .jsonObject["idempotencyKey"] + ?.jsonPrimitive + ?.content + """{"runId":"canonical-run","status":"started"}""" + } + "chat.history" -> + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "canonical", 1_000, idempotencyKey = "$clientRunId:user"), + ReplayHistoryMessage("assistant", "done", 2_000), + ), + ) + else -> "{}" + } + }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + assertTrue(controller.sendMessageAwaitAcceptance("canonical", "off", emptyList())) + controller.handleGatewayEvent( + "chat", + chatTerminalPayload("main", "canonical-run", seq = 2, assistantText = "done"), + ) + advanceUntilIdle() + + assertEquals(0, controller.pendingRunCount.value) + assertEquals(1, controller.messages.value.count { it.role == "user" }) + assertEquals( + "$clientRunId:user", + controller.messages.value + .single { it.role == "user" } + .idempotencyKey, + ) + assertNull(controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() = + runTest { + val requestedMethods = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + requestedMethods += method + when (method) { + "chat.send" -> """{"runId":"run-ok","status":"ok"}""" + "chat.history" -> + """ + { + "sessionId": "session-1", + "messages": [ + { "role": "assistant", "content": "cached success reply", "timestamp": 1 } + ] + } + """.trimIndent() + else -> "{}" + } + }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + val accepted = + controller.sendMessageAwaitAcceptance( + message = "message that already completed", + thinkingLevel = "off", + attachments = emptyList(), + ) + advanceUntilIdle() + + assertTrue(accepted) + assertEquals( + listOf("chat.send", "chat.history"), + requestedMethods.filter { method -> method == "chat.send" || method == "chat.history" }, + ) + assertEquals(0, controller.pendingRunCount.value) + assertNull(controller.errorText.value) + assertFalse(controller.messages.value.hasUserText("message that already completed")) + assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + val accepted = + controller.sendMessageAwaitAcceptance( + message = "message that errors before start", + thinkingLevel = "off", + attachments = emptyList(), + ) + + assertFalse(accepted) + assertEquals(0, controller.pendingRunCount.value) + assertEquals("Chat failed before the run started; try again.", controller.errorText.value) + assertFalse(controller.messages.value.hasUserText("message that errors before start")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun definitiveRpcRejectionRestoresComposerOwnership() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> + throw GatewayRequestRejected(GatewaySession.ErrorShape("INVALID_REQUEST", "message rejected")) + }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + val accepted = controller.sendMessageAwaitAcceptance("rejected", "off", emptyList()) + + assertFalse(accepted) + assertEquals(0, controller.pendingRunCount.value) + assertEquals("INVALID_REQUEST: message rejected", controller.errorText.value) + assertFalse(controller.messages.value.hasUserText("rejected")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun requestNotEnqueuedRestoresComposerOwnership() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw GatewayRequestNotEnqueued("not connected") }, + currentDefaultAgentId = { "main" }, + ) + controller.handleGatewayEvent("health", null) + + val accepted = controller.sendMessageAwaitAcceptance("never sent", "off", emptyList()) + + assertFalse(accepted) + assertEquals(0, controller.pendingRunCount.value) + assertEquals("not connected", controller.errorText.value) + assertFalse(controller.messages.value.hasUserText("never sent")) + } + + private fun List.hasUserText(text: String): Boolean = + any { message -> + message.role == "user" && message.content.any { part -> part.text == text } + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt new file mode 100644 index 0000000..26379de --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt @@ -0,0 +1,1385 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerTranscriptCacheTest { + private val json = Json { ignoreUnknownKeys = true } + private val gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + + private data class TranscriptKey( + val gatewayId: String, + val agentId: String, + val sessionKey: String, + ) + + private data class SavedTranscript( + val gatewayId: String, + val agentId: String, + val sessionKey: String, + val messages: List, + ) + + private data class SavedSessions( + val gatewayId: String, + val agentId: String, + val sessions: List, + ) + + private class FakeTranscriptCache : ChatTranscriptCache { + val lastDefaultAgents = mutableMapOf() + val transcripts = mutableMapOf>() + var sessions: List = emptyList() + val sessionsByOwner = mutableMapOf, List>() + val savedTranscripts = mutableListOf() + val savedSessions = mutableListOf() + val retainedSessionKeys = mutableListOf() + val deletedSessions = mutableListOf>() + var beforeLastDefaultAgentLoad: suspend (String) -> Unit = {} + var beforeLastDefaultAgentSave: suspend (String, String) -> Unit = { _, _ -> } + var beforeSessionsLoad: suspend (String, String) -> Unit = { _, _ -> } + + override suspend fun loadLastDefaultAgentId(gatewayId: String): String? { + val cached = lastDefaultAgents[gatewayId] + beforeLastDefaultAgentLoad(gatewayId) + return cached + } + + override suspend fun saveLastDefaultAgentId( + gatewayId: String, + agentId: String, + ) { + beforeLastDefaultAgentSave(gatewayId, agentId) + lastDefaultAgents[gatewayId] = agentId + } + + override suspend fun loadSessions( + gatewayId: String, + agentId: String, + ): List { + val cached = sessionsByOwner[gatewayId to agentId] ?: sessions + beforeSessionsLoad(gatewayId, agentId) + return cached + } + + override suspend fun loadTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + ): List = transcripts[TranscriptKey(gatewayId, agentId, sessionKey)].orEmpty() + + override suspend fun saveSessions( + gatewayId: String, + agentId: String, + sessions: List, + retainedSessionKey: String?, + ) { + savedSessions += SavedSessions(gatewayId, agentId, sessions) + retainedSessionKeys += retainedSessionKey + } + + override suspend fun saveTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + messages: List, + ) { + savedTranscripts += SavedTranscript(gatewayId, agentId, sessionKey, messages) + } + + override suspend fun deleteSession( + gatewayId: String, + agentId: String, + sessionKey: String, + ) { + deletedSessions += Triple(gatewayId, agentId, sessionKey) + } + + override suspend fun clearGateway(gatewayId: String) { + lastDefaultAgents.remove(gatewayId) + transcripts.keys.removeAll { it.gatewayId == gatewayId } + sessionsByOwner.keys.removeAll { it.first == gatewayId } + savedTranscripts.removeAll { it.gatewayId == gatewayId } + savedSessions.removeAll { it.gatewayId == gatewayId } + } + } + + private fun cachedMessage( + text: String, + role: String = "assistant", + timestampMs: Long = 1L, + ): ChatMessage = + ChatMessage( + id = "cached-$text", + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = timestampMs, + ) + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun offlineColdOpenShowsCachedTranscriptAndSessionsAndKeepsSendBlocked() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts[TranscriptKey("gateway-a", "main", "main")] = + listOf(cachedMessage("cached hello"), cachedMessage("cached reply")) + cache.sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = 5, displayName = "Main")) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("main") + advanceUntilIdle() + + assertEquals( + listOf("cached hello", "cached reply"), + controller.messages.value.map { it.content.single().text }, + ) + assertTrue(controller.messagesFromCache.value) + assertEquals(listOf("main"), controller.sessions.value.map { it.key }) + assertFalse(controller.healthOk.value) + + val accepted = + controller.sendMessageAwaitAcceptance(message = "hi", thinkingLevel = "off", attachments = emptyList()) + assertFalse(accepted) + assertEquals("Gateway health not OK; cannot send", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun delayedCachedGlobalDigestIsScopedToTheRequestedOwner() = + runTest { + val cache = FakeTranscriptCache() + cache.sessionsByOwner["gateway-a" to "work"] = + listOf( + ChatSessionEntry( + key = "global", + updatedAtMs = 5, + observerDigest = + ai.openclaw.app.gateway.SessionObserverDigest( + sessionKey = "global", + agentId = "main", + runId = "run-main", + revision = 3, + updatedAt = 300, + headline = "Main owner", + health = "on-track", + ), + ), + ) + val loadStarted = CompletableDeferred() + val releaseLoad = CompletableDeferred() + cache.beforeSessionsLoad = { gatewayId, agentId -> + if (gatewayId == "gateway-a" && agentId == "work") { + loadStarted.complete(Unit) + releaseLoad.await() + } + } + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "work" }, + ) + + controller.load("global", ownerAgentId = "work") + loadStarted.await() + releaseLoad.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf("global"), controller.sessions.value.map { it.key }) + assertEquals( + null, + controller.sessions.value + .single() + .observerDigest, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun offlineCachedOwnerRebuildsCanonicalMainSessionBeforeComposerSend() = + runTest { + val cache = FakeTranscriptCache() + cache.lastDefaultAgents["gateway-a"] = "work" + lateinit var controller: ChatController + controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { null }, + onOfflineDefaultAgentRestored = { agentId -> + controller.applyMainSessionKey("agent:$agentId:node-test") + }, + ) + + controller.load("main") + advanceUntilIdle() + + val owner = ChatComposerOwner("gateway-a", "work", "agent:work:node-test") + assertEquals("agent:work:node-test", controller.sessionKey.value) + assertTrue(controller.canSendForOwner(owner)) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun restoredPendingRunKeepsCachedTranscriptVisible() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts[TranscriptKey("gateway-a", "main", "main")] = listOf(cachedMessage("cached history")) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "chat.send" -> """{"runId":"run-pending"}""" + "health" -> "{}" + else -> throw IllegalStateException("offline") + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("main") + runCurrent() + controller.handleGatewayEvent("health", null) + assertTrue(controller.sendMessageAwaitAcceptance("pending turn", "off", emptyList())) + + controller.switchSession("agent:other:main") + runCurrent() + controller.switchSession("main") + runCurrent() + + assertEquals( + listOf("cached history", "pending turn"), + controller.messages.value.map { it.content.single().text }, + ) + assertTrue(controller.messagesFromCache.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cachedTranscriptEmitsFirstThenLiveHistoryReplacesWholesale() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts[TranscriptKey("gateway-a", "main", "main")] = + listOf( + cachedMessage("cached hello", role = "user", timestampMs = 10), + cachedMessage("stale line", role = "assistant", timestampMs = 11), + ) + val historyGate = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "chat.history" -> { + historyGate.await() + """ + { + "sessionId": "session-1", + "messages": [ + { "role": "user", "content": "cached hello", "timestamp": 10 }, + { "role": "assistant", "content": "fresh reply", "timestamp": 20 } + ] + } + """.trimIndent() + } + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("main") + runCurrent() + + // Cached transcript is visible while chat.history is still in flight. + assertTrue(controller.messagesFromCache.value) + assertEquals( + listOf("cached hello", "stale line"), + controller.messages.value.map { it.content.single().text }, + ) + val cachedFirstMessageId = + controller.messages.value + .first() + .id + + historyGate.complete(Unit) + advanceUntilIdle() + + assertFalse(controller.messagesFromCache.value) + assertEquals( + listOf("cached hello", "fresh reply"), + controller.messages.value.map { it.content.single().text }, + ) + // Existing reconciliation keeps stable ids for rows the live history confirms. + val liveFirstMessageId = + controller.messages.value + .first() + .id + assertEquals(cachedFirstMessageId, liveFirstMessageId) + // Live history is written through to the cache. + val savedTranscript = cache.savedTranscripts.last() + assertEquals("gateway-a", savedTranscript.gatewayId) + assertEquals("main", savedTranscript.agentId) + assertEquals("main", savedTranscript.sessionKey) + assertEquals( + listOf("cached hello", "fresh reply"), + savedTranscript.messages.map { it.content.single().text }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun switchSessionOfflineShowsCachedTranscriptForThatSession() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts[TranscriptKey("gateway-a", "other", "agent:other:main")] = listOf(cachedMessage("other session text")) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + controller.load("main") + advanceUntilIdle() + assertEquals(emptyList(), controller.messages.value) + + controller.switchSession("agent:other:main") + advanceUntilIdle() + + assertEquals( + listOf("other session text"), + controller.messages.value.map { it.content.single().text }, + ) + assertTrue(controller.messagesFromCache.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sessionDeleteEventPurgesCachedSession() = + runTest { + val cache = FakeTranscriptCache() + val deletions = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + onSessionDeleted = deletions::add, + ) + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"agent:old:main"}""", + ) + advanceUntilIdle() + + assertEquals(listOf(Triple("gateway-a", "old", "agent:old:main")), cache.deletedSessions) + assertEquals( + listOf(ChatSessionDeletion("gateway-a", "old", "agent:old:main", "main")), + deletions, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unscopedDeleteEventDoesNotGuessACacheOwner() = + runTest { + val cache = FakeTranscriptCache() + var sessionListRequests = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") sessionListRequests += 1 + if (method == "sessions.list") """{"sessions":[]}""" else "{}" + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "new-default" }, + ) + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom"}""", + ) + advanceUntilIdle() + + assertTrue(cache.deletedSessions.isEmpty()) + assertEquals(1, sessionListRequests) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun ownerlessDeleteEventFallsBackAfterCurrentOwnersRefreshConfirmsRemoval() = + runTest { + var deleted = false + val deletions = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") { + if (deleted) """{"sessions":[]}""" else """{"sessions":[{"key":"custom"}]}""" + } else { + "{}" + } + }, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "owner-a" }, + onSessionDeleted = deletions::add, + ) + controller.load("custom", ownerAgentId = "owner-a") + advanceUntilIdle() + assertEquals("custom", controller.sessionKey.value) + + deleted = true + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom"}""", + ) + advanceUntilIdle() + + assertEquals("main", controller.sessionKey.value) + assertEquals( + listOf(ChatSessionDeletion("gateway-a", "owner-a", "custom", "main")), + deletions, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun ownerlessDeleteProofStaysBoundToCapturedOwnerAcrossAgentSwitch() = + runTest { + val cache = FakeTranscriptCache() + val proofStarted = CompletableDeferred() + val releaseProof = CompletableDeferred() + var deleting = false + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + if (method == "sessions.list") { + val ownerA = params.orEmpty().contains("\"agentId\":\"owner-a\"") + if (deleting && ownerA) { + proofStarted.complete(Unit) + releaseProof.await() + """{"sessions":[]}""" + } else { + """{"sessions":[{"key":"custom"}]}""" + } + } else { + "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "owner-a" }, + ) + controller.load("custom", ownerAgentId = "owner-a") + advanceUntilIdle() + + deleting = true + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom"}""", + ) + proofStarted.await() + + controller.load("custom", ownerAgentId = "owner-b") + runCurrent() + releaseProof.complete(Unit) + advanceUntilIdle() + + assertEquals("custom", controller.sessionKey.value) + assertEquals("owner-b", controller.sessionOwnerAgentId.value) + assertEquals(listOf(Triple("gateway-a", "owner-a", "custom")), cache.deletedSessions) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun overlappingOwnerlessDeletesReconcileEveryCapturedKey() = + runTest { + val cache = FakeTranscriptCache() + val deletedKeys = mutableSetOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") { + val sessions = + listOf("custom-a", "custom-b") + .filterNot(deletedKeys::contains) + .joinToString(",") { key -> """{"key":"$key"}""" } + """{"sessions":[$sessions]}""" + } else { + "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "owner-a" }, + ) + controller.refreshSessions() + advanceUntilIdle() + + deletedKeys += "custom-a" + deletedKeys += "custom-b" + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom-a"}""", + ) + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom-b"}""", + ) + advanceUntilIdle() + + assertEquals( + setOf( + Triple("gateway-a", "owner-a", "custom-a"), + Triple("gateway-a", "owner-a", "custom-b"), + ), + cache.deletedSessions.toSet(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun truncatedOwnerlessDeleteProofPreservesLocalState() = + runTest { + val cache = FakeTranscriptCache() + var deleting = false + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when { + method != "sessions.list" -> "{}" + deleting -> """{"sessions":[],"hasMore":true}""" + else -> """{"sessions":[{"key":"custom"}]}""" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "owner-a" }, + ) + controller.refreshSessions() + advanceUntilIdle() + + deleting = true + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom"}""", + ) + advanceUntilIdle() + + assertEquals(listOf("custom"), controller.sessions.value.map(ChatSessionEntry::key)) + assertTrue(cache.deletedSessions.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun deleteEventForAnotherOwnerDoesNotMutateTheVisibleSessionList() = + runTest { + val cache = FakeTranscriptCache() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") """{"sessions":[{"key":"custom"}]}""" else "{}" + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "owner-b" }, + ) + controller.refreshSessions() + advanceUntilIdle() + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"custom","agentId":"owner-a"}""", + ) + advanceUntilIdle() + + assertEquals(listOf("custom"), controller.sessions.value.map { it.key }) + assertEquals(listOf(Triple("gateway-a", "owner-a", "custom")), cache.deletedSessions) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sessionUpdatesStayBoundToTheVisibleOwnerAndRefreshAmbiguousEvents() = + runTest { + var sessionListRequests = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") { + sessionListRequests += 1 + """{"sessions":[{"key":"custom","label":"Original"}]}""" + } else { + "{}" + } + }, + currentDefaultAgentId = { "owner-a" }, + ) + controller.refreshSessions() + advanceUntilIdle() + + controller.handleGatewayEvent( + "sessions.changed", + """{"session":{"key":"custom","agentId":"owner-b","label":"Foreign"}}""", + ) + controller.handleGatewayEvent( + "session.message", + """{"session":{"key":"custom","agentId":"owner-b","label":"Also foreign"}}""", + ) + assertEquals( + "Original", + controller.sessions.value + .single() + .label, + ) + + controller.handleGatewayEvent( + "sessions.changed", + """{"session":{"key":"custom","label":"Ambiguous"}}""", + ) + advanceUntilIdle() + + assertEquals(2, sessionListRequests) + assertEquals( + "Original", + controller.sessions.value + .single() + .label, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun requestedUnscopedDeleteCarriesAndPurgesItsCapturedOwner() = + runTest { + val cache = FakeTranscriptCache() + var deleteParams = "" + var defaultAgentId = "owner-a" + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + if (method == "sessions.delete") deleteParams = params.orEmpty() + when (method) { + "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" + "sessions.delete" -> """{"deleted":true}""" + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { defaultAgentId }, + ) + + controller.refreshSessions() + advanceUntilIdle() + val renderedRow = controller.sessions.value.single() + defaultAgentId = "owner-b" + val deletion = controller.deleteSession(renderedRow.key, ownerAgentId = renderedRow.ownerAgentId) + advanceUntilIdle() + + assertEquals("gateway-a", deletion?.gatewayId) + assertEquals("owner-a", deletion?.agentId) + assertEquals("custom", deletion?.sessionKey) + assertTrue(deleteParams.contains("\"agentId\":\"owner-a\"")) + assertEquals(listOf(Triple("gateway-a", "owner-a", "custom")), cache.deletedSessions) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun openingUnscopedSessionRetainsTheRenderedOwnerAfterDefaultChanges() = + runTest { + var defaultAgentId = "owner-a" + var defaultAgentRevision = 1L + val historyOwners = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" + "chat.history" -> { + historyOwners += if (params.orEmpty().contains("\"agentId\":\"owner-a\"")) "owner-a" else "owner-b" + """{"sessionId":"custom-id","messages":[]}""" + } + else -> "{}" + } + }, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + ) + + controller.refreshSessions() + advanceUntilIdle() + val renderedRow = controller.sessions.value.single() + defaultAgentId = "owner-b" + defaultAgentRevision += 1 + + controller.switchSession(renderedRow.key, renderedRow.ownerAgentId) + advanceUntilIdle() + + assertEquals("owner-a", controller.sessionOwnerAgentId.value) + assertEquals(listOf("owner-a"), historyOwners) + + controller.onDefaultAgentChanged("owner-b") + advanceUntilIdle() + + assertEquals("owner-a", controller.sessionOwnerAgentId.value) + assertEquals(listOf("owner-a"), historyOwners) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun oldGatewayDeleteResponseDoesNotRemoveTheCurrentGatewayRow() = + runTest { + val cache = FakeTranscriptCache() + val deleteStarted = CompletableDeferred() + val deleteGate = CompletableDeferred() + var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + var defaultAgentId = "owner-a" + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" + "sessions.delete" -> { + deleteStarted.complete(Unit) + deleteGate.await() + """{"deleted":true}""" + } + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { currentScope }, + currentDefaultAgentId = { defaultAgentId }, + ) + + controller.refreshSessions() + advanceUntilIdle() + val oldRow = controller.sessions.value.single() + val deleteJob = launch { controller.deleteSession(oldRow.key, oldRow.ownerAgentId) } + deleteStarted.await() + + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + defaultAgentId = "owner-b" + controller.onGatewayScopeChanging() + controller.refreshSessions() + runCurrent() + assertEquals( + "owner-b", + controller.sessions.value + .single() + .ownerAgentId, + ) + + deleteGate.complete(Unit) + deleteJob.join() + advanceUntilIdle() + + assertEquals(listOf("custom"), controller.sessions.value.map { it.key }) + assertEquals( + "owner-b", + controller.sessions.value + .single() + .ownerAgentId, + ) + assertEquals(listOf(Triple("gateway-a", "owner-a", "custom")), cache.deletedSessions) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unsuccessfulDeleteResponseKeepsTheOfflineCopy() = + runTest { + val cache = FakeTranscriptCache() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.delete") """{"deleted":false}""" else """{"sessions":[]}""" + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "owner-a" }, + ) + + assertEquals(null, controller.deleteSession("custom", ownerAgentId = "owner-a")) + advanceUntilIdle() + + assertTrue(cache.deletedSessions.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun liveSessionListIsWrittenThroughToCache() = + runTest { + val cache = FakeTranscriptCache() + var sessionListParams = "" + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + if (method == "sessions.list") sessionListParams = params.orEmpty() + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","updatedAt":7,"displayName":"Main"}]}""" + "chat.history" -> """{"sessionId":"session-1","messages":[]}""" + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("main") + advanceUntilIdle() + + assertEquals("gateway-a", cache.savedSessions.last().gatewayId) + assertEquals("main", cache.savedSessions.last().agentId) + assertEquals( + listOf("main"), + cache.savedSessions + .last() + .sessions + .map { it.key }, + ) + assertEquals(null, cache.retainedSessionKeys.last()) + assertEquals(listOf("main"), controller.sessions.value.map { it.key }) + assertTrue(sessionListParams.contains("\"agentId\":\"main\"")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sessionListParsesGroupingAndUnreadMetadata() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> + """ + { + "sessions": [{ + "key": "main", + "label": "Daily", + "category": "Work", + "pinned": true, + "archived": false, + "unread": true, + "lastReadAt": 10, + "lastActivityAt": 20 + }] + } + """.trimIndent() + else -> "{}" + } + }, + ) + + controller.refreshSessions() + advanceUntilIdle() + + val session = controller.sessions.value.single() + assertEquals("Daily", session.label) + assertEquals("Work", session.category) + assertEquals(true, session.pinned) + assertEquals(false, session.archived) + assertEquals(true, session.unread) + assertEquals(10L, session.lastReadAt) + assertEquals(20L, session.lastActivityAt) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun partialSessionChangedEventPreservesExistingMetadata() = + runTest { + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> + """{"sessions":[{"key":"main","label":"Daily","category":"Work","pinned":true,"unread":true}]}""" + else -> "{}" + } + }, + ) + controller.refreshSessions() + advanceUntilIdle() + + controller.handleGatewayEvent( + "sessions.changed", + """{"session":{"key":"main","agentId":"main","lastActivityAt":30}}""", + ) + + val session = controller.sessions.value.single() + assertEquals("Daily", session.label) + assertEquals("Work", session.category) + assertEquals(true, session.pinned) + assertEquals(true, session.unread) + assertEquals(30L, session.lastActivityAt) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun truncatedSessionListRetainsActiveDeepTranscript() = + runTest { + val cache = FakeTranscriptCache() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> + """{"totalCount":2,"hasMore":true,"sessions":[{"key":"main","updatedAt":7}]}""" + "chat.history" -> """{"sessionId":"session-1","messages":[]}""" + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("deep-session") + advanceUntilIdle() + + assertEquals("deep-session", cache.retainedSessionKeys.last()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun completeSessionListRetainsActiveTranscriptBeyondLocalCacheWindow() = + runTest { + val cache = FakeTranscriptCache() + val sessions = + (0 until MAX_CACHED_SESSIONS + 10).joinToString(",") { index -> + """{"key":"session-$index","updatedAt":${100 - index}}""" + } + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> + """{"totalCount":60,"hasMore":false,"sessions":[$sessions]}""" + "chat.history" -> """{"sessionId":"session-55","messages":[]}""" + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("session-55") + advanceUntilIdle() + + assertEquals("session-55", cache.retainedSessionKeys.last()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun oldGatewayHistoryResponseIsNeitherAppliedNorCachedAfterScopeChange() = + runTest { + val cache = FakeTranscriptCache() + val historyGate = CompletableDeferred() + var currentScope = gatewayScope + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "chat.history") { + historyGate.await() + """{"sessionId":"old","messages":[{"role":"assistant","content":"old gateway"}]}""" + } else { + "{}" + } + }, + transcriptCache = cache, + cacheScope = { currentScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("main") + runCurrent() + assertTrue(controller.historyLoading.value) + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + assertFalse(controller.historyLoading.value) + historyGate.complete(Unit) + advanceUntilIdle() + + assertTrue(controller.messages.value.isEmpty()) + assertTrue(cache.savedTranscripts.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun oldGatewaySessionListIsNeitherAppliedNorCachedAfterScopeChange() = + runTest { + val cache = FakeTranscriptCache() + val sessionsGate = CompletableDeferred() + var currentScope = gatewayScope + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") { + sessionsGate.await() + """{"sessions":[{"key":"old-gateway-session"}]}""" + } else { + "{}" + } + }, + transcriptCache = cache, + cacheScope = { currentScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.refreshSessions() + runCurrent() + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + sessionsGate.complete(Unit) + advanceUntilIdle() + + assertTrue(controller.sessions.value.isEmpty()) + assertTrue(cache.savedSessions.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun switchingGatewayScopeIsolatesCachedTranscriptAndSessionsThenRestoresThem() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts[TranscriptKey("gateway-a", "main", "main")] = listOf(cachedMessage("gateway A transcript")) + cache.sessionsByOwner["gateway-a" to "main"] = listOf(ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Gateway A")) + cache.sessionsByOwner["gateway-b" to "main"] = emptyList() + var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { currentScope }, + currentDefaultAgentId = { "main" }, + ) + + controller.load("main") + advanceUntilIdle() + assertEquals(listOf("gateway A transcript"), controller.messages.value.map { it.content.single().text }) + assertEquals(listOf("Gateway A"), controller.sessions.value.mapNotNull { it.displayName }) + + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + controller.load("main") + advanceUntilIdle() + assertTrue(controller.messages.value.isEmpty()) + assertTrue(controller.sessions.value.isEmpty()) + + currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 3) + controller.onGatewayScopeChanging() + controller.load("main") + advanceUntilIdle() + assertEquals(listOf("gateway A transcript"), controller.messages.value.map { it.content.single().text }) + assertEquals(listOf("Gateway A"), controller.sessions.value.mapNotNull { it.displayName }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unscopedHistoryWaitsForAProvableDefaultOwner() = + runTest { + var requestCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> + requestCount += 1 + "{}" + }, + transcriptCache = FakeTranscriptCache(), + cacheScope = { gatewayScope }, + currentDefaultAgentId = { null }, + ) + + controller.load("custom") + advanceUntilIdle() + + assertEquals(0, requestCount) + assertFalse(controller.historyLoading.value) + assertTrue(controller.messages.value.isEmpty()) + assertEquals(null, controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun offlineUnscopedHistoryUsesTheLastVerifiedGatewayOwner() = + runTest { + val cache = FakeTranscriptCache() + cache.lastDefaultAgents["gateway-a"] = "agent-a" + cache.transcripts[TranscriptKey("gateway-a", "agent-a", "custom")] = listOf(cachedMessage("offline custom")) + cache.sessionsByOwner["gateway-a" to "agent-a"] = + listOf(ChatSessionEntry(key = "custom", updatedAtMs = 1, displayName = "Offline custom")) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> error("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { null }, + ) + + controller.load("custom") + advanceUntilIdle() + + assertEquals(listOf("offline custom"), controller.messages.value.map { it.content.single().text }) + assertEquals(listOf("Offline custom"), controller.sessions.value.mapNotNull { it.displayName }) + assertEquals(GatewayDefaultAgentOwner("gateway-a", "agent-a"), controller.composerDefaultAgentOwner.value) + assertFalse(controller.historyLoading.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun defaultOwnerChangeClearsAndReloadsActiveUnscopedHistory() = + runTest { + var defaultAgentId: String? = "agent-a" + var defaultAgentRevision = 1L + val requestedOwners = mutableListOf() + val cache = FakeTranscriptCache() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + when (method) { + "chat.history" -> { + val owner = if (params.orEmpty().contains("\"agentId\":\"agent-a\"")) "agent-a" else "agent-b" + requestedOwners += owner + """{"sessionId":"$owner","messages":[{"role":"assistant","content":"$owner history"}]}""" + } + "sessions.list" -> { + val owner = defaultAgentId ?: "unknown" + """{"sessions":[{"key":"custom","displayName":"$owner title","updatedAt":1}]}""" + } + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + ) + + controller.load("custom") + advanceUntilIdle() + assertEquals(listOf("agent-a history"), controller.messages.value.map { it.content.single().text }) + assertEquals(listOf("agent-a title"), controller.sessions.value.mapNotNull { it.displayName }) + + defaultAgentId = null + defaultAgentRevision += 1 + controller.onDefaultAgentChanged(null) + runCurrent() + assertEquals(listOf("agent-a"), requestedOwners) + assertEquals(listOf("agent-a history"), controller.messages.value.map { it.content.single().text }) + assertEquals(listOf("agent-a title"), controller.sessions.value.mapNotNull { it.displayName }) + + defaultAgentId = "agent-a" + defaultAgentRevision += 1 + controller.onDefaultAgentChanged(defaultAgentId) + runCurrent() + assertEquals(listOf("agent-a"), requestedOwners) + + defaultAgentId = "agent-b" + defaultAgentRevision += 1 + controller.onDefaultAgentChanged(defaultAgentId) + advanceUntilIdle() + + assertEquals(listOf("agent-a", "agent-b"), requestedOwners) + assertEquals("agent-b", cache.lastDefaultAgents["gateway-a"]) + assertEquals(listOf("agent-b history"), controller.messages.value.map { it.content.single().text }) + assertEquals(listOf("agent-b title"), controller.sessions.value.mapNotNull { it.displayName }) + assertFalse(controller.historyLoading.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun latestDefaultOwnerWinsWhenThePreviousCacheWriteFinishesLate() = + runTest { + val cache = FakeTranscriptCache() + val firstWriteStarted = CompletableDeferred() + val releaseFirstWrite = CompletableDeferred() + cache.beforeLastDefaultAgentSave = { _, agentId -> + if (agentId == "agent-a") { + firstWriteStarted.complete(Unit) + releaseFirstWrite.await() + } + } + var defaultAgentId: String? = "agent-a" + var defaultAgentRevision = 1L + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + ) + + controller.onDefaultAgentChanged("agent-a") + runCurrent() + firstWriteStarted.await() + defaultAgentId = "agent-b" + defaultAgentRevision += 1 + controller.onDefaultAgentChanged("agent-b") + runCurrent() + releaseFirstWrite.complete(Unit) + advanceUntilIdle() + + assertEquals("agent-b", cache.lastDefaultAgents["gateway-a"]) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun gatewayCachePurgeDeletesAnInFlightDefaultOwnerWriteAndInvalidatesQueuedWrites() = + runTest { + val cache = FakeTranscriptCache() + val firstWriteStarted = CompletableDeferred() + val releaseFirstWrite = CompletableDeferred() + cache.beforeLastDefaultAgentSave = { _, agentId -> + if (agentId == "agent-a") { + firstWriteStarted.complete(Unit) + releaseFirstWrite.await() + } + } + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + ) + + controller.onDefaultAgentChanged("agent-a") + runCurrent() + firstWriteStarted.await() + controller.onDefaultAgentChanged("agent-b") + val purge = launch { controller.clearGatewayCache("gateway-a") } + runCurrent() + + releaseFirstWrite.complete(Unit) + purge.join() + advanceUntilIdle() + + assertFalse(cache.lastDefaultAgents.containsKey("gateway-a")) + assertEquals(null, controller.composerDefaultAgentOwner.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun liveDefaultOwnerWinsWhenPersistedOwnerLoadFinishesLate() = + runTest { + val cache = FakeTranscriptCache() + cache.lastDefaultAgents["gateway-a"] = "agent-b" + val cacheLoadStarted = CompletableDeferred() + val releaseCacheLoad = CompletableDeferred() + cache.beforeLastDefaultAgentLoad = { + cacheLoadStarted.complete(Unit) + releaseCacheLoad.await() + } + var defaultAgentId: String? = null + var defaultAgentRevision = 1L + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + currentDefaultAgentId = { defaultAgentId }, + currentDefaultAgentRevision = { defaultAgentRevision }, + ) + + controller.load("custom") + runCurrent() + cacheLoadStarted.await() + defaultAgentId = "agent-a" + defaultAgentRevision += 1 + controller.onDefaultAgentChanged("agent-a") + runCurrent() + releaseCacheLoad.complete(Unit) + advanceUntilIdle() + + assertEquals(GatewayDefaultAgentOwner("gateway-a", "agent-a"), controller.composerDefaultAgentOwner.value) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatControllerUsageStreamTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatControllerUsageStreamTest.kt new file mode 100644 index 0000000..e19b763 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatControllerUsageStreamTest.kt @@ -0,0 +1,228 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerUsageStreamTest { + private val json = Json { ignoreUnknownKeys = true } + + private data class StartedRun( + val controller: ChatController, + val gateway: ScriptedGateway, + val runId: String, + ) + + private suspend fun TestScope.startRun(): StartedRun { + val gateway = ScriptedGateway(json) + gateway.respondChatSend(status = "started") + gateway.respondWith("question.list", """{"questions":[]}""") + val controller = ChatController(scope = backgroundScope, json = json, requestGateway = gateway::request) + controller.handleGatewayEvent("health", null) + assertTrue(controller.sendMessageAwaitAcceptance("count this", "off", emptyList())) + return StartedRun(controller, gateway, requireNotNull(gateway.lastRunId)) + } + + private fun usagePayload( + runId: String, + sequence: Long, + outputTokens: String, + ): String = """{"sessionKey":"main","runId":"$runId","seq":$sequence,"ts":10,"stream":"usage","data":{"outputTokens":$outputTokens}}""" + + private fun lifecyclePayload( + runId: String, + sequence: Long, + phase: String, + ): String = """{"sessionKey":"main","runId":"$runId","seq":$sequence,"ts":11,"stream":"lifecycle","data":{"phase":"$phase"}}""" + + private fun advertise(vararg runIds: String): String = """{"reason":"patch","session":{"key":"main","agentId":"main","hasActiveRun":${runIds.isNotEmpty()},"activeRunIds":[${runIds.joinToString(",") { "\"$it\"" }}]}}""" + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun usageRequiresOwnershipAndKeepsHighestSequenceAndCumulativeMaximum() = + runTest { + val (controller, _, runId) = startRun() + + controller.handleGatewayEvent("agent", usagePayload(runId, 2L, "12")) + controller.handleGatewayEvent("agent", usagePayload(runId, 1L, "90")) + controller.handleGatewayEvent("agent", usagePayload(runId, 3L, "8")) + controller.handleGatewayEvent("agent", usagePayload(runId, 4L, "0")) + controller.handleGatewayEvent("agent", usagePayload("foreign", 5L, "99")) + + assertEquals(12L, controller.selectedActiveRunPresentation.value.outputTokens) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unsequencedTerminalWithoutRunIdSettlesSoleLocalRun() = + runTest { + val (controller, _, _) = startRun() + + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","stream":"lifecycle","data":{"phase":"end"}}""", + ) + + assertEquals(0, controller.pendingRunCount.value) + assertEquals(0, controller.selectedActiveRunPresentation.value.count) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unsequencedForeignTerminalDoesNotSettleSoleLocalRun() = + runTest { + val (controller, _, localRunId) = startRun() + + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"foreign-run","stream":"lifecycle","data":{"phase":"end"}}""", + ) + + assertEquals(1, controller.pendingRunCount.value) + assertEquals(localRunId, controller.selectedActiveRunPresentation.value.runId) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun activeBooleanWithoutRunIdUsesStableSessionFallback() = + runTest { + val gateway = ScriptedGateway(json) + val controller = ChatController(scope = backgroundScope, json = json, requestGateway = gateway::request) + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"patch","session":{"key":"main","agentId":"main","hasActiveRun":true,"activeRunIds":[]}}""", + ) + + val presentation = controller.selectedActiveRunPresentation.value + assertEquals(1, presentation.count) + assertNull(presentation.runId) + assertEquals("main:active", presentation.clockKey) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun idlessReplacementRunGetsANewStartedAtClockKey() = + runTest { + val gateway = ScriptedGateway(json) + val controller = ChatController(scope = backgroundScope, json = json, requestGateway = gateway::request) + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"patch","session":{"key":"main","agentId":"main","status":"running","hasActiveRun":true,"activeRunIds":[],"startedAt":100}}""", + ) + val firstClockKey = controller.selectedActiveRunPresentation.value.clockKey + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"patch","session":{"key":"main","agentId":"main","status":"running","hasActiveRun":true,"activeRunIds":[],"startedAt":200}}""", + ) + + assertEquals("main:active:100", firstClockKey) + assertEquals("main:active:200", controller.selectedActiveRunPresentation.value.clockKey) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalTombstoneIgnoresLaterStartAndUsageUntilOwnershipRemoval() = + runTest { + val gateway = ScriptedGateway(json) + val controller = ChatController(scope = backgroundScope, json = json, requestGateway = gateway::request) + controller.handleGatewayEvent("sessions.changed", advertise("server-run")) + controller.handleGatewayEvent("agent", usagePayload("server-run", 1L, "20")) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"server-run","seq":2,"ts":10,"stream":"assistant","data":{"text":"foreign"}}""", + ) + assertNull(controller.streamingAssistantText.value) + controller.handleGatewayEvent("agent", lifecyclePayload("server-run", 2L, "end")) + controller.handleGatewayEvent("agent", lifecyclePayload("server-run", 3L, "start")) + controller.handleGatewayEvent("agent", usagePayload("server-run", 4L, "40")) + + assertEquals(0, controller.selectedActiveRunPresentation.value.count) + assertNull(controller.selectedActiveRunPresentation.value.outputTokens) + + controller.handleGatewayEvent("sessions.changed", advertise()) + controller.handleGatewayEvent("sessions.changed", advertise("server-run")) + controller.handleGatewayEvent("agent", usagePayload("server-run", 1L, "40")) + + assertEquals(1, controller.selectedActiveRunPresentation.value.count) + assertEquals(40L, controller.selectedActiveRunPresentation.value.outputTokens) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sequenceGapForConcurrentAdvertisedRunPreservesLocalPendingOwnership() = + runTest { + val (controller, gateway, localRunId) = startRun() + controller.handleGatewayEvent("sessions.changed", advertise("server-run")) + controller.handleGatewayEvent("agent", usagePayload(localRunId, 1L, "15")) + controller.handleGatewayEvent( + "agent", + """{"sessionKey":"main","runId":"$localRunId","seq":2,"ts":10,"stream":"assistant","data":{"text":"partial"}}""", + ) + gateway.respondWith( + "chat.history", + historyResponse( + sessionId = "session-1", + messages = emptyList(), + inFlightRun = "server-run" to "", + ), + ) + + controller.handleGatewayEvent("seqGap", null) + assertNull(controller.selectedActiveRunPresentation.value.outputTokens) + runCurrent() + + assertEquals(1, controller.pendingRunCount.value) + assertEquals(localRunId, controller.selectedActiveRunPresentation.value.runId) + assertNull(controller.selectedActiveRunPresentation.value.outputTokens) + assertNull(controller.streamingAssistantText.value) + assertTrue(gateway.callCount("chat.history") > 0) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun ackRekeyPreservesOptimisticClockIdentity() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("question.list", """{"questions":[]}""") + val requestSeen = CompletableDeferred() + val releaseAck = CompletableDeferred() + gateway.respond("chat.send") { paramsJson -> + val clientRunId = + json + .parseToJsonElement(requireNotNull(paramsJson)) + .jsonObject["idempotencyKey"]!! + .jsonPrimitive + .content + requestSeen.complete(clientRunId) + releaseAck.await() + """{"runId":"server-run","status":"started"}""" + } + val controller = ChatController(scope = backgroundScope, json = json, requestGateway = gateway::request) + controller.handleGatewayEvent("health", null) + val send = async { controller.sendMessageAwaitAcceptance("hello", "off", emptyList()) } + val clientRunId = requestSeen.await() + runCurrent() + val before = controller.selectedActiveRunPresentation.value + + releaseAck.complete(Unit) + assertTrue(send.await()) + val after = controller.selectedActiveRunPresentation.value + + assertEquals(clientRunId, before.runId) + assertEquals("server-run", after.runId) + assertNotNull(before.clockKey) + assertEquals(before.clockKey, after.clockKey) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt new file mode 100644 index 0000000..9912e9a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt @@ -0,0 +1,441 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.ui.chat.readBoundedWidgetDocument +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import okio.Buffer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatMessageContentParsingTest { + @Test + fun boundedWidgetDocumentReadAcceptsAtMostLimitAndRejectsOverflow() { + assertArrayEquals( + byteArrayOf(1, 2), + readBoundedWidgetDocument(Buffer().write(byteArrayOf(1, 2)), maxBytes = 3), + ) + assertArrayEquals( + byteArrayOf(1, 2, 3), + readBoundedWidgetDocument(Buffer().write(byteArrayOf(1, 2, 3)), maxBytes = 3), + ) + assertNull(readBoundedWidgetDocument(Buffer().write(byteArrayOf(1, 2, 3, 4)), maxBytes = 3)) + } + + @Test + fun dropsInternalToolBlocksFromDisplayHistory() { + val content = + Json.parseToJsonElement( + """{"type":"toolResult","content":"large internal output"}""", + ) + + assertNull(parseChatMessageContent(content)) + } + + @Test + fun parsesCodexTextBlocksAsVisibleText() { + val content = + Json.parseToJsonElement( + """{"type":"output_text","text":"Done."}""", + ) + + assertEquals(ChatMessageContent(type = "text", text = "Done."), parseChatMessageContent(content)) + } + + @Test + fun parsesCapabilityGatedCanvasWidgets() { + val content = + Json.parseToJsonElement( + """{"type":"canvas","preview":{"kind":"canvas","surface":"assistant_message","render":"url","title":"Status","preferredHeight":240,"url":"/__openclaw__/canvas/documents/widget-1/index.html","sandbox":"scripts"}}""", + ) + + assertEquals( + ChatMessageContent( + type = "canvas", + widget = + ChatWidgetPreview( + title = "Status", + path = "/__openclaw__/canvas/documents/widget-1/index.html", + preferredHeight = 240, + sandbox = "scripts", + ), + ), + parseChatMessageContent(content), + ) + } + + @Test + fun dropsCanvasBlocksWithoutWidgetSandbox() { + val content = + Json.parseToJsonElement( + """{"type":"canvas","preview":{"kind":"canvas","surface":"assistant_message","render":"url","url":"/__openclaw__/canvas/documents/widget-1/index.html"}}""", + ) + + assertNull(parseChatMessageContent(content)) + } + + @Test + fun dropsCanvasBlocksWithUntrustedWidgetTargets() { + val content = + Json.parseToJsonElement( + """{"type":"canvas","preview":{"kind":"canvas","surface":"assistant_message","render":"url","url":"https://attacker.example/widget.html","sandbox":"scripts"}}""", + ) + + assertNull(parseChatMessageContent(content)) + } + + @Test + fun resolvesOnlyCapabilityScopedWidgetDocuments() { + val surface = "https://gateway.example/__openclaw__/cap/token" + + assertEquals( + "https://gateway.example/__openclaw__/cap/token/__openclaw__/canvas/documents/widget-1/index.html", + ChatWidgetUrlResolver.resolve(surface, "/__openclaw__/canvas/documents/widget-1/index.html"), + ) + assertEquals( + "https://gateway.example/__openclaw__/cap/token/__openclaw__/canvas/documents/widget-1/index.html", + ChatWidgetUrlResolver.resolve( + "HTTPS://gateway.example/__openclaw__/cap/token", + "/__openclaw__/canvas/documents/widget-1/index.html", + ), + ) + assertNull(ChatWidgetUrlResolver.resolve("https://gateway.example", "/__openclaw__/canvas/documents/widget-1/index.html")) + assertNull(ChatWidgetUrlResolver.resolve(surface, "https://attacker.example/widget.html")) + assertNull(ChatWidgetUrlResolver.resolve(surface, "/__openclaw__/a2ui/index.html")) + assertNull(ChatWidgetUrlResolver.resolve(surface, "/__openclaw__/canvas/documents/%252e%252e/index.html")) + } + + @Test + fun initialResolutionUsesOperatorFallbackWhenNodeUnavailable() { + val target = "/__openclaw__/canvas/documents/widget-1/index.html" + val fallbackSurface = "https://operator.example/__openclaw__/cap/fallback" + val surfaces = + ChatWidgetSurfaceUrls( + node = null, + operator = ChatWidgetSurface(url = fallbackSurface, tlsFingerprintSha256 = null), + ) + + val resolved = ChatWidgetUrlResolver.resolvePreferred(surfaces, target, excluding = null) + + assertEquals(ChatWidgetUrlResolver.resolve(fallbackSurface, target), resolved?.url) + } + + @Test + fun usesReplacementRouteAfterCapabilityRefreshLosesItsLease() = + runTest { + val target = "/__openclaw__/canvas/documents/widget-1/index.html" + val oldSurface = "https://gateway.example/__openclaw__/cap/old" + val newSurface = "https://gateway.example/__openclaw__/cap/new" + val oldPin = "aa".repeat(32) + val newPin = "bb".repeat(32) + val failedUrl = ChatWidgetUrlResolver.resolve(oldSurface, target) + val failedResource = ChatWidgetResource(url = requireNotNull(failedUrl), tlsFingerprintSha256 = oldPin) + var current = + ChatWidgetSurfaceUrls( + node = ChatWidgetSurface(url = oldSurface, tlsFingerprintSha256 = oldPin), + operator = null, + ) + + val resolved = + ChatWidgetUrlResolver.resolveAfterFailure( + target = target, + failedResource = failedResource, + currentSurfaceUrls = { current }, + refreshNodeSurface = { + current = + ChatWidgetSurfaceUrls( + node = ChatWidgetSurface(url = newSurface, tlsFingerprintSha256 = newPin), + operator = null, + ) + null + }, + refreshOperatorSurface = { null }, + ) + + assertEquals(ChatWidgetUrlResolver.resolve(newSurface, target), resolved?.url) + assertEquals(newPin, resolved?.tlsFingerprintSha256) + } + + @Test + fun acceptsSameUrlReplacementWhenTlsPinChanged() = + runTest { + val target = "/__openclaw__/canvas/documents/widget-1/index.html" + val surface = "https://gateway.example/__openclaw__/cap/token" + val oldPin = "aa".repeat(32) + val newPin = "bb".repeat(32) + val url = requireNotNull(ChatWidgetUrlResolver.resolve(surface, target)) + val failedResource = ChatWidgetResource(url = url, tlsFingerprintSha256 = oldPin) + var current = + ChatWidgetSurfaceUrls( + node = ChatWidgetSurface(url = surface, tlsFingerprintSha256 = oldPin), + operator = null, + ) + + val resolved = + ChatWidgetUrlResolver.resolveAfterFailure( + target = target, + failedResource = failedResource, + currentSurfaceUrls = { current }, + refreshNodeSurface = { + current = + ChatWidgetSurfaceUrls( + node = ChatWidgetSurface(url = surface, tlsFingerprintSha256 = newPin), + operator = null, + ) + null + }, + refreshOperatorSurface = { null }, + ) + + assertEquals(url, resolved?.url) + assertEquals(newPin, resolved?.tlsFingerprintSha256) + } + + @Test + fun refreshesNodeOnceBeforeTryingOperatorFallback() = + runTest { + val target = "/__openclaw__/canvas/documents/widget-1/index.html" + val oldSurface = "https://gateway.example/__openclaw__/cap/old" + val newSurface = "https://gateway.example/__openclaw__/cap/new" + val fallbackSurface = "https://operator.example/__openclaw__/cap/fallback" + var refreshCount = 0 + var current = + ChatWidgetSurfaceUrls( + node = ChatWidgetSurface(url = oldSurface, tlsFingerprintSha256 = null), + operator = ChatWidgetSurface(url = fallbackSurface, tlsFingerprintSha256 = null), + ) + val initialNode = ChatWidgetUrlResolver.resolvePreferred(current, target, excluding = null) + + val refreshedNode = + ChatWidgetUrlResolver.resolveAfterFailure( + target = target, + failedResource = requireNotNull(initialNode), + currentSurfaceUrls = { current }, + refreshNodeSurface = { + refreshCount += 1 + current = current.copy(node = ChatWidgetSurface(url = newSurface, tlsFingerprintSha256 = null)) + null + }, + refreshOperatorSurface = { null }, + ) + + assertEquals(ChatWidgetUrlResolver.resolve(newSurface, target), refreshedNode?.url) + + val fallback = + ChatWidgetUrlResolver.resolveAfterFailure( + target = target, + failedResource = requireNotNull(refreshedNode), + currentSurfaceUrls = { current }, + refreshNodeSurface = { + refreshCount += 1 + null + }, + refreshOperatorSurface = { null }, + ) + + assertEquals(ChatWidgetUrlResolver.resolve(fallbackSurface, target), fallback?.url) + assertEquals(1, refreshCount) + } + + @Test + fun refreshesOperatorCapabilityWhenNodeUnavailable() = + runTest { + val target = "/__openclaw__/canvas/documents/widget-1/index.html" + val oldSurface = "https://operator.example/__openclaw__/cap/old" + val newSurface = "https://operator.example/__openclaw__/cap/new" + val failedResource = + ChatWidgetResource( + url = requireNotNull(ChatWidgetUrlResolver.resolve(oldSurface, target)), + tlsFingerprintSha256 = null, + ) + var operatorRefreshCount = 0 + var current = + ChatWidgetSurfaceUrls( + node = null, + operator = ChatWidgetSurface(url = oldSurface, tlsFingerprintSha256 = null), + ) + + val resolved = + ChatWidgetUrlResolver.resolveAfterFailure( + target = target, + failedResource = failedResource, + currentSurfaceUrls = { current }, + refreshNodeSurface = { null }, + refreshOperatorSurface = { + operatorRefreshCount += 1 + ChatWidgetSurface(url = newSurface, tlsFingerprintSha256 = null).also { + current = current.copy(operator = it) + } + }, + ) + + assertEquals(ChatWidgetUrlResolver.resolve(newSurface, target), resolved?.url) + assertEquals(1, operatorRefreshCount) + } + + @Test + fun parsesInlineAndManagedImageBlocks() { + val image = + Json.parseToJsonElement( + """{"type":"image","mimeType":"image/png","fileName":"chart.png","content":"abc123"}""", + ) + val managedImage = + Json.parseToJsonElement( + """{"type":"image","artifactId":"artifact_managed_image_11111111-1111-4111-8111-111111111111","mimeType":"image/png","fileName":"chart.png","url":"/api/chat/media/outgoing/main/id","openUrl":"/api/chat/media/outgoing/main/id","alt":"Chart","width":1200,"height":800,"sizeBytes":2048}""", + ) + + assertEquals( + ChatMessageContent(type = "image", mimeType = "image/png", fileName = "chart.png", base64 = "abc123"), + parseChatMessageContent(image), + ) + assertEquals( + ChatMessageContent( + type = "image", + mimeType = "image/png", + fileName = "chart.png", + artifactId = "artifact_managed_image_11111111-1111-4111-8111-111111111111", + url = "/api/chat/media/outgoing/main/id", + openUrl = "/api/chat/media/outgoing/main/id", + alt = "Chart", + width = 1200, + height = 800, + sizeBytes = 2048, + ), + parseChatMessageContent(managedImage), + ) + } + + @Test + fun derivesArtifactIdentityForShippedManagedImageBlocks() { + val image = + Json.parseToJsonElement( + """{"type":"image","mimeType":"image/png","url":"/api/chat/media/outgoing/main/11111111-1111-4111-8111-111111111111/full"}""", + ) + + assertEquals( + "artifact_managed_image_11111111-1111-4111-8111-111111111111", + parseChatMessageContent(image)?.artifactId, + ) + } + + @Test + fun derivesArtifactIdentityForManagedAudioAndVideoBlocks() { + val attachmentId = "22222222-2222-4222-8222-222222222222" + val url = "/api/chat/media/outgoing/main/$attachmentId/full" + + assertEquals("artifact_managed_media_$attachmentId", managedMediaArtifactId(url)) + assertEquals( + "artifact_managed_media_$attachmentId", + parseChatMessageContent(Json.parseToJsonElement("""{"type":"video","mimeType":"video/mp4","url":"$url"}"""))?.artifactId, + ) + } + + @Test + fun parsesSupportedPlaybackRenditions() { + val direct = + Json.parseToJsonElement( + """{"type":"video","mimeType":"video/mp4","playback":"transcode"}""", + ) + val attachment = + Json.parseToJsonElement( + """{"type":"attachment","attachment":{"kind":"audio","mimeType":"audio/mp4","playback":"native"}}""", + ) + val unsupported = + Json.parseToJsonElement( + """{"type":"video","mimeType":"video/mp4","playback":"future"}""", + ) + + assertEquals("transcode", parseChatMessageContent(direct)?.playback) + assertEquals("native", parseChatMessageContent(attachment)?.playback) + assertEquals(null, parseChatMessageContent(unsupported)?.playback) + } + + @Test + fun dropsOversizedInlineImageContentBeforeRendering() { + val oversized = "A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1) + val image = + Json.parseToJsonElement( + """{"type":"image","mimeType":"image/png","fileName":"large.png","content":"$oversized"}""", + ) + + assertEquals( + ChatMessageContent(type = "image", mimeType = "image/png", fileName = "large.png", base64 = null), + parseChatMessageContent(image), + ) + } + + @Test + fun dropsInlineAudioAndVideoContentThatRequiresManagedArtifacts() { + val audio = Json.parseToJsonElement("""{"type":"audio","mimeType":"audio/mpeg","content":"audio-bytes"}""") + val video = Json.parseToJsonElement("""{"type":"video","mimeType":"video/mp4","content":"video-bytes"}""") + + assertEquals(ChatMessageContent(type = "audio", mimeType = "audio/mpeg"), parseChatMessageContent(audio)) + assertEquals(ChatMessageContent(type = "video", mimeType = "video/mp4"), parseChatMessageContent(video)) + } + + @Test + fun parsesDirectAndAttachmentAudioVideoBlocks() { + val direct = + Json.parseToJsonElement( + """{"type":"audio","mimeType":"audio/mp4","fileName":"voice.m4a"}""", + ) + val attachment = + Json.parseToJsonElement( + """{"type":"attachment","attachment":{"kind":"audio","mimeType":"audio/mpeg","label":"reply.mp3","artifactId":"artifact_managed_media_33333333-3333-4333-8333-333333333333","url":"/api/chat/media/outgoing/main/33333333-3333-4333-8333-333333333333/full","sizeBytes":4096,"durationMs":2100}}""", + ) + val video = + Json.parseToJsonElement( + """{"type":"attachment","attachment":{"kind":"video","mimeType":"video/mp4","fileName":"demo.mp4","artifactId":"artifact_managed_media_44444444-4444-4444-8444-444444444444","url":"/api/chat/media/outgoing/main/44444444-4444-4444-8444-444444444444/full","sizeBytes":8192,"durationMs":5300,"width":1920,"height":1080}}""", + ) + + assertEquals( + ChatMessageContent(type = "audio", mimeType = "audio/mp4", fileName = "voice.m4a"), + parseChatMessageContent(direct), + ) + assertEquals( + ChatMessageContent( + type = "audio", + mimeType = "audio/mpeg", + fileName = "reply.mp3", + artifactId = "artifact_managed_media_33333333-3333-4333-8333-333333333333", + url = "/api/chat/media/outgoing/main/33333333-3333-4333-8333-333333333333/full", + sizeBytes = 4096, + durationMs = 2100, + ), + parseChatMessageContent(attachment), + ) + assertEquals( + ChatMessageContent( + type = "video", + mimeType = "video/mp4", + fileName = "demo.mp4", + artifactId = "artifact_managed_media_44444444-4444-4444-8444-444444444444", + url = "/api/chat/media/outgoing/main/44444444-4444-4444-8444-444444444444/full", + width = 1920, + height = 1080, + sizeBytes = 8192, + durationMs = 5300, + ), + parseChatMessageContent(video), + ) + } + + @Test + fun parsesTranscriptAudioMediaFieldsAlongsideCaption() { + val message = + Json + .parseToJsonElement( + """{"content":[{"type":"text","text":"See attached."}],"MediaPaths":["media/inbound/voice.m4a"],"MediaTypes":["audio/x-m4a"]}""", + ).jsonObject + + assertEquals( + listOf( + ChatMessageContent(type = "text", text = "See attached."), + ChatMessageContent(type = "audio", mimeType = "audio/x-m4a", fileName = "voice.m4a"), + ), + parseChatMessageContents(message), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt new file mode 100644 index 0000000..9de429c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt @@ -0,0 +1,1057 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.Question +import ai.openclaw.app.gateway.QuestionAnswers +import ai.openclaw.app.gateway.QuestionGetResult +import ai.openclaw.app.gateway.QuestionListResult +import ai.openclaw.app.gateway.QuestionOption +import ai.openclaw.app.gateway.QuestionRecord +import ai.openclaw.app.ui.chat.questionCountdown +import ai.openclaw.app.ui.chat.terminalQuestionAnswer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatQuestionTest { + private val question = + Question( + questionId = "meal", + header = "Meal", + question = "Choose dinner", + options = listOf(QuestionOption("Pizza"), QuestionOption("Tacos")), + multiSelect = true, + isOther = true, + ) + + @Test + fun multiSelectAnswersFollowDeclaredOrderAndIncludeOther() { + val draft = + ChatQuestionDraft() + .toggle(question, "Tacos") + .toggle(question, "Pizza") + .setOther(question, " Salad ") + + assertEquals(mapOf("meal" to listOf("Pizza", "Tacos", "Salad")), draft.answers(listOf(question))) + } + + @Test + fun statusDistinguishesLocalRemoteAndExpiry() { + val record = record(status = "pending", expiresAtMs = 2_000) + assertEquals(ChatQuestionStatus.Expired, ChatQuestionPrompt(record).status(nowMs = 2_000)) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + ChatQuestionPrompt(record.copy(status = "answered")).status(nowMs = 1_000), + ) + assertEquals( + ChatQuestionStatus.Answered, + ChatQuestionPrompt(record.copy(status = "answered"), answeredLocally = true).status(nowMs = 1_000), + ) + } + + @Test + fun terminalPromptsRemainInTheTimeline() { + val prompt = + ChatQuestionPrompt( + record = record(status = "answered"), + terminalObservedAtMs = 1_000, + ) + + assertEquals(ChatQuestionStatus.AnsweredElsewhere, prompt.status(nowMs = Long.MAX_VALUE)) + } + + @Test + fun countdownMatchesWebMinuteSecondFormat() { + assertEquals("1:05", questionCountdown(expiresAtMs = 65_000, nowMs = 0)) + assertEquals("0:05", questionCountdown(expiresAtMs = 4_001, nowMs = 0)) + assertEquals("0:00", questionCountdown(expiresAtMs = 1_000, nowMs = 2_000)) + } + + @Test + fun terminalSummaryUsesAnswersAndStatusLabels() { + val answered = + ChatQuestionPrompt( + record = + record(status = "answered").copy( + answers = QuestionAnswers(mapOf("meal" to listOf("Pizza", "Salad"))), + ), + answeredLocally = true, + ) + + assertEquals("Pizza, Salad", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Answered)) + assertEquals("Skipped", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Cancelled)) + assertEquals("Expired", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Expired)) + assertEquals("Unavailable", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Unavailable)) + assertEquals( + "Answered elsewhere", + terminalQuestionAnswer(answered.copy(record = answered.record.copy(answers = null)), question, ChatQuestionStatus.AnsweredElsewhere), + ) + } + + @Test + fun sessionFilterKeepsGlobalAndCurrentPrompts() { + val prompts = + listOf( + ChatQuestionPrompt(record(sessionKey = null)), + ChatQuestionPrompt(record(id = "current", sessionKey = "agent:main:main")), + ChatQuestionPrompt(record(id = "other", sessionKey = "agent:main:other")), + ChatQuestionPrompt(record(id = "foreign-main", sessionKey = "main", agentId = "other")), + ) + val visible = questionsForSession(prompts, "main", "agent:main:main", "main") + assertEquals(listOf("ask_123", "current"), visible.map { it.record.id }) + assertTrue(visible.all { it.status(1_000) == ChatQuestionStatus.Pending }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleQuestionListCannotOverwriteNewerEvent() = + runTest { + val listStarted = CompletableDeferred() + val listResponse = CompletableDeferred() + val json = Json { ignoreUnknownKeys = true } + var listCallCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "question.list") { + listCallCount += 1 + if (listCallCount == 1) { + listStarted.complete(Unit) + listResponse.await() + } else { + json.encodeToString(QuestionListResult(listOf(record(id = "ask_new")))) + } + } else { + "{}" + } + }, + ) + + controller.handleGatewayEvent("health", null) + runCurrent() + listStarted.await() + controller.handleGatewayEvent("question.requested", json.encodeToString(record(id = "ask_new"))) + listResponse.complete(json.encodeToString(QuestionListResult(listOf(record(id = "ask_old"))))) + advanceUntilIdle() + + assertEquals(listOf("ask_new"), controller.questions.value.map { it.record.id }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun structuredMissingQuestionScopeClearsStaleCards() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "question.list") { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "FORBIDDEN", + message = "permission denied", + details = + GatewayErrorDetails( + code = "MISSING_SCOPE", + missingScope = "operator.questions", + requiredScopes = listOf("operator.questions"), + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ), + ) + } + "{}" + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(record(id = "ask_stale"))) + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(controller.questions.value.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun pendingRefreshPreservesSubmissionLock() = + runTest { + val resolveStarted = CompletableDeferred() + val resolveResponse = CompletableDeferred() + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(listOf(pending.copy(createdAtMs = 2_000)))) + "question.resolve" -> { + resolveStarted.complete(Unit) + resolveResponse.await() + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.resolveQuestion(pending.id, mapOf("meal" to listOf("Pizza"))) + runCurrent() + resolveStarted.await() + controller.handleGatewayEvent("health", null) + runCurrent() + + assertEquals( + ChatQuestionStatus.Submitting, + controller.questions.value + .single() + .status(nowMs = 3_000), + ) + assertFalse( + controller.questions.value + .single() + .answeredLocally, + ) + resolveResponse.complete("{}") + advanceUntilIdle() + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun replayedPendingEventCannotReopenResolvedQuestion() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val controller = ChatController(scope = this, json = json, requestGateway = { _, _ -> "{}" }) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.handleGatewayEvent("question.resolved", """{"id":"ask_123","status":"answered"}""") + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun pendingListRecordCannotReopenResolvedQuestion() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "question.list") json.encodeToString(QuestionListResult(listOf(pending))) else "{}" + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.handleGatewayEvent("question.resolved", """{"id":"ask_123","status":"cancelled"}""") + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals( + ChatQuestionStatus.Cancelled, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun resolvedEventReconcilesAfterDiscardingOlderList() = + runTest { + val firstListStarted = CompletableDeferred() + val firstListResponse = CompletableDeferred() + val json = Json { ignoreUnknownKeys = true } + var listCallCount = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method != "question.list") { + "{}" + } else { + listCallCount += 1 + if (listCallCount == 1) { + firstListStarted.complete(Unit) + firstListResponse.await() + } else { + json.encodeToString(QuestionListResult(listOf(record(id = "ask_other")))) + } + } + }, + ) + + controller.handleGatewayEvent("health", null) + runCurrent() + firstListStarted.await() + controller.handleGatewayEvent( + "question.resolved", + """{"id":"ask_done","status":"answered"}""", + ) + runCurrent() + firstListResponse.complete(json.encodeToString(QuestionListResult(listOf(record(id = "ask_done"))))) + advanceUntilIdle() + + assertEquals(listOf("ask_other"), controller.questions.value.map { it.record.id }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun questionListRetainsResolvedSummaryPermanently() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(id = "ask_done", expiresAtMs = Long.MAX_VALUE) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> json.encodeToString(QuestionListResult(emptyList())) }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.handleGatewayEvent( + "question.resolved", + """{"id":"ask_done","status":"answered"}""", + ) + runCurrent() + + assertEquals(listOf("ask_done"), controller.questions.value.map { it.record.id }) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single() + .status(), + ) + + advanceTimeBy(60_000) + runCurrent() + + assertEquals(listOf("ask_done"), controller.questions.value.map { it.record.id }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun locallyExpiredQuestionRemainsAsSummary() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val controller = ChatController(scope = this, json = json, requestGateway = { _, _ -> "{}" }) + val pending = record(expiresAtMs = 1_000) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + advanceUntilIdle() + + assertEquals( + ChatQuestionStatus.Expired, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun localExpiryReconcilesMissedRemoteAnswer() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = System.currentTimeMillis() + 1_000) + val answered = pending.copy(status = "answered") + var listCalls = 0 + var getCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> { + listCalls += 1 + json.encodeToString(QuestionListResult(if (listCalls == 1) listOf(pending) else emptyList())) + } + "question.get" -> { + getCalls += 1 + json.encodeToString(QuestionGetResult(answered)) + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + runCurrent() + advanceTimeBy(2_000) + advanceUntilIdle() + + assertEquals(2, listCalls) + assertEquals(1, getCalls) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun missingPendingQuestionUsesPerIdGetFallback() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val answered = + pending.copy( + status = "answered", + answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))), + ) + var getParams: String? = null + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(emptyList())) + "question.get" -> { + getParams = params + json.encodeToString(QuestionGetResult(answered)) + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + advanceUntilIdle() + + assertEquals( + "ask_123", + json + .parseToJsonElement(checkNotNull(getParams)) + .jsonObject["id"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + listOf("Tacos"), + controller.questions.value + .single() + .record.answers + ?.answers + ?.get("meal"), + ) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun successfulRefreshRecordsApplyWhenAnotherFallbackFails() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val listedPending = record(id = "ask_listed") + val recoveredPending = record(id = "ask_recovered") + val failingPending = record(id = "ask_failing") + val newlyMissingPending = record(id = "ask_newly_missing") + val listedAnswered = + listedPending.copy( + status = "answered", + answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))), + ) + val recoveredAnswered = recoveredPending.copy(status = "answered") + val failingAnswered = failingPending.copy(status = "answered") + val newlyMissingAnswered = newlyMissingPending.copy(status = "answered") + val getCalls = mutableMapOf() + var fallbackFailed = false + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + when (method) { + "question.list" -> { + val records = + if (!fallbackFailed) { + listOf(listedAnswered, newlyMissingPending) + } else { + listOf(listedAnswered) + } + json.encodeToString(QuestionListResult(records)) + } + "question.get" -> { + val id = + json + .parseToJsonElement(checkNotNull(params)) + .jsonObject + .getValue("id") + .jsonPrimitive + .content + getCalls[id] = getCalls.getOrDefault(id, 0) + 1 + when (id) { + recoveredPending.id -> + json.encodeToString( + QuestionGetResult( + if (getCalls.getValue(id) == 1) recoveredPending else recoveredAnswered, + ), + ) + newlyMissingPending.id -> json.encodeToString(QuestionGetResult(newlyMissingAnswered)) + failingPending.id -> { + if (getCalls.getValue(id) == 1) { + fallbackFailed = true + error("temporary question.get failure") + } + json.encodeToString(QuestionGetResult(failingAnswered)) + } + else -> error("unexpected question id") + } + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(listedPending)) + controller.handleGatewayEvent("question.requested", json.encodeToString(recoveredPending)) + controller.handleGatewayEvent("question.requested", json.encodeToString(failingPending)) + controller.handleGatewayEvent("question.requested", json.encodeToString(newlyMissingPending)) + runCurrent() + + val prompts = controller.questions.value.associateBy { it.record.id } + assertEquals(ChatQuestionStatus.AnsweredElsewhere, prompts.getValue("ask_listed").status()) + assertEquals( + listOf("Tacos"), + prompts + .getValue("ask_listed") + .record + .answers + ?.answers + ?.get("meal"), + ) + assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_recovered").status()) + assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_failing").status()) + assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_newly_missing").status()) + + advanceTimeBy(1_000) + runCurrent() + assertEquals(2, getCalls["ask_recovered"]) + assertEquals(2, getCalls["ask_failing"]) + assertEquals(1, getCalls["ask_newly_missing"]) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single { it.record.id == "ask_newly_missing" } + .status(), + ) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single { it.record.id == "ask_recovered" } + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun questionGetRetryResetsExhaustedBudgetAfterAnotherQuestionChanges() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val recovering = record(id = "ask_recovering") + val unrelated = record(id = "ask_unrelated") + val recovered = recovering.copy(status = "answered") + var getCalls = 0 + val finalGetStarted = CompletableDeferred() + val releaseFinalGet = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(listOf(unrelated))) + "question.get" -> { + getCalls += 1 + if (getCalls < 4) error("temporary question.get failure") + if (getCalls == 4) { + finalGetStarted.complete(Unit) + releaseFinalGet.await() + } + json.encodeToString(QuestionGetResult(recovered)) + } + "question.resolve" -> "{}" + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(recovering)) + controller.handleGatewayEvent("question.requested", json.encodeToString(unrelated)) + runCurrent() + assertEquals(1, getCalls) + + advanceTimeBy(1_000) + runCurrent() + advanceTimeBy(2_000) + runCurrent() + advanceTimeBy(4_000) + runCurrent() + finalGetStarted.await() + assertEquals(4, getCalls) + + controller.skipQuestion(unrelated.id) + runCurrent() + releaseFinalGet.complete(Unit) + runCurrent() + advanceTimeBy(1_000) + runCurrent() + + assertEquals(5, getCalls) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single { it.record.id == recovering.id } + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun questionGetRetryResetsBudgetWhenRevisionChangesDuringFinalBackoff() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val recovering = record(id = "ask_recovering") + val unrelated = record(id = "ask_unrelated") + val recovered = recovering.copy(status = "answered") + var getCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(listOf(unrelated))) + "question.get" -> { + getCalls += 1 + if (getCalls < 5) error("temporary question.get failure") + json.encodeToString(QuestionGetResult(recovered)) + } + "question.resolve" -> "{}" + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(recovering)) + controller.handleGatewayEvent("question.requested", json.encodeToString(unrelated)) + runCurrent() + advanceTimeBy(1_000) + runCurrent() + advanceTimeBy(2_000) + runCurrent() + assertEquals(3, getCalls) + + controller.skipQuestion(unrelated.id) + runCurrent() + advanceTimeBy(4_000) + runCurrent() + assertEquals(4, getCalls) + advanceTimeBy(1_000) + runCurrent() + + assertEquals(5, getCalls) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single { it.record.id == recovering.id } + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun locallyExpiredMissingQuestionUsesPerIdGetFallback() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = 0) + val answered = + pending.copy( + status = "answered", + answers = QuestionAnswers(mapOf("meal" to listOf("Tacos"))), + ) + var getCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(emptyList())) + "question.get" -> { + getCalls += 1 + json.encodeToString(QuestionGetResult(answered)) + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + runCurrent() + + assertEquals(1, getCalls) + assertEquals( + listOf("Tacos"), + controller.questions.value + .single() + .record.answers + ?.answers + ?.get("meal"), + ) + assertEquals( + ChatQuestionStatus.AnsweredElsewhere, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun missingQuestionGetRetriesAfterOneTwoAndFourSeconds() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + var getCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(emptyList())) + "question.get" -> { + getCalls += 1 + if (getCalls < 4) error("temporary question.get failure") + json.encodeToString(QuestionGetResult(pending)) + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + runCurrent() + assertEquals(1, getCalls) + advanceTimeBy(999) + runCurrent() + assertEquals(1, getCalls) + advanceTimeBy(1) + runCurrent() + assertEquals(2, getCalls) + advanceTimeBy(2_000) + runCurrent() + assertEquals(3, getCalls) + advanceTimeBy(4_000) + runCurrent() + assertEquals(4, getCalls) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun missingQuestionNotFoundHasUnknownTerminalOutcome() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + var getCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(emptyList())) + "question.get" -> + run { + getCalls += 1 + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "question not found", + details = + GatewayErrorDetails( + code = null, + reason = "QUESTION_NOT_FOUND", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ), + ) + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + advanceUntilIdle() + + assertEquals( + ChatQuestionStatus.Unavailable, + controller.questions.value + .single() + .status(), + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertEquals(1, getCalls) + assertEquals( + ChatQuestionStatus.Unavailable, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun skipUsesCancelResolutionAndKeepsSkippedSummary() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + var resolveParams: String? = null + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(listOf(pending))) + "question.resolve" -> { + resolveParams = params + "{}" + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.skipQuestion(pending.id) + advanceUntilIdle() + + val params = json.parseToJsonElement(checkNotNull(resolveParams)).jsonObject + assertEquals("ask_123", params["id"]?.jsonPrimitive?.content) + assertTrue(params["cancel"]?.jsonPrimitive?.content?.toBoolean() == true) + assertFalse("answers" in params) + assertEquals( + ChatQuestionStatus.Cancelled, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun skipClaimExposesSkippingProgress() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + "{}" + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.skipQuestion(pending.id) + runCurrent() + resolveStarted.await() + + val submitting = controller.questions.value.single() + assertEquals(ChatQuestionStatus.Submitting, submitting.status()) + assertTrue(submitting.submitting) + assertTrue(submitting.skipping) + + releaseResolve.complete(Unit) + advanceUntilIdle() + + val completed = controller.questions.value.single() + assertEquals(ChatQuestionStatus.Cancelled, completed.status()) + assertFalse(completed.submitting) + assertFalse(completed.skipping) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun answerClaimBlocksCompetingSkip() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val requestStarted = CompletableDeferred() + val releaseRequest = CompletableDeferred() + val resolveParams = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, params -> + when (method) { + "question.list" -> json.encodeToString(QuestionListResult(listOf(pending))) + "question.resolve" -> { + resolveParams.add(checkNotNull(params)) + requestStarted.complete(Unit) + releaseRequest.await() + "{}" + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + controller.resolveQuestion(pending.id, mapOf("meal" to listOf("Pizza"))) + runCurrent() + requestStarted.await() + controller.skipQuestion(pending.id) + releaseRequest.complete(Unit) + advanceUntilIdle() + + assertEquals(1, resolveParams.size) + assertFalse("cancel" in resolveParams.single()) + assertEquals( + ChatQuestionStatus.Answered, + controller.questions.value + .single() + .status(), + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun successfulAnswerOverridesUnavailableRecoveryRace() = + runTest { + val json = Json { ignoreUnknownKeys = true } + val pending = record(expiresAtMs = Long.MAX_VALUE) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "question.list" -> + json.encodeToString( + QuestionListResult(if (resolveStarted.isCompleted) emptyList() else listOf(pending)), + ) + "question.get" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "question not found", + details = + GatewayErrorDetails( + code = null, + reason = "QUESTION_NOT_FOUND", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ), + ) + "question.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + "{}" + } + else -> "{}" + } + }, + ) + + controller.handleGatewayEvent("question.requested", json.encodeToString(pending)) + runCurrent() + controller.resolveQuestion(pending.id, mapOf("meal" to listOf("Pizza"))) + runCurrent() + resolveStarted.await() + controller.handleGatewayEvent("health", null) + runCurrent() + assertEquals( + ChatQuestionStatus.Unavailable, + controller.questions.value + .single() + .status(), + ) + + releaseResolve.complete(Unit) + advanceUntilIdle() + + assertEquals( + ChatQuestionStatus.Answered, + controller.questions.value + .single() + .status(), + ) + } + + private fun record( + id: String = "ask_123", + status: String = "pending", + expiresAtMs: Long = Long.MAX_VALUE, + sessionKey: String? = "agent:main:main", + agentId: String? = "main", + ) = QuestionRecord( + id = id, + questions = listOf(question), + agentId = agentId, + sessionKey = sessionKey, + createdAtMs = 1_000, + expiresAtMs = expiresAtMs, + status = status, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt b/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt new file mode 100644 index 0000000..6984ad3 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt @@ -0,0 +1,268 @@ +package ai.openclaw.app.chat + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Scripted gateway responder for deterministic chat replay tests. + * + * Plugs into the same internal ChatController(requestGateway) seam the other + * controller tests use; scenarios script per-method responses and replay + * chat/agent events through ChatController.handleGatewayEvent under + * kotlinx-coroutines-test virtual time. + */ +internal class ScriptedGateway( + private val json: Json, +) { + data class Call( + val method: String, + val paramsJson: String?, + ) + + val calls = mutableListOf() + private val handlers = mutableMapOf String>() + + /** Client-generated run id captured from the latest chat.send params. */ + var lastRunId: String? = null + private set + + init { + // Benign defaults so bootstrap/health/commands side requests never fail a scenario. + respondWith("health", "{}") + respondWith("chat.metadata", """{"commands":[],"models":[]}""") + respondWith("sessions.list", """{"sessions":[]}""") + } + + fun respond( + method: String, + handler: suspend (paramsJson: String?) -> String, + ) { + handlers[method] = handler + } + + fun respondWith( + method: String, + responseJson: String, + ) { + respond(method) { responseJson } + } + + /** Acks chat.send echoing the client idempotency key as run id, like the live gateway. */ + fun respondChatSend(status: String) { + respond("chat.send") { paramsJson -> + val runId = + paramsJson + ?.let { value -> + json + .parseToJsonElement(value) + .jsonObject["idempotencyKey"] + ?.jsonPrimitive + ?.content + } + lastRunId = runId + buildJsonObject { + if (runId != null) put("runId", JsonPrimitive(runId)) + put("status", JsonPrimitive(status)) + }.toString() + } + } + + suspend fun request( + method: String, + paramsJson: String?, + ): String { + calls += Call(method, paramsJson) + val handler = handlers[method] ?: error("ScriptedGateway: no scripted response for $method") + return handler(paramsJson) + } + + fun sessionKeyOf(paramsJson: String?): String? = + paramsJson?.let { value -> + json + .parseToJsonElement(value) + .jsonObject["sessionKey"] + ?.jsonPrimitive + ?.content + } + + fun callCount(method: String): Int = calls.count { it.method == method } +} + +/** One transcript row for a scripted chat.history response. */ +internal data class ReplayHistoryMessage( + val role: String, + val text: String, + val timestampMs: Long, + val idempotencyKey: String? = null, + val entryId: String? = null, +) + +internal fun historyResponse( + sessionId: String, + messages: List, + inFlightRun: Pair? = null, + inFlightPlan: ChatPlanSnapshot? = null, + hasActiveRun: Boolean? = inFlightRun?.let { true }, + activeRunIds: List? = inFlightRun?.let { listOf(it.first) }, +): String = + buildJsonObject { + put("sessionId", JsonPrimitive(sessionId)) + if (inFlightRun != null) { + put( + "inFlightRun", + buildJsonObject { + put("runId", JsonPrimitive(inFlightRun.first)) + put("text", JsonPrimitive(inFlightRun.second)) + if (inFlightPlan != null) { + put( + "plan", + buildJsonObject { + put( + "steps", + JsonArray( + inFlightPlan.steps.map { step -> + buildJsonObject { + put("step", JsonPrimitive(step.step)) + put( + "status", + JsonPrimitive( + when (step.status) { + ChatPlanStepStatus.Pending -> "pending" + ChatPlanStepStatus.InProgress -> "in_progress" + ChatPlanStepStatus.Completed -> "completed" + }, + ), + ) + } + }, + ), + ) + inFlightPlan.explanation?.let { put("explanation", JsonPrimitive(it)) } + }, + ) + } + }, + ) + } + if (hasActiveRun != null || activeRunIds != null) { + put( + "sessionInfo", + buildJsonObject { + hasActiveRun?.let { put("hasActiveRun", JsonPrimitive(it)) } + activeRunIds?.let { ids -> + put("activeRunIds", JsonArray(ids.map(::JsonPrimitive))) + } + }, + ) + } + put( + "messages", + JsonArray( + messages.map { message -> + buildJsonObject { + put("role", JsonPrimitive(message.role)) + put("content", JsonPrimitive(message.text)) + put("timestamp", JsonPrimitive(message.timestampMs)) + if (message.idempotencyKey != null) { + put("idempotencyKey", JsonPrimitive(message.idempotencyKey)) + } + if (message.entryId != null) { + put("__openclaw", buildJsonObject { put("id", JsonPrimitive(message.entryId)) }) + } + } + }, + ), + ) + }.toString() + +/** Gateway delta carrying the accumulated snapshot plus the v4 incremental chunk when present. */ +internal fun chatDeltaPayload( + sessionKey: String, + runId: String, + seq: Int, + deltaText: String?, + accumulatedText: String, +): String = + buildJsonObject { + put("sessionKey", JsonPrimitive(sessionKey)) + put("runId", JsonPrimitive(runId)) + put("seq", JsonPrimitive(seq)) + put("state", JsonPrimitive("delta")) + if (deltaText != null) put("deltaText", JsonPrimitive(deltaText)) + put( + "message", + buildJsonObject { + put("role", JsonPrimitive("assistant")) + put( + "content", + JsonArray( + listOf( + buildJsonObject { + put("type", JsonPrimitive("text")) + put("text", JsonPrimitive(accumulatedText)) + }, + ), + ), + ) + }, + ) + }.toString() + +internal fun chatTerminalPayload( + sessionKey: String, + runId: String, + seq: Int, + state: String = "final", + assistantText: String? = null, +): String = + buildJsonObject { + put("sessionKey", JsonPrimitive(sessionKey)) + put("runId", JsonPrimitive(runId)) + put("seq", JsonPrimitive(seq)) + put("state", JsonPrimitive(state)) + if (assistantText != null) { + put( + "message", + buildJsonObject { + put("role", JsonPrimitive("assistant")) + put( + "content", + JsonArray( + listOf( + buildJsonObject { + put("type", JsonPrimitive("text")) + put("text", JsonPrimitive(assistantText)) + }, + ), + ), + ) + }, + ) + } + }.toString() + +/** + * Splits text into fixed-size chunks without splitting surrogate pairs; encoding half + * a pair through the JSON event pipeline would corrupt the streamed byte sequence. + */ +internal fun chunkPreservingCodePoints( + text: String, + chunkSize: Int, +): List { + require(chunkSize > 1) { "chunkSize must leave room for surrogate pairs" } + val chunks = mutableListOf() + var start = 0 + while (start < text.length) { + var end = minOf(start + chunkSize, text.length) + if (end < text.length && Character.isHighSurrogate(text[end - 1])) { + end -= 1 + } + chunks += text.substring(start, end) + start = end + } + return chunks +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatSwarmProgressTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatSwarmProgressTest.kt new file mode 100644 index 0000000..814181a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatSwarmProgressTest.kt @@ -0,0 +1,244 @@ +package ai.openclaw.app.chat + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatSwarmProgressTest { + @Test + fun activityNotesDecorateChildrenInObservationOrder() { + val groupId = "swarm:agent:main:parent:turn-1" + val tracker = ChatSwarmActivityTracker() + + assertTrue( + tracker.observe( + buildJsonObject { + put("sessionKey", JsonPrimitive("agent:main:parent")) + put("reason", JsonPrimitive("swarm-note")) + put("swarmGroupId", JsonPrimitive(groupId)) + put("kind", JsonPrimitive("phase")) + put("text", JsonPrimitive("Research")) + }, + ), + ) + assertTrue( + tracker.observe( + buildJsonObject { + put("sessionKey", JsonPrimitive("agent:main:child")) + put("reason", JsonPrimitive("create")) + put("swarmGroupId", JsonPrimitive(groupId)) + }, + ), + ) + assertTrue( + tracker.observe( + buildJsonObject { + put("sessionKey", JsonPrimitive("agent:main:parent")) + put("reason", JsonPrimitive("swarm-note")) + put("swarmGroupId", JsonPrimitive(groupId)) + put("kind", JsonPrimitive("log")) + put("text", JsonPrimitive("Comparing sources")) + }, + ), + ) + + val row = tracker.decorate(listOf(session("agent:main:child", "running", groupId))).single() + assertEquals("Research", row.swarmPhase) + assertEquals(0, row.swarmPhaseRank) + assertEquals("Comparing sources", row.swarmLog) + } + + @Test + fun nestedActivityNotesDecorateChildren() { + val groupId = "swarm:agent:main:parent:turn-1" + val tracker = ChatSwarmActivityTracker() + + assertTrue( + tracker.observe( + buildJsonObject { + put( + "session", + buildJsonObject { + put("sessionKey", JsonPrimitive("agent:main:parent")) + put("swarmGroupId", JsonPrimitive(groupId)) + put("kind", JsonPrimitive("phase")) + put("text", JsonPrimitive("Research")) + }, + ) + }, + ), + ) + assertTrue( + tracker.observe( + buildJsonObject { + put("reason", JsonPrimitive("create")) + put( + "session", + buildJsonObject { + put("key", JsonPrimitive("agent:main:child")) + put("swarmGroupId", JsonPrimitive(groupId)) + }, + ) + }, + ), + ) + + val row = tracker.decorate(listOf(session("agent:main:child", "running", groupId))).single() + assertEquals("Research", row.swarmPhase) + } + + @Test + fun projectionMapsStatesAndHidesTerminalGroups() { + val active = "swarm:agent:main:parent:active" + val finished = "swarm:agent:main:parent:finished" + val groups = + buildChatSwarmGroups( + sessions = + listOf( + session("queued", null, active, subagentRunState = "active"), + session("running", "running", active), + session("done", "done", active), + session("failed", "timeout", active), + session("finished", "done", finished), + ), + matchesParent = { it == "agent:main:parent" }, + ) + + assertEquals(1, groups.size) + assertEquals(active, groups.single().groupId) + assertEquals(1, groups.single().running) + assertEquals(1, groups.single().done) + assertEquals(1, groups.single().failed) + assertEquals( + listOf(ChatSwarmDotStatus.Queued, ChatSwarmDotStatus.Running, ChatSwarmDotStatus.Done, ChatSwarmDotStatus.Failed), + groups + .single() + .phases + .single() + .dots + .map(ChatSwarmDot::status), + ) + } + + @Test + fun childPagerRepeatsFromZeroWhenRowsMoveAcrossOffsets() = + kotlinx.coroutines.test.runTest { + val groupId = "swarm:agent:main:parent:paged" + val pages = + listOf( + listOf(session("zero", "running", groupId), session("one", "running", groupId)), + listOf(session("one", "running", groupId), session("two", "running", groupId)), + listOf(session("zero", "running", groupId), session("one", "done", groupId)), + listOf(session("three", "running", groupId), session("two", "running", groupId)), + ) + var call = 0 + + val rows = + collectChatSwarmChildSessions { offset -> + val page = pages[call++] + ChatSwarmSessionPage( + sessions = page, + totalCount = 4, + nextOffset = if (offset == 0) 2 else null, + hasMore = offset == 0, + ) + } + + assertEquals(setOf("zero", "one", "two", "three"), rows.map(ChatSessionEntry::key).toSet()) + assertEquals("done", rows.first { it.key == "one" }.status) + assertEquals(4, call) + } + + @Test + fun childPagerUsesTotalCountWhenHasMoreIsAbsent() = + kotlinx.coroutines.test.runTest { + var call = 0 + val rows = + collectChatSwarmChildSessions { offset -> + call += 1 + ChatSwarmSessionPage( + sessions = listOf(session("child-$offset", "running", "swarm:agent:main:parent:paged")), + totalCount = 2, + nextOffset = if (offset == 0) 1 else null, + hasMore = null, + ) + } + + assertEquals(listOf("child-0", "child-1"), rows.map(ChatSessionEntry::key)) + assertEquals(2, call) + } + + @Test + fun childPagerBoundsAdvancingMalformedPagination() = + kotlinx.coroutines.test.runTest { + var call = 0 + val rows = + collectChatSwarmChildSessions { offset -> + call += 1 + ChatSwarmSessionPage( + sessions = listOf(session("child", "running", "swarm:agent:main:parent:paged")), + totalCount = Int.MAX_VALUE, + nextOffset = offset + 1, + hasMore = true, + ) + } + + assertEquals(listOf("child"), rows.map(ChatSessionEntry::key)) + assertEquals(100, call) + } + + @Test + fun swarmEventsIgnoreOtherParents() { + val current = "agent:main:parent" + val other = "agent:main:other" + val ownChild = + buildJsonObject { + put("sessionKey", JsonPrimitive("agent:main:child")) + put("parentSessionKey", JsonPrimitive(current)) + put("reason", JsonPrimitive("create")) + put("swarmGroupId", JsonPrimitive("custom-group")) + } + val otherPhase = + buildJsonObject { + put("sessionKey", JsonPrimitive(other)) + put("reason", JsonPrimitive("swarm-note")) + put("swarmGroupId", JsonPrimitive("swarm:$other:turn")) + put("kind", JsonPrimitive("phase")) + put("text", JsonPrimitive("Research")) + } + + assertTrue(chatSwarmEventBelongsToParent(ownChild) { it == current }) + assertTrue(!chatSwarmEventBelongsToParent(otherPhase) { it == current }) + } + + @Test + fun projectionCapsHistoryAndKeepsActiveWorker() { + val groupId = "swarm:agent:main:parent:large" + val sessions = + (0 until 300).map { session("done-$it", "done", groupId) } + + session("running", "running", groupId) + + val phase = buildChatSwarmGroups(sessions) { it == "agent:main:parent" }.single().phases.single() + assertEquals(256, phase.dots.size) + assertEquals(45, phase.hidden) + assertTrue(phase.dots.any { it.status == ChatSwarmDotStatus.Running }) + } + + private fun session( + key: String, + status: String?, + groupId: String, + subagentRunState: String? = null, + ): ChatSessionEntry = + ChatSessionEntry( + key = key, + updatedAtMs = 1, + displayName = key, + parentSessionKey = "agent:main:parent", + subagentRunState = subagentRunState, + swarmGroupId = groupId, + status = status, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ChatVoiceNoteAttachmentTest.kt b/app/src/test/java/ai/openclaw/app/chat/ChatVoiceNoteAttachmentTest.kt new file mode 100644 index 0000000..9561e5b --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ChatVoiceNoteAttachmentTest.kt @@ -0,0 +1,74 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.ui.chat.stageVoiceNoteAttachment +import ai.openclaw.app.ui.chat.toOutgoingAttachment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatVoiceNoteAttachmentTest { + @Test + fun stagedVoiceNoteUsesAudioPayloadAndDurationInLocalEcho() = + runTest { + val file = Files.createTempFile("voice-note-", ".m4a").toFile() + file.writeBytes("voice-bytes".encodeToByteArray()) + val pending = stageVoiceNoteAttachment(VoiceNoteRecording(file = file, durationMs = 12_345L)) + val outgoing = pending.toOutgoingAttachment() + var sentParams: JsonObject? = null + val json = Json { ignoreUnknownKeys = true } + val chat = + ChatController( + scope = backgroundScope, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "chat.send" -> { + sentParams = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + """{"runId":"voice-run","status":"started"}""" + } + else -> "{}" + } + }, + ) + chat.handleGatewayEvent("health", null) + runCurrent() + + val accepted = + chat.sendMessageAwaitAcceptance( + message = "listen", + thinkingLevel = "off", + attachments = listOf(outgoing), + ) + + assertTrue(accepted) + assertFalse(file.exists()) + assertEquals("audio", outgoing.type) + assertEquals(VOICE_NOTE_MIME_TYPE, outgoing.mimeType) + assertTrue(outgoing.fileName.endsWith(".m4a")) + assertEquals("dm9pY2UtYnl0ZXM=", outgoing.base64) + + val sentAttachment = ((sentParams?.get("attachments") as JsonArray).single() as JsonObject) + assertEquals("audio", (sentAttachment["type"] as JsonPrimitive).content) + assertEquals(VOICE_NOTE_MIME_TYPE, (sentAttachment["mimeType"] as JsonPrimitive).content) + assertTrue((sentAttachment["fileName"] as JsonPrimitive).content.endsWith(".m4a")) + assertEquals("dm9pY2UtYnl0ZXM=", (sentAttachment["content"] as JsonPrimitive).content) + + val echoedAudio = + chat.messages.value + .single() + .content + .single { it.type == "audio" } + assertEquals(VOICE_NOTE_MIME_TYPE, echoedAudio.mimeType) + assertEquals(12_345L, echoedAudio.durationMs) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt b/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt new file mode 100644 index 0000000..0e4fcbd --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt @@ -0,0 +1,508 @@ +package ai.openclaw.app.chat + +import android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class ClientDatabasesTest { + @Test + fun deferredOutboxPersistsAtomicMutationDemotion() = + runTest { + val names = databaseNames() + val databases = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + val outbox = databases.commandOutbox() + val scope = ChatOutboxScope("main", "main") + val lease = requireNotNull(outbox.beginSessionMutation("gateway-a", scope, nowMs = 1_000)) + + val state = requireNotNull(outbox.demoteSessionMutationToReconciliationState("gateway-a", scope, lease)) + + assertTrue(state.needsReconciliation) + assertNull(state.switchPendingSinceMs) + val persisted = requireNotNull(outbox.branchState("gateway-a", scope)) + assertTrue(persisted.needsReconciliation) + assertNull(persisted.switchPendingSinceMs) + } finally { + databases.close() + delete(names) + } + } + + @Test + fun v2DurableRowsImportIntoClientStateWhileLegacyCacheIsDiscarded() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + createV2Fixture(context.getDatabasePath(names.legacy).path) + + val databases = open(names, registeredGatewayIds = setOf("gateway-test")) + try { + assertEquals( + 2, + databases + .gatewayCacheDatabase() + .openHelper.writableDatabase.version, + ) + assertEquals( + 1, + databases + .clientStateDatabase() + .openHelper.writableDatabase.version, + ) + + val rows = databases.commandOutbox().load("gateway-test").associateBy { it.id } + val pristine = rows.getValue("pristine") + assertEquals(ChatOutboxStatus.Failed, pristine.status) + assertEquals(OUTBOX_OWNER_CHANGED_ERROR, pristine.lastError) + assertNull(pristine.ownerAgentId) + assertNull(pristine.gatedEpoch) + assertTrue(pristine.attachments.isEmpty()) + + for (id in listOf("legacy-queued-error", "interrupted-send")) { + val migrated = rows.getValue(id) + assertEquals(ChatOutboxStatus.Failed, migrated.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, migrated.lastError) + } + val alreadyFailed = rows.getValue("already-failed") + assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status) + assertEquals("original failure", alreadyFailed.lastError) + val accepted = rows.getValue("accepted") + assertEquals(ChatOutboxStatus.Failed, accepted.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, accepted.lastError) + val explicitOwner = rows.getValue("explicit-owner") + assertEquals(ChatOutboxStatus.Queued, explicitOwner.status) + assertEquals("ops", explicitOwner.ownerAgentId) + databases.commandOutbox().deleteForSession("gateway-test", "agent:ops:side", "ops") + assertTrue(databases.commandOutbox().load("gateway-test").none { it.id == explicitOwner.id }) + + val legacyCommand = rows.getValue("legacy-command") + assertEquals(ChatOutboxStatus.Failed, legacyCommand.status) + assertEquals(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch) + assertEquals(OUTBOX_OWNER_CHANGED_ERROR, legacyCommand.lastError) + + // Legacy gateway snapshots are disposable and never cross into the new cache file. + assertTrue(databases.transcriptCache().loadSessions("gateway-test", "main").isEmpty()) + assertTrue(databases.transcriptCache().loadTranscript("gateway-test", "main", "main").isEmpty()) + assertFalse(context.getDatabasePath(names.legacy).exists()) + assertTrue(context.getDatabasePath(names.cache).exists()) + assertTrue(context.getDatabasePath(names.state).exists()) + } finally { + databases.close() + delete(names) + } + } + + @Test + fun v8AttachmentBytesAndAdmissionReceiptsImportOnceAndSurviveReopen() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + createV2Fixture(context.getDatabasePath(names.legacy).path) + val bytes = ByteArray((OUTBOX_ATTACHMENT_CHUNK_BYTES * 9) + 77) { (it % 127).toByte() } + addV8AttachmentFixture(names.legacy, bytes) + + val first = open(names, registeredGatewayIds = setOf("gateway-test")) + try { + val loaded = first.commandOutbox().loadAttachments("media-command") + assertEquals(1, loaded.size) + assertTrue(bytes.contentEquals(loaded.single().bytes)) + assertTrue(first.commandOutbox().wasAdmitted("media-command")) + } finally { + first.close() + } + + // A fresh open reads only client-state.db. The completion marker prevents a stale legacy + // file from being imported twice if deletion was interrupted. + val reopened = open(names, registeredGatewayIds = setOf("gateway-test")) + try { + val loaded = reopened.commandOutbox().loadAttachments("media-command") + assertEquals(1, loaded.size) + assertTrue(bytes.contentEquals(loaded.single().bytes)) + assertTrue(reopened.commandOutbox().wasAdmitted("media-command")) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun cacheFormatMismatchRebuildsWithoutTouchingClientState() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + val first = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + first.transcriptCache().saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + messages = listOf(cachedMessage("cache me")), + ) + assertTrue( + first.commandOutbox().enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "preserve me", + thinkingLevel = "off", + nowMs = 1, + ownerAgentId = "main", + ) is ChatOutboxEnqueueResult.Queued, + ) + } finally { + first.close() + } + + SQLiteDatabase.openDatabase(context.getDatabasePath(names.cache).path, null, SQLiteDatabase.OPEN_READWRITE).use { + it.version = 99 + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty()) + assertEquals(listOf("preserve me"), reopened.commandOutbox().load("gateway-a").map { it.text }) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun clientStateFormatMismatchFailsClosedWithoutDeletingDurableFile() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + val first = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + seedGateway(first, "gateway-a", "preserve") + } finally { + first.close() + } + + val statePath = context.getDatabasePath(names.state).path + SQLiteDatabase.openDatabase(statePath, null, SQLiteDatabase.OPEN_READWRITE).use { + it.version = 99 + } + + val failedOpen = open(names, registeredGatewayIds = setOf("gateway-a")) + val failure = runCatching { failedOpen.clientStateDatabase() } + failedOpen.close() + assertTrue(failure.isFailure) + assertTrue(context.getDatabasePath(names.state).exists()) + SQLiteDatabase.openDatabase(statePath, null, SQLiteDatabase.OPEN_READONLY).use { + assertEquals(99, it.version) + } + delete(names) + } + + @Test + fun absentGatewayCommitsStagedRemovalAcrossBothDatabasesAndKeepsOtherGateway() = + runTest { + val names = databaseNames() + val first = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b")) + try { + seedGateway(first, "gateway-a", "remove") + seedGateway(first, "gateway-b", "keep") + first.stageGatewayRemoval("gateway-a") + } finally { + first.close() + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-b")) + try { + assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty()) + assertTrue(reopened.commandOutbox().load("gateway-a").isEmpty()) + assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-b", "main", "main").map { it.content.single().text }) + assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-b").map { it.text }) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun cachePendingRemovalNeverDeletesNewDurableRowsOnResume() = + runTest { + val names = databaseNames() + val first = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b")) + try { + seedGateway(first, "gateway-a", "remove") + seedGateway(first, "gateway-b", "keep") + // Force only the disposable half to fail after the durable state transaction commits. + first.gatewayCacheDatabase().close() + first.commitGatewayRemoval("gateway-a") + assertTrue(first.commandOutbox().load("gateway-a").isEmpty()) + assertTrue( + first.commandOutbox().enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "new after purge", + thinkingLevel = "off", + nowMs = 2, + ownerAgentId = "main", + ) is ChatOutboxEnqueueResult.Queued, + ) + // A retry may stage again before restart; it must not downgrade cache-pending into a + // cancelable marker that could strand the old derived rows. + first.stageGatewayRemoval("gateway-a") + } finally { + first.close() + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b")) + try { + assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty()) + assertEquals(listOf("new after purge"), reopened.commandOutbox().load("gateway-a").map { it.text }) + assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-b", "main", "main").map { it.content.single().text }) + assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-b").map { it.text }) + assertTrue( + reopened + .clientStateDatabase() + .controlDao() + .gatewayRemovals() + .isEmpty(), + ) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun stillRegisteredGatewayCancelsCancelableStagedRemoval() = + runTest { + val names = databaseNames() + val first = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + seedGateway(first, "gateway-a", "keep") + first.stageGatewayRemoval("gateway-a") + } finally { + first.close() + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").map { it.content.single().text }) + assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-a").map { it.text }) + } finally { + reopened.close() + delete(names) + } + } + + private suspend fun seedGateway( + databases: AndroidClientDatabases, + gatewayId: String, + text: String, + ) { + databases.transcriptCache().saveTranscript( + gatewayId = gatewayId, + agentId = "main", + sessionKey = "main", + messages = listOf(cachedMessage(text)), + ) + assertTrue( + databases.commandOutbox().enqueue( + gatewayId = gatewayId, + sessionKey = "main", + text = text, + thinkingLevel = "off", + nowMs = 1, + ownerAgentId = "main", + ) is ChatOutboxEnqueueResult.Queued, + ) + } + + private fun cachedMessage(text: String): ChatMessage = + ChatMessage( + id = "id-$text", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = 1, + ) + + private fun addV8AttachmentFixture( + legacyName: String, + bytes: ByteArray, + ) { + val context = RuntimeEnvironment.getApplication() + val legacy = LegacyChatDatabase.open(context, legacyName) + try { + val database = legacy.openHelper.writableDatabase + database.execSQL( + "INSERT INTO outbox_commands " + + "(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError, gatedEpoch, ownerAgentId) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf("media-command", "gateway-test", "main", "media", "off", 100L, "queued", 0, null, null, "main"), + ) + database.execSQL( + "INSERT INTO composer_send_admissions (id, gatewayId, ownerAgentId, sessionKey) VALUES (?, ?, ?, ?)", + arrayOf("media-command", "gateway-test", "main", "main"), + ) + database.execSQL( + "INSERT INTO outbox_attachments (id, commandId, position, type, mimeType, fileName, durationMs, byteLength) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf("media-attachment", "media-command", 0, "image", "image/jpeg", "a.jpg", null, bytes.size.toLong()), + ) + var offset = 0 + var index = 0 + while (offset < bytes.size) { + val end = minOf(offset + OUTBOX_ATTACHMENT_CHUNK_BYTES, bytes.size) + database.execSQL( + "INSERT INTO outbox_attachment_chunks (attachmentId, chunkIndex, bytes) VALUES (?, ?, ?)", + arrayOf("media-attachment", index, bytes.copyOfRange(offset, end)), + ) + offset = end + index += 1 + } + } finally { + legacy.close() + } + } + + private fun open( + names: DatabaseNames, + registeredGatewayIds: Set, + ): AndroidClientDatabases = + AndroidClientDatabases.start( + RuntimeEnvironment.getApplication(), + gatewayCacheName = names.cache, + clientStateName = names.state, + legacyName = names.legacy, + registeredGatewayIds = registeredGatewayIds, + ) + + private fun databaseNames(): DatabaseNames { + val id = UUID.randomUUID().toString() + return DatabaseNames( + cache = "gateway-cache-$id.db", + state = "client-state-$id.db", + legacy = "chat-transcript-cache-$id.db", + ) + } + + private fun delete(names: DatabaseNames) { + val context = RuntimeEnvironment.getApplication() + context.deleteDatabase(names.cache) + context.deleteDatabase(names.state) + context.deleteDatabase(names.legacy) + } + + private data class DatabaseNames( + val cache: String, + val state: String, + val legacy: String, + ) + + private fun createV2Fixture(path: String) { + SQLiteDatabase.openOrCreateDatabase(path, null).use { database -> + val now = System.currentTimeMillis() + database.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_sessions` " + + "(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, " + + "`updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `sessionKey`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_messages` " + + "(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, " + + "`role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, " + + "`idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `sessionKey`, `rowOrder`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `outbox_commands` " + + "(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + + "`text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, " + + "`status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + ) + database.execSQL( + "INSERT INTO cached_sessions " + + "(gatewayId, sessionKey, displayName, updatedAtMs, rowOrder) VALUES (?, ?, ?, ?, ?)", + arrayOf("gateway-test", "main", "Cached session", 10L, 0), + ) + database.execSQL( + "INSERT INTO cached_messages " + + "(gatewayId, sessionKey, rowOrder, role, textPartsJson, timestampMs, idempotencyKey) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + arrayOf("gateway-test", "main", 0, "assistant", "[\"legacy transcript\"]", 10L, null), + ) + insertOutbox(database, id = "pristine", status = "queued", retryCount = 0, lastError = null, createdAtMs = now) + insertOutbox( + database, + id = "legacy-queued-error", + status = "queued", + retryCount = 0, + lastError = "socket closed after send", + createdAtMs = now + 1, + ) + insertOutbox( + database, + id = "interrupted-send", + status = "sending", + retryCount = 1, + lastError = null, + createdAtMs = now + 2, + ) + insertOutbox( + database, + id = "already-failed", + status = "failed", + retryCount = 3, + lastError = "original failure", + createdAtMs = now + 3, + ) + insertOutbox( + database, + id = "legacy-command", + status = "queued", + retryCount = 0, + lastError = null, + createdAtMs = now + 4, + text = "/clear", + ) + insertOutbox( + database, + id = "accepted", + status = "accepted", + retryCount = 0, + lastError = null, + createdAtMs = now + 5, + ) + insertOutbox( + database, + id = "explicit-owner", + status = "queued", + retryCount = 0, + lastError = null, + createdAtMs = now + 6, + sessionKey = "agent:ops:side", + ) + database.version = 2 + } + } + + private fun insertOutbox( + database: SQLiteDatabase, + id: String, + status: String, + retryCount: Int, + lastError: String?, + createdAtMs: Long, + text: String = id, + sessionKey: String = "main", + ) { + database.execSQL( + "INSERT INTO outbox_commands " + + "(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(id, "gateway-test", sessionKey, text, "off", createdAtMs, status, retryCount, lastError), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/MessageSpeechControllerTest.kt b/app/src/test/java/ai/openclaw/app/chat/MessageSpeechControllerTest.kt new file mode 100644 index 0000000..de538b1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/MessageSpeechControllerTest.kt @@ -0,0 +1,222 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.voice.TalkAudioPlaying +import ai.openclaw.app.voice.TalkSpeakAudio +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +private fun speechClip(bytes: ByteArray = byteArrayOf(1, 2, 3)): TalkSpeakAudio = + TalkSpeakAudio( + bytes = bytes, + provider = "openai", + outputFormat = "mp3", + voiceCompatible = null, + mimeType = "audio/mpeg", + fileExtension = ".mp3", + ) + +private class FakePlayer : TalkAudioPlaying { + val played = mutableListOf() + var stopCount = 0 + var gate: CompletableDeferred? = null + var failure: Throwable? = null + private var activeGate: CompletableDeferred? = null + + override suspend fun play(audio: TalkSpeakAudio) { + played += audio + failure?.let { throw it } + val currentGate = gate + activeGate = currentGate + currentGate?.await() + } + + override fun stop() { + stopCount += 1 + activeGate?.cancel() + activeGate = null + } +} + +private class FakeLocalSpeech : LocalSpeechSpeaking { + val spoken = mutableListOf() + var stopCount = 0 + + override suspend fun speak(text: String) { + spoken += text + } + + override fun stop() { + stopCount += 1 + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class MessageSpeechControllerTest { + @Test + fun clientRequestsTtsSpeakAndPreservesPlaybackMetadata() = + runTest { + var method = "" + var params = "" + var timeoutMs = 0L + val client = + MessageSpeechClient( + requestDetailed = { requestMethod, requestParams, requestTimeoutMs -> + method = requestMethod + params = requestParams + timeoutMs = requestTimeoutMs + GatewaySession.RpcResult( + ok = true, + payloadJson = + """{"audioBase64":"AQID","provider":"openai","outputFormat":"mp3","mimeType":"audio/mpeg","fileExtension":".mp3"}""", + error = null, + ) + }, + ) + + val audio = checkNotNull(client.synthesize("Hello")) + + assertEquals("tts.speak", method) + assertEquals("""{"text":"Hello"}""", params) + assertEquals(60_000L, timeoutMs) + assertArrayEquals(byteArrayOf(1, 2, 3), audio.bytes) + assertEquals("openai", audio.provider) + assertEquals("mp3", audio.outputFormat) + assertEquals("audio/mpeg", audio.mimeType) + assertEquals(".mp3", audio.fileExtension) + } + + @Test + fun playsGatewayClipAndClearsState() = + runTest { + val player = FakePlayer().also { it.gate = CompletableDeferred() } + val local = FakeLocalSpeech() + val controller = controller(player = player, local = local) + + controller.toggle(messageId = "m1", text = "Hello there.") + assertEquals( + MessageSpeechState(messageId = "m1", phase = MessageSpeechPhase.Preparing), + controller.state.value, + ) + + runCurrent() + assertEquals( + MessageSpeechState(messageId = "m1", phase = MessageSpeechPhase.Speaking), + controller.state.value, + ) + assertEquals(1, player.played.size) + + player.gate?.complete(Unit) + advanceUntilIdle() + assertNull(controller.state.value) + assertTrue(local.spoken.isEmpty()) + } + + @Test + fun fallsBackToLocalSpeechWhenGatewayCannotRender() = + runTest { + val player = FakePlayer() + val local = FakeLocalSpeech() + val controller = controller(player = player, local = local, synthesizer = { null }) + + controller.toggle(messageId = "m1", text = "Read me aloud") + advanceUntilIdle() + + assertEquals(listOf("Read me aloud"), local.spoken) + assertTrue(player.played.isEmpty()) + assertNull(controller.state.value) + } + + @Test + fun fallsBackToLocalSpeechWhenClipPlaybackFails() = + runTest { + val player = FakePlayer().also { it.failure = IllegalStateException("Unsupported talk audio format") } + val local = FakeLocalSpeech() + val controller = controller(player = player, local = local) + + controller.toggle(messageId = "m1", text = "Broken clip") + advanceUntilIdle() + + assertEquals(listOf("Broken clip"), local.spoken) + assertNull(controller.state.value) + } + + @Test + fun toggleWhileActiveStopsWithoutFallback() = + runTest { + val player = FakePlayer().also { it.gate = CompletableDeferred() } + val local = FakeLocalSpeech() + val controller = controller(player = player, local = local) + + controller.toggle(messageId = "m1", text = "Long reply") + runCurrent() + controller.toggle(messageId = "m1", text = "Long reply") + + assertNull(controller.state.value) + assertTrue(player.stopCount > 0) + advanceUntilIdle() + assertTrue(local.spoken.isEmpty()) + assertNull(controller.state.value) + } + + @Test + fun startingAnotherMessageSupersedesTheFirst() = + runTest { + val player = FakePlayer().also { it.gate = CompletableDeferred() } + val local = FakeLocalSpeech() + val controller = controller(player = player, local = local) + + controller.toggle(messageId = "m1", text = "First message") + runCurrent() + player.gate = CompletableDeferred() + controller.toggle(messageId = "m2", text = "Second message") + runCurrent() + + assertEquals( + MessageSpeechState(messageId = "m2", phase = MessageSpeechPhase.Speaking), + controller.state.value, + ) + player.gate?.complete(Unit) + advanceUntilIdle() + assertNull(controller.state.value) + assertTrue(local.spoken.isEmpty()) + } + + @Test + fun blankTextStaysIdle() = + runTest { + val player = FakePlayer() + val local = FakeLocalSpeech() + val controller = controller(player = player, local = local) + + controller.toggle(messageId = "m1", text = " \n ") + advanceUntilIdle() + + assertNull(controller.state.value) + assertTrue(player.played.isEmpty()) + assertTrue(local.spoken.isEmpty()) + } + + private fun kotlinx.coroutines.test.TestScope.controller( + player: FakePlayer, + local: FakeLocalSpeech, + synthesizer: MessageSpeechSynthesizing = MessageSpeechSynthesizing { speechClip() }, + ): MessageSpeechController = + MessageSpeechController( + scope = this, + synthesizer = synthesizer, + player = player, + localSpeech = local, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt b/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt new file mode 100644 index 0000000..6367e59 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt @@ -0,0 +1,1080 @@ +package ai.openclaw.app.chat + +import androidx.room.Room +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class RoomChatCommandOutboxTest { + private val database: ClientStateDatabase = + Room + .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ClientStateDatabase::class.java) + .build() + + private val store = RoomChatCommandOutbox(database = database) + + @After + fun tearDown() { + database.close() + } + + private suspend fun ChatCommandOutbox.enqueueQueued( + text: String, + nowMs: Long, + gatewayId: String = "gateway-a", + sessionKey: String = "main", + thinkingLevel: String = "off", + ownerAgentId: String = "main", + ): ChatOutboxItem { + val result = + enqueue( + gatewayId = gatewayId, + sessionKey = sessionKey, + text = text, + thinkingLevel = thinkingLevel, + nowMs = nowMs, + ownerAgentId = ownerAgentId, + ) + return (result as ChatOutboxEnqueueResult.Queued).item + } + + @Test + fun enqueuePersistsAndLoadsInEnqueueOrderEvenForCollidingClocks() = + runTest { + store.enqueueQueued("first", nowMs = 20, thinkingLevel = "high") + // Same millisecond and a backwards clock must not scramble FIFO flush order. + store.enqueueQueued("second", nowMs = 20) + store.enqueueQueued("third", nowMs = 10) + + val loaded = store.load("gateway-a") + + assertEquals(listOf("first", "second", "third"), loaded.map { it.text }) + assertTrue(loaded.all { it.status == ChatOutboxStatus.Queued && it.retryCount == 0 && it.lastError == null }) + assertEquals(listOf("main", "main", "main"), loaded.map { it.sessionKey }) + assertEquals(listOf("main", "main", "main"), loaded.map { it.ownerAgentId }) + // Enqueue-time thinking level survives the round trip. + assertEquals(listOf("high", "off", "off"), loaded.map { it.thinkingLevel }) + assertEquals(loaded.map { it.createdAtMs }.sorted(), loaded.map { it.createdAtMs }) + } + + @Test + fun callerSuppliedIdempotencyKeyCanReconcileComposerAdmissionAfterRestart() = + runTest { + val result = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "agent:main:device", + text = "send once", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + idempotencyKey = "composer-command-a", + ) as ChatOutboxEnqueueResult.Queued + + assertEquals("composer-command-a", result.item.id) + assertTrue(store.wasAdmitted("composer-command-a")) + store.delete("composer-command-a") + assertTrue(store.wasAdmitted("composer-command-a")) + assertFalse(store.wasAdmitted("never-admitted")) + } + + @Test + fun admissionReceiptsStayBoundedAcrossSessionsForOneRoutingOwner() = + runTest { + repeat(OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER + 2) { index -> + val id = "composer-command-$index" + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "agent:main:device-$index", + text = "message $index", + thinkingLevel = "off", + nowMs = index.toLong(), + ownerAgentId = "main", + idempotencyKey = id, + ) + store.delete(id) + } + + assertFalse(store.wasAdmitted("composer-command-0")) + assertFalse(store.wasAdmitted("composer-command-1")) + repeat(OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER) { offset -> + assertTrue(store.wasAdmitted("composer-command-${offset + 2}")) + } + } + + @Test + fun activeAdmissionReceiptSurvivesFallbackPruningUntilCommandRetires() = + runTest { + val protectedId = "active-checkpoint" + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "agent:main:protected", + text = "still pending", + thinkingLevel = "off", + nowMs = 0, + ownerAgentId = "main", + idempotencyKey = protectedId, + ) + repeat(OUTBOX_ADMISSION_RECEIPTS_PER_ROUTING_OWNER + 2) { index -> + val id = "retired-command-$index" + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "agent:main:device-$index", + text = "message $index", + thinkingLevel = "off", + nowMs = index.toLong() + 1, + ownerAgentId = "main", + idempotencyKey = id, + ) + store.delete(id) + } + + store.delete(protectedId) + assertTrue(store.wasAdmitted(protectedId)) + val nextId = "next-retired-command" + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "agent:main:next", + text = "advance the recovery window", + thinkingLevel = "off", + nowMs = 100, + ownerAgentId = "main", + idempotencyKey = nextId, + ) + store.delete(nextId) + assertFalse(store.wasAdmitted(protectedId)) + } + + @Test + fun enqueueRefusesBeyondMaxQueued() = + runTest { + repeat(OUTBOX_MAX_QUEUED) { index -> + store.enqueueQueued("m$index", nowMs = index.toLong()) + } + + val refused = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "overflow", + thinkingLevel = "off", + nowMs = 999, + ownerAgentId = "main", + ) + + assertEquals(ChatOutboxEnqueueResult.QueueFull, refused) + assertEquals(OUTBOX_MAX_QUEUED, store.load("gateway-a").size) + } + + @Test + fun expireStaleFailsRowsAtOrPastTheBoundaryOnly() = + runTest { + val now = 1_000_000_000L + val atBoundary = store.enqueueQueued("stale", nowMs = now - OUTBOX_EXPIRY_MS) + val justInside = store.enqueueQueued("fresh", nowMs = now - OUTBOX_EXPIRY_MS + 1) + + store.expireStale("gateway-a", nowMs = now) + + val byId = store.load("gateway-a").associateBy { it.id } + assertEquals(ChatOutboxStatus.Failed, byId.getValue(atBoundary.id).status) + assertEquals(OUTBOX_EXPIRED_ERROR, byId.getValue(atBoundary.id).lastError) + assertEquals(ChatOutboxStatus.Queued, byId.getValue(justInside.id).status) + assertNull(byId.getValue(justInside.id).lastError) + } + + @Test + fun expireStaleLeavesFailedAndSendingRowsUntouched() = + runTest { + val now = 1_000_000_000L + val failed = store.enqueueQueued("already failed", nowMs = now - OUTBOX_EXPIRY_MS - 5) + store.updateStatus(failed.id, ChatOutboxStatus.Failed, retryCount = 3, lastError = "boom") + val sending = store.enqueueQueued("in flight", nowMs = now - OUTBOX_EXPIRY_MS - 5) + store.updateStatus(sending.id, ChatOutboxStatus.Sending, retryCount = 0, lastError = null) + + store.expireStale("gateway-a", nowMs = now) + + val byId = store.load("gateway-a").associateBy { it.id } + assertEquals("boom", byId.getValue(failed.id).lastError) + assertEquals(ChatOutboxStatus.Sending, byId.getValue(sending.id).status) + } + + @Test + fun failSendingAfterRestartKeepsInterruptedRowsVisibleForExplicitRetry() = + runTest { + val interrupted = store.enqueueQueued("interrupted", nowMs = 10) + store.updateStatus(interrupted.id, ChatOutboxStatus.Sending, retryCount = 1, lastError = "socket closed") + val failed = store.enqueueQueued("dead", nowMs = 20) + store.updateStatus(failed.id, ChatOutboxStatus.Failed, retryCount = 3, lastError = "boom") + + store.failSendingAfterRestart() + + val byId = store.load("gateway-a").associateBy { it.id } + assertEquals(ChatOutboxStatus.Failed, byId.getValue(interrupted.id).status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, byId.getValue(interrupted.id).lastError) + // Retry bookkeeping survives the restart so an explicit retry retains the original context. + assertEquals(1, byId.getValue(interrupted.id).retryCount) + assertEquals(ChatOutboxStatus.Failed, byId.getValue(failed.id).status) + } + + @Test + fun restartRecoveryCreatesAmbiguityStateForRowsWithoutDeliveryMetadata() = + runTest { + database.outboxDao().insert( + OutboxCommandEntity( + id = "legacy-sending", + gatewayId = "gateway-a", + sessionKey = "main", + text = "legacy", + thinkingLevel = "off", + createdAtMs = 10, + status = ChatOutboxStatus.Sending.dbValue, + retryCount = 0, + lastError = null, + gatedEpoch = null, + ownerAgentId = "main", + ), + ) + + store.failSendingAfterRestart() + + val recovered = store.load("gateway-a").single() + assertEquals(ChatOutboxStatus.Failed, recovered.status) + assertTrue(recovered.hadUnacknowledgedSend) + } + + @Test + fun legacyAmbiguousFailureBackfillsFreshRetryIdentityEvidence() = + runTest { + database.outboxDao().insert( + OutboxCommandEntity( + id = "legacy-ambiguous", + gatewayId = "gateway-a", + sessionKey = "main", + text = "legacy", + thinkingLevel = "off", + createdAtMs = 10, + status = ChatOutboxStatus.Failed.dbValue, + retryCount = 1, + lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR, + gatedEpoch = null, + ownerAgentId = "main", + ), + ) + val legacy = store.load("gateway-a").single() + assertTrue(legacy.hadUnacknowledgedSend) + store.confirmBranchChange("gateway-a", ChatOutboxScope("main", "main"), "leaf-new", OUTBOX_BRANCH_CHANGED_ERROR) + val parked = store.load("gateway-a").single() + + store.requeueForRetryIfCurrent( + gatewayId = "gateway-a", + id = parked.id, + expectedAttemptVersion = parked.attemptVersion, + expectedRetryCount = parked.retryCount, + expectedLastError = parked.lastError, + nowMs = 20, + gatedEpoch = null, + ownerAgentId = "main", + replacementId = "legacy-fresh-id", + ) + + assertEquals("legacy-fresh-id", store.load("gateway-a").single().id) + } + + @Test + fun requeueForRetryRefreshesCreatedAtSoExpirySweepCannotRefailIt() = + runTest { + val now = 1_000_000_000L + val stale = store.enqueueQueued("expired once", nowMs = now - OUTBOX_EXPIRY_MS - 10) + store.expireStale("gateway-a", nowMs = now) + assertEquals(ChatOutboxStatus.Failed, store.load("gateway-a").single().status) + + assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = stale.id, nowMs = now, gatedEpoch = null)) + store.expireStale("gateway-a", nowMs = now) + + val retried = store.load("gateway-a").single() + assertEquals(ChatOutboxStatus.Queued, retried.status) + assertEquals(0, retried.retryCount) + assertNull(retried.lastError) + assertTrue(retried.createdAtMs >= now) + } + + @Test + fun requeueForRetryCannotCrossGatewayOwnership() = + runTest { + val failed = store.enqueueQueued("gateway a failed", nowMs = 10, gatewayId = "gateway-a") + store.updateStatus(failed.id, ChatOutboxStatus.Failed, retryCount = 1, lastError = "boom") + + val changed = store.requeueForRetry(gatewayId = "gateway-b", id = failed.id, nowMs = 20, gatedEpoch = null) + + assertEquals(0, changed) + val untouched = store.load("gateway-a").single() + assertEquals(ChatOutboxStatus.Failed, untouched.status) + assertEquals(10L, untouched.createdAtMs) + assertEquals("boom", untouched.lastError) + } + + @Test + fun secondRetryCannotRequeueARowAlreadySending() = + runTest { + val failed = store.enqueueQueued("retry once", nowMs = 10) + store.updateStatus(failed.id, ChatOutboxStatus.Failed, retryCount = 1, lastError = "boom") + assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = failed.id, nowMs = 20, gatedEpoch = null)) + store.updateStatus(failed.id, ChatOutboxStatus.Sending, retryCount = 0, lastError = null) + val sendingCreatedAt = store.load("gateway-a").single().createdAtMs + + val changed = store.requeueForRetry(gatewayId = "gateway-a", id = failed.id, nowMs = 30, gatedEpoch = null) + + assertEquals(0, changed) + val untouched = store.load("gateway-a").single() + assertEquals(ChatOutboxStatus.Sending, untouched.status) + assertEquals(sendingCreatedAt, untouched.createdAtMs) + } + + @Test + fun rowsAreScopedToGatewayIdentity() = + runTest { + store.enqueueQueued("gateway a command", nowMs = 10, gatewayId = "gateway-a") + + assertEquals(emptyList(), store.load("gateway-b")) + store.enqueueQueued("gateway b command", nowMs = 20, gatewayId = "gateway-b") + + assertEquals(listOf("gateway a command"), store.load("gateway-a").map { it.text }) + assertEquals(listOf("gateway b command"), store.load("gateway-b").map { it.text }) + } + + @Test + fun blankGatewayIdentityDisablesReadsAndWrites() = + runTest { + assertEquals( + ChatOutboxEnqueueResult.Unavailable, + store.enqueue( + gatewayId = " ", + sessionKey = "main", + text = "hi", + thinkingLevel = "off", + nowMs = 1, + ownerAgentId = "main", + ), + ) + assertEquals(emptyList(), store.load(" ")) + + // Nothing was written under a fallback scope either. + assertEquals(emptyList(), store.load("gateway-a")) + } + + @Test + fun branchChangeParksQueuedRowsFromTheSupersededEpoch() = + runTest { + val scope = ChatOutboxScope("main", "main") + val queued = store.enqueueQueued("old branch", nowMs = 10) + + assertTrue(store.confirmBranchChange("gateway-a", scope, "leaf-new", OUTBOX_BRANCH_CHANGED_ERROR)) + + val parked = store.load("gateway-a").single() + assertEquals(queued.id, parked.id) + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(OUTBOX_BRANCH_CHANGED_ERROR, chatOutboxDisplayError(parked.lastError)) + assertEquals(0, parked.branchEpoch) + assertEquals(1, parked.scopeBranchEpoch) + } + + @Test + fun parkedAcceptedRetryMintsFreshIdentityButQueuedRetryKeepsIdentity() = + runTest { + val scope = ChatOutboxScope("main", "main") + val accepted = store.enqueueQueued("maybe delivered", nowMs = 10) + store.updateStatusIfAttempt(accepted.id, accepted.attemptVersion, ChatOutboxStatus.Accepted, 0, null) + store.confirmBranchChange("gateway-a", scope, "leaf-new", OUTBOX_BRANCH_CHANGED_ERROR) + val parkedAccepted = store.load("gateway-a").single() + assertTrue(parkedAccepted.parkedWasAccepted) + + assertEquals( + 1, + store.requeueForRetryIfCurrent( + gatewayId = "gateway-a", + id = parkedAccepted.id, + expectedAttemptVersion = parkedAccepted.attemptVersion, + expectedRetryCount = parkedAccepted.retryCount, + expectedLastError = parkedAccepted.lastError, + nowMs = 20, + gatedEpoch = null, + ownerAgentId = "main", + replacementId = "fresh-client-id", + ), + ) + val retriedAccepted = store.load("gateway-a").single() + assertEquals("fresh-client-id", retriedAccepted.id) + assertEquals(1, retriedAccepted.attemptVersion) + + store.delete(retriedAccepted.id) + val queued = store.enqueueQueued("never dispatched", nowMs = 30) + store.confirmBranchChange("gateway-a", scope, "leaf-newer", OUTBOX_BRANCH_CHANGED_ERROR) + val parkedQueued = store.load("gateway-a").single() + store.requeueForRetryIfCurrent( + gatewayId = "gateway-a", + id = parkedQueued.id, + expectedAttemptVersion = parkedQueued.attemptVersion, + expectedRetryCount = parkedQueued.retryCount, + expectedLastError = parkedQueued.lastError, + nowMs = 40, + gatedEpoch = null, + ownerAgentId = "main", + replacementId = "unused-replacement", + ) + val retriedQueued = store.load("gateway-a").single() + assertEquals(queued.id, retriedQueued.id) + assertEquals(2, retriedQueued.attemptVersion) + } + + @Test + fun freshRetryIdentityKeepsAttachmentMetadataAndChunksReachable() = + runTest { + val bytes = ByteArray(OUTBOX_ATTACHMENT_CHUNK_BYTES + 17) { (it % 251).toByte() } + val queued = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "attachment retry", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = listOf(payload(bytes, fileName = "proof.jpg")), + ) as ChatOutboxEnqueueResult.Queued + store.updateStatusIfAttempt(queued.item.id, 1, ChatOutboxStatus.Accepted, 0, null) + store.confirmBranchChange("gateway-a", ChatOutboxScope("main", "main"), "leaf-new", OUTBOX_BRANCH_CHANGED_ERROR) + val parked = store.load("gateway-a").single() + + assertEquals( + 1, + store.requeueForRetryIfCurrent( + gatewayId = "gateway-a", + id = parked.id, + expectedAttemptVersion = parked.attemptVersion, + expectedRetryCount = parked.retryCount, + expectedLastError = parked.lastError, + nowMs = 20, + gatedEpoch = null, + ownerAgentId = "main", + replacementId = "fresh-attachment-id", + ), + ) + + val loaded = store.loadAttachments("fresh-attachment-id").single() + assertEquals("proof.jpg", loaded.attachment.fileName) + assertTrue(bytes.contentEquals(loaded.bytes)) + } + + @Test + fun staleDeliveryCallbackCannotOverwriteANewerAttempt() = + runTest { + val queued = store.enqueueQueued("retry safely", nowMs = 10) + assertEquals(1, store.claimForSendingIfAttempt(queued.id, 1, 0, null)) + assertEquals( + 1, + store.updateStatusIfAttempt( + queued.id, + 1, + ChatOutboxStatus.Queued, + 1, + "not dispatched", + expectedStatus = ChatOutboxStatus.Sending, + ), + ) + + val retried = store.load("gateway-a").single() + assertEquals(2, retried.attemptVersion) + assertEquals( + 0, + store.updateStatusIfAttempt( + queued.id, + 1, + ChatOutboxStatus.Accepted, + 0, + null, + expectedStatus = ChatOutboxStatus.Sending, + ), + ) + assertEquals(ChatOutboxStatus.Queued, store.load("gateway-a").single().status) + } + + @Test + fun deliveryCallbackCannotResurrectARowParkedByBranchChange() = + runTest { + val scope = ChatOutboxScope("main", "main") + val queued = store.enqueueQueued("claimed on old branch", nowMs = 10) + assertEquals(1, store.claimForSendingIfAttempt(queued.id, queued.attemptVersion, 0, null)) + assertTrue(store.confirmBranchChange("gateway-a", scope, "leaf-new", OUTBOX_BRANCH_CHANGED_ERROR)) + + assertEquals( + 0, + store.updateStatusIfAttempt( + queued.id, + queued.attemptVersion, + ChatOutboxStatus.Accepted, + 0, + null, + expectedStatus = ChatOutboxStatus.Sending, + ), + ) + assertEquals(ChatOutboxStatus.Failed, store.load("gateway-a").single().status) + } + + @Test + fun sessionMutationLeaseParksRowsEnqueuedWhileTheGatewayMutationRuns() = + runTest { + val scope = ChatOutboxScope("main", "main") + assertTrue(store.beginSessionMutation("gateway-a", scope, nowMs = 1_000) != null) + val racing = store.enqueueQueued("racing enqueue", nowMs = 1_001) + + assertTrue(store.confirmBranchChange("gateway-a", scope, "leaf-after-rewind", OUTBOX_BRANCH_CHANGED_ERROR)) + + val parked = store.load("gateway-a").single() + assertEquals(racing.id, parked.id) + assertEquals(ChatOutboxStatus.Failed, parked.status) + assertEquals(1, store.branchState("gateway-a", scope)?.epoch) + } + + @Test + fun demotedMutationNeedsReconciliationAndCannotClaimQueuedWork() = + runTest { + val scope = ChatOutboxScope("main", "main") + val lease = requireNotNull(store.beginSessionMutation("gateway-a", scope, nowMs = 1_000)) + assertTrue(store.demoteSessionMutationToReconciliation("gateway-a", scope, lease)) + val queued = store.enqueueQueued("wait for reconcile", nowMs = 1_001) + + assertTrue(store.branchState("gateway-a", scope)?.needsReconciliation == true) + assertEquals(0, store.claimForSendingIfAttempt(queued.id, queued.attemptVersion, 0, null)) + } + + @Test + fun staleMutationCancellationCannotClearNewerRemoteReconciliation() = + runTest { + val scope = ChatOutboxScope("main", "main") + val lease = requireNotNull(store.beginSessionMutation("gateway-a", scope, nowMs = 1_000)) + assertTrue(store.demoteSessionMutationToReconciliation("gateway-a", scope, lease = null)) + + assertFalse(store.cancelSessionMutation("gateway-a", scope, lease)) + assertTrue(store.branchState("gateway-a", scope)?.needsReconciliation == true) + } + + @Test + fun staleMutationLeaseCannotConfirmOverANewerLease() = + runTest { + val scope = ChatOutboxScope("main", "main") + val staleLease = requireNotNull(store.beginSessionMutation("gateway-a", scope, nowMs = 1_000)) + assertTrue(store.demoteSessionMutationToReconciliation("gateway-a", scope, lease = null)) + val reconciliationState = requireNotNull(store.branchState("gateway-a", scope)) + assertTrue( + store.reconcileBranchScope( + gatewayId = "gateway-a", + scope = scope, + previousState = reconciliationState, + activeLeafEntryId = null, + branchLeafEntryIds = emptySet(), + activeTranscriptEntryIds = emptySet(), + lastError = OUTBOX_BRANCH_CHANGED_ERROR, + ), + ) + val currentLease = requireNotNull(store.beginSessionMutation("gateway-a", scope, nowMs = 2_000)) + + assertFalse( + store.confirmBranchChange( + "gateway-a", + scope, + "stale-leaf", + OUTBOX_BRANCH_CHANGED_ERROR, + staleLease, + ), + ) + assertEquals(currentLease.startedAtMs, store.branchState("gateway-a", scope)?.switchPendingSinceMs) + } + + @Test + fun ancestryDisambiguatesTranscriptAdvanceFromRemoteBranchChange() = + runTest { + val advancingScope = ChatOutboxScope("advance", "main") + val initialAdvance = requireNotNull(store.branchState("gateway-a", advancingScope)) + assertTrue(store.updateLastActiveLeafEntryId("gateway-a", advancingScope, "leaf-old", initialAdvance.epoch, initialAdvance.revision)) + val advanceState = requireNotNull(store.branchState("gateway-a", advancingScope)) + val advancingRow = store.enqueueQueued("stay active", nowMs = 10, sessionKey = "advance") + assertTrue( + store.reconcileBranchScope( + gatewayId = "gateway-a", + scope = advancingScope, + previousState = advanceState, + activeLeafEntryId = "leaf-new", + branchLeafEntryIds = setOf("leaf-new"), + activeTranscriptEntryIds = setOf("leaf-old", "leaf-new"), + lastError = OUTBOX_BRANCH_CHANGED_ERROR, + ), + ) + assertEquals(ChatOutboxStatus.Queued, store.load("gateway-a").single { it.id == advancingRow.id }.status) + + val switchedScope = ChatOutboxScope("switched", "main") + val initialSwitch = requireNotNull(store.branchState("gateway-a", switchedScope)) + assertTrue(store.updateLastActiveLeafEntryId("gateway-a", switchedScope, "leaf-a", initialSwitch.epoch, initialSwitch.revision)) + val switchState = requireNotNull(store.branchState("gateway-a", switchedScope)) + val switchedRow = store.enqueueQueued("park me", nowMs = 20, sessionKey = "switched") + assertTrue( + store.reconcileBranchScope( + gatewayId = "gateway-a", + scope = switchedScope, + previousState = switchState, + activeLeafEntryId = "leaf-b", + branchLeafEntryIds = setOf("leaf-a", "leaf-b"), + activeTranscriptEntryIds = setOf("leaf-b"), + lastError = OUTBOX_BRANCH_CHANGED_ERROR, + ), + ) + assertEquals(ChatOutboxStatus.Failed, store.load("gateway-a").single { it.id == switchedRow.id }.status) + } + + @Test + fun branchOwnershipIsAgentScopedAndEmptyRootReconciles() = + runTest { + val mainScope = ChatOutboxScope("shared", "main") + val opsScope = ChatOutboxScope("shared", "ops") + val mainState = requireNotNull(store.branchState("gateway-a", mainScope)) + val opsState = requireNotNull(store.branchState("gateway-a", opsScope)) + + assertTrue( + store.reconcileBranchScope( + "gateway-a", + mainScope, + mainState, + activeLeafEntryId = null, + branchLeafEntryIds = emptySet(), + activeTranscriptEntryIds = emptySet(), + lastError = OUTBOX_BRANCH_CHANGED_ERROR, + ), + ) + assertTrue(store.confirmBranchChange("gateway-a", mainScope, "main-leaf", OUTBOX_BRANCH_CHANGED_ERROR)) + assertEquals(1, store.branchState("gateway-a", mainScope)?.epoch) + assertEquals(0, store.branchState("gateway-a", opsScope)?.epoch) + assertEquals(opsState, store.branchState("gateway-a", opsScope)) + } + + @Test + fun commandAdmittedAfterEmptyRootSnapshotBindsToTheListedBranch() = + runTest { + val scope = ChatOutboxScope("main", "main") + val emptyRoot = requireNotNull(store.branchState("gateway-a", scope)) + val admitted = store.enqueueQueued("after snapshot", nowMs = 10) + + assertTrue( + store.reconcileBranchScope( + gatewayId = "gateway-a", + scope = scope, + previousState = emptyRoot, + activeLeafEntryId = "leaf-current", + branchLeafEntryIds = setOf("leaf-current"), + activeTranscriptEntryIds = setOf("leaf-current"), + lastError = OUTBOX_BRANCH_CHANGED_ERROR, + ), + ) + + val rebound = store.load("gateway-a").single() + assertEquals(admitted.id, rebound.id) + assertEquals(ChatOutboxStatus.Queued, rebound.status) + assertEquals("leaf-current", store.branchState("gateway-a", scope)?.lastActiveLeafEntryId) + } + + @Test + fun staleTranscriptTipRevisionCannotOverwriteTheCurrentLeaf() = + runTest { + val scope = ChatOutboxScope("main", "main") + val captured = requireNotNull(store.branchState("gateway-a", scope)) + + assertTrue(store.updateLastActiveLeafEntryId("gateway-a", scope, "leaf-current", captured.epoch, captured.revision)) + assertFalse(store.updateLastActiveLeafEntryId("gateway-a", scope, "leaf-stale", captured.epoch, captured.revision)) + assertEquals("leaf-current", store.branchState("gateway-a", scope)?.lastActiveLeafEntryId) + } + + @Test + fun pinningMainAliasRebasesDeliveryOntoTheCanonicalBranchEpoch() = + runTest { + val canonicalScope = ChatOutboxScope("agent:main:device", "main") + assertTrue(store.confirmBranchChange("gateway-a", canonicalScope, "leaf-current", OUTBOX_BRANCH_CHANGED_ERROR)) + val queued = store.enqueueQueued("pre-hello", nowMs = 10, sessionKey = "main") + + store.pinSessionKey(queued.id, canonicalScope.sessionKey) + + val pinned = store.load("gateway-a").single() + assertEquals(canonicalScope.sessionKey, pinned.sessionKey) + assertEquals(1, pinned.branchEpoch) + assertEquals(1, pinned.scopeBranchEpoch) + assertEquals(1, store.claimForSendingIfAttempt(pinned.id, pinned.attemptVersion, 0, null)) + } + + @Test + fun deleteForSessionRemovesOnlyThatSessionsRows() = + runTest { + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "for main", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + idempotencyKey = "main-admission", + ) + store.enqueueQueued("for other", nowMs = 20, sessionKey = "agent:other:main") + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "other owner", + thinkingLevel = "off", + nowMs = 30, + ownerAgentId = "other", + idempotencyKey = "other-owner-admission", + ) + + store.deleteForSession("gateway-a", "main", "main") + + assertEquals(listOf("for other", "other owner"), store.load("gateway-a").map { it.text }) + assertFalse(store.wasAdmitted("main-admission")) + assertTrue(store.wasAdmitted("other-owner-admission")) + } + + private fun payload( + bytes: ByteArray, + fileName: String = "a.jpg", + type: String = "image", + mimeType: String = "image/jpeg", + durationMs: Long? = null, + ): OutboxAttachmentPayload = OutboxAttachmentPayload(type = type, mimeType = mimeType, fileName = fileName, durationMs = durationMs, bytes = bytes) + + @Test + fun attachmentBytesRoundTripExactlyAcrossStoreReopen() = + runTest { + // Spans multiple chunks to prove chunked reassembly is byte-exact and ordered. + val big = ByteArray(OUTBOX_ATTACHMENT_CHUNK_BYTES + 1234) { (it % 251).toByte() } + val small = byteArrayOf(5, 4, 3) + val queued = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "with media", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = + listOf( + payload(big, fileName = "big.jpg"), + payload(small, fileName = "note.m4a", type = "audio", mimeType = "audio/mp4", durationMs = 900L), + ), + ) as ChatOutboxEnqueueResult.Queued + + val loadedItem = store.load("gateway-a").single() + assertEquals(listOf("big.jpg", "note.m4a"), loadedItem.attachments.map { it.fileName }) + assertEquals(listOf(big.size.toLong(), small.size.toLong()), loadedItem.attachments.map { it.byteLength }) + assertEquals(900L, loadedItem.attachments[1].durationMs) + + val loaded = store.loadAttachments(queued.item.id) + assertTrue(big.contentEquals(loaded[0].bytes)) + assertTrue(small.contentEquals(loaded[1].bytes)) + } + + @Test + fun perCommandAttachmentByteCapRefusesOversizedSends() = + runTest { + val oversized = ByteArray((OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES + 1).toInt()) + val refused = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "too big", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = listOf(payload(oversized)), + ) + assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, refused) + assertTrue(store.load("gateway-a").isEmpty()) + } + + @Test + fun videoCommandsUseServerAttachmentCapWithoutRaisingOtherMediaCaps() = + runTest { + val aboveDefaultCap = ByteArray((OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES + 1L).toInt()) + val video = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "video", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = listOf(payload(aboveDefaultCap, type = "video", mimeType = "video/mp4")), + ) + val document = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "document", + thinkingLevel = "off", + nowMs = 11, + ownerAgentId = "main", + attachments = listOf(payload(aboveDefaultCap, type = "file", mimeType = "application/pdf")), + ) + + assertTrue(video is ChatOutboxEnqueueResult.Queued) + assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, document) + assertEquals(20L * 1024L * 1024L, OUTBOX_MAX_VIDEO_COMMAND_ATTACHMENT_BYTES) + } + + @Test + fun mixedVideoCommandKeepsNonVideoAggregateCap() = + runTest { + val document = ByteArray(5 * 1024 * 1024) + val refused = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "mixed", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = + listOf( + payload(document, fileName = "one.pdf", type = "file", mimeType = "application/pdf"), + payload(document, fileName = "two.pdf", type = "file", mimeType = "application/pdf"), + payload(byteArrayOf(1), fileName = "clip.mp4", type = "video", mimeType = "video/mp4"), + ), + ) + + assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, refused) + } + + @Test + fun gatewayAttachmentByteBudgetRefusesWhenExhaustedAndRecoversAfterDelete() = + runTest { + val chunk = ByteArray(OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES.toInt()) + val stored = mutableListOf() + var index = 0 + while (true) { + val result = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "bulk $index", + thinkingLevel = "off", + nowMs = index.toLong(), + ownerAgentId = "main", + attachments = listOf(payload(chunk)), + ) + if (result !is ChatOutboxEnqueueResult.Queued) { + assertEquals(ChatOutboxEnqueueResult.StorageFull, result) + break + } + stored += result.item.id + index += 1 + } + assertTrue(stored.isNotEmpty()) + + // Deleting a queued row releases its bytes, so admission recovers. + store.delete(stored.first()) + val retried = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "fits again", + thinkingLevel = "off", + nowMs = 999, + ownerAgentId = "main", + attachments = listOf(payload(chunk)), + ) + assertTrue(retried is ChatOutboxEnqueueResult.Queued) + } + + @Test + fun conditionalDeleteNeverRemovesAClaimedRow() = + runTest { + val first = + ( + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "delete queued", + thinkingLevel = "off", + nowMs = 1, + ownerAgentId = "main", + idempotencyKey = "rollback-receipt", + ) as ChatOutboxEnqueueResult.Queued + ).item + assertTrue(store.wasAdmitted("rollback-receipt")) + assertTrue(store.deleteIfQueued(first.id)) + assertTrue(store.load("gateway-a").isEmpty()) + assertFalse(store.wasAdmitted("rollback-receipt")) + + val claimed = store.enqueueQueued(text = "already claimed", nowMs = 2) + assertEquals(1, store.claimForSending(claimed.id, retryCount = 0, lastError = null)) + assertFalse(store.deleteIfQueued(claimed.id)) + assertEquals(ChatOutboxStatus.Sending, store.load("gateway-a").single().status) + } + + @Test + fun confirmDeliveredRetiresRowsAndTheirAttachmentBytesAtomically() = + runTest { + val bytes = byteArrayOf(1, 2, 3) + val queued = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "confirmed", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = listOf(payload(bytes)), + ) as ChatOutboxEnqueueResult.Queued + store.updateStatus(queued.item.id, ChatOutboxStatus.Accepted, retryCount = 0, lastError = null) + val keep = store.enqueueQueued("kept", nowMs = 20) + + assertEquals(1, store.confirmDelivered(setOf(queued.item.id, "missing-row"))) + + assertEquals(listOf(keep.id), store.load("gateway-a").map { it.id }) + assertTrue(store.loadAttachments(queued.item.id).isEmpty()) + } + + @Test + fun clearGatewayAndSessionDeleteAlsoDropAttachmentBytes() = + runTest { + val a = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "a", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + attachments = listOf(payload(byteArrayOf(1))), + ) as ChatOutboxEnqueueResult.Queued + val b = + store.enqueue( + gatewayId = "gateway-b", + sessionKey = "other", + text = "b", + thinkingLevel = "off", + nowMs = 20, + ownerAgentId = "main", + attachments = listOf(payload(byteArrayOf(2))), + ) as ChatOutboxEnqueueResult.Queued + + store.deleteForSession("gateway-b", "other", "main") + store.clearGateway("gateway-a") + + assertTrue(store.load("gateway-a").isEmpty()) + assertTrue(store.load("gateway-b").isEmpty()) + assertTrue(store.loadAttachments(a.item.id).isEmpty()) + assertTrue(store.loadAttachments(b.item.id).isEmpty()) + } + + @Test + fun pinSessionKeyRewritesTheAliasExactlyOnce() = + runTest { + val queued = store.enqueueQueued("pinned", nowMs = 10) + store.pinSessionKey(queued.id, "agent:work:main") + assertEquals("agent:work:main", store.load("gateway-a").single().sessionKey) + } + + @Test + fun retryAndExactSessionDeletionCanonicalizeOwnerAgentIds() = + runTest { + val queued = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "mixed owner", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "Main", + ) as ChatOutboxEnqueueResult.Queued + assertEquals("main", store.load("gateway-a").single().ownerAgentId) + store.updateStatus(queued.item.id, ChatOutboxStatus.Failed, retryCount = 1, lastError = "retry") + + assertEquals( + 1, + store.requeueForRetry( + gatewayId = "gateway-a", + id = queued.item.id, + nowMs = 20, + gatedEpoch = null, + ownerAgentId = "MAIN", + ), + ) + assertEquals("main", store.load("gateway-a").single().ownerAgentId) + + store.deleteForSession("gateway-a", "main", "MAIN") + assertTrue(store.load("gateway-a").isEmpty()) + } + + @Test + fun gatedEpochSurvivesPersistenceAndRetryRestamping() = + runTest { + val queued = + store.enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "/clear", + thinkingLevel = "off", + nowMs = 10, + ownerAgentId = "main", + gatedEpoch = 7L, + ) as ChatOutboxEnqueueResult.Queued + assertEquals(7L, store.load("gateway-a").single().gatedEpoch) + + store.updateStatus(queued.item.id, ChatOutboxStatus.Failed, retryCount = 0, lastError = OUTBOX_CONNECTION_CHANGED_ERROR) + assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = queued.item.id, nowMs = 20, gatedEpoch = 9L)) + assertEquals(9L, store.load("gateway-a").single().gatedEpoch) + } + + @Test + fun staleAcceptedRowsExpireToDeliveryUnconfirmed() = + runTest { + val now = 1_000_000_000L + val accepted = store.enqueueQueued("acked long ago", nowMs = now - OUTBOX_EXPIRY_MS - 1) + store.updateStatus(accepted.id, ChatOutboxStatus.Accepted, retryCount = 0, lastError = null) + + store.expireStale("gateway-a", nowMs = now) + + val row = store.load("gateway-a").single() + assertEquals(ChatOutboxStatus.Failed, row.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, row.lastError) + } + + @Test + fun claimForSendingIsAtomicAcrossCompetingDispatchers() = + runTest { + val queued = store.enqueueQueued("claim me", nowMs = 10) + + assertEquals(1, store.claimForSending(queued.id, 0, null)) + // The losing dispatcher gets 0 and must not send; the row is already claimed. + assertEquals(0, store.claimForSending(queued.id, 0, null)) + assertEquals(ChatOutboxStatus.Sending, store.load("gateway-a").single().status) + } + + @Test + fun requeueForRetryKeepsSameSessionQueuedSuccessorsBehindTheRetriedRow() = + runTest { + val head = store.enqueueQueued("head", nowMs = 10) + val tail = store.enqueueQueued("tail", nowMs = 20) + val other = store.enqueueQueued("other", nowMs = 30, sessionKey = "agent:other:main") + store.updateStatus(head.id, ChatOutboxStatus.Failed, retryCount = 0, lastError = OUTBOX_DELIVERY_UNCONFIRMED_ERROR) + + assertEquals(1, store.requeueForRetry(gatewayId = "gateway-a", id = head.id, nowMs = 1_000_000_000L, gatedEpoch = null)) + + val byId = store.load("gateway-a").associateBy { it.id } + // The retried head still precedes its session successor; unrelated sessions keep position. + assertTrue(byId.getValue(head.id).createdAtMs < byId.getValue(tail.id).createdAtMs) + assertEquals(ChatOutboxStatus.Queued, byId.getValue(tail.id).status) + assertEquals(30L, byId.getValue(other.id).createdAtMs) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt b/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt new file mode 100644 index 0000000..28371d8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt @@ -0,0 +1,444 @@ +package ai.openclaw.app.chat + +import androidx.room.Room +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class RoomChatTranscriptCacheTest { + private val database: GatewayCacheDatabase = + Room + .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), GatewayCacheDatabase::class.java) + .build() + + @After + fun tearDown() { + database.close() + } + + private fun cache(): RoomChatTranscriptCache = RoomChatTranscriptCache(database = database) + + private fun message( + text: String, + role: String = "user", + timestampMs: Long? = 1L, + idempotencyKey: String? = null, + extraParts: List = emptyList(), + ): ChatMessage = + ChatMessage( + id = "id-$text", + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)) + extraParts, + timestampMs = timestampMs, + idempotencyKey = idempotencyKey, + ) + + @Test + fun transcriptRoundTripKeepsTextAndManagedReferencesWithoutBinaryParts() = + runTest { + val store = cache() + val imagePart = ChatMessageContent(type = "image", mimeType = "image/png", fileName = "a.png", base64 = "AAAA") + val managedImage = + ChatMessageContent( + type = "image", + mimeType = "image/png", + artifactId = "artifact_managed_image_11111111-1111-4111-8111-111111111111", + url = "/api/chat/media/outgoing/main/11111111-1111-4111-8111-111111111111/full", + alt = "Managed image", + ) + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + messages = + listOf( + message("hello", role = "user", timestampMs = 10, idempotencyKey = "run-1:user", extraParts = listOf(imagePart)), + // Inline binary-only messages remain disposable and are skipped entirely. + ChatMessage(id = "img", role = "user", content = listOf(imagePart), timestampMs = 11), + ChatMessage(id = "managed", role = "assistant", content = listOf(managedImage), timestampMs = 11), + message("world", role = "assistant", timestampMs = 12), + ), + ) + + val loaded = store.loadTranscript("gateway-a", "main", "main") + + assertEquals(listOf("hello", null, "world"), loaded.map { it.content.single().text }) + assertTrue(loaded.all { message -> message.content.all { part -> part.base64 == null } }) + assertEquals(managedImage.artifactId, loaded[1].content.single().artifactId) + assertEquals(listOf("user", "assistant", "assistant"), loaded.map { it.role }) + assertEquals(listOf(10L, 11L, 12L), loaded.map { it.timestampMs }) + assertEquals(listOf("run-1:user", null, null), loaded.map { it.idempotencyKey }) + } + + @Test + fun transcriptRoundTripKeepsManagedAudioAndVideoMetadata() = + runTest { + val store = cache() + val audio = + ChatMessageContent( + type = "audio", + mimeType = "audio/mpeg", + fileName = "reply.mp3", + artifactId = "artifact_managed_media_33333333-3333-4333-8333-333333333333", + durationMs = 2_100, + ) + val video = + ChatMessageContent( + type = "video", + mimeType = "video/mp4", + fileName = "demo.mp4", + artifactId = "artifact_managed_media_44444444-4444-4444-8444-444444444444", + durationMs = 5_300, + playback = "transcode", + width = 1920, + height = 1080, + ) + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + messages = + listOf( + ChatMessage(id = "audio", role = "assistant", content = listOf(audio), timestampMs = 10), + ChatMessage(id = "video", role = "assistant", content = listOf(video), timestampMs = 11), + ), + ) + + val loaded = store.loadTranscript("gateway-a", "main", "main") + + assertEquals(listOf(audio, video), loaded.map { it.content.single() }) + } + + @Test + fun legacyStringArrayTranscriptRowsRemainReadable() = + runTest { + database.dao().insertMessages( + listOf( + CachedMessageEntity( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + rowOrder = 0, + role = "assistant", + textPartsJson = """["legacy one","legacy two"]""", + timestampMs = 10, + idempotencyKey = null, + ), + ), + ) + + val loaded = cache().loadTranscript("gateway-a", "main", "main").single() + + assertEquals(listOf("legacy one", "legacy two"), loaded.content.map { it.text }) + } + + @Test + fun lastDefaultOwnerIsGatewayScopedAndClearedWithItsCache() = + runTest { + val store = cache() + store.saveLastDefaultAgentId("gateway-a", "agent-a") + store.saveLastDefaultAgentId("gateway-b", "agent-b") + + assertEquals("agent-a", store.loadLastDefaultAgentId("gateway-a")) + assertEquals("agent-b", store.loadLastDefaultAgentId("gateway-b")) + + store.clearGateway("gateway-a") + + assertEquals(null, store.loadLastDefaultAgentId("gateway-a")) + assertEquals("agent-b", store.loadLastDefaultAgentId("gateway-b")) + } + + @Test + fun transcriptRoundTripDropsInternalRoleRows() = + runTest { + val store = cache() + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + messages = + listOf( + message("hello", role = "user"), + message("private tool output", role = "toolResult"), + message("visible plugin notice", role = "custom"), + message("reply", role = "assistant"), + ), + ) + + val loaded = store.loadTranscript("gateway-a", "main", "main") + + assertEquals(listOf("hello", "visible plugin notice", "reply"), loaded.map { it.content.single().text }) + assertEquals(listOf("user", "custom", "assistant"), loaded.map { it.role }) + } + + @Test + fun transcriptWriteKeepsOnlyNewestBoundedMessages() = + runTest { + val store = cache() + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + messages = (0 until MAX_CACHED_MESSAGES_PER_SESSION + 50).map { index -> message("m$index", timestampMs = index.toLong()) }, + ) + + val loadedTexts = store.loadTranscript("gateway-a", "main", "main").map { it.content.single().text } + + assertEquals(MAX_CACHED_MESSAGES_PER_SESSION, loadedTexts.size) + assertEquals("m50", loadedTexts.first()) + assertEquals("m249", loadedTexts.last()) + } + + @Test + fun sessionWriteEvictsBeyondBoundAndDropsOrphanedTranscripts() = + runTest { + val store = cache() + store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "session-10", messages = listOf(message("kept"))) + store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "session-55", messages = listOf(message("evicted"))) + + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = + (0 until MAX_CACHED_SESSIONS + 10).map { index -> + ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index, displayName = "Session $index") + }, + ) + + val sessions = store.loadSessions("gateway-a", "main") + assertEquals(MAX_CACHED_SESSIONS, sessions.size) + assertEquals("session-0", sessions.first().key) + assertEquals("session-${MAX_CACHED_SESSIONS - 1}", sessions.last().key) + assertEquals("Session 0", sessions.first().displayName) + assertEquals(listOf("kept"), store.loadTranscript("gateway-a", "main", "session-10").map { it.content.single().text }) + assertEquals(emptyList(), store.loadTranscript("gateway-a", "main", "session-55")) + } + + @Test + fun sessionRoundTripKeepsRunMetadata() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = + listOf( + ChatSessionEntry( + key = "main", + updatedAtMs = 20L, + status = "done", + startedAt = 1_000L, + endedAt = 5_000L, + runtimeMs = 4_000L, + outputTokens = 485L, + ), + ), + ) + + val loaded = store.loadSessions("gateway-a", "main").single() + + assertEquals("done", loaded.status) + assertEquals(1_000L, loaded.startedAt) + assertEquals(5_000L, loaded.endedAt) + assertEquals(4_000L, loaded.runtimeMs) + assertEquals(485L, loaded.outputTokens) + assertTrue(loaded.hasRunMetadata) + } + + @Test + fun transcriptForSessionOutsideFullCachedListSurvivesEviction() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = + (0 until MAX_CACHED_SESSIONS).map { index -> + ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index) + }, + ) + + store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "deep-session", messages = listOf(message("deep text"))) + + assertEquals(listOf("deep text"), store.loadTranscript("gateway-a", "main", "deep-session").map { it.content.single().text }) + val sessionKeys = store.loadSessions("gateway-a", "main").map { it.key } + assertEquals(MAX_CACHED_SESSIONS, sessionKeys.size) + assertTrue(sessionKeys.contains("deep-session")) + } + + @Test + fun sessionCacheIsBoundedAcrossEveryAgentInOneGateway() = + runTest { + val store = cache() + repeat(MAX_CACHED_SESSIONS + 1) { index -> + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "agent-$index", + sessionKey = "main", + messages = listOf(message("message-$index")), + ) + } + + val cachedSessionCount = + (0..MAX_CACHED_SESSIONS).sumOf { index -> + store.loadSessions("gateway-a", "agent-$index").size + } + assertEquals(MAX_CACHED_SESSIONS, cachedSessionCount) + assertTrue(store.loadTranscript("gateway-a", "agent-0", "main").isEmpty()) + assertEquals( + listOf("message-$MAX_CACHED_SESSIONS"), + store + .loadTranscript("gateway-a", "agent-$MAX_CACHED_SESSIONS", "main") + .map { it.content.single().text }, + ) + } + + @Test + fun activeDeepTranscriptSurvivesSessionListRefresh() = + runTest { + val store = cache() + val listedSessions = + (0 until MAX_CACHED_SESSIONS).map { index -> + ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index) + } + store.saveSessions(gatewayId = "gateway-a", agentId = "main", sessions = listedSessions) + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "deep-session", + messages = listOf(message("deep text")), + ) + + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = listedSessions, + retainedSessionKey = "deep-session", + ) + + assertEquals(MAX_CACHED_SESSIONS, store.loadSessions("gateway-a", "main").size) + assertTrue(store.loadSessions("gateway-a", "main").any { it.key == "deep-session" }) + assertEquals( + listOf("deep text"), + store.loadTranscript("gateway-a", "main", "deep-session").map { it.content.single().text }, + ) + } + + @Test + fun completeSessionListRefreshDropsMissingDeepTranscript() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = listOf(ChatSessionEntry(key = "deep-session", updatedAtMs = 1)), + ) + store.saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "deep-session", + messages = listOf(message("deleted remotely")), + ) + + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = 2)), + ) + + assertEquals(listOf("main"), store.loadSessions("gateway-a", "main").map { it.key }) + assertTrue(store.loadTranscript("gateway-a", "main", "deep-session").isEmpty()) + } + + @Test + fun deleteSessionRemovesSessionRowAndTranscript() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + agentId = "main", + sessions = + listOf( + ChatSessionEntry(key = "main", updatedAtMs = 1), + ChatSessionEntry(key = "other", updatedAtMs = 2), + ), + ) + store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "main", messages = listOf(message("delete me"))) + store.saveTranscript(gatewayId = "gateway-a", agentId = "other", sessionKey = "main", messages = listOf(message("delete me too"))) + store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "other", messages = listOf(message("keep me"))) + + store.deleteSession("gateway-a", "main", "main") + + assertEquals(emptyList(), store.loadTranscript("gateway-a", "main", "main")) + assertEquals(listOf("delete me too"), store.loadTranscript("gateway-a", "other", "main").map { it.content.single().text }) + assertEquals(listOf("other"), store.loadSessions("gateway-a", "main").map { it.key }) + assertEquals(listOf("keep me"), store.loadTranscript("gateway-a", "main", "other").map { it.content.single().text }) + } + + @Test + fun transcriptsAreScopedToGatewayIdentity() = + runTest { + val store = cache() + store.saveTranscript(gatewayId = "gateway-a", agentId = "main", sessionKey = "main", messages = listOf(message("gateway a text"))) + store.saveSessions("gateway-a", "main", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1))) + + assertEquals(emptyList(), store.loadTranscript("gateway-b", "main", "main")) + assertEquals(emptyList(), store.loadSessions("gateway-b", "main")) + store.saveTranscript(gatewayId = "gateway-b", agentId = "main", sessionKey = "main", messages = listOf(message("gateway b text"))) + + assertEquals(listOf("gateway a text"), store.loadTranscript("gateway-a", "main", "main").map { it.content.single().text }) + assertEquals(listOf("main"), store.loadSessions("gateway-a", "main").map { it.key }) + } + + @Test + fun blankGatewayIdentityDisablesReadsAndWrites() = + runTest { + val store = cache() + store.saveTranscript(gatewayId = "", agentId = "main", sessionKey = "main", messages = listOf(message("must not persist"))) + store.saveSessions("", "main", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1))) + + assertEquals(emptyList(), store.loadTranscript("", "main", "main")) + assertEquals(emptyList(), store.loadSessions("", "main")) + + // Nothing was written under a fallback scope either. + assertEquals(emptyList(), store.loadTranscript("gateway-a", "main", "main")) + assertEquals(emptyList(), store.loadSessions("gateway-a", "main")) + } + + @Test + fun transcriptsAreScopedToAgentOwnership() = + runTest { + val store = cache() + store.saveTranscript("gateway-a", "agent-a", "custom", listOf(message("agent a text"))) + store.saveTranscript("gateway-a", "agent-b", "custom", listOf(message("agent b text"))) + store.saveSessions( + "gateway-a", + "agent-a", + listOf(ChatSessionEntry(key = "agent-a-session", updatedAtMs = 1)), + retainedSessionKey = "custom", + ) + store.saveSessions( + "gateway-a", + "agent-b", + listOf(ChatSessionEntry(key = "agent-b-session", updatedAtMs = 2)), + retainedSessionKey = "custom", + ) + + assertEquals( + listOf("agent a text"), + store.loadTranscript("gateway-a", "agent-a", "custom").map { it.content.single().text }, + ) + assertEquals( + listOf("agent b text"), + store.loadTranscript("gateway-a", "agent-b", "custom").map { it.content.single().text }, + ) + assertEquals(listOf("agent-a-session", "custom"), store.loadSessions("gateway-a", "agent-a").map { it.key }) + assertEquals(listOf("agent-b-session", "custom"), store.loadSessions("gateway-a", "agent-b").map { it.key }) + } +} diff --git a/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt b/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt new file mode 100644 index 0000000..eb59f41 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt @@ -0,0 +1,320 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +@OptIn(ExperimentalCoroutinesApi::class) +class VoiceNoteRecorderControllerTest { + private class FakeEngine( + var durationMs: Long = 1_200L, + var outputBytes: ByteArray = byteArrayOf(1, 2, 3), + ) : VoiceNoteRecordingEngine { + var startCount = 0 + var stopCount = 0 + var cancelCount = 0 + var outputFile: File? = null + var amplitude = 0 + + override fun start(outputFile: File) { + startCount += 1 + this.outputFile = outputFile + outputFile.writeBytes(outputBytes) + } + + override fun stop(): Long { + stopCount += 1 + return durationMs + } + + override fun cancel() { + cancelCount += 1 + } + + override fun pollAmplitude(): Int = amplitude + } + + @Test + fun startTransitionsToRecordingAndPublishesElapsedTime() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + var now = 1_000L + val engine = FakeEngine() + val controller = controller(directory, engine, elapsedRealtimeMillis = { now }) + + assertTrue(controller.start()) + assertEquals(VoiceNoteRecorderState.Recording(startedAtMillis = 1_000L), controller.state.value) + + now = 3_500L + advanceTimeBy(250L) + runCurrent() + assertEquals(2_500L, controller.elapsedMs.value) + + controller.cancel() + directory.deleteRecursively() + } + + @Test + fun stopReturnsRetainedFileAndDuration() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine(durationMs = 4_321L) + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add) + + controller.start() + assertTrue(controller.finish()) + + val recording = finished.single() + assertEquals(4_321L, recording.durationMs) + assertTrue(recording.file.exists()) + assertEquals(VoiceNoteRecorderState.Preparing, controller.state.value) + controller.completePreparation() + assertEquals(VoiceNoteRecorderState.Idle, controller.state.value) + recording.file.delete() + directory.deleteRecursively() + } + + @Test + fun stopMarksMpeg4ContainerAsM4aForGatewaySniffing() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val mpeg4 = ByteArray(24) + "ftypmp42".toByteArray(Charsets.US_ASCII).copyInto(mpeg4, destinationOffset = 4) + val engine = FakeEngine(outputBytes = mpeg4) + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add) + + controller.start() + controller.finish() + + val recording = finished.single() + val majorBrand = + recording.file + .readBytes() + .copyOfRange(8, 12) + .toString(Charsets.US_ASCII) + assertEquals("M4A ", majorBrand) + recording.file.delete() + directory.deleteRecursively() + } + + @Test + fun recordingPublishesSmoothedLevelAndCancelClearsIt() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val controller = controller(directory, engine) + + controller.start() + engine.amplitude = 32_767 + advanceTimeBy(50L) + runCurrent() + assertEquals(0.2f, controller.inputLevel.value, 1e-4f) + advanceTimeBy(100L) + runCurrent() + assertEquals(0.36f, controller.inputLevel.value, 1e-4f) + + controller.cancel() + assertEquals(0f, controller.inputLevel.value, 0f) + directory.deleteRecursively() + } + + @Test + fun cancelDeletesTemporaryFileAndReturnsIdle() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val controller = controller(directory, engine) + + controller.start() + val file = requireNotNull(engine.outputFile) + controller.cancel() + + assertFalse(file.exists()) + assertEquals(1, engine.cancelCount) + assertEquals(VoiceNoteRecorderState.Idle, controller.state.value) + directory.deleteRecursively() + } + + @Test + fun durationCapUsesNormalFinishPath() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + var now = 1_000L + val engine = FakeEngine(durationMs = VOICE_NOTE_MAX_DURATION_MS) + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add, elapsedRealtimeMillis = { now }) + + controller.start() + now += VOICE_NOTE_MAX_DURATION_MS + advanceTimeBy(250L) + runCurrent() + + assertEquals(1, engine.stopCount) + assertEquals(VOICE_NOTE_MAX_DURATION_MS, finished.single().durationMs) + finished.single().file.delete() + directory.deleteRecursively() + } + + @Test + fun oversizeRecordingFailsAndDeletesFile() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine(outputBytes = ByteArray(VOICE_NOTE_MAX_BYTES.toInt() + 1)) + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add) + + controller.start() + val file = requireNotNull(engine.outputFile) + assertFalse(controller.finish()) + + assertFalse(file.exists()) + assertTrue(finished.isEmpty()) + assertEquals( + VoiceNoteRecorderState.Failure("Voice note is too large. Record a shorter message."), + controller.state.value, + ) + directory.deleteRecursively() + } + + @Test + fun startIsRefusedWhileAlreadyRecording() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val controller = controller(directory, engine) + + assertTrue(controller.start()) + assertFalse(controller.start()) + assertEquals(1, engine.startCount) + + controller.cancel() + directory.deleteRecursively() + } + + @Test + fun startIsRefusedWhilePreparingAttachment() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add) + + controller.start() + controller.finish() + + assertFalse(controller.start()) + assertEquals(1, engine.startCount) + assertEquals(VoiceNoteRecorderState.Preparing, controller.state.value) + + finished.single().file.delete() + directory.deleteRecursively() + } + + @Test + fun cancelDuringPreparingDeletesHandedOffFile() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add) + + controller.start() + controller.finish() + assertEquals(VoiceNoteRecorderState.Preparing, controller.state.value) + assertTrue(finished.single().file.exists()) + assertTrue(controller.canCommitPreparation(finished.single().id)) + + // Composition-scoped staging may be cancelled before it runs; cancel() + // must still delete the handed-off recording. + controller.cancel() + + assertEquals(VoiceNoteRecorderState.Idle, controller.state.value) + assertFalse(controller.canCommitPreparation(finished.single().id)) + assertFalse(finished.single().file.exists()) + directory.deleteRecursively() + } + + @Test + fun reportFailureDuringPreparingDeletesHandedOffFile() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val finished = mutableListOf() + val controller = controller(directory, engine, onFinished = finished::add) + + controller.start() + controller.finish() + + controller.reportFailure("Could not prepare voice note.") + + assertEquals( + VoiceNoteRecorderState.Failure("Could not prepare voice note."), + controller.state.value, + ) + assertFalse(finished.single().file.exists()) + directory.deleteRecursively() + } + + @Test + fun startIsRefusedWhileVoiceCaptureOwnsMic() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val controller = controller(directory, engine, acquireMic = { false }) + + assertFalse(controller.start()) + + assertEquals(0, engine.startCount) + assertEquals( + VoiceNoteRecorderState.Failure("Voice capture is already using the microphone."), + controller.state.value, + ) + directory.deleteRecursively() + } + + @Test + fun permissionDeniedIsUserVisibleAndDoesNotStartEngine() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val controller = controller(directory, engine, requestPermission = { false }) + + assertFalse(controller.start()) + + assertEquals(0, engine.startCount) + assertEquals( + VoiceNoteRecorderState.Failure("Microphone permission is required to record a voice note."), + controller.state.value, + ) + directory.deleteRecursively() + } + + private fun kotlinx.coroutines.test.TestScope.controller( + directory: File, + engine: FakeEngine, + requestPermission: suspend () -> Boolean = { true }, + acquireMic: () -> Boolean = { true }, + releaseMic: () -> Unit = {}, + onFinished: (VoiceNoteRecording) -> Unit = {}, + elapsedRealtimeMillis: () -> Long = { 1_000L }, + ): VoiceNoteRecorderController = + VoiceNoteRecorderController( + scope = this, + outputDirectory = directory, + engine = engine, + requestPermission = requestPermission, + acquireMic = acquireMic, + releaseMic = releaseMic, + onFinished = onFinished, + elapsedRealtimeMillis = elapsedRealtimeMillis, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt b/app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt new file mode 100644 index 0000000..f0db7f0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/BonjourEscapesTest.kt @@ -0,0 +1,19 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BonjourEscapesTest { + @Test + fun decodeNoop() { + assertEquals("", BonjourEscapes.decode("")) + assertEquals("hello", BonjourEscapes.decode("hello")) + } + + @Test + fun decodeDecodesDecimalEscapes() { + assertEquals("OpenClaw Gateway", BonjourEscapes.decode("OpenClaw\\032Gateway")) + assertEquals("A B", BonjourEscapes.decode("A\\032B")) + assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/ChatSendAckTest.kt b/app/src/test/java/ai/openclaw/app/gateway/ChatSendAckTest.kt new file mode 100644 index 0000000..075d989 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/ChatSendAckTest.kt @@ -0,0 +1,85 @@ +package ai.openclaw.app.gateway + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatSendAckTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parseChatSendAckPreservesNonTerminalPublicAdmissionStatuses() { + for (status in listOf("started", "in_flight")) { + val ack = parseChatSendAck(json, """{"runId":"run-1","status":"$status"}""") + + assertEquals("run-1", ack.runId) + assertEquals(status, ack.normalizedStatus) + assertFalse(ack.isTerminal) + } + } + + @Test + fun parseChatSendAckNormalizesMissingAndMalformedStatusToEmpty() { + val missing = parseChatSendAck(json, """{"runId":"legacy"}""") + val malformed = + listOf( + parseChatSendAck(json, """{"runId":"number","status":42}"""), + parseChatSendAck(json, """{"runId":"null","status":null}"""), + parseChatSendAck(json, """{"runId":"blank","status":" "}"""), + parseChatSendAck(json, "not-json"), + ) + + assertEquals("", missing.normalizedStatus) + assertTrue(malformed.all { it.normalizedStatus.isEmpty() }) + } + + @Test + fun parseChatSendAckMarksOkAsTerminalSuccess() { + val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""") + + assertEquals("run-ok", ack.runId) + assertEquals("ok", ack.normalizedStatus) + assertTrue(ack.isTerminal) + assertTrue(ack.isTerminalSuccess) + assertFalse(ack.isTerminalFailure) + } + + @Test + fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() { + val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""") + val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""") + + assertEquals("run-timeout", timeout.runId) + assertTrue(timeout.isTerminal) + assertFalse(timeout.isTerminalSuccess) + assertTrue(timeout.isTerminalFailure) + assertEquals("run-error", error.runId) + assertTrue(error.isTerminal) + assertFalse(error.isTerminalSuccess) + assertTrue(error.isTerminalFailure) + } + + @Test + fun cachedOkAckUsesUnfilteredHistoryFallback() { + val startedAt = 123.0 + val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""") + val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""") + + assertNull(chatSendAckHistorySinceSeconds(ok, startedAt)) + assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0) + } + + @Test + fun parseChatSendAckToleratesMalformedPayloads() { + val ack = parseChatSendAck(json, "not-json") + + assertNull(ack.runId) + assertEquals("", ack.normalizedStatus) + assertFalse(ack.isTerminal) + assertFalse(ack.isTerminalSuccess) + assertFalse(ack.isTerminalFailure) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt b/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt new file mode 100644 index 0000000..4f7e7ea --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthPayloadTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceAuthPayloadTest { + @Test + fun buildV3_matchesCanonicalVector() { + val payload = + DeviceAuthPayload.buildV3( + deviceId = "dev-1", + clientId = "openclaw-macos", + clientMode = "ui", + role = "operator", + scopes = listOf("operator.admin", "operator.read"), + signedAtMs = 1_700_000_000_000, + token = "tok-123", + nonce = "nonce-abc", + platform = " IOS ", + deviceFamily = " iPhone ", + ) + + assertEquals( + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", + payload, + ) + } + + @Test + fun normalizeMetadataField_asciiOnlyLowercase() { + assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS ")) + assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC ")) + assertEquals("", DeviceAuthPayload.normalizeMetadataField(null)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt b/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt new file mode 100644 index 0000000..834c249 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt @@ -0,0 +1,66 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.SecurePrefs +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DeviceAuthStoreTest { + @Test + fun saveTokenPersistsNormalizedScopesMetadata() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val store = DeviceAuthStore(prefs) + + store.saveToken( + gatewayId = "gateway-a", + deviceId = " Device-1 ", + role = " Operator ", + token = " operator-token ", + scopes = listOf("operator.write", "operator.read", "operator.write", " "), + ) + + val entry = store.loadEntry("gateway-a", "device-1", "operator") + assertNotNull(entry) + assertEquals("operator-token", entry?.token) + assertEquals("operator", entry?.role) + assertEquals(listOf("operator.read", "operator.write"), entry?.scopes) + assertTrue((entry?.updatedAtMs ?: 0L) > 0L) + } + + @Test + fun gatewayIdsIsolateSameDeviceAndRole() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val store = DeviceAuthStore(prefs) + store.saveToken("gateway-a", "device-1", "operator", "token-a") + store.saveToken("gateway-b", "device-1", "operator", "token-b") + + assertEquals("token-a", store.loadToken("gateway-a", "device-1", "operator")) + assertEquals("token-b", store.loadToken("gateway-b", "device-1", "operator")) + + store.clearToken("gateway-a", "device-1", "operator") + + assertEquals(null, store.loadToken("gateway-a", "device-1", "operator")) + assertEquals("token-b", store.loadToken("gateway-b", "device-1", "operator")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityStoreTest.kt b/app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityStoreTest.kt new file mode 100644 index 0000000..5a8b191 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityStoreTest.kt @@ -0,0 +1,80 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.SecurePrefs +import android.content.Context +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DeviceIdentityStoreTest { + private val app get() = RuntimeEnvironment.getApplication() + private val legacyFile get() = File(app.filesDir, "openclaw/identity/device.json") + + @Before + fun setUp() { + legacyFile.delete() + } + + @After + fun tearDown() { + legacyFile.delete() + } + + @Test + fun migratesLegacyIdentityAndKeepsItStableAcrossReopen() { + val backing = newBackingPrefs() + val prefs = SecurePrefs(app, securePrefsOverride = backing) + val seed = DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate() + backing.edit().clear().commit() + legacyFile.parentFile?.mkdirs() + legacyFile.writeText(Json.encodeToString(seed), Charsets.UTF_8) + + val migrated = DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate() + + assertEquals(seed, migrated) + assertFalse(legacyFile.exists()) + assertEquals(migrated, DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate()) + } + + @Test + fun freshInstallPersistsIdentityOnlyInSecurePrefs() { + val backing = newBackingPrefs() + val prefs = SecurePrefs(app, securePrefsOverride = backing) + + val created = DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate() + + assertFalse(legacyFile.exists()) + assertEquals(created, DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate()) + } + + @Test + fun corruptedLegacyFileIsDeletedAndReplacedWithStableIdentity() { + val backing = newBackingPrefs() + val prefs = SecurePrefs(app, securePrefsOverride = backing) + legacyFile.parentFile?.mkdirs() + legacyFile.writeText("{not-json", Charsets.UTF_8) + + val regenerated = DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate() + + assertFalse(legacyFile.exists()) + assertEquals(regenerated, DeviceIdentityStore.withPrefs(app, prefs).loadOrCreate()) + } + + private fun newBackingPrefs() = + app.getSharedPreferences( + "device-identity-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityTestSupport.kt b/app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityTestSupport.kt new file mode 100644 index 0000000..406d6aa --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/DeviceIdentityTestSupport.kt @@ -0,0 +1,16 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.SecurePrefs +import android.content.Context + +internal fun testDeviceIdentityStore(context: Context): DeviceIdentityStore { + val backing = + context.getSharedPreferences( + "openclaw.node.secure.test.device-identity", + Context.MODE_PRIVATE, + ) + return DeviceIdentityStore.withPrefs( + context, + SecurePrefs(context, securePrefsOverride = backing), + ) +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt new file mode 100644 index 0000000..e8b2f78 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewayDiscoveryTest.kt @@ -0,0 +1,68 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.xbill.DNS.Rcode + +class GatewayDiscoveryTest { + @Test + fun statusTextFormatsLocalAndWideAreaDiscoveryStates() { + val cases = + listOf( + StatusCase( + localCount = 0, + wideAreaRcode = null, + wideAreaCount = 0, + expected = "Searching for gateways…", + ), + StatusCase( + localCount = 0, + wideAreaRcode = Rcode.NOERROR, + wideAreaCount = 2, + expected = "Wide: 2", + ), + StatusCase( + localCount = 1, + wideAreaRcode = Rcode.NOERROR, + wideAreaCount = 2, + expected = "Local: 1 • Wide: 2", + ), + StatusCase( + localCount = 1, + wideAreaRcode = null, + wideAreaCount = 0, + expected = "Local: 1 • Wide: ?", + ), + StatusCase( + localCount = 0, + wideAreaRcode = Rcode.NXDOMAIN, + wideAreaCount = 0, + expected = "Wide: NXDOMAIN", + ), + StatusCase( + localCount = 2, + wideAreaRcode = Rcode.SERVFAIL, + wideAreaCount = 0, + expected = "Local: 2 • Wide: SERVFAIL", + ), + ) + + for (case in cases) { + assertEquals( + case.expected, + gatewayDiscoveryStatusText( + localCount = case.localCount, + wideAreaRcode = case.wideAreaRcode, + wideAreaCount = case.wideAreaCount, + ), + ) + } + } +} + +private data class StatusCase( + val localCount: Int, + val wideAreaRcode: Int?, + val wideAreaCount: Int, + val expected: String, +) diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt new file mode 100644 index 0000000..683a355 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewayErrorDetailsTest.kt @@ -0,0 +1,52 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GatewayErrorDetailsTest { + @Test + fun readsStructuredMissingScopeWithoutMessageParsing() { + val error = + GatewaySession.ErrorShape( + code = "FORBIDDEN", + message = "permission denied", + details = + GatewayErrorDetails( + code = "MISSING_SCOPE", + missingScope = "operator.questions", + requiredScopes = listOf("operator.read", "operator.questions"), + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ) + + assertEquals("operator.questions", error.missingScope()) + assertEquals( + GatewayMissingScopeErrorDetails( + missingScope = "operator.questions", + requiredScopes = listOf("operator.read", "operator.questions"), + ), + error.missingScopeDetails(), + ) + } + + @Test + fun legacyFallbackRequiresAnAuthorizationErrorCode() { + assertEquals( + "operator.read", + GatewaySession + .ErrorShape( + code = "INVALID_REQUEST", + message = "missing scope: operator.read", + ).missingScope(), + ) + assertNull( + GatewaySession + .ErrorShape( + code = "UNAVAILABLE", + message = "missing scope: operator.read", + ).missingScope(), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt new file mode 100644 index 0000000..84ebe30 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.app.gateway + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayProtocolGeneratedTest { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + + @Test + fun requestFrameEncodingIncludesTheDiscriminatorAndOmitsNullParams() { + val encoded = + json + .encodeToJsonElement( + GatewayRequestFrame.serializer(), + GatewayRequestFrame(id = "request-1", method = GatewayMethod.Health.rawValue), + ).jsonObject + + assertEquals("req", encoded.getValue("type").jsonPrimitive.content) + assertEquals("request-1", encoded.getValue("id").jsonPrimitive.content) + assertEquals(GatewayMethod.Health.rawValue, encoded.getValue("method").jsonPrimitive.content) + assertNull(encoded["params"]) + } + + @Test + fun nodeInvokeRequestUsesTheSchemaWireNames() { + val decoded = + json.decodeFromString( + GatewayNodeInvokeRequest.serializer(), + """{"id":"invoke-1","nodeId":"node-1","command":"device.info","paramsJSON":"{}","timeoutMs":5000}""", + ) + + assertEquals("invoke-1", decoded.id) + assertEquals("node-1", decoded.nodeId) + assertEquals("device.info", decoded.command) + assertEquals("{}", decoded.paramsJson) + assertEquals(5_000L, decoded.timeoutMs) + } + + @Test + fun generatedGatewayCatalogsAreCompleteAndUnique() { + val methods = GatewayMethod.entries.map { it.rawValue } + val events = GatewayEvent.entries.map { it.rawValue } + + assertTrue(methods.size > 200) + assertTrue(events.size > 20) + assertEquals(methods.size, methods.toSet().size) + assertEquals(events.size, events.toSet().size) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt new file mode 100644 index 0000000..599298d --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt @@ -0,0 +1,199 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.SecurePrefs +import android.content.Context +import android.content.SharedPreferences +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class GatewayRegistryStoreTest { + @Test + fun roundTripUpsertActiveAndRemove() { + val (prefs, securePrefs) = freshPrefs() + val store = prefs.gatewayRegistry + val alpha = manualEntry("alpha", "alpha.example") + val beta = manualEntry("Beta", "beta.example") + + store.upsert(beta) + store.upsert(alpha) + store.setActive(alpha.stableId) + store.markConnected(alpha.stableId, 42L) + + val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + assertEquals(listOf("alpha", "Beta"), restored.entries.value.map { it.name }) + assertEquals(alpha.stableId, restored.activeStableId.value) + assertEquals(listOf(alpha.stableId), restored.connectedStableIds.value) + assertEquals(42L, restored.activeEntry()?.lastConnectedAtMs) + + restored.setConnectionEnabled(beta.stableId, true) + assertEquals(listOf(alpha.stableId, beta.stableId), restored.connectedStableIds.value) + restored.setConnectionEnabled(alpha.stableId, false) + assertEquals(listOf(beta.stableId), restored.connectedStableIds.value) + + assertTrue(restored.remove(alpha.stableId)) + assertNull(restored.activeStableId.value) + assertEquals(listOf(beta.stableId), restored.entries.value.map { it.stableId }) + assertEquals(listOf(beta.stableId), restored.connectedStableIds.value) + + val afterRemoval = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + assertNull(afterRemoval.activeStableId.value) + assertEquals(listOf(beta.stableId), afterRemoval.entries.value.map { it.stableId }) + } + + @Test + fun serializationIsDeterministicAndPreservesConnectedTimestampOnMetadataUpdate() { + val (prefs, securePrefs) = freshPrefs() + val store = prefs.gatewayRegistry + val alpha = manualEntry("alpha", "alpha.example") + val beta = manualEntry("Beta", "beta.example") + + store.upsert(beta.copy(lastConnectedAtMs = 7L)) + store.upsert(alpha) + val first = securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null) + store.upsert(beta.copy(name = "Beta renamed")) + assertEquals( + 7L, + store.entries.value + .first { it.stableId == beta.stableId } + .lastConnectedAtMs, + ) + store.upsert(beta) + val second = securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null) + + assertEquals(first, second) + } + + @Test + fun failedRemovalCommitDoesNotPublishCandidateState() { + val (_, securePrefs) = freshPrefs() + val failingCommitPrefs = + object : SharedPreferences by securePrefs { + override fun edit(): SharedPreferences.Editor { + val editor = securePrefs.edit() + return object : SharedPreferences.Editor by editor { + override fun putString( + key: String?, + value: String?, + ): SharedPreferences.Editor { + editor.putString(key, value) + return this + } + + override fun commit(): Boolean = false + } + } + } + val store = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), failingCommitPrefs)) + val alpha = manualEntry("alpha", "alpha.example") + store.upsert(alpha) + store.setActive(alpha.stableId) + + assertFalse(store.remove(alpha.stableId)) + assertEquals(listOf(alpha.stableId), store.entries.value.map { it.stableId }) + assertEquals(alpha.stableId, store.activeStableId.value) + assertEquals(listOf(alpha.stableId), store.connectedStableIds.value) + } + + @Test + fun versionOneRegistryUpgradesActiveGatewayToConnected() { + val (_, securePrefs) = freshPrefs() + securePrefs + .edit() + .putString( + GatewayRegistryStore.STORAGE_KEY, + """{"version":1,"activeStableId":"manual|alpha.example|18789","entries":[{"stableId":"manual|alpha.example|18789","kind":"manual","name":"Alpha","host":"alpha.example","port":18789}]}""", + ).commit() + + val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + + assertEquals(1, Json.decodeFromString(securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null)!!).version) + assertEquals(listOf("manual|alpha.example|18789"), restored.connectedStableIds.value) + } + + @Test + fun unsupportedOrMalformedRegistryIsNotOverwrittenOnLaunch() { + val (_, securePrefs) = freshPrefs() + val unsupported = """{"version":3,"future":["keep-me"]}""" + securePrefs.edit().putString(GatewayRegistryStore.STORAGE_KEY, unsupported).commit() + + val unsupportedStore = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + + assertTrue(unsupportedStore.entries.value.isEmpty()) + unsupportedStore.upsert(manualEntry("new", "new.example")) + assertEquals(unsupported, securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null)) + + val malformed = "{not-json" + securePrefs.edit().putString(GatewayRegistryStore.STORAGE_KEY, malformed).commit() + + val malformedStore = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + + assertTrue(malformedStore.entries.value.isEmpty()) + malformedStore.upsert(manualEntry("new", "new.example")) + assertEquals(malformed, securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null)) + + val missingVersion = """{"entries":[]}""" + securePrefs.edit().putString(GatewayRegistryStore.STORAGE_KEY, missingVersion).commit() + + val missingVersionStore = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + missingVersionStore.upsert(manualEntry("new", "new.example")) + + assertEquals(missingVersion, securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null)) + } + + @Test + fun postCommitObserverFailureDoesNotUndoDurableRemoval() { + val (prefs, securePrefs) = freshPrefs() + var failObserver = false + val store = + GatewayRegistryStore(prefs) { + if (failObserver) error("simulated observer failure") + } + val alpha = manualEntry("alpha", "alpha.example") + store.upsert(alpha) + store.setActive(alpha.stableId) + failObserver = true + + assertTrue(store.remove(alpha.stableId)) + assertTrue(store.entries.value.isEmpty()) + assertNull(store.activeStableId.value) + val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + assertTrue(restored.entries.value.isEmpty()) + assertNull(restored.activeStableId.value) + } + + private fun freshPrefs(): Pair { + val context = RuntimeEnvironment.getApplication() + context + .getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + val securePrefs = + context.getSharedPreferences("gateway-registry-${UUID.randomUUID()}", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + return SecurePrefs(context, securePrefs) to securePrefs + } + + private fun manualEntry( + name: String, + host: String, + ): GatewayRegistryEntry { + val endpoint = GatewayEndpoint.manual(host, 18789) + return GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = name, + host = host, + port = 18789, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt new file mode 100644 index 0000000..e4e6dd0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt @@ -0,0 +1,446 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.SecurePrefs +import android.content.Context +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +private const val TEST_TIMEOUT_MS = 8_000L +private const val CONNECT_CHALLENGE_FRAME = + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":1700000000123}}""" + +private class NoopDeviceAuthStore : DeviceAuthTokenStore { + override fun loadEntry( + gatewayId: String, + deviceId: String, + role: String, + ): DeviceAuthEntry? = null + + override fun saveToken( + gatewayId: String, + deviceId: String, + role: String, + token: String, + scopes: List, + ) = Unit + + override fun clearToken( + gatewayId: String, + deviceId: String, + role: String, + ) = Unit +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewaySessionCustomHeadersTest { + @Test + fun managedMediaDownload_usesArtifactTicketWithoutGatewayBearer() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val imageRequest = CompletableDeferred() + val imageBytes = byteArrayOf(1, 2, 3, 4) + val attachmentId = "11111111-1111-4111-8111-111111111111" + val artifactId = "artifact_managed_image_$attachmentId" + val imagePath = "/api/chat/media/outgoing/main/$attachmentId/full?mediaTicket=ticket" + val videoAttachmentId = "22222222-2222-4222-8222-222222222222" + val videoArtifactId = "artifact_managed_media_$videoAttachmentId" + val videoPath = "/api/chat/media/outgoing/main/$videoAttachmentId/full?mediaTicket=video-ticket" + val audioAttachmentId = "33333333-3333-4333-8333-333333333333" + val audioArtifactId = "artifact_managed_media_$audioAttachmentId" + val audioPath = "/api/chat/media/outgoing/main/$audioAttachmentId/full?mediaTicket=audio-ticket" + val audioPlaybackPath = "$audioPath&playback=1" + val audioBytes = byteArrayOf(5, 6, 7, 8) + val audioRequestCount = AtomicInteger() + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path == imagePath) { + imageRequest.complete(request) + return MockResponse() + .setHeader("Content-Type", "image/png") + .setBody(Buffer().write(imageBytes)) + } + if (request.path == audioPlaybackPath) { + if (audioRequestCount.incrementAndGet() == 1) { + return MockResponse().setResponseCode(202).setBody("""{"status":"preparing"}""") + } + return MockResponse() + .setHeader("Content-Type", "audio/mp4") + .setBody(Buffer().write(audioBytes)) + } + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + webSocket.send(CONNECT_CHALLENGE_FRAME) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + when (frame["method"]?.jsonPrimitive?.content) { + "connect" -> + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + "artifacts.download" -> + if (frame["params"] + ?.jsonObject + ?.get("artifactId") + ?.jsonPrimitive + ?.content == videoArtifactId + ) { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"artifact":{"id":"$videoArtifactId","type":"video","mimeType":"video/mp4","download":{"mode":"url"}},"url":"$videoPath"}}""", + ) + } else if (frame["params"] + ?.jsonObject + ?.get("artifactId") + ?.jsonPrimitive + ?.content == audioArtifactId + ) { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"artifact":{"id":"$audioArtifactId","type":"audio","mimeType":"audio/mp4","download":{"mode":"url"}},"url":"$audioPath"}}""", + ) + } else { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"url":"$imagePath"}}""", + ) + } + } + } + }, + ) + } + } + start() + } + val stableId = "manual|127.0.0.1|${server.port}" + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val session = + GatewaySession( + scope = scope, + identityStore = testDeviceIdentityStore(app), + deviceAuthStore = NoopDeviceAuthStore(), + onConnected = { if (!connected.isCompleted) connected.complete(Unit) }, + onDisconnected = {}, + onEvent = { _, _ -> }, + ) + + try { + session.connect( + endpoint = GatewayEndpoint(stableId, "test", "127.0.0.1", server.port, tlsEnabled = false), + token = "bootstrap-token", + bootstrapToken = null, + password = null, + options = + GatewayConnectOptions( + role = "operator", + scopes = listOf("operator.read"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "ui", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + withTimeout(TEST_TIMEOUT_MS) { connected.await() } + + val loaded = session.loadImageArtifact(stableId, "main", "main", artifactId) + assertArrayEquals(imageBytes, loaded?.bytes) + assertEquals("image/png", loaded?.mimeType) + val request = withTimeout(TEST_TIMEOUT_MS) { imageRequest.await() } + assertNull(request.getHeader("Authorization")) + assertEquals("image/*", request.getHeader("Accept")) + + val streamed = + session.loadMediaArtifact(stableId, "main", "main", videoArtifactId, GatewayMediaKind.Video) as GatewayLoadedMedia.Streaming + assertEquals("http://127.0.0.1:${server.port}$videoPath", streamed.url) + assertEquals("video/*", streamed.headers["Accept"]) + assertEquals("video/mp4", streamed.mimeType) + assertEquals(false, streamed.retryPreparingPlayback) + + val transcodedVideo = + session.loadMediaArtifact(stableId, "main", "main", videoArtifactId, GatewayMediaKind.Video, true) as GatewayLoadedMedia.Streaming + assertEquals("http://127.0.0.1:${server.port}$videoPath&playback=1", transcodedVideo.url) + assertTrue(transcodedVideo.retryPreparingPlayback) + + val audio = + session.loadMediaArtifact(stableId, "main", "main", audioArtifactId, GatewayMediaKind.Audio, true) as GatewayLoadedMedia.Buffered + assertArrayEquals(audioBytes, audio.bytes) + assertEquals(2, audioRequestCount.get()) + } finally { + session.disconnectAndJoin() + scope.cancel() + server.shutdown() + } + } + + @Test + fun preparingPlaybackInterceptorRetries202WithoutSurfacingLoadError() { + val server = MockWebServer() + server.enqueue(MockResponse().setResponseCode(202).setBody("""{"status":"preparing"}""")) + server.enqueue(MockResponse().setResponseCode(200).setBody("ready")) + server.start() + var nowMs = 0L + val client = + OkHttpClient + .Builder() + .addInterceptor( + GatewayPreparingPlaybackInterceptor( + policy = GatewayPlaybackRetryPolicy(maxElapsedMs = 100L, initialDelayMs = 0L, maxDelayMs = 0L), + nowMs = { nowMs++ }, + sleepMs = {}, + ), + ).build() + + try { + client.newCall(Request.Builder().url(server.url("/video?playback=1")).build()).execute().use { response -> + assertEquals(200, response.code) + assertEquals("ready", response.body.string()) + } + assertEquals(2, server.requestCount) + } finally { + server.shutdown() + } + } + + @Test + fun preparingPlaybackRetryStopsAtTwoMinuteCap() { + val retry = GatewayPlaybackRetryState(startedAtMs = 1_000L) + + assertTrue(retry.canAttempt(nowMs = 1_000L)) + assertEquals(500L, retry.nextDelayMs(nowMs = 1_000L)) + assertEquals(false, retry.canAttempt(nowMs = 121_000L)) + assertNull(retry.nextDelayMs(nowMs = 121_001L)) + } + + @Test + fun preparingPlaybackInterceptorDoesNotStartRequestAfterOvershootingDeadline() { + val server = MockWebServer() + server.enqueue(MockResponse().setResponseCode(202).setBody("""{"status":"preparing"}""")) + server.start() + var nowMs = 0L + val client = + OkHttpClient + .Builder() + .addInterceptor( + GatewayPreparingPlaybackInterceptor( + policy = GatewayPlaybackRetryPolicy(maxElapsedMs = 2L, initialDelayMs = 1L, maxDelayMs = 1L), + nowMs = { nowMs }, + sleepMs = { delayMs -> nowMs += delayMs + 1L }, + ), + ).build() + + try { + val failure = + runCatching { + client.newCall(Request.Builder().url(server.url("/video?playback=1")).build()).execute().use { } + }.exceptionOrNull() + assertTrue(failure is java.io.IOException) + assertEquals(1, server.requestCount) + } finally { + server.shutdown() + } + } + + @Test + fun tlsUpgradeRequest_carriesLatestSanitizedHeadersForOnlyThisGateway() { + val app = RuntimeEnvironment.getApplication() + val securePrefsBacking = + app.getSharedPreferences("openclaw.node.secure.test.${UUID.randomUUID()}", Context.MODE_PRIVATE) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefsBacking) + val stableId = "manual|gateway.example|443" + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 443) + val tls = GatewayTlsParams(required = true, expectedFingerprint = "aa".repeat(32), allowTOFU = false, stableId = stableId) + + prefs.saveGatewayCustomHeaders(stableId, mapOf("CF-Access-Client-Id" to "client-id")) + securePrefsBacking + .edit() + .putString( + "gateway.customHeaders.$stableId", + """{"CF-Access-Client-Id":"client-id","Host":"smuggled.example"}""", + ).commit() + prefs.saveGatewayCustomHeaders("manual|other.example|443", mapOf("X-Other-Gateway" to "leak")) + + val first = buildGatewayWebSocketUpgradeRequest(endpoint, tls, prefs::loadGatewayCustomHeaders) + assertTrue(first.url.isHttps) + assertEquals("client-id", first.header("CF-Access-Client-Id")) + assertNull(first.header("Host")) + assertNull(first.header("X-Other-Gateway")) + + prefs.saveGatewayCustomHeaders(stableId, mapOf("CF-Access-Client-Id" to "updated-id")) + val reconnected = buildGatewayWebSocketUpgradeRequest(endpoint, tls, prefs::loadGatewayCustomHeaders) + assertEquals("updated-id", reconnected.header("CF-Access-Client-Id")) + } + + @Test + fun cleartextUpgrade_neverReadsOrSendsStoredCustomHeaders() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefsBacking = + app.getSharedPreferences("openclaw.node.secure.test.${UUID.randomUUID()}", Context.MODE_PRIVATE) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefsBacking) + + val handshake = AtomicReference(null) + val server = startCapturingGatewayServer { request -> handshake.compareAndSet(null, request) } + val stableId = "manual|127.0.0.1|${server.port}" + prefs.saveGatewayCustomHeaders( + stableId, + mapOf("CF-Access-Client-Id" to "client-id", "CF-Access-Client-Secret" to "client-secret"), + ) + val providerRead = AtomicBoolean(false) + + val sessionJob = SupervisorJob() + val scope = CoroutineScope(sessionJob + Dispatchers.Default) + val connected = CompletableDeferred() + val session = + GatewaySession( + scope = scope, + identityStore = testDeviceIdentityStore(app), + deviceAuthStore = NoopDeviceAuthStore(), + onConnected = { if (!connected.isCompleted) connected.complete(Unit) }, + onDisconnected = {}, + onEvent = { _, _ -> }, + customHeadersProvider = { id -> + providerRead.set(true) + prefs.loadGatewayCustomHeaders(id) + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = stableId, + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + bootstrapToken = null, + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = emptyList(), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + withTimeout(TEST_TIMEOUT_MS) { connected.await() } + + val request = requireNotNull(handshake.get()) { "no websocket upgrade recorded" } + assertEquals(false, providerRead.get()) + assertNull(request.getHeader("CF-Access-Client-Id")) + assertNull(request.getHeader("CF-Access-Client-Secret")) + assertEquals("127.0.0.1:${server.port}", request.getHeader("Host")) + } finally { + session.disconnectAndJoin() + scope.cancel() + server.shutdown() + } + } + + private fun startCapturingGatewayServer(onHandshake: (RecordedRequest) -> Unit): MockWebServer { + val json = Json { ignoreUnknownKeys = true } + return MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + onHandshake(request) + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + webSocket.send(CONNECT_CHALLENGE_FRAME) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + if (frame["method"]?.jsonPrimitive?.content != "connect") return + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + } + }, + ) + } + } + start() + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt new file mode 100644 index 0000000..af4d537 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -0,0 +1,1592 @@ +package ai.openclaw.app.gateway + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +private const val TEST_TIMEOUT_MS = 8_000L +private const val CONNECT_CHALLENGE_TS = 1_700_000_000_123L +private const val CONNECT_CHALLENGE_FRAME = + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":$CONNECT_CHALLENGE_TS}}""" + +private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { + private val tokens = mutableMapOf() + + override fun loadEntry( + gatewayId: String, + deviceId: String, + role: String, + ): DeviceAuthEntry? = tokens["${gatewayId.trim()}|${deviceId.trim()}|${role.trim()}"] + + override fun saveToken( + gatewayId: String, + deviceId: String, + role: String, + token: String, + scopes: List, + ) { + tokens["${gatewayId.trim()}|${deviceId.trim()}|${role.trim()}"] = + DeviceAuthEntry( + token = token.trim(), + role = role.trim(), + scopes = scopes, + updatedAtMs = System.currentTimeMillis(), + ) + } + + override fun clearToken( + gatewayId: String, + deviceId: String, + role: String, + ) { + tokens.remove("${gatewayId.trim()}|${deviceId.trim()}|${role.trim()}") + } +} + +private data class NodeHarness( + val session: GatewaySession, + val sessionJob: Job, + val deviceAuthStore: InMemoryDeviceAuthStore, +) + +private data class InvokeScenarioResult( + val request: GatewaySession.InvokeRequest, + val resultParams: JsonObject, +) + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewaySessionInvokeTest { + @Test + fun connect_usesGatewayChallengeTimestamp() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + if (method == "connect") { + assertEquals( + CONNECT_CHALLENGE_TS, + frame["params"] + ?.jsonObject + ?.get("device") + ?.jsonObject + ?.get("signedAt") + ?.jsonPrimitive + ?.content + ?.toLong(), + ) + webSocket.send(connectResponseFrame(id)) + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_rejectsChallengeWithoutTimestamp() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val connectRequests = AtomicInteger() + val server = + startGatewayServer( + json = json, + challengeFrame = + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) { _, _, method, _ -> + if (method == "connect") connectRequests.incrementAndGet() + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + withTimeout(TEST_TIMEOUT_MS) { + while (lastDisconnect.get().isEmpty()) delay(10) + } + assertFalse(connected.isCompleted) + assertEquals(0, connectRequests.get()) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun canvasRoutePinsOnlyTheConnectedTlsEndpoint() { + val fingerprint = "ab".repeat(32) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 7443) + + assertEquals( + fingerprint, + gatewayTlsFingerprintForCanvasSurface( + fingerprint = fingerprint, + surfaceUrl = "https://gateway.example:7443/__openclaw__/cap/token", + endpoint = endpoint, + isTlsConnection = true, + ), + ) + assertNull( + gatewayTlsFingerprintForCanvasSurface( + fingerprint = fingerprint, + surfaceUrl = "https://canvas.example:7443/__openclaw__/cap/token", + endpoint = endpoint, + isTlsConnection = true, + ), + ) + assertNull( + gatewayTlsFingerprintForCanvasSurface( + fingerprint = fingerprint, + surfaceUrl = "https://gateway.example:9443/__openclaw__/cap/token", + endpoint = endpoint, + isTlsConnection = true, + ), + ) + } + + @Test + fun refreshCanvasHostUrl_usesNodeRefreshMethod() = + runBlocking { + assertCanvasHostRefreshMethod(role = "node", expectedMethod = "node.pluginSurface.refresh") + } + + @Test + fun refreshCanvasHostUrl_usesOperatorRefreshMethod() = + runBlocking { + assertCanvasHostRefreshMethod(role = "operator", expectedMethod = "plugin.surface.refresh") + } + + private suspend fun assertCanvasHostRefreshMethod( + role: String, + expectedMethod: String, + ) { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val refreshRequests = AtomicInteger() + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> + webSocket.send( + connectResponseFrame( + id, + pluginSurfaceUrls = + mapOf("canvas" to "http://127.0.0.1:18789/__openclaw__/cap/old-token"), + ), + ) + expectedMethod -> { + refreshRequests.incrementAndGet() + assertEquals( + "canvas", + frame["params"] + ?.jsonObject + ?.get("surface") + ?.jsonPrimitive + ?.content, + ) + assertTrue( + frame["params"] + ?.jsonObject + ?.get("observedUrl") + ?.jsonPrimitive + ?.content + ?.endsWith("/old-token") == true, + ) + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"surface":"canvas","pluginSurfaceUrls":{"canvas":"http://127.0.0.1:18789/__openclaw__/cap/new-token"}}}""", + ) + } + } + } + val harness = + createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { + GatewaySession.InvokeResult.ok("""{"handled":true}""") + } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + role = role, + scopes = if (role == "operator") listOf("operator.read") else listOf("node:invoke"), + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + val oldUrl = requireNotNull(harness.session.currentCanvasHostUrl()) + assertTrue(oldUrl.endsWith("/old-token")) + + val refreshed = harness.session.refreshCanvasHostUrlIfCurrent(oldUrl) + val lagging = harness.session.refreshCanvasHostUrlIfCurrent(oldUrl) + + assertTrue(refreshed?.endsWith("/new-token") == true) + assertEquals(refreshed, harness.session.currentCanvasHostUrl()) + assertEquals(refreshed, lagging) + assertEquals(1, refreshRequests.get()) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_advertisesCompatibleProtocolRange() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectParams.isCompleted) { + connectParams.complete(frame["params"]!!.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val params = withTimeout(TEST_TIMEOUT_MS) { connectParams.await() } + assertEquals( + GATEWAY_MIN_PROTOCOL_VERSION, + params["minProtocol"]?.jsonPrimitive?.content?.toInt(), + ) + assertEquals( + GATEWAY_PROTOCOL_VERSION, + params["maxProtocol"]?.jsonPrimitive?.content?.toInt(), + ) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun disconnectFailsPendingRpcWithUnknownOutcomeWithoutWaitingForTimeout() { + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val slowRequestSeen = CompletableDeferred() + val requestResult = CompletableDeferred>() + val lastDisconnect = AtomicReference("") + val serverWebSocket = AtomicReference(null) + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + serverWebSocket.set(webSocket) + when (method) { + "connect" -> webSocket.send(connectResponseFrame(id)) + "slow.method" -> { + if (!slowRequestSeen.isCompleted) slowRequestSeen.complete(Unit) + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + var requestJob: Job? = null + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + requestJob = + launch { + requestResult.complete( + runCatching { + harness.session.requestDetailed("slow.method", null, timeoutMs = 30_000) + }, + ) + } + withTimeout(TEST_TIMEOUT_MS) { slowRequestSeen.await() } + + harness.session.disconnect() + + val result = withTimeout(2_000) { requestResult.await() } + assertEquals(true, result.exceptionOrNull() is GatewayRequestOutcomeUnknown) + serverWebSocket.get()?.close(1000, "done") + withTimeoutOrNull(2_000) { + while (lastDisconnect.get().isEmpty()) delay(10) + } + } finally { + requestJob?.cancelAndJoin() + runCatching { serverWebSocket.get()?.close(1000, "done") } + delay(100) + harness.session.disconnect() + harness.sessionJob.cancelAndJoin() + server.shutdown() + } + } + } + + @Test + fun disconnectReportsUnknownOutcomeForFireAndForgetRpc() { + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val requestSeen = CompletableDeferred() + val requestError = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val serverWebSocket = AtomicReference(null) + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + serverWebSocket.set(webSocket) + when (method) { + "connect" -> webSocket.send(connectResponseFrame(id)) + "fire.and.forget" -> requestSeen.complete(Unit) + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + harness.session.sendRequestFrame( + method = "fire.and.forget", + paramsJson = null, + timeoutMs = 30_000, + onError = { requestError.complete(it) }, + ) + withTimeout(TEST_TIMEOUT_MS) { requestSeen.await() } + + harness.session.disconnect() + + val error = withTimeout(2_000) { requestError.await() } + assertEquals("UNAVAILABLE", error.code) + assertEquals("Gateway disconnected before response", error.message) + serverWebSocket.get()?.close(1000, "done") + } finally { + runCatching { serverWebSocket.get()?.close(1000, "done") } + delay(100) + harness.session.disconnect() + harness.sessionJob.cancelAndJoin() + server.shutdown() + } + } + } + + @Test + fun eventsAreDispatchedInWebSocketFrameOrder() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val firstEventStarted = CompletableDeferred() + val releaseFirstEvent = CompletableDeferred() + val secondEventHandled = CompletableDeferred() + val events = CopyOnWriteArrayList() + val lastDisconnect = AtomicReference("") + val serverWebSocket = AtomicReference(null) + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + serverWebSocket.set(webSocket) + if (method == "connect") { + webSocket.send(connectResponseFrame(id)) + webSocket.send("""{"type":"event","event":"voice.first","payload":{}}""") + webSocket.send("""{"type":"event","event":"voice.second","payload":{}}""") + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + onEvent = { event, _ -> + if (event == "voice.first") { + firstEventStarted.complete(Unit) + runBlocking { releaseFirstEvent.await() } + } + events += event + if (event == "voice.second") { + secondEventHandled.complete(Unit) + } + }, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + withTimeout(TEST_TIMEOUT_MS) { firstEventStarted.await() } + + assertNull(withTimeoutOrNull(200) { secondEventHandled.await() }) + + releaseFirstEvent.complete(Unit) + withTimeout(TEST_TIMEOUT_MS) { secondEventHandled.await() } + assertEquals(listOf("voice.first", "voice.second"), events.toList()) + } finally { + releaseFirstEvent.complete(Unit) + runCatching { serverWebSocket.get()?.close(1000, "done") } + delay(100) + shutdownHarness(harness, server) + } + } + + @Test + fun explicitNullPayloadsRemainPresentForResponsesAndEvents() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val eventPayload = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id)) + webSocket.send("""{"type":"event","event":"health","payload":null}""") + } + "test.null-payload" -> + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":null}""") + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + onEvent = { event, payload -> + if (event == GatewayEvent.Health.rawValue) eventPayload.complete(payload) + }, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val response = harness.session.requestDetailed("test.null-payload", null) + + assertEquals("null", response.payloadJson) + assertEquals("null", withTimeout(TEST_TIMEOUT_MS) { eventPayload.await() }) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectAuth = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectAuth.isCompleted) { + connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() } + assertEquals("bootstrap-token", auth?.get("bootstrapToken")?.jsonPrimitive?.content) + assertNull(auth?.get("token")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_prefersStoredDeviceTokenOverBootstrapToken() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectAuth = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectAuth.isCompleted) { + connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken(gatewayIdForPort(server.port), deviceId, "node", "device-token") + + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() } + assertEquals("device-token", auth?.get("token")?.jsonPrimitive?.content) + assertNull(auth?.get("bootstrapToken")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_reusesStoredDeviceTokenScopes() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + if (method == "connect") { + if (!connectParams.isCompleted) { + connectParams.complete(frame["params"]!!.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken( + gatewayId = gatewayIdForPort(server.port), + deviceId = deviceId, + role = "operator", + token = "operator-device-token", + scopes = listOf("operator.pairing", "operator.write"), + ) + + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + role = "operator", + scopes = listOf("operator.approvals", "operator.read", "operator.write"), + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val params = withTimeout(TEST_TIMEOUT_MS) { connectParams.await() } + assertEquals( + "operator-device-token", + params["auth"] + ?.jsonObject + ?.get("token") + ?.jsonPrimitive + ?.content, + ) + assertEquals(listOf("operator.pairing", "operator.write"), params.scopes()) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun bootstrapConnect_requestsCanonicalLimitedOperatorHandoffScopes() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + if (method == "connect") { + if (!connectParams.isCompleted) { + connectParams.complete(frame["params"]!!.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "setup-bootstrap-token", + role = "operator", + scopes = + listOf( + "operator.approvals", + "operator.pairing", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ), + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val params = withTimeout(TEST_TIMEOUT_MS) { connectParams.await() } + assertEquals( + "setup-bootstrap-token", + params["auth"] + ?.jsonObject + ?.get("bootstrapToken") + ?.jsonPrimitive + ?.content, + ) + assertEquals( + listOf( + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ), + params.scopes(), + ) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val firstConnectAuth = CompletableDeferred() + val secondConnectAuth = CompletableDeferred() + val connectAttempts = AtomicInteger(0) + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject + when (connectAttempts.incrementAndGet()) { + 1 -> { + if (!firstConnectAuth.isCompleted) { + firstConnectAuth.complete(auth) + } + webSocket.send( + """{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}}}""", + ) + webSocket.close(1000, "retry") + } + else -> { + if (!secondConnectAuth.isCompleted) { + secondConnectAuth.complete(auth) + } + webSocket.send(connectResponseFrame(id)) + } + } + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken(gatewayIdForPort(server.port), deviceId, "node", "stored-device-token") + + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() } + val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() } + assertEquals("shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content) + assertNull(firstAuth?.get("deviceToken")) + assertEquals("shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content) + assertEquals("stored-device-token", secondAuth?.get("deviceToken")?.jsonPrimitive?.content) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_storesPrimaryDeviceTokenFromSuccessfulSharedTokenConnect() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send( + connectResponseFrame( + id, + authJson = """{"deviceToken":"shared-node-token","role":"node","scopes":[]}""", + ), + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(gatewayIdForPort(server.port), deviceId, "node")) + assertNull(harness.deviceAuthStore.loadToken(gatewayIdForPort(server.port), deviceId, "operator")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun bootstrapConnect_storesAdditionalBoundedDeviceTokensOnTrustedTransport() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send( + connectResponseFrame( + id, + authJson = + """{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.pairing","operator.read","operator.talk.secrets","operator.write"]}]}""", + ), + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + val nodeEntry = harness.deviceAuthStore.loadEntry(gatewayIdForPort(server.port), deviceId, "node") + val operatorEntry = harness.deviceAuthStore.loadEntry(gatewayIdForPort(server.port), deviceId, "operator") + assertEquals("bootstrap-node-token", nodeEntry?.token) + assertEquals(emptyList(), nodeEntry?.scopes) + assertEquals("bootstrap-operator-token", operatorEntry?.token) + assertEquals( + listOf( + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ), + operatorEntry?.scopes, + ) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun nonBootstrapConnect_ignoresAdditionalBootstrapDeviceTokens() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send( + connectResponseFrame( + id, + authJson = + """{"deviceToken":"shared-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"shared-operator-token","role":"operator","scopes":["operator.approvals","operator.read"]}]}""", + ), + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(gatewayIdForPort(server.port), deviceId, "node")) + assertNull(harness.deviceAuthStore.loadToken(gatewayIdForPort(server.port), deviceId, "operator")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun nodeInvokeRequest_roundTripsInvokeResult() = + runBlocking { + val handshakeOrigin = AtomicReference(null) + val result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""", + onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) }, + ) { + GatewaySession.InvokeResult.ok("""{"handled":true}""") + } + + assertEquals("invoke-1", result.request.id) + assertEquals("node-1", result.request.nodeId) + assertEquals("debug.ping", result.request.command) + assertEquals("""{"ping":"pong"}""", result.request.paramsJson) + assertNull(handshakeOrigin.get()) + assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals( + true, + result.resultParams["ok"] + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + assertEquals( + true, + result.resultParams["payload"] + ?.jsonObject + ?.get("handled") + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + } + + @Test + fun nodeInvokeRequest_usesParamsJsonWhenProvided() = + runBlocking { + val result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""", + ) { + GatewaySession.InvokeResult.ok("""{"handled":true}""") + } + + assertEquals("invoke-2", result.request.id) + assertEquals("node-2", result.request.nodeId) + assertEquals("debug.raw", result.request.command) + assertEquals("""{"raw":true}""", result.request.paramsJson) + assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals( + true, + result.resultParams["ok"] + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + } + + @Test + fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = + runBlocking { + val result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""", + ) { + throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + } + + assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals( + false, + result.resultParams["ok"] + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + assertEquals( + "CAMERA_PERMISSION_REQUIRED", + result.resultParams["error"] + ?.jsonObject + ?.get("code") + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "grant Camera permission", + result.resultParams["error"] + ?.jsonObject + ?.get("message") + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun nodeInvokeRequest_cancelsHandlerWhenExecutionTimeoutExpires() = + runBlocking { + val handlerCancelled = CompletableDeferred() + val result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-timeout","nodeId":"node-1","command":"camera.clip","timeoutMs":100}}""", + ) { + try { + awaitCancellation() + } finally { + handlerCancelled.complete(Unit) + } + } + + withTimeout(TEST_TIMEOUT_MS) { handlerCancelled.await() } + assertEquals( + false, + result.resultParams["ok"] + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + assertEquals( + "TIMEOUT", + result.resultParams["error"] + ?.jsonObject + ?.get("code") + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "node invoke timed out", + result.resultParams["error"] + ?.jsonObject + ?.get("message") + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun nodeInvokeRequest_sendsResultForHandlerOwnedTimeout() = + runBlocking { + val result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"handler-timeout","nodeId":"node-1","command":"camera.snap","timeoutMs":5000}}""", + ) { + withTimeout(10) { awaitCancellation() } + } + + assertEquals( + false, + result.resultParams["ok"] + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + assertEquals( + "TIMEOUT", + result.resultParams["error"] + ?.jsonObject + ?.get("code") + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun nodeInvokeRequest_sendsTimeoutWhileBlockingHandlerIsStillRunning() = + runBlocking { + val releaseHandler = CountDownLatch(1) + val handlerFinished = CompletableDeferred() + val result = + runInvokeScenario( + invokeEventFrame = + """{"type":"event","event":"node.invoke.request","payload":{"id":"blocking-timeout","nodeId":"node-1","command":"camera.clip","timeoutMs":100}}""", + afterResult = { + assertFalse(handlerFinished.isCompleted) + releaseHandler.countDown() + withTimeout(TEST_TIMEOUT_MS) { handlerFinished.await() } + }, + ) { + try { + check(releaseHandler.await(5, TimeUnit.SECONDS)) { "blocking handler was not released" } + GatewaySession.InvokeResult.ok(null) + } finally { + handlerFinished.complete(Unit) + } + } + + assertEquals( + false, + result.resultParams["ok"] + ?.jsonPrimitive + ?.content + ?.toBooleanStrict(), + ) + assertEquals( + "TIMEOUT", + result.resultParams["error"] + ?.jsonObject + ?.get("code") + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun nodeInvokeRequest_doesNotSendResultAfterCancellation() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val invokeStarted = CompletableDeferred() + val invokeResult = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val serverWebSocket = AtomicReference(null) + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + serverWebSocket.set(webSocket) + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id)) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-cancelled","nodeId":"node-1","command":"camera.snap","timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> invokeResult.complete(Unit) + } + } + val harness = + createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { + invokeStarted.complete(Unit) + throw CancellationException("cancelled") + } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + withTimeout(TEST_TIMEOUT_MS) { invokeStarted.await() } + + assertNull(withTimeoutOrNull(250) { invokeResult.await() }) + } finally { + serverWebSocket.get()?.close(1000, "done") + delay(100) + shutdownHarness(harness, server) + } + } + + @Test + fun sendNodeEventDetailed_sendsPresenceAlivePayloadAndReturnsStructuredResponse() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val nodeEventParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id)) + } + "node.event" -> { + if (!nodeEventParams.isCompleted) { + nodeEventParams.complete(frame["params"]?.jsonObject ?: JsonObject(emptyMap())) + } + val payload = + """{"ok":true,"event":"node.presence.alive","handled":true,"reason":"persisted"}""" + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":$payload}""", + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val result = + harness.session.sendNodeEventDetailed( + event = "node.presence.alive", + payloadJson = """{"trigger":"connect","sentAtMs":123}""", + timeoutMs = TEST_TIMEOUT_MS, + ) + val params = withTimeout(TEST_TIMEOUT_MS) { nodeEventParams.await() } + val response = json.parseToJsonElement(result.payloadJson.orEmpty()).jsonObject + val payload = json.parseToJsonElement(params["payloadJSON"]?.jsonPrimitive?.content.orEmpty()).jsonObject + + assertEquals(true, result.ok) + assertEquals("node.presence.alive", params["event"]?.jsonPrimitive?.content) + assertEquals("connect", payload["trigger"]?.jsonPrimitive?.content) + assertEquals("123", payload["sentAtMs"]?.jsonPrimitive?.content) + assertEquals(true, response["handled"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals("persisted", response["reason"]?.jsonPrimitive?.content) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun sendNodeEvent_preservesCompletedRpcAsSuccessWhenGatewayReturnsError() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val nodeEventParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id)) + } + "node.event" -> { + if (!nodeEventParams.isCompleted) { + nodeEventParams.complete(frame["params"]?.jsonObject ?: JsonObject(emptyMap())) + } + webSocket.send( + """{"type":"res","id":"$id","ok":false,"error":{"code":"RATE_LIMITED","message":"slow down"}}""", + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val sent = + harness.session.sendNodeEvent( + event = "agent.request", + payloadJson = """{"message":"restore"}""", + ) + val params = withTimeout(TEST_TIMEOUT_MS) { nodeEventParams.await() } + + assertEquals(true, sent) + assertEquals("agent.request", params["event"]?.jsonPrimitive?.content) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun sendNodeEvent_waitsForCompletedConnectHandshake() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectRequestSeen = CompletableDeferred() + val releaseConnectResponse = CompletableDeferred() + val nodeEvents = CopyOnWriteArrayList() + val eventAfterConnect = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + connectRequestSeen.complete(Unit) + launch(Dispatchers.Default) { + releaseConnectResponse.await() + webSocket.send(connectResponseFrame(id)) + } + } + "node.event" -> { + val event = + frame["params"] + ?.jsonObject + ?.get("event") + ?.jsonPrimitive + ?.content + .orEmpty() + nodeEvents += event + eventAfterConnect.complete(Unit) + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""", + ) + webSocket.close(1000, "done") + } + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + withTimeout(TEST_TIMEOUT_MS) { connectRequestSeen.await() } + + assertFalse( + harness.session.sendNodeEvent( + event = "notifications.changed", + payloadJson = """{"change":"posted","key":"before"}""", + ), + ) + assertTrue(nodeEvents.isEmpty()) + + releaseConnectResponse.complete(Unit) + awaitConnectedOrThrow(connected, lastDisconnect, server) + assertTrue( + harness.session.sendNodeEvent( + event = "notifications.changed", + payloadJson = """{"change":"posted","key":"after"}""", + ), + ) + withTimeout(TEST_TIMEOUT_MS) { eventAfterConnect.await() } + assertEquals(listOf("notifications.changed"), nodeEvents.toList()) + } finally { + releaseConnectResponse.complete(Unit) + shutdownHarness(harness, server) + } + } + + private fun testJson(): Json = Json { ignoreUnknownKeys = true } + + private fun JsonObject.scopes(): List = + (this["scopes"] as? JsonArray) + ?.map { it.jsonPrimitive.content } + ?: emptyList() + + private fun createNodeHarness( + connected: CompletableDeferred, + lastDisconnect: AtomicReference, + onEvent: (event: String, payloadJson: String?) -> Unit = { _, _ -> }, + onInvoke: suspend (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult, + ): NodeHarness { + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = testDeviceIdentityStore(app), + deviceAuthStore = deviceAuthStore, + onConnected = { + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = onEvent, + onInvoke = onInvoke, + ) + + return NodeHarness(session = session, sessionJob = sessionJob, deviceAuthStore = deviceAuthStore) + } + + private suspend fun connectNodeSession( + session: GatewaySession, + port: Int, + token: String? = "test-token", + bootstrapToken: String? = null, + role: String = "node", + scopes: List = listOf("node:invoke"), + ) { + session.connect( + endpoint = + GatewayEndpoint( + stableId = gatewayIdForPort(port), + name = "test", + host = "127.0.0.1", + port = port, + tlsEnabled = false, + ), + token = token, + bootstrapToken = bootstrapToken, + password = null, + options = + GatewayConnectOptions( + role = role, + scopes = scopes, + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = role, + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + } + + private fun gatewayIdForPort(port: Int): String = "manual|127.0.0.1|$port" + + private suspend fun awaitConnectedOrThrow( + connected: CompletableDeferred, + lastDisconnect: AtomicReference, + server: MockWebServer, + ) { + val connectedWithinTimeout = + withTimeoutOrNull(TEST_TIMEOUT_MS) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + } + + private suspend fun shutdownHarness( + harness: NodeHarness, + server: MockWebServer, + ) { + harness.session.disconnect() + harness.sessionJob.cancelAndJoin() + server.shutdown() + } + + private suspend fun runInvokeScenario( + invokeEventFrame: String, + onHandshake: ((RecordedRequest) -> Unit)? = null, + afterResult: suspend (InvokeScenarioResult) -> Unit = {}, + onInvoke: suspend (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult, + ): InvokeScenarioResult { + val json = testJson() + val connected = CompletableDeferred() + val invokeRequest = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer( + json = json, + onHandshake = onHandshake, + ) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + webSocket.send(connectResponseFrame(id)) + webSocket.send(invokeEventFrame) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { req -> + if (!invokeRequest.isCompleted) invokeRequest.complete(req) + onInvoke(req) + } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() } + val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + val result = InvokeScenarioResult(request = request, resultParams = resultParams) + afterResult(result) + return result + } finally { + shutdownHarness(harness, server) + } + } + + private fun connectResponseFrame( + id: String, + pluginSurfaceUrls: Map = emptyMap(), + authJson: String? = null, + ): String { + val surfaces = + pluginSurfaceUrls.entries + .joinToString(",") { (key, value) -> """"$key":"$value"""" } + .takeIf { it.isNotEmpty() } + ?.let { """"pluginSurfaceUrls":{$it},""" } + ?: "" + val auth = authJson?.let { "\"auth\":$it," } ?: "" + return """{"type":"res","id":"$id","ok":true,"payload":{$surfaces$auth"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" + } + + private fun startGatewayServer( + json: Json, + challengeFrame: String = CONNECT_CHALLENGE_FRAME, + onHandshake: ((RecordedRequest) -> Unit)? = null, + onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit, + ): MockWebServer = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + onHandshake?.invoke(request) + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + webSocket.send(challengeFrame) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + onRequestFrame(webSocket, id, method, frame) + } + }, + ) + } + } + start() + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt new file mode 100644 index 0000000..4bdcb23 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt @@ -0,0 +1,54 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GatewaySessionInvokeTimeoutTest { + @Test + fun formatGatewayAuthority_bracketsIpv6Hosts() { + assertEquals("[::1]:18789", formatGatewayAuthority("::1", 18_789)) + } + + @Test + fun buildGatewayWebSocketUrl_bracketsIpv6Hosts() { + assertEquals("ws://[::1]:18789", buildGatewayWebSocketUrl("::1", 18_789, useTls = false)) + assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("::1", 443, useTls = true)) + } + + @Test + fun buildGatewayWebSocketUrl_normalizesPersistedBracketedIpv6Hosts() { + assertEquals("ws://[::1]:18789", buildGatewayWebSocketUrl("[::1]", 18_789, useTls = false)) + assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("[::1]", 443, useTls = true)) + } + + @Test + fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() { + assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null)) + assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(0L)) + assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(5_000L)) + } + + @Test + fun resolveInvokeResultAckTimeoutMs_usesInvokeBudgetWithinBounds() { + assertEquals(30_000L, resolveInvokeResultAckTimeoutMs(30_000L)) + assertEquals(90_000L, resolveInvokeResultAckTimeoutMs(90_000L)) + } + + @Test + fun resolveInvokeResultAckTimeoutMs_capsAtUpperBound() { + assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L)) + assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE)) + } + + @Test + fun resolveInvokeExecutionTimeoutMs_defaultsAndAllowsExplicitDisable() { + assertEquals(30_000L, resolveInvokeExecutionTimeoutMs(null)) + assertEquals(null, resolveInvokeExecutionTimeoutMs(0L)) + assertEquals(null, resolveInvokeExecutionTimeoutMs(-1L)) + } + + @Test + fun resolveInvokeExecutionTimeoutMs_capsAtCoroutineTimerBound() { + assertEquals(Int.MAX_VALUE.toLong(), resolveInvokeExecutionTimeoutMs(Long.MAX_VALUE)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt new file mode 100644 index 0000000..2aca809 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -0,0 +1,1191 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.NotificationNodeEventOutbox +import ai.openclaw.app.PendingNotificationNodeEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.ByteString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.IOException +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +private const val LIFECYCLE_TEST_TIMEOUT_MS = 8_000L +private const val LIFECYCLE_CONNECT_CHALLENGE_FRAME = + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce","ts":1700000000123}}""" + +private class ReconnectDeviceAuthStore : DeviceAuthTokenStore { + override fun loadEntry( + gatewayId: String, + deviceId: String, + role: String, + ): DeviceAuthEntry? = null + + override fun saveToken( + gatewayId: String, + deviceId: String, + role: String, + token: String, + scopes: List, + ) = Unit + + override fun clearToken( + gatewayId: String, + deviceId: String, + role: String, + ) = Unit +} + +private class BlockingSaveDeviceAuthStore : DeviceAuthTokenStore { + val saveStarted = CountDownLatch(1) + val allowSave = CountDownLatch(1) + + override fun loadEntry( + gatewayId: String, + deviceId: String, + role: String, + ): DeviceAuthEntry? = null + + override fun saveToken( + gatewayId: String, + deviceId: String, + role: String, + token: String, + scopes: List, + ) { + saveStarted.countDown() + allowSave.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + override fun clearToken( + gatewayId: String, + deviceId: String, + role: String, + ) = Unit +} + +private class RecordingDeviceAuthStore : DeviceAuthTokenStore { + val savedToken = CompletableDeferred() + + override fun loadEntry( + gatewayId: String, + deviceId: String, + role: String, + ): DeviceAuthEntry? = null + + override fun saveToken( + gatewayId: String, + deviceId: String, + role: String, + token: String, + scopes: List, + ) { + savedToken.complete(token) + } + + override fun clearToken( + gatewayId: String, + deviceId: String, + role: String, + ) = Unit +} + +private data class ReconnectHarness( + val session: GatewaySession, + val sessionJob: Job, +) + +private data class TerminalCallbackObservation( + val inFlightHandlerCompleted: Boolean, + val issuedTokenPersisted: Boolean, +) + +private data class ReconnectServer( + val server: MockWebServer, + val sockets: ConcurrentLinkedQueue, +) { + val port: Int + get() = server.port + + val requestCount: Int + get() = server.requestCount + + fun shutdown() { + sockets.forEach { runCatching { it.cancel() } } + runCatching { server.shutdown() } + .onFailure { err -> + if (err.message != "Gave up waiting for queue to shut down") throw err + } + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewaySessionReconnectTest { + @Test + fun capturedRequestLeaseRejectsReplacementSocketBeforeEnqueue() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val reconnected = CompletableDeferred() + val connectionCount = AtomicInteger() + val unexpectedRequest = CompletableDeferred() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send(connectResponseFrame(id)) + } else { + unexpectedRequest.complete(Unit) + } + } + val harness = + createReconnectHarness( + onConnected = { + if (connectionCount.incrementAndGet() == 1) { + connected.complete(Unit) + } else { + reconnected.complete(Unit) + } + }, + ) + + try { + connectNodeSession(harness.session, server.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connected.await() } + val lease = + requireNotNull( + harness.session.captureRequestLease("manual|127.0.0.1|${server.port}"), + ) + assertTrue(lease.isCurrent()) + harness.session.reconnect() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { reconnected.await() } + assertFalse(lease.isCurrent()) + var committed = false + assertFalse(lease.commitIfCurrent { committed = true }) + assertFalse(committed) + val result = + runCatching { + lease.request( + method = "sessions.patch", + paramsJson = "{}", + ) + } + + assertTrue(result.exceptionOrNull() is GatewayRequestNotEnqueued) + assertNull(withTimeoutOrNull(200) { unexpectedRequest.await() }) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun connectedHelloPublishesCanonicalAndLegacyApprovalMethods() = + runBlocking { + val catalogs = + listOf( + setOf("approval.get", "approval.resolve"), + setOf("exec.approval.get", "exec.approval.resolve"), + ) + + for (methods in catalogs) { + val json = Json { ignoreUnknownKeys = true } + val hello = CompletableDeferred() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") webSocket.send(connectResponseFrame(id, methods)) + } + val harness = createReconnectHarness(onHello = hello::complete) + + try { + connectNodeSession(harness.session, server.port) + assertEquals(methods, withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { hello.await() }.methods) + } finally { + shutdownReconnectHarness(harness, server) + } + } + } + + @Test + fun disconnectAndJoinWaitsForNaturalFailureCallback() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val terminalCallbackStarted = CountDownLatch(1) + val allowTerminalCallback = CountDownLatch(1) + val blockNextTerminalCallback = AtomicBoolean(true) + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") webSocket.send(connectResponseFrame(id)) + } + val harness = + createReconnectHarness( + onConnected = { connected.complete(Unit) }, + onDisconnected = { message -> + val shouldBlock = + message.startsWith("Gateway ") && + blockNextTerminalCallback.compareAndSet(true, false) + if (shouldBlock) { + terminalCallbackStarted.countDown() + allowTerminalCallback.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + }, + ) + + try { + connectNodeSession(harness.session, server.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connected.await() } + val connection = readField(harness.session, "currentConnection") + val listener = readField(connection, "listener") + val socket = readField(connection, "socket") + val failure = + launch(Dispatchers.IO) { + listener.onFailure(socket, IOException("test failure"), null) + } + assertTrue( + terminalCallbackStarted.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS), + ) + + val disconnect = async { harness.session.disconnectAndJoin() } + delay(100) + assertFalse(disconnect.isCompleted) + + allowTerminalCallback.countDown() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { disconnect.await() } + failure.join() + } finally { + allowTerminalCallback.countDown() + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun disconnectAndJoinWaitsForTerminalCallback() = + runBlocking { + val disconnected = CompletableDeferred() + val harness = createReconnectHarness(onDisconnected = { disconnected.complete(it) }) + + try { + harness.session.disconnectAndJoin() + + assertEquals("Offline", disconnected.await()) + } finally { + harness.sessionJob.cancelAndJoin() + } + } + + @Test + fun disconnectAndJoinWaitsForInFlightIssuedTokenPersistence() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val authStore = BlockingSaveDeviceAuthStore() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"auth":{"deviceToken":"issued-token","role":"node","scopes":[]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + } + } + val harness = createReconnectHarness(deviceAuthStore = authStore) + + try { + connectNodeSession(harness.session, server.port) + assertTrue(authStore.saveStarted.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + + val disconnect = async { harness.session.disconnectAndJoin() } + delay(100) + assertFalse(disconnect.isCompleted) + + authStore.allowSave.countDown() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { disconnect.await() } + } finally { + authStore.allowSave.countDown() + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun reconnectDoesNotRetireConnectionBeforeIssuedTokenPersistenceFinishes() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val authStore = BlockingSaveDeviceAuthStore() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"auth":{"deviceToken":"issued-token","role":"node","scopes":[]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + } + } + val harness = createReconnectHarness(deviceAuthStore = authStore) + + try { + connectNodeSession(harness.session, server.port) + assertTrue(authStore.saveStarted.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + val connection = readField(harness.session, "currentConnection") + + harness.session.reconnect() + delay(200) + + assertTrue(readField(harness.session, "currentConnection") === connection) + authStore.allowSave.countDown() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { harness.session.disconnectAndJoin() } + } finally { + authStore.allowSave.countDown() + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun failureOrdersDisconnectAfterInFlightHandlerAndAcceptedConnectResponse() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val authStore = RecordingDeviceAuthStore() + val connectRequestId = CompletableDeferred() + val blockEventStarted = CountDownLatch(1) + val allowBlockEvent = CountDownLatch(1) + val blockEventCompleted = AtomicBoolean() + val terminalCallback = CompletableDeferred() + val allowTerminalCallback = CountDownLatch(1) + val retiredInvokeCount = AtomicInteger() + val server = + startGatewayServer(json = json) { _, id, method -> + if (method == "connect") connectRequestId.complete(id) + } + val harness = + createReconnectHarness( + onDisconnected = { message -> + if (message.startsWith("Gateway error:")) { + terminalCallback.complete( + TerminalCallbackObservation( + inFlightHandlerCompleted = blockEventCompleted.get(), + issuedTokenPersisted = authStore.savedToken.isCompleted, + ), + ) + allowTerminalCallback.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + }, + deviceAuthStore = authStore, + onEvent = { event, _ -> + if (event == "block") { + blockEventStarted.countDown() + try { + allowBlockEvent.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } finally { + blockEventCompleted.set(true) + } + } + }, + onInvoke = { + retiredInvokeCount.incrementAndGet() + GatewaySession.InvokeResult.ok("{}") + }, + ) + + try { + connectNodeSession(harness.session, server.port) + val requestId = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectRequestId.await() } + val connection = readField(harness.session, "currentConnection") + val listener = readField(connection, "listener") + val socket = readField(connection, "socket") + listener.onMessage(socket, """{"type":"event","event":"block","payload":{}}""") + assertTrue(blockEventStarted.await(LIFECYCLE_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + listener.onMessage( + socket, + """{"type":"event","event":"node.invoke.request","payload":{"id":"retired-invoke","nodeId":"node-1","command":"notification.action"}}""", + ) + listener.onMessage( + socket, + """{"type":"res","id":"$requestId","ok":true,"payload":{"auth":{"deviceToken":"issued-token","role":"node","scopes":[]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + listener.onFailure(socket, IOException("test failure"), null) + assertNull(withTimeoutOrNull(100) { terminalCallback.await() }) + + allowBlockEvent.countDown() + + val observation = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { terminalCallback.await() } + assertTrue(observation.inFlightHandlerCompleted) + assertTrue(observation.issuedTokenPersisted) + assertEquals("issued-token", withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { authStore.savedToken.await() }) + assertEquals(0, retiredInvokeCount.get()) + val messagePumpJob = readField(connection, "messagePumpJob") + assertTrue(withTimeoutOrNull(1_000) { messagePumpJob.join() } != null) + + allowTerminalCallback.countDown() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { harness.session.disconnectAndJoin() } + } finally { + allowBlockEvent.countDown() + allowTerminalCallback.countDown() + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun definitelyUnsentNodeEventRemainsQueued() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val rejectedNodeEvent = CompletableDeferred() + val receivedNodeEvent = CompletableDeferred() + val receivedNodeEventCount = AtomicInteger() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + when (method) { + "connect" -> webSocket.send(connectResponseFrame(id)) + "node.event" -> { + receivedNodeEventCount.incrementAndGet() + receivedNodeEvent.complete(Unit) + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{}}""") + } + } + } + val harness = createReconnectHarness(onConnected = { connected.complete(Unit) }) + + try { + connectNodeSession(harness.session, server.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connected.await() } + val connection = readField(harness.session, "currentConnection") + val socketField = connection.javaClass.getDeclaredField("socket").apply { isAccessible = true } + val socket = socketField.get(connection) as WebSocket + socketField.set(connection, RejectFirstSendWebSocket(socket) { rejectedNodeEvent.complete(Unit) }) + val outbox = + NotificationNodeEventOutbox { + harness.session.sendNodeEventWithOutcome(it.event, it.payloadJson) + } + val deliveryJob = launch { outbox.deliver() } + + try { + outbox.enqueue(PendingNotificationNodeEvent("notifications.changed", "{}")) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { rejectedNodeEvent.await() } + outbox.onConnected() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { receivedNodeEvent.await() } + delay(100) + assertEquals(1, receivedNodeEventCount.get()) + } finally { + deliveryJob.cancelAndJoin() + } + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun connectedCallbackFailureClosesSocketBeforeRetry() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val firstClosed = CompletableDeferred() + val secondConnected = CompletableDeferred() + val callbackCount = AtomicInteger() + val server = + startGatewayServer( + json = json, + onClosed = { firstClosed.complete(Unit) }, + ) { webSocket, id, method -> + if (method == "connect") webSocket.send(connectResponseFrame(id)) + } + val harness = + createReconnectHarness( + onConnected = { + if (callbackCount.incrementAndGet() == 1) { + throw IllegalStateException("callback failed") + } + secondConnected.complete(Unit) + }, + ) + + try { + connectNodeSession(harness.session, server.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstClosed.await() } + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondConnected.await() } + assertEquals(2, callbackCount.get()) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun staleConnectionDrainCannotCancelReplacementRpc() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val firstConnected = CompletableDeferred() + val secondConnected = CompletableDeferred() + val replacementRequest = CompletableDeferred>() + val connectionCount = AtomicInteger(0) + val firstServer = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") webSocket.send(connectResponseFrame(id)) + } + val secondServer = + startGatewayServer(json = json) { webSocket, id, method -> + when (method) { + "connect" -> webSocket.send(connectResponseFrame(id)) + "slow.method" -> replacementRequest.complete(webSocket to id) + } + } + val harness = + createReconnectHarness( + onConnected = { + when (connectionCount.incrementAndGet()) { + 1 -> firstConnected.complete(Unit) + 2 -> secondConnected.complete(Unit) + } + }, + ) + + try { + connectNodeSession(harness.session, firstServer.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstConnected.await() } + val oldConnection = readField(harness.session, "currentConnection") + + connectNodeSession(harness.session, secondServer.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondConnected.await() } + val newRequest = + async { + harness.session.requestDetailed("slow.method", null, timeoutMs = 30_000) + } + val (replacementSocket, requestId) = + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { replacementRequest.await() } + + val failPending = oldConnection.javaClass.getDeclaredMethod("failPending") + failPending.isAccessible = true + failPending.invoke(oldConnection) + + assertNull(withTimeoutOrNull(200) { newRequest.await() }) + replacementSocket.send( + """{"type":"res","id":"$requestId","ok":true,"payload":{"connection":2}}""", + ) + val newResult = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { newRequest.await() } + assertTrue(newResult.ok) + assertEquals("""{"connection":2}""", newResult.payloadJson) + } finally { + shutdownReconnectHarness(harness, firstServer, secondServer) + } + } + + @Suppress("UNCHECKED_CAST") + private fun readField( + target: Any, + name: String, + ): T { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + return field.get(target) as T + } + + @Test + fun connectToNewGatewayClosesActiveConnectionAndStartsReplacement() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val firstConnect = CompletableDeferred() + val firstClosed = CompletableDeferred() + val secondConnect = CompletableDeferred() + val secondClosed = CompletableDeferred() + val firstServer = + startGatewayServer( + json = json, + onClosed = { firstClosed.complete(Unit) }, + ) { webSocket, id, method -> + if (method == "connect") { + firstConnect.complete(Unit) + webSocket.send(connectResponseFrame(id)) + } + } + val secondServer = + startGatewayServer( + json = json, + onClosed = { secondClosed.complete(Unit) }, + ) { webSocket, id, method -> + if (method == "connect") { + secondConnect.complete(Unit) + webSocket.send(connectResponseFrame(id)) + } + } + val harness = createReconnectHarness() + + try { + connectNodeSession(harness.session, firstServer.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstConnect.await() } + + connectNodeSession(harness.session, secondServer.port) + + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstClosed.await() } + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondConnect.await() } + assertEquals(1, secondServer.requestCount) + harness.session.disconnect() + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondClosed.await() } + } finally { + shutdownReconnectHarness(harness, firstServer, secondServer) + } + } + + @Test + fun bootstrapNodePairingRequiredKeepsReconnectActive() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + reason = "not-paired", + ), + ) + + assertFalse( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = true, + role = "node", + scopes = emptyList(), + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun bootstrapNodePairingRequiredWithoutRetryHintPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = "not-paired", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = true, + role = "node", + scopes = emptyList(), + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun nonBootstrapPairingRequiredStillPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + reason = "not-paired", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = false, + role = "node", + scopes = emptyList(), + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun tokenFailuresPauseUnlessOneDeviceTokenRetryIsPending() { + val cases = + listOf( + Triple("AUTH_TOKEN_MISMATCH", false, true), + Triple("AUTH_TOKEN_MISMATCH", true, false), + Triple("AUTH_DEVICE_TOKEN_MISMATCH", false, true), + Triple("AUTH_TOKEN_NOT_CONFIGURED", false, true), + Triple("AUTH_PASSWORD_NOT_CONFIGURED", false, true), + Triple("AUTH_SCOPE_MISMATCH", false, true), + ) + + for ((code, pendingDeviceTokenRetry, expected) in cases) { + val error = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "authentication failed", + details = + GatewayErrorDetails( + code = code, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ) + val actual = + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = false, + role = "operator", + scopes = listOf("operator.read"), + pendingDeviceTokenRetry = pendingDeviceTokenRetry, + ) + + assertEquals("$code pending=$pendingDeviceTokenRetry", expected, actual) + } + } + + @Test + fun structuredRecoveryAdviceControlsReconnectPause() { + val cases = + listOf( + Triple("wait_then_retry", false, false), + Triple("retry_with_device_token", true, false), + Triple("retry_with_device_token", false, true), + Triple("update_auth_configuration", false, true), + Triple("update_auth_credentials", false, true), + Triple("review_auth_configuration", false, true), + ) + + for ((nextStep, pendingDeviceTokenRetry, expected) in cases) { + val error = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "authentication failed", + details = + GatewayErrorDetails( + code = "AUTH_UNAUTHORIZED", + canRetryWithDeviceToken = nextStep == "retry_with_device_token", + recommendedNextStep = nextStep, + ), + ) + val actual = + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = false, + role = "operator", + scopes = listOf("operator.read"), + pendingDeviceTokenRetry = pendingDeviceTokenRetry, + ) + + assertEquals("$nextStep pending=$pendingDeviceTokenRetry", expected, actual) + } + } + + @Test + fun authRateLimitPausesDespiteRetryAdvice() { + val error = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "authentication rate limited", + details = + GatewayErrorDetails( + code = "AUTH_RATE_LIMITED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = false, + role = "operator", + scopes = listOf("operator.read"), + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun protocolMismatchPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "protocol mismatch", + details = + GatewayErrorDetails( + code = "PROTOCOL_MISMATCH", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + clientMinProtocol = 4, + clientMaxProtocol = 4, + expectedProtocol = 5, + minimumProbeProtocol = 4, + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = false, + role = "node", + scopes = emptyList(), + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun bootstrapRoleUpgradeStillPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = "role-upgrade", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = true, + role = "node", + scopes = emptyList(), + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun pairingRequiredFailureNotifiesPauseReconnectProblem() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connectFailure = CompletableDeferred>() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1"}}} + """.trimIndent(), + ) + } + } + val harness = + createReconnectHarness { error, pauseReconnect -> + connectFailure.complete(error to pauseReconnect) + } + + try { + connectNodeSession(harness.session, server.port) + val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() } + + assertEquals("PAIRING_REQUIRED", error.details?.code) + assertEquals("not-paired", error.details?.reason) + assertEquals("request-1", error.details?.requestId) + assertTrue(pauseReconnect) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun pairingRequiredFailureDropsUnsafeRequestId() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connectFailure = CompletableDeferred>() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1;echo unsafe"}}} + """.trimIndent(), + ) + } + } + val harness = + createReconnectHarness { error, pauseReconnect -> + connectFailure.complete(error to pauseReconnect) + } + + try { + connectNodeSession(harness.session, server.port) + val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() } + + assertEquals("PAIRING_REQUIRED", error.details?.code) + assertEquals("not-paired", error.details?.reason) + assertNull(error.details?.requestId) + assertTrue(pauseReconnect) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun protocolMismatchFailurePreservesProtocolDetailsAndPausesReconnect() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connectFailure = CompletableDeferred>() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"protocol mismatch","details":{"code":"PROTOCOL_MISMATCH","clientMinProtocol":4,"clientMaxProtocol":4,"expectedProtocol":5,"minimumProbeProtocol":4}}} + """.trimIndent(), + ) + } + } + val harness = + createReconnectHarness { error, pauseReconnect -> + connectFailure.complete(error to pauseReconnect) + } + + try { + connectNodeSession(harness.session, server.port) + val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() } + + assertEquals("PROTOCOL_MISMATCH", error.details?.code) + assertEquals(4, error.details?.clientMinProtocol) + assertEquals(4, error.details?.clientMaxProtocol) + assertEquals(5, error.details?.expectedProtocol) + assertEquals(4, error.details?.minimumProbeProtocol) + assertTrue(pauseReconnect) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun methodFailurePreservesMissingScopeDetails() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + when (method) { + "connect" -> webSocket.send(connectResponseFrame(id)) + "question.list" -> + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"permission denied","details":{"code":"MISSING_SCOPE","missingScope":"operator.questions","requiredScopes":["operator.questions"]}}} + """.trimIndent(), + ) + } + } + val harness = createReconnectHarness(onConnected = { connected.complete(Unit) }) + + try { + connectNodeSession(harness.session, server.port) + withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connected.await() } + + val result = harness.session.requestDetailed("question.list", "{}") + + assertFalse(result.ok) + assertEquals("MISSING_SCOPE", result.error?.details?.code) + assertEquals("operator.questions", result.error?.details?.missingScope) + assertEquals(listOf("operator.questions"), result.error?.details?.requiredScopes) + assertEquals("operator.questions", result.error?.missingScope()) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + private fun createReconnectHarness( + onConnected: () -> Unit = {}, + onHello: (GatewayHelloSummary) -> Unit = {}, + onDisconnected: (String) -> Unit = {}, + deviceAuthStore: DeviceAuthTokenStore = ReconnectDeviceAuthStore(), + onEvent: (String, String?) -> Unit = { _, _ -> }, + onInvoke: suspend (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult = { + GatewaySession.InvokeResult.ok("""{"handled":true}""") + }, + onConnectFailure: (GatewaySession.ErrorShape, Boolean) -> Unit = { _, _ -> }, + ): ReconnectHarness { + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = testDeviceIdentityStore(app), + deviceAuthStore = deviceAuthStore, + onConnected = { summary -> + onConnected() + onHello(summary) + }, + onDisconnected = onDisconnected, + onConnectFailure = onConnectFailure, + onEvent = onEvent, + onInvoke = onInvoke, + ) + return ReconnectHarness(session = session, sessionJob = sessionJob) + } + + private suspend fun connectNodeSession( + session: GatewaySession, + port: Int, + ) { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|$port", + name = "test", + host = "127.0.0.1", + port = port, + tlsEnabled = false, + ), + token = "test-token", + bootstrapToken = null, + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + } + + private suspend fun shutdownReconnectHarness( + harness: ReconnectHarness, + vararg servers: ReconnectServer, + ) { + harness.session.disconnect() + harness.sessionJob.cancelAndJoin() + servers.forEach { it.shutdown() } + } + + private fun connectResponseFrame( + id: String, + methods: Set = emptySet(), + ): String { + val encodedMethods = methods.joinToString(",") { JsonPrimitive(it).toString() } + return """{"type":"res","id":"$id","ok":true,"payload":{"features":{"methods":[$encodedMethods]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" + } + + private fun startGatewayServer( + json: Json, + onClosed: () -> Unit = {}, + onRequestFrame: (webSocket: WebSocket, id: String, method: String) -> Unit, + ): ReconnectServer { + val sockets = ConcurrentLinkedQueue() + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + sockets += webSocket + webSocket.send(LIFECYCLE_CONNECT_CHALLENGE_FRAME) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + onRequestFrame(webSocket, id, method) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + onClosed() + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + onClosed() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + onClosed() + } + }, + ) + } + start() + } + return ReconnectServer(server = server, sockets = sockets) + } +} + +private class RejectFirstSendWebSocket( + private val delegate: WebSocket, + private val onReject: () -> Unit, +) : WebSocket by delegate { + private var rejectNext = true + + override fun send(text: String): Boolean { + if (rejectNext) { + rejectNext = false + onReject() + return false + } + return delegate.send(text) + } + + override fun send(bytes: ByteString): Boolean = delegate.send(bytes) + + override fun request(): Request = delegate.request() +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewayStoreMigrationTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewayStoreMigrationTest.kt new file mode 100644 index 0000000..77b48f3 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewayStoreMigrationTest.kt @@ -0,0 +1,172 @@ +package ai.openclaw.app.gateway + +import ai.openclaw.app.GatewayCredentials +import ai.openclaw.app.SecurePrefs +import android.content.Context +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class GatewayStoreMigrationTest { + @Test + fun manualStateMigratesCredentialsDeviceTokensAndNotificationKey() { + val fixture = fixture() + fixture.plain + .edit() + .putString("node.instanceId", "install-1") + .putBoolean("gateway.manual.enabled", true) + .putString("gateway.manual.host", "Example.COM") + .putInt("gateway.manual.port", 18789) + .putBoolean("gateway.manual.tls", false) + .putString("notifications.forwarding.sessionKey", " notify-main ") + .commit() + fixture.secure + .edit() + .putString("gateway.manual.token", "manual-token") + .putString("gateway.token.install-1", "fallback-token") + .putString("gateway.bootstrapToken.install-1", "bootstrap-token") + .putString("gateway.password.install-1", "password") + .putString("gateway.deviceToken.device-1.operator", "device-token") + .putString("gateway.deviceTokenMeta.device-1.operator", "{\"scopes\":[\"operator.read\"]}") + .commit() + + val prefs = SecurePrefs(fixture.context, fixture.secure) + val stableId = GatewayEndpoint.manual("Example.COM", 18789).stableId + + assertEquals(stableId, prefs.gatewayRegistry.activeStableId.value) + assertEquals(GatewayRegistryEntryKind.MANUAL, prefs.gatewayRegistry.activeEntry()?.kind) + assertEquals("Example.COM:18789", prefs.gatewayRegistry.activeEntry()?.name) + assertEquals(false, prefs.gatewayRegistry.activeEntry()?.tls) + assertEquals( + GatewayCredentials("manual-token", "bootstrap-token", "password"), + prefs.loadGatewayCredentials(stableId), + ) + assertEquals("device-token", fixture.secure.getString("gateway.deviceToken.$stableId.device-1.operator", null)) + assertTrue(fixture.secure.contains("gateway.deviceTokenMeta.$stableId.device-1.operator")) + assertFalse(fixture.secure.contains("gateway.manual.token")) + assertFalse(fixture.secure.contains("gateway.token.install-1")) + assertFalse(fixture.secure.contains("gateway.bootstrapToken.install-1")) + assertFalse(fixture.secure.contains("gateway.password.install-1")) + assertFalse(fixture.plain.contains("notifications.forwarding.sessionKey")) + assertEquals("notify-main", fixture.plain.getString("notifications.forwarding.sessionKey.$stableId", null)) + assertTrue(fixture.plain.getBoolean("gateway.manual.enabled", false)) + } + + @Test + fun discoveredOnlyStateBecomesActivePlaceholderEntry() { + val fixture = fixture() + fixture.plain + .edit() + .putString("gateway.lastDiscoveredStableID", "bonjour-gateway") + .commit() + + val prefs = SecurePrefs(fixture.context, fixture.secure) + + assertEquals("bonjour-gateway", prefs.gatewayRegistry.activeStableId.value) + assertEquals( + GatewayRegistryEntry( + stableId = "bonjour-gateway", + kind = GatewayRegistryEntryKind.DISCOVERED, + name = "bonjour-gateway", + ), + prefs.gatewayRegistry.activeEntry(), + ) + } + + @Test + fun blankManualTokenFallsBackToInstanceScopedToken() { + val fixture = fixture() + fixture.plain + .edit() + .putString("node.instanceId", "install-1") + .putBoolean("gateway.manual.enabled", true) + .putString("gateway.manual.host", "gateway.example") + .putInt("gateway.manual.port", 18789) + .commit() + fixture.secure + .edit() + .putString("gateway.manual.token", " ") + .putString("gateway.token.install-1", "fallback-token") + .commit() + + val prefs = SecurePrefs(fixture.context, fixture.secure) + val stableId = GatewayEndpoint.manual("gateway.example", 18789).stableId + + assertEquals("fallback-token", prefs.loadGatewayCredentials(stableId).token) + assertFalse(fixture.secure.contains("gateway.manual.token")) + assertFalse(fixture.secure.contains("gateway.token.install-1")) + } + + @Test + fun emptyLegacyStateWritesEmptyRegistryAndDeletesOwnerlessDeviceTokens() { + val fixture = fixture() + fixture.secure + .edit() + .putString("gateway.deviceToken.device-1.node", "orphan") + .putString("gateway.deviceTokenMeta.device-1.node", "{}") + .commit() + + val prefs = SecurePrefs(fixture.context, fixture.secure) + + // Migration runs on first gateway-state access, not at construction. + assertTrue( + prefs.gatewayRegistry.entries.value + .isEmpty(), + ) + assertTrue(fixture.secure.contains(GatewayRegistryStore.STORAGE_KEY)) + assertNull(prefs.gatewayRegistry.activeStableId.value) + assertFalse(fixture.secure.contains("gateway.deviceToken.device-1.node")) + assertFalse(fixture.secure.contains("gateway.deviceTokenMeta.device-1.node")) + } + + @Test + fun secondMigrationRunIsNoOp() { + val fixture = fixture() + fixture.plain + .edit() + .putBoolean("gateway.manual.enabled", true) + .putString("gateway.manual.host", "first.example") + .putInt("gateway.manual.port", 18789) + .commit() + val prefs = SecurePrefs(fixture.context, fixture.secure) + // First gateway-state access performs the one-time migration. + prefs.gatewayRegistry.activeStableId.value + val registryBefore = fixture.secure.getString(GatewayRegistryStore.STORAGE_KEY, null) + fixture.plain + .edit() + .putString("gateway.manual.host", "changed.example") + .commit() + fixture.secure + .edit() + .putString("gateway.manual.token", "late-legacy-token") + .commit() + + GatewayStoreMigration(prefs).run() + + assertEquals(registryBefore, fixture.secure.getString(GatewayRegistryStore.STORAGE_KEY, null)) + assertEquals("late-legacy-token", fixture.secure.getString("gateway.manual.token", null)) + } + + private fun fixture(): Fixture { + val context = RuntimeEnvironment.getApplication() + val plain = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plain.edit().clear().commit() + val secure = context.getSharedPreferences("gateway-migration-${UUID.randomUUID()}", Context.MODE_PRIVATE) + secure.edit().clear().commit() + return Fixture(context, plain, secure) + } + + private data class Fixture( + val context: android.app.Application, + val plain: SharedPreferences, + val secure: SharedPreferences, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/GatewayTlsTest.kt b/app/src/test/java/ai/openclaw/app/gateway/GatewayTlsTest.kt new file mode 100644 index 0000000..0632eda --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/GatewayTlsTest.kt @@ -0,0 +1,372 @@ +package ai.openclaw.app.gateway + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedTrustManager +import kotlin.concurrent.thread + +class GatewayTlsTest { + @Test + fun splitGatewayTlsFallbackProbeTimeouts_skipsFallbackAfterBudgetExpires() { + assertNull( + splitGatewayTlsFallbackProbeTimeouts( + connectTimeoutMs = 3_000, + handshakeTimeoutMs = 10_000, + elapsedMs = 13_001, + ), + ) + } + + @Test + fun splitGatewayTlsFallbackProbeTimeouts_preservesNearFullBudgetAfterFastFailure() { + val timeouts = + splitGatewayTlsFallbackProbeTimeouts( + connectTimeoutMs = 3_000, + handshakeTimeoutMs = 10_000, + elapsedMs = 50, + ) ?: error("expected fallback timeouts") + + assertEquals(2_988, timeouts.connectTimeoutMs) + assertEquals(9_962, timeouts.handshakeTimeoutMs) + assertEquals(12_950, timeouts.connectTimeoutMs + timeouts.handshakeTimeoutMs) + } + + @Test + fun normalizeGatewayTlsFingerprintInput_acceptsPrefixColonsWhitespaceAndCase() { + val expected = "ab".repeat(32) + val colonSeparated = expected.uppercase().chunked(2).joinToString(":") + + assertEquals(expected, normalizeGatewayTlsFingerprintInput(" SHA256: $colonSeparated\n")) + assertEquals(expected, normalizeGatewayTlsFingerprintInput("sha-256:\t${expected.uppercase()}")) + assertEquals(expected, normalizeGatewayTlsFingerprintInput(expected)) + } + + @Test + fun normalizeGatewayTlsFingerprintInput_rejectsWrongLengthAndGarbage() { + assertNull(normalizeGatewayTlsFingerprintInput("ab".repeat(31))) + assertNull(normalizeGatewayTlsFingerprintInput("ab".repeat(32) + "00")) + assertNull(normalizeGatewayTlsFingerprintInput("sha256: ${"ab".repeat(31)}:gg")) + assertNull(normalizeGatewayTlsFingerprintInput("not-a-fingerprint")) + } + + @Test + fun isGatewayTlsSystemTrustCandidate_classifiesPublicDnsOnly() { + assertFalse(isGatewayTlsSystemTrustCandidate("gateway.local")) + assertFalse(isGatewayTlsSystemTrustCandidate("gateway.local.")) + assertFalse(isGatewayTlsSystemTrustCandidate("192.0.2.10")) + assertFalse(isGatewayTlsSystemTrustCandidate("127.1")) + assertFalse(isGatewayTlsSystemTrustCandidate("[2001:db8::10]")) + assertFalse(isGatewayTlsSystemTrustCandidate("2001:db8::10")) + assertFalse(isGatewayTlsSystemTrustCandidate("[gateway.example.com]")) + assertFalse(isGatewayTlsSystemTrustCandidate("gateway")) + assertTrue(isGatewayTlsSystemTrustCandidate("gateway.example.com")) + assertTrue(isGatewayTlsSystemTrustCandidate("node.tailnet-name.ts.net")) + } + + @Test + fun decideGatewayTlsTrust_coversStoredPinSystemTrustCandidateMatrix() { + val stored = "aa".repeat(32) + val observed = "bb".repeat(32) + for (hasStoredPin in listOf(false, true)) { + for (systemTrusted in listOf(false, true)) { + for (candidate in listOf(false, true)) { + val decision = + decideGatewayTlsTrust( + storedFingerprint = stored.takeIf { hasStoredPin }, + systemTrustCandidate = candidate, + probeResult = GatewayTlsProbeResult(fingerprintSha256 = observed, systemTrusted = systemTrusted), + ) + if (!hasStoredPin && systemTrusted && candidate) { + assertEquals(GatewayTlsTrustDecision.SystemTrusted, decision) + } else { + val prompt = decision as GatewayTlsTrustDecision.PromptRequired + assertEquals(observed, prompt.fingerprintSha256) + assertEquals(stored.takeIf { hasStoredPin }, prompt.previousFingerprintSha256) + assertEquals(hasStoredPin && systemTrusted && candidate, prompt.systemTrustAvailable) + } + } + } + } + } + + @Test + fun decideGatewayTlsTrust_keepsPinWhenSystemTrustDoesNotReplaceIt() { + val stored = "aa".repeat(32) + + assertEquals( + GatewayTlsTrustDecision.PinnedTrust(stored), + decideGatewayTlsTrust( + storedFingerprint = stored, + systemTrustCandidate = true, + probeResult = GatewayTlsProbeResult(fingerprintSha256 = stored, systemTrusted = true), + ), + ) + assertEquals( + GatewayTlsTrustDecision.PinnedTrust(stored), + decideGatewayTlsTrust( + storedFingerprint = stored, + systemTrustCandidate = true, + probeResult = GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE), + ), + ) + assertEquals( + GatewayTlsTrustDecision.PromptRequired( + fingerprintSha256 = null, + previousFingerprintSha256 = null, + probeFailure = GatewayTlsProbeFailure.TLS_UNAVAILABLE, + ), + decideGatewayTlsTrust( + storedFingerprint = null, + systemTrustCandidate = true, + probeResult = GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE), + ), + ) + } + + @Test + fun buildGatewayTlsConfig_exposesNormalizedExpectedRouteFingerprint() { + val expected = "ab".repeat(32) + val config: GatewayTlsConfig = + buildGatewayTlsConfig( + params = + GatewayTlsParams( + required = true, + expectedFingerprint = "SHA-256: $expected", + allowTOFU = false, + stableId = "gateway-1", + ), + defaultTrust = RecordingExtendedTrustManager(), + ) + + assertEquals(expected, config.effectiveFingerprintSha256) + } + + @Test + fun buildGatewayTlsConfig_forwardsPlatformTrustWithSocketAndEngineContext() { + val defaultTrust = RecordingExtendedTrustManager() + val config = + buildGatewayTlsConfig( + params = + GatewayTlsParams( + required = true, + expectedFingerprint = null, + allowTOFU = false, + stableId = "gateway-1", + ), + defaultTrust = defaultTrust, + ) + val extendedTrust = config.trustManager as X509ExtendedTrustManager + + Socket().use { socket -> + extendedTrust.checkServerTrusted(emptyArray(), "RSA", socket) + } + extendedTrust.checkServerTrusted( + emptyArray(), + "RSA", + SSLContext.getDefault().createSSLEngine(), + ) + + assertEquals(1, defaultTrust.serverSocketCalls) + assertEquals(1, defaultTrust.serverEngineCalls) + assertEquals(0, defaultTrust.serverTwoArgumentCalls) + } + + @Test + fun buildGatewayTlsConfig_rejectsInvalidStoredFingerprintInsteadOfUsingPlatformTrust() { + val defaultTrust = RecordingExtendedTrustManager() + val config = + buildGatewayTlsConfig( + params = + GatewayTlsParams( + required = true, + expectedFingerprint = "not-a-sha256-fingerprint", + allowTOFU = false, + stableId = "gateway-1", + ), + defaultTrust = defaultTrust, + ) + + val failure = + runCatching { + Socket().use { socket -> + (config.trustManager as X509ExtendedTrustManager).checkServerTrusted( + emptyArray(), + "RSA", + socket, + ) + } + }.exceptionOrNull() + + assertTrue(failure is java.security.cert.CertificateException) + assertEquals(0, defaultTrust.serverSocketCalls) + assertEquals(0, defaultTrust.serverTwoArgumentCalls) + } + + @Test + fun probeGatewayTlsFingerprint_reportsHandshakeTimeoutAfterTcpConnect() = + runBlocking { + TcpTestServer { socket -> + socket.soTimeout = 1_000 + runCatching { socket.getInputStream().read(ByteArray(512)) } + Thread.sleep(700) + }.use { server -> + val result = + probeGatewayTlsFingerprint( + host = LOOPBACK_HOST, + port = server.port, + connectTimeoutMs = 250, + handshakeTimeoutMs = 250, + ) + + assertEquals(GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT, result.failure) + } + } + + @Test + fun probeGatewayTlsFingerprint_reportsTlsUnavailableForPlainHttpEndpoint() = + runBlocking { + TcpTestServer { socket -> + socket.soTimeout = 1_000 + runCatching { socket.getInputStream().read(ByteArray(512)) } + socket.getOutputStream().write("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".toByteArray()) + socket.getOutputStream().flush() + }.use { server -> + val result = + probeGatewayTlsFingerprint( + host = LOOPBACK_HOST, + port = server.port, + connectTimeoutMs = 250, + handshakeTimeoutMs = 1_000, + ) + + assertEquals(GatewayTlsProbeFailure.TLS_UNAVAILABLE, result.failure) + } + } + + @Test + fun probeGatewayTlsFingerprint_reportsTlsUnavailableForConnectedReset() = + runBlocking { + TcpTestServer { socket -> + socket.close() + }.use { server -> + val result = + probeGatewayTlsFingerprint( + host = LOOPBACK_HOST, + port = server.port, + connectTimeoutMs = 250, + handshakeTimeoutMs = 1_000, + ) + + assertEquals(GatewayTlsProbeFailure.TLS_UNAVAILABLE, result.failure) + } + } + + @Test + fun probeGatewayTlsFingerprint_reportsUnreachableWhenTcpConnectFails() = + runBlocking { + val result = + probeGatewayTlsFingerprint( + host = LOOPBACK_HOST, + port = unusedLoopbackPort(), + connectTimeoutMs = 250, + handshakeTimeoutMs = 250, + ) + + assertEquals(GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, result.failure) + } + + private class TcpTestServer( + private val handler: (Socket) -> Unit, + ) : AutoCloseable { + private val serverSocket = ServerSocket(0, 50, LOOPBACK_ADDRESS) + private var acceptedSocket: Socket? = null + private val worker = + thread(start = true, isDaemon = true, name = "openclaw-tls-probe-test-server") { + try { + serverSocket.accept().use { socket -> + acceptedSocket = socket + handler(socket) + } + } catch (_: SocketException) { + // Closing the server during test cleanup interrupts accept/read. + } + } + + val port: Int = serverSocket.localPort + + override fun close() { + runCatching { acceptedSocket?.close() } + runCatching { serverSocket.close() } + worker.join(1_000) + } + } + + private class RecordingExtendedTrustManager : X509ExtendedTrustManager() { + var serverTwoArgumentCalls = 0 + var serverSocketCalls = 0 + var serverEngineCalls = 0 + + override fun checkClientTrusted( + chain: Array, + authType: String, + ) = Unit + + override fun checkClientTrusted( + chain: Array, + authType: String, + socket: Socket, + ) = Unit + + override fun checkClientTrusted( + chain: Array, + authType: String, + engine: SSLEngine, + ) = Unit + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) { + serverTwoArgumentCalls += 1 + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + socket: Socket, + ) { + serverSocketCalls += 1 + } + + override fun checkServerTrusted( + chain: Array, + authType: String, + engine: SSLEngine, + ) { + serverEngineCalls += 1 + } + + override fun getAcceptedIssuers(): Array = emptyArray() + } + + private companion object { + const val LOOPBACK_HOST = "127.0.0.1" + val LOOPBACK_ADDRESS: InetAddress = InetAddress.getByName(LOOPBACK_HOST) + + fun unusedLoopbackPort(): Int = + ServerSocket(0, 50, LOOPBACK_ADDRESS).use { server -> + server.localPort + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt b/app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt new file mode 100644 index 0000000..4dff79e --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/InvokeErrorParserTest.kt @@ -0,0 +1,48 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvokeErrorParserTest { + @Test + fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() { + val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code) + assertEquals("grant Camera permission", parsed.message) + assertTrue(parsed.hadExplicitCode) + assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage) + } + + @Test + fun parseInvokeErrorMessage_parsesNumericCodePrefix() { + val parsed = parseInvokeErrorMessage("A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable") + assertEquals("A2UI_HOST_UNAVAILABLE", parsed.code) + assertEquals("bundled A2UI host not reachable", parsed.message) + assertTrue(parsed.hadExplicitCode) + } + + @Test + fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() { + listOf( + "IllegalStateException: boom", + "2FAST: boom", + "_PRIVATE: boom", + "CAMERA-PERMISSION: boom", + ).forEach { raw -> + val parsed = parseInvokeErrorMessage(raw) + assertEquals("UNAVAILABLE", parsed.code) + assertEquals(raw, parsed.message) + assertFalse(parsed.hadExplicitCode) + } + } + + @Test + fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() { + val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback") + assertEquals("UNAVAILABLE", parsed.code) + assertEquals("fallback", parsed.message) + assertFalse(parsed.hadExplicitCode) + } +} diff --git a/app/src/test/java/ai/openclaw/app/gateway/NetworkMonitorTest.kt b/app/src/test/java/ai/openclaw/app/gateway/NetworkMonitorTest.kt new file mode 100644 index 0000000..7756e83 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/gateway/NetworkMonitorTest.kt @@ -0,0 +1,42 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NetworkMonitorTest { + @Test + fun emitsOnceOnOfflineToOnline() { + val state = ValidatedNetworkState() + + assertEquals(true, state.update("wifi", isValidated = true)) + assertEquals(false, state.update("wifi", isValidated = true)) + } + + @Test + fun emitsAgainAfterAllValidatedNetworksAreLost() { + val state = ValidatedNetworkState() + + assertEquals(true, state.update("wifi", isValidated = true)) + assertEquals(false, state.update("wifi", isValidated = false)) + assertEquals(true, state.update("wifi", isValidated = true)) + } + + @Test + fun suppressesReconnectWhenOneOfMultipleValidatedNetworksIsLost() { + val state = ValidatedNetworkState() + + assertEquals(true, state.update("wifi", isValidated = true)) + assertEquals(false, state.update("cellular", isValidated = true)) + assertEquals(false, state.update("wifi", isValidated = false)) + assertEquals(false, state.update("cellular", isValidated = true)) + assertEquals(false, state.update("cellular", isValidated = false)) + assertEquals(true, state.update("wifi", isValidated = true)) + } + + @Test + fun initialValidatedNetworkSuppressesRegistrationSnapshot() { + val state = ValidatedNetworkState(setOf("wifi")) + + assertEquals(false, state.update("wifi", isValidated = true)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt b/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt new file mode 100644 index 0000000..4dc09cb --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/i18n/NativeStringsTest.kt @@ -0,0 +1,155 @@ +package ai.openclaw.app.i18n + +import android.content.Context +import android.content.res.Configuration +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.ConfigurationCompat +import androidx.core.os.LocaleListCompat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31]) +class NativeStringsTest { + @Test + fun sourceFallbackFormatsNestedKotlinInterpolations() { + assertEquals( + "2/3 fallback active tokens", + nativeString( + """${'$'}{device.tokens.count { !it.revoked }}/${'$'}{device.tokens.size} fallback active tokens""", + 2, + 3, + ), + ) + } + + @Test + fun configurationLocaleUpdatesSystemMode() { + val app = RuntimeEnvironment.getApplication() + NativeStringResources.install(app) + NativeStringResources.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + assertEquals("Mic off", nativeString("Mic off")) + + val configuration = Configuration(app.resources.configuration) + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("fr")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Micro désactivé", nativeString("Mic off")) + + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("de")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Mikrofon aus", nativeString("Mic off")) + } + + @Test + fun configurationLocaleDoesNotReplacePinnedAppLocale() { + val app = RuntimeEnvironment.getApplication() + persistAppLocales(app, "en") + try { + NativeStringResources.install(app) + + val configuration = Configuration(app.resources.configuration) + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("fr")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Mic off", nativeString("Mic off")) + } finally { + app.deleteFile(APP_LOCALES_FILE) + NativeStringResources.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } + } + + @Test + fun configurationLocaleUsesLiveAppCompatLocaleBeforeStorage() { + val app = RuntimeEnvironment.getApplication() + persistAppLocales(app, "en") + try { + NativeStringResources.install(app) + assertEquals("Mic off", nativeString("Mic off")) + + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("fr")) + val configuration = Configuration(app.resources.configuration) + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("en")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Micro désactivé", nativeString("Mic off")) + } finally { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + app.deleteFile(APP_LOCALES_FILE) + NativeStringResources.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } + } + + @Test + fun configurationLocaleDoesNotReplacePersistedAppLocale() { + val app = RuntimeEnvironment.getApplication() + persistAppLocales(app, "fr") + try { + NativeStringResources.install(app) + + val configuration = Configuration(app.resources.configuration) + ConfigurationCompat.setLocales(configuration, LocaleListCompat.forLanguageTags("en")) + NativeStringResources.setConfigurationLocales(configuration) + + assertEquals("Micro désactivé", nativeString("Mic off")) + } finally { + app.deleteFile(APP_LOCALES_FILE) + NativeStringResources.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } + } + + @Test + fun resolvingStateFlowEmitsWhenTheAppLocaleChanges() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + persistAppLocales(app, "en") + NativeStringResources.install(app) + val resolved = MutableStateFlow(nativeText("Mic off")).resolveNativeText() + val firstEmission = CompletableDeferred() + val emissions = mutableListOf() + val collection = + launch(start = CoroutineStart.UNDISPATCHED) { + resolved.take(2).collect { value -> + emissions += value + if (emissions.size == 1) firstEmission.complete(Unit) + } + } + + try { + firstEmission.await() + NativeStringResources.setApplicationLocales(LocaleListCompat.forLanguageTags("fr")) + notifyNativeLocaleChanged() + collection.join() + + assertEquals(listOf("Mic off", "Micro désactivé"), emissions) + } finally { + collection.cancel() + NativeStringResources.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + app.deleteFile(APP_LOCALES_FILE) + } + } + + private fun persistAppLocales( + context: Context, + languageTags: String, + ) { + context.openFileOutput(APP_LOCALES_FILE, Context.MODE_PRIVATE).bufferedWriter().use { writer -> + writer.write("""""") + } + } + + private companion object { + const val APP_LOCALES_FILE = "androidx.appcompat.app.AppCompatDelegate.application_locales_record_file" + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt b/app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt new file mode 100644 index 0000000..958b371 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt @@ -0,0 +1,150 @@ +package ai.openclaw.app.node + +import android.Manifest +import android.app.Application +import android.content.pm.PackageManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AndroidPermissionSnapshotTest { + @Test + fun gatewayPermissions_keepIndependentlyGrantableAuthoritySeparate() { + val app = appContext() + shadowOf(app.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, true) + shadowOf(app).grantPermissions( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.SEND_SMS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CALENDAR, + ) + + val permissions = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = true, + callLogEnabled = true, + photosEnabled = true, + backgroundLocationEnabled = true, + ).gatewayPermissions() + + assertTrue(permissions.getValue("camera")) + assertTrue(permissions.getValue("location")) + assertFalse(permissions.getValue("locationPrecise")) + assertFalse(permissions.getValue("locationBackground")) + assertTrue(permissions.getValue("smsSend")) + assertFalse(permissions.getValue("smsRead")) + assertTrue(permissions.getValue("contactsRead")) + assertFalse(permissions.getValue("contactsWrite")) + assertFalse(permissions.getValue("calendarRead")) + assertTrue(permissions.getValue("calendarWrite")) + } + + @Test + fun snapshotGatesVariantSpecificPermissionsByAvailableFeature() { + val app = appContext() + shadowOf(app.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, true) + shadowOf(app).grantPermissions( + Manifest.permission.SEND_SMS, + Manifest.permission.READ_SMS, + Manifest.permission.READ_CALL_LOG, + Manifest.permission.READ_MEDIA_IMAGES, + ) + + val snapshot = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = false, + ) + + assertFalse(snapshot.smsSend) + assertFalse(snapshot.smsRead) + assertFalse(snapshot.callLog) + assertFalse(snapshot.photos) + } + + @Test + fun backgroundLocationRequiresFeatureAndForegroundAuthority() { + val app = appContext() + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + + val withoutForeground = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = true, + ) + assertFalse(withoutForeground.locationBackground) + + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION) + val featureDisabled = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = false, + ) + assertFalse(featureDisabled.locationBackground) + + val available = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = true, + ) + assertTrue(available.locationBackground) + } + + @Test + fun gatewayPermissionOrderIsStable() { + val permissions = + readAndroidPermissionSnapshot( + context = appContext(), + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = false, + ).gatewayPermissions() + + assertEquals( + listOf( + "camera", + "microphone", + "location", + "locationPrecise", + "locationBackground", + "smsSend", + "smsRead", + "notificationListener", + "notifications", + "photos", + "contactsRead", + "contactsWrite", + "calendarRead", + "calendarWrite", + "callLog", + "motion", + ), + permissions.keys.toList(), + ) + } + + private fun appContext(): Application = RuntimeEnvironment.getApplication() +} diff --git a/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt new file mode 100644 index 0000000..edfadc4 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CalendarHandlerTest.kt @@ -0,0 +1,181 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.util.TimeZone + +class CalendarHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleCalendarEvents_requiresPermission() { + val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false)) + + val result = handler.handleCalendarEvents(null) + + assertFalse(result.ok) + assertEquals("CALENDAR_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleCalendarAdd_rejectsEndBeforeStart() { + val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = true, canWrite = true)) + + val result = + handler.handleCalendarAdd( + """{"title":"Standup","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T09:00:00Z"}""", + ) + + assertFalse(result.ok) + assertEquals("CALENDAR_INVALID", result.error?.code) + } + + @Test + fun handleCalendarEvents_returnsEvents() { + val event = + CalendarEventRecord( + identifier = "101", + title = "Sprint Planning", + startISO = "2026-02-28T10:00:00Z", + endISO = "2026-02-28T11:00:00Z", + isAllDay = false, + location = "Room 1", + calendarTitle = "Work", + ) + val handler = + CalendarHandler.forTesting( + appContext(), + FakeCalendarDataSource(canRead = true, events = listOf(event)), + ) + + val result = handler.handleCalendarEvents("""{"limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val events = payload.getValue("events").jsonArray + assertEquals(1, events.size) + assertEquals( + "Sprint Planning", + events + .first() + .jsonObject + .getValue("title") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCalendarAdd_mapsNotFoundErrorCode() { + val source = + FakeCalendarDataSource( + canRead = true, + canWrite = true, + addError = IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar"), + ) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Call","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T11:00:00Z"}""", + ) + + assertFalse(result.ok) + assertEquals("CALENDAR_NOT_FOUND", result.error?.code) + } + + @Test + fun handleCalendarAdd_normalizesAllDayEventForAndroidProvider() { + val source = FakeCalendarDataSource(canRead = true, canWrite = true) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Holiday","startISO":"2026-07-05T09:00:00Z","endISO":"2026-07-06T09:00:00Z","isAllDay":true}""", + ) + + assertTrue(result.ok) + val request = source.addedRequest ?: error("missing add request") + assertTrue(request.isAllDay) + assertEquals("UTC", request.timeZoneId) + assertEquals(Instant.parse("2026-07-05T00:00:00Z").toEpochMilli(), request.startMs) + assertEquals(Instant.parse("2026-07-06T00:00:00Z").toEpochMilli(), request.endMs) + } + + @Test + fun handleCalendarAdd_expandsSameDayAllDayRangeToOneDay() { + val source = FakeCalendarDataSource(canRead = true, canWrite = true) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Holiday","startISO":"2026-07-05T09:00:00Z","endISO":"2026-07-05T17:00:00Z","isAllDay":true}""", + ) + + assertTrue(result.ok) + val request = source.addedRequest ?: error("missing add request") + assertEquals(Instant.parse("2026-07-05T00:00:00Z").toEpochMilli(), request.startMs) + assertEquals(Instant.parse("2026-07-06T00:00:00Z").toEpochMilli(), request.endMs) + } + + @Test + fun handleCalendarAdd_preservesTimedInstantsAndDeviceTimezone() { + val source = FakeCalendarDataSource(canRead = true, canWrite = true) + val handler = CalendarHandler.forTesting(appContext(), source) + + val result = + handler.handleCalendarAdd( + """{"title":"Call","startISO":"2026-07-05T09:15:00Z","endISO":"2026-07-05T10:45:00Z"}""", + ) + + assertTrue(result.ok) + val request = source.addedRequest ?: error("missing add request") + assertFalse(request.isAllDay) + assertEquals(TimeZone.getDefault().id, request.timeZoneId) + assertEquals(Instant.parse("2026-07-05T09:15:00Z").toEpochMilli(), request.startMs) + assertEquals(Instant.parse("2026-07-05T10:45:00Z").toEpochMilli(), request.endMs) + } +} + +private class FakeCalendarDataSource( + private val canRead: Boolean, + private val canWrite: Boolean = false, + private val events: List = emptyList(), + private val addResult: CalendarEventRecord = + CalendarEventRecord( + identifier = "0", + title = "Default", + startISO = "2026-01-01T00:00:00Z", + endISO = "2026-01-01T01:00:00Z", + isAllDay = false, + location = null, + calendarTitle = null, + ), + private val addError: Throwable? = null, +) : CalendarDataSource { + var addedRequest: CalendarAddRequest? = null + private set + + override fun hasReadPermission(context: Context): Boolean = canRead + + override fun hasWritePermission(context: Context): Boolean = canWrite + + override fun events( + context: Context, + request: CalendarEventsRequest, + ): List = events + + override fun add( + context: Context, + request: CalendarAddRequest, + ): CalendarEventRecord { + addError?.let { throw it } + addedRequest = request + return addResult + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/CameraFacingPreferenceTest.kt b/app/src/test/java/ai/openclaw/app/node/CameraFacingPreferenceTest.kt new file mode 100644 index 0000000..bcc863c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CameraFacingPreferenceTest.kt @@ -0,0 +1,18 @@ +package ai.openclaw.app.node + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CameraFacingPreferenceTest { + @Test + fun explicitFacingWinsOverPreference() { + assertEquals("front", resolveCameraFacing(explicitFacing = "front", preferredFacing = "back")) + assertEquals("back", resolveCameraFacing(explicitFacing = "back", preferredFacing = "front")) + } + + @Test + fun preferenceProvidesSafeDefault() { + assertEquals("back", resolveCameraFacing(explicitFacing = null, preferredFacing = "back")) + assertEquals("front", resolveCameraFacing(explicitFacing = null, preferredFacing = "side")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt new file mode 100644 index 0000000..cf38911 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt @@ -0,0 +1,172 @@ +package ai.openclaw.app.node + +import android.Manifest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +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 java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class CameraHandlerTest { + @Before + fun setUpMainDispatcher() { + Dispatchers.setMain(Dispatchers.Unconfined) + } + + @After + fun resetMainDispatcher() { + Dispatchers.resetMain() + } + + @Test + fun snapFailsImmediatelyWhenCameraPermissionIsMissing() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).denyPermissions(Manifest.permission.CAMERA) + + val error = + assertThrows(IllegalStateException::class.java) { + runBlocking { CameraCaptureManager(app).snap(null) } + } + + assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", error.message) + } + + @Test + fun clipFailsImmediatelyWhenCameraPermissionIsMissing() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).denyPermissions(Manifest.permission.CAMERA) + + val error = + assertThrows(IllegalStateException::class.java) { + runBlocking { CameraCaptureManager(app).clip("""{"includeAudio":false}""") } + } + + assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", error.message) + } + + @Test + fun clipFailsImmediatelyWhenMicrophonePermissionIsMissing() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.CAMERA) + shadowOf(app).denyPermissions(Manifest.permission.RECORD_AUDIO) + val camera = CameraCaptureManager(app) + + val error = + assertThrows(IllegalStateException::class.java) { + runBlocking { camera.clip("""{"includeAudio":true}""") } + } + + assertEquals("MIC_PERMISSION_REQUIRED: grant Microphone permission", error.message) + } + + @Test + fun clipWithAudioFailsBeforeCameraStartsWhenMicrophoneIsBusy() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val handler = + CameraHandler( + appContext = app, + camera = CameraCaptureManager(app), + setCameraAudioCaptureActive = { false }, + showCameraHud = { _, _, _ -> }, + invokeErrorFromThrowable = { "UNAVAILABLE" to (it.message ?: "camera failed") }, + ) + + val result = handler.handleClip("""{"includeAudio":true}""") + + assertFalse(result.ok) + assertEquals("MIC_BUSY", result.error?.code) + } + + @Test + fun isCameraClipWithinPayloadLimit_allowsZeroAndLimit() { + assertTrue(isCameraClipWithinPayloadLimit(0L)) + assertTrue(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES)) + } + + @Test + fun isCameraClipWithinPayloadLimit_rejectsNegativeAndTooLarge() { + assertFalse(isCameraClipWithinPayloadLimit(-1L)) + assertFalse(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES + 1L)) + } + + @Test + fun cameraClipMaxRawBytes_matchesExpectedBudget() { + assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES) + } + + @Test + fun cameraClipSession_closesRecordingUnbindsAndDeletesOwnedFile() { + val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4") + val cleanup = mutableListOf() + val session = + CameraClipSession( + unbind = { cleanup += "unbind" }, + deleteTemporaryFile = { file -> + cleanup += "file" + assertSame(tempFile, file) + file.delete() + }, + ) + session.ownRecording(AutoCloseable { cleanup += "recording" }) + session.ownFile(tempFile) + + session.close() + session.close() + + assertEquals(listOf("recording", "unbind", "file"), cleanup) + assertFalse(tempFile.exists()) + } + + @Test + fun cameraClipSession_unbindsBeforeRecordingStarts() { + val cleanup = mutableListOf() + + CameraClipSession( + unbind = { cleanup += "unbind" }, + deleteTemporaryFile = { cleanup += "file" }, + ).close() + + assertEquals(listOf("unbind"), cleanup) + } + + @Test + fun cameraClipSession_keepsFileTransferredToCaller() { + val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4") + try { + val cleanup = mutableListOf() + val session = + CameraClipSession( + unbind = { cleanup += "unbind" }, + deleteTemporaryFile = { cleanup += "file" }, + ) + session.ownRecording(AutoCloseable { cleanup += "recording" }) + session.ownFile(tempFile) + + assertSame(tempFile, session.transferFile()) + session.close() + + assertEquals(listOf("recording", "unbind"), cleanup) + assertTrue(tempFile.exists()) + } finally { + tempFile.delete() + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt b/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt new file mode 100644 index 0000000..2fcf4f0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt @@ -0,0 +1,63 @@ +package ai.openclaw.app.node + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CanvasActionTrustTest { + @Test + fun acceptsBundledScaffoldAsset() { + assertTrue(CanvasActionTrust.isTrustedCanvasActionUrl(CanvasActionTrust.scaffoldAssetUrl)) + } + + @Test + fun acceptsBundledA2uiAsset() { + assertTrue(CanvasActionTrust.isTrustedCanvasActionUrl(CanvasActionTrust.localA2uiAssetUrl)) + } + + @Test + fun rejectsRemoteHttpA2uiPageEvenWhenGatewayAdvertised() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "http://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android", + ), + ) + } + + @Test + fun rejectsRemoteHttpsA2uiPageEvenWhenGatewayAdvertised() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android", + ), + ) + } + + @Test + fun rejectsRemoteCanvasPage() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/__openclaw__/canvas/", + ), + ) + } + + @Test + fun rejectsDescendantPathUnderBundledA2uiRoot() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "file:///android_asset/CanvasA2UI/child/index.html", + ), + ) + } + + @Test + fun rejectsQueryOrFragmentChangesToBundledA2uiAsset() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "${CanvasActionTrust.localA2uiAssetUrl}?platform=android", + ), + ) + assertFalse(CanvasActionTrust.isTrustedCanvasActionUrl("${CanvasActionTrust.localA2uiAssetUrl}#step2")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/CanvasControllerPresentationTest.kt b/app/src/test/java/ai/openclaw/app/node/CanvasControllerPresentationTest.kt new file mode 100644 index 0000000..2c09865 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CanvasControllerPresentationTest.kt @@ -0,0 +1,38 @@ +package ai.openclaw.app.node + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class CanvasControllerPresentationTest { + @Test + fun presentationStateKeepsTheHostUnmountedUntilFirstShow() { + val controller = CanvasController() + + controller.hide() + assertEquals(CanvasController.PresentationState.Unmounted, controller.presentationState.value) + + controller.show() + assertEquals(CanvasController.PresentationState.Visible, controller.presentationState.value) + + controller.hide() + assertEquals(CanvasController.PresentationState.Hidden, controller.presentationState.value) + + controller.show() + assertEquals(CanvasController.PresentationState.Visible, controller.presentationState.value) + + controller.releaseHost() + assertEquals(CanvasController.PresentationState.Unmounted, controller.presentationState.value) + } + + @Test + fun failedHostHandoffRestoresThePreviousPresentationState() = + runTest { + val controller = CanvasController() + + assertFalse(controller.showAndAwaitHost()) + + assertEquals(CanvasController.PresentationState.Unmounted, controller.presentationState.value) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt b/app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt new file mode 100644 index 0000000..f1e2044 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CanvasControllerSnapshotParamsTest.kt @@ -0,0 +1,43 @@ +package ai.openclaw.app.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CanvasControllerSnapshotParamsTest { + @Test + fun parseSnapshotParamsDefaultsToJpeg() { + val params = CanvasController.parseSnapshotParams(null) + assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format) + assertNull(params.quality) + assertNull(params.maxWidth) + } + + @Test + fun parseSnapshotParamsParsesPng() { + val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""") + assertEquals(CanvasController.SnapshotFormat.Png, params.format) + assertEquals(900, params.maxWidth) + } + + @Test + fun parseSnapshotParamsParsesJpegAliases() { + assertEquals( + CanvasController.SnapshotFormat.Jpeg, + CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format, + ) + assertEquals( + CanvasController.SnapshotFormat.Jpeg, + CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format, + ) + } + + @Test + fun parseSnapshotParamsClampsQuality() { + val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""") + assertEquals(0.1, low.quality) + + val high = CanvasController.parseSnapshotParams("""{"quality":5}""") + assertEquals(1.0, high.quality) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/CanvasNavigationPolicyTest.kt b/app/src/test/java/ai/openclaw/app/node/CanvasNavigationPolicyTest.kt new file mode 100644 index 0000000..439afbb --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/CanvasNavigationPolicyTest.kt @@ -0,0 +1,99 @@ +package ai.openclaw.app.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CanvasNavigationPolicyTest { + @Test + fun blocksDeviceLocalWebUrls() { + listOf( + "http://127.0.0.1:18789", + "https://localhost", + "https://canvas.localhost/path", + "http://0.0.0.0:18789", + "http://0/canvas", + "http://[::]/canvas", + "http://[::1]/canvas", + "http://[::1%25lo]/canvas", + "http://[::ffff:127.0.0.1]/canvas", + "http://2130706433/canvas", + "http://0x7f000001/canvas", + "http://017700000001/canvas", + "http://127.1/canvas", + "http://0x7f.1/canvas", + "http://127.0.0.1/a raw space", + "http://127.0.0.1/#raw space", + "http://127.0.0.1\\@example.com/", + "http://%31%32%37.0.0.1:18789/", + "http://%6c%6f%63%61%6c%68%6f%73%74/", + "http://localhost:18789/", + "http://127.0.0.1:18789/", + "http:\\127.0.0.1:18789/", + "http:127.0.0.1:18789/", + ).forEach { url -> + assertEquals(url, true, CanvasNavigationPolicy.shouldBlock(url)) + assertEquals(url, "", CanvasNavigationPolicy.normalize(url)) + } + } + + @Test + fun blocksMalformedWebHosts() { + listOf( + "http:///missing-host", + "https://double%252dencoded.example/", + "http://example.com%00.evil/", + ).forEach { url -> assertEquals(url, true, CanvasNavigationPolicy.shouldBlock(url)) } + } + + @Test + fun keepsRemoteEmulatorBridgeAndBundledUrls() { + val accepted = + listOf( + "https://example.com/canvas", + "https://xn--mnich-kva.example/canvas", + "http://gateway.local:18789/__openclaw__/canvas/", + "http://10.0.2.2:18789/__openclaw__/canvas/", + CanvasActionTrust.scaffoldAssetUrl, + ) + accepted.forEach { url -> + assertEquals(url, false, CanvasNavigationPolicy.shouldBlock(url)) + assertEquals(url, url, CanvasNavigationPolicy.normalize(" $url ")) + } + } + + @Test + fun blankAndRootSelectBundledCanvasWithoutBeingSecurityBlocks() { + listOf("", " / ").forEach { url -> + assertEquals(url, false, CanvasNavigationPolicy.shouldBlock(url)) + assertEquals(url, "", CanvasNavigationPolicy.normalize(url)) + } + } + + @Test + fun controllerUsesSharedPolicyForDirectLoads() { + val controller = CanvasController() + + controller.navigate("http://127.0.0.1:18789") + assertNull(controller.currentUrl()) + + controller.navigate("http://10.0.2.2:18789/__openclaw__/canvas/") + assertEquals("http://10.0.2.2:18789/__openclaw__/canvas/", controller.currentUrl()) + } + + @Test + fun nonGetMainFrameRequestsFailClosedBeforeRedirects() { + assertEquals( + true, + CanvasNavigationPolicy.shouldBlockNonGetMainFrame("POST", isForMainFrame = true), + ) + assertEquals( + false, + CanvasNavigationPolicy.shouldBlockNonGetMainFrame("GET", isForMainFrame = true), + ) + assertEquals( + false, + CanvasNavigationPolicy.shouldBlockNonGetMainFrame("POST", isForMainFrame = false), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt new file mode 100644 index 0000000..6d517bc --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -0,0 +1,722 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.LocationMode +import ai.openclaw.app.SecurePrefs +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.isLocalCleartextGatewayHost +import ai.openclaw.app.gateway.isLoopbackGatewayHost +import ai.openclaw.app.protocol.OpenClawCallLogCommand +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawCapability +import ai.openclaw.app.protocol.OpenClawDeviceCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawMobileUiCommand +import ai.openclaw.app.protocol.OpenClawMotionCommand +import ai.openclaw.app.protocol.OpenClawPhotosCommand +import ai.openclaw.app.protocol.OpenClawSmsCommand +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ConnectionManagerTest { + @Test + fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.0.2", + port = 18789, + tlsEnabled = true, + tlsFingerprintSha256 = "attacker", + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = "legit", + manualTlsEnabled = false, + ) + + assertEquals("legit", params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.0.2", + port = 18789, + tlsEnabled = true, + tlsFingerprintSha256 = "attacker", + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() { + val endpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 443) + + val off = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + assertNull(off) + + val on = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = true, + ) + assertNull(on?.expectedFingerprint) + assertEquals(false, on?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_manualNonLoopbackForcesTlsWhenToggleIsOff() { + val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_manualPrivateLanRespectsManualTlsToggle() { + val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_manualMdnsRespectsManualTlsToggle() { + val endpoint = GatewayEndpoint.manual(host = "gateway.local", port = 18789) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_manualPrivateLanCleartextCanOverrideStoredPin() { + val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = "pinned", + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryTailnetWithoutHintsStillRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "100.64.0.9", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsStillRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "192.168.1.20", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryMdnsWithoutHintsStillRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "gateway.local", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryLoopbackWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "127.0.0.1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryLocalhostWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "localhost", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryAndroidEmulatorWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.2.2", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun isLoopbackGatewayHost_onlyTreatsEmulatorBridgeAsLocalWhenAllowed() { + assertTrue(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = true)) + assertFalse(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = false)) + } + + @Test + fun isLocalCleartextGatewayHost_acceptsLanIpsAndMdnsButRejectsRemoteHosts() { + assertTrue(isLocalCleartextGatewayHost("192.168.1.20")) + assertTrue(isLocalCleartextGatewayHost("gateway.local")) + assertTrue(isLocalCleartextGatewayHost("GATEWAY.LOCAL.")) + assertFalse(isLocalCleartextGatewayHost("gateway.local.evil.com")) + assertFalse(isLocalCleartextGatewayHost("gatewaylocal")) + assertFalse(isLocalCleartextGatewayHost("local")) + assertFalse(isLocalCleartextGatewayHost(".local")) + assertFalse(isLocalCleartextGatewayHost("gateway..local")) + assertFalse(isLocalCleartextGatewayHost("gateway.local%25wlan0")) + assertFalse(isLocalCleartextGatewayHost("100.64.0.9")) + assertFalse(isLocalCleartextGatewayHost("gateway.tailnet.ts.net")) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryIpv6LoopbackWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "::1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryMappedIpv4LoopbackWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "::ffff:127.0.0.1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryNonLoopbackIpv6WithoutHintsRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "2001:db8::1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryUnspecifiedIpv4WithoutHintsRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "0.0.0.0", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryUnspecifiedIpv6WithoutHintsRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "::", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun buildOperatorConnectOptions_requestsNativeClientOperatorScopes() { + val options = newManager().buildOperatorConnectOptions() + + assertEquals( + listOf( + "operator.admin", + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ), + options.scopes, + ) + assertEquals( + listOf( + ConnectionManager.AGENT_KIND_CLIENT_CAPABILITY, + ConnectionManager.INLINE_WIDGETS_CLIENT_CAPABILITY, + ), + options.caps, + ) + } + + @Test + fun buildOperatorConnectOptions_omitsInlineWidgetsWithoutIsolatedWebViews() { + val options = newManager(inlineWidgetsAvailable = false).buildOperatorConnectOptions() + + assertEquals(listOf(ConnectionManager.AGENT_KIND_CLIENT_CAPABILITY), options.caps) + } + + @Test + fun operatorScopesForStoredDeviceToken_preservesRecordedScopes() { + assertEquals( + listOf("operator.read", "operator.write"), + ConnectionManager.operatorScopesForStoredDeviceToken( + listOf("operator.read", "operator.write", "operator.read", " "), + ), + ) + } + + @Test + fun operatorScopesForStoredDeviceToken_fallsBackToLegacyScopesWhenMetadataMissing() { + assertEquals( + ConnectionManager.legacyOperatorScopes, + ConnectionManager.operatorScopesForStoredDeviceToken(emptyList()), + ) + } + + @Test + fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() { + val options = + newManager( + sendSmsAvailable = false, + readSmsAvailable = false, + smsSearchPossible = true, + ).buildNodeConnectOptions() + + assertTrue(options.commands.contains(OpenClawSmsCommand.Search.rawValue)) + assertFalse(options.commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.Sms.rawValue)) + } + + @Test + fun buildNodeConnectOptions_doesNotAdvertiseSmsWhenSearchIsImpossible() { + val options = + newManager( + sendSmsAvailable = false, + readSmsAvailable = false, + smsSearchPossible = false, + ).buildNodeConnectOptions() + + assertFalse(options.commands.contains(OpenClawSmsCommand.Search.rawValue)) + assertFalse(options.commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.Sms.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesSmsCapabilityWhenReadSmsIsAvailable() { + val options = + newManager( + sendSmsAvailable = false, + readSmsAvailable = true, + smsSearchPossible = true, + ).buildNodeConnectOptions() + + assertTrue(options.commands.contains(OpenClawSmsCommand.Search.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Sms.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesSmsSendWithoutSearchWhenOnlySendIsAvailable() { + val options = + newManager( + sendSmsAvailable = true, + readSmsAvailable = false, + smsSearchPossible = false, + ).buildNodeConnectOptions() + + assertTrue(options.commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertFalse(options.commands.contains(OpenClawSmsCommand.Search.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Sms.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesAvailableNonSmsCommandsAndCapabilities() { + val options = + newManager( + cameraEnabled = true, + locationMode = LocationMode.WhileUsing, + motionActivityAvailable = true, + callLogAvailable = true, + photosAvailable = true, + ).buildNodeConnectOptions() + + assertTrue(options.commands.contains(OpenClawCameraCommand.List.rawValue)) + assertTrue(options.commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(options.commands.contains(OpenClawMotionCommand.Activity.rawValue)) + assertTrue(options.commands.contains(OpenClawCallLogCommand.Search.rawValue)) + assertTrue(options.commands.contains(OpenClawPhotosCommand.Latest.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Camera.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Location.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.CallLog.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Photos.rawValue)) + assertFalse(options.caps.contains("voiceWake")) + } + + @Test + fun buildNodeConnectOptions_advertisesVoiceWakeOnlyWhenEnabledAndAvailable() { + val disabled = newManager(voiceWakeEnabled = false).buildNodeConnectOptions() + val unavailable = newManager(voiceWakeEnabled = true, voiceWakeAvailable = false).buildNodeConnectOptions() + val enabled = newManager(voiceWakeEnabled = true).buildNodeConnectOptions() + + assertFalse(disabled.caps.contains(OpenClawCapability.VoiceWake.rawValue)) + assertFalse(unavailable.caps.contains(OpenClawCapability.VoiceWake.rawValue)) + assertTrue(enabled.caps.contains(OpenClawCapability.VoiceWake.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesMobileUiOnlyWhileAvailable() { + val unavailable = newManager(mobileUiAvailable = false).buildNodeConnectOptions() + val available = newManager(mobileUiAvailable = true).buildNodeConnectOptions() + + assertFalse(unavailable.caps.contains(OpenClawCapability.MobileUI.rawValue)) + assertFalse(unavailable.commands.contains(OpenClawMobileUiCommand.Observe.rawValue)) + assertFalse(unavailable.commands.contains(OpenClawMobileUiCommand.Act.rawValue)) + assertTrue(available.caps.contains(OpenClawCapability.MobileUI.rawValue)) + assertTrue(available.commands.contains(OpenClawMobileUiCommand.Observe.rawValue)) + assertTrue(available.commands.contains(OpenClawMobileUiCommand.Act.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesDeviceAppsOnlyWhenUserOptedIn() { + val disabled = newManager(installedAppsSharingEnabled = false).buildNodeConnectOptions() + val enabled = newManager(installedAppsSharingEnabled = true).buildNodeConnectOptions() + + assertFalse(disabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue)) + assertTrue(enabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue)) + } + + @Test + fun buildNodeConnectOptions_omitsUnavailableCameraLocationCallLogAndPhotosSurfaces() { + val options = + newManager( + cameraEnabled = false, + locationMode = LocationMode.Off, + callLogAvailable = false, + photosAvailable = false, + ).buildNodeConnectOptions() + + assertFalse(options.commands.contains(OpenClawCameraCommand.List.rawValue)) + assertFalse(options.commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertFalse(options.commands.contains(OpenClawCameraCommand.Clip.rawValue)) + assertFalse(options.commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertFalse(options.commands.contains(OpenClawCallLogCommand.Search.rawValue)) + assertFalse(options.commands.contains(OpenClawPhotosCommand.Latest.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.Camera.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.Location.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.CallLog.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.Photos.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesOnlyAvailableMotionCommand() { + val options = + newManager( + motionActivityAvailable = false, + motionPedometerAvailable = true, + ).buildNodeConnectOptions() + + assertFalse(options.commands.contains(OpenClawMotionCommand.Activity.rawValue)) + assertTrue(options.commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) + assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue)) + } + + @Test + fun buildNodeConnectOptions_omitsMotionSurfaceWhenMotionApisUnavailable() { + val options = + newManager( + motionActivityAvailable = false, + motionPedometerAvailable = false, + ).buildNodeConnectOptions() + + assertFalse(options.commands.contains(OpenClawMotionCommand.Activity.rawValue)) + assertFalse(options.commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) + assertFalse(options.caps.contains(OpenClawCapability.Motion.rawValue)) + } + + @Test + fun buildNodeConnectOptions_advertisesCurrentPermissionSnapshot() { + val permissionSnapshot = + emptyPermissionSnapshot().copy( + camera = true, + location = true, + locationPrecise = false, + smsSend = true, + ) + + val options = newManager(permissionSnapshot = permissionSnapshot).buildNodeConnectOptions() + + assertEquals(permissionSnapshot.gatewayPermissions(), options.permissions) + } + + private fun newManager( + cameraEnabled: Boolean = false, + locationMode: LocationMode = LocationMode.Off, + motionActivityAvailable: Boolean = false, + motionPedometerAvailable: Boolean = false, + sendSmsAvailable: Boolean = false, + readSmsAvailable: Boolean = false, + smsSearchPossible: Boolean = false, + callLogAvailable: Boolean = false, + photosAvailable: Boolean = false, + installedAppsSharingEnabled: Boolean = false, + voiceWakeEnabled: Boolean = false, + voiceWakeAvailable: Boolean = true, + mobileUiAvailable: Boolean = false, + inlineWidgetsAvailable: Boolean = true, + permissionSnapshot: AndroidPermissionSnapshot = emptyPermissionSnapshot(), + ): ConnectionManager { + val context = RuntimeEnvironment.getApplication() + context + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + val prefs = + SecurePrefs( + context, + securePrefsOverride = context.getSharedPreferences("connection-manager-test", android.content.Context.MODE_PRIVATE), + ) + prefs.setVoiceWakeEnabled(voiceWakeEnabled) + + return ConnectionManager( + prefs = prefs, + cameraEnabled = { cameraEnabled }, + locationMode = { locationMode }, + motionActivityAvailable = { motionActivityAvailable }, + motionPedometerAvailable = { motionPedometerAvailable }, + sendSmsAvailable = { sendSmsAvailable }, + readSmsAvailable = { readSmsAvailable }, + smsSearchPossible = { smsSearchPossible }, + callLogAvailable = { callLogAvailable }, + photosAvailable = { photosAvailable }, + installedAppsSharingEnabled = { installedAppsSharingEnabled }, + voiceWakeAvailable = { voiceWakeAvailable }, + mobileUiAvailable = { mobileUiAvailable }, + inlineWidgetsAvailable = { inlineWidgetsAvailable }, + permissionSnapshot = { permissionSnapshot }, + manualTls = { false }, + ) + } + + private fun emptyPermissionSnapshot(): AndroidPermissionSnapshot = + AndroidPermissionSnapshot( + camera = false, + microphone = false, + location = false, + locationPrecise = false, + locationBackground = false, + smsSend = false, + smsRead = false, + notificationListener = false, + notifications = false, + photos = false, + contactsRead = false, + contactsWrite = false, + calendarRead = false, + calendarWrite = false, + callLog = false, + motion = false, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt new file mode 100644 index 0000000..5498b88 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/ContactsHandlerTest.kt @@ -0,0 +1,134 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContactsHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleContactsSearch_requiresReadPermission() { + val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false)) + + val result = handler.handleContactsSearch(null) + + assertFalse(result.ok) + assertEquals("CONTACTS_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleContactsAdd_rejectsEmptyContact() { + val handler = + ContactsHandler.forTesting( + appContext(), + FakeContactsDataSource(canRead = true, canWrite = true), + ) + + val result = handler.handleContactsAdd("""{"givenName":" ","emails":[]}""") + + assertFalse(result.ok) + assertEquals("CONTACTS_INVALID", result.error?.code) + } + + @Test + fun handleContactsSearch_returnsContacts() { + val contact = + ContactRecord( + identifier = "1", + displayName = "Ada Lovelace", + givenName = "Ada", + familyName = "Lovelace", + organizationName = "Analytical Engine", + phoneNumbers = listOf("+12025550123"), + emails = listOf("ada@example.com"), + ) + val handler = + ContactsHandler.forTesting( + appContext(), + FakeContactsDataSource(canRead = true, searchResults = listOf(contact)), + ) + + val result = handler.handleContactsSearch("""{"query":"ada","limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val contacts = payload.getValue("contacts").jsonArray + assertEquals(1, contacts.size) + assertEquals( + "Ada Lovelace", + contacts + .first() + .jsonObject + .getValue("displayName") + .jsonPrimitive.content, + ) + } + + @Test + fun handleContactsAdd_returnsAddedContact() { + val added = + ContactRecord( + identifier = "2", + displayName = "Grace Hopper", + givenName = "Grace", + familyName = "Hopper", + organizationName = "US Navy", + phoneNumbers = listOf(), + emails = listOf("grace@example.com"), + ) + val source = FakeContactsDataSource(canRead = true, canWrite = true, addResult = added) + val handler = ContactsHandler.forTesting(appContext(), source) + + val result = + handler.handleContactsAdd( + """{"givenName":"Grace","familyName":"Hopper","emails":["grace@example.com"]}""", + ) + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val contact = payload.getValue("contact").jsonObject + assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content) + assertEquals(1, source.addCalls) + } +} + +private class FakeContactsDataSource( + private val canRead: Boolean, + private val canWrite: Boolean = false, + private val searchResults: List = emptyList(), + private val addResult: ContactRecord = + ContactRecord( + identifier = "0", + displayName = "Default", + givenName = "", + familyName = "", + organizationName = "", + phoneNumbers = emptyList(), + emails = emptyList(), + ), +) : ContactsDataSource { + var addCalls: Int = 0 + private set + + override fun hasReadPermission(context: Context): Boolean = canRead + + override fun hasWritePermission(context: Context): Boolean = canWrite + + override fun search( + context: Context, + request: ContactsSearchRequest, + ): List = searchResults + + override fun add( + context: Context, + request: ContactsAddRequest, + ): ContactRecord { + addCalls += 1 + return addResult + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/DebugHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/DebugHandlerTest.kt new file mode 100644 index 0000000..a11b178 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/DebugHandlerTest.kt @@ -0,0 +1,41 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.testDeviceIdentityStore +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class DebugHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleLogs_preservesUtf16BoundariesInCameraLog() { + val splitPairPrefix = "x".repeat(3_999) + assertEquals(splitPairPrefix, cameraLogFromResponse("${splitPairPrefix}\uD83D\uDE00tail")) + + val completePairPrefix = "x".repeat(3_998) + assertEquals( + "${completePairPrefix}\uD83D\uDE00", + cameraLogFromResponse("${completePairPrefix}\uD83D\uDE00tail"), + ) + } + + private fun cameraLogFromResponse(raw: String): String { + val context = appContext() + File(context.cacheDir, "camera_debug.log").writeText(raw) + + val result = DebugHandler(context, testDeviceIdentityStore(context)).handleLogs() + + assertTrue(result.ok) + val logs = + Json + .parseToJsonElement(result.payloadJson ?: error("missing payload")) + .jsonObject + .getValue("logs") + .jsonPrimitive + .content + return logs.substringAfter("\n--- camera_debug.log ---\n") + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt new file mode 100644 index 0000000..89f4191 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -0,0 +1,554 @@ +package ai.openclaw.app.node + +import android.Manifest +import android.app.Application +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class DeviceHandlerTest { + @Test + fun handleDeviceInfo_returnsStablePayload() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDeviceInfo(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + assertEquals("Android", payload.getValue("systemName").jsonPrimitive.content) + assertTrue( + payload + .getValue("deviceName") + .jsonPrimitive.content + .isNotBlank(), + ) + assertTrue( + payload + .getValue("modelIdentifier") + .jsonPrimitive.content + .isNotBlank(), + ) + assertTrue( + payload + .getValue("systemVersion") + .jsonPrimitive.content + .isNotBlank(), + ) + assertTrue( + payload + .getValue("appVersion") + .jsonPrimitive.content + .isNotBlank(), + ) + assertTrue( + payload + .getValue("appBuild") + .jsonPrimitive.content + .isNotBlank(), + ) + assertTrue( + payload + .getValue("locale") + .jsonPrimitive.content + .isNotBlank(), + ) + } + + @Test + fun handleDeviceStatus_returnsExpectedShape() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDeviceStatus(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val battery = payload.getValue("battery").jsonObject + val storage = payload.getValue("storage").jsonObject + val thermal = payload.getValue("thermal").jsonObject + val network = payload.getValue("network").jsonObject + + val state = battery.getValue("state").jsonPrimitive.content + assertTrue(state in setOf("unknown", "unplugged", "charging", "full")) + battery["level"]?.jsonPrimitive?.double?.let { level -> + assertTrue(level in 0.0..1.0) + } + battery.getValue("lowPowerModeEnabled").jsonPrimitive.boolean + + val totalBytes = + storage + .getValue("totalBytes") + .jsonPrimitive.content + .toLong() + val freeBytes = + storage + .getValue("freeBytes") + .jsonPrimitive.content + .toLong() + val usedBytes = + storage + .getValue("usedBytes") + .jsonPrimitive.content + .toLong() + assertTrue(totalBytes >= 0L) + assertTrue(freeBytes >= 0L) + assertTrue(usedBytes >= 0L) + assertEquals((totalBytes - freeBytes).coerceAtLeast(0L), usedBytes) + + val thermalState = thermal.getValue("state").jsonPrimitive.content + assertTrue(thermalState in setOf("nominal", "fair", "serious", "critical")) + + val networkStatus = network.getValue("status").jsonPrimitive.content + assertTrue(networkStatus in setOf("satisfied", "unsatisfied", "requiresConnection")) + val interfaces = network.getValue("interfaces").jsonArray.map { it.jsonPrimitive.content } + assertTrue(interfaces.all { it in setOf("wifi", "cellular", "wired", "other") }) + + assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0) + } + + @Test + fun handleDevicePermissions_returnsExpectedShape() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDevicePermissions(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val permissions = payload.getValue("permissions").jsonObject + val expected = + listOf( + "camera", + "microphone", + "location", + "sms", + "notificationListener", + "notifications", + "photos", + "contacts", + "calendar", + "callLog", + "motion", + ) + for (key in expected) { + val state = permissions.getValue(key).jsonObject + val status = state.getValue("status").jsonPrimitive.content + assertTrue(status == "granted" || status == "denied") + state.getValue("promptable").jsonPrimitive.boolean + if (key == "sms") { + val capabilities = state.getValue("capabilities").jsonObject + for (capabilityKey in listOf("send", "read")) { + val capability = capabilities.getValue(capabilityKey).jsonObject + val capabilityStatus = capability.getValue("status").jsonPrimitive.content + assertTrue(capabilityStatus == "granted" || capabilityStatus == "denied") + capability.getValue("promptable").jsonPrimitive.boolean + } + } + } + } + + @Test + fun handleDevicePermissions_derivesCompositeStatesFromCanonicalSnapshot() { + val app = appContext() + shadowOf(app.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, true) + val snapshot = + emptyPermissionSnapshot().copy( + smsSend = true, + contactsRead = true, + calendarRead = true, + calendarWrite = true, + ) + val handler = + DeviceHandler.forTesting( + appContext = app, + appSource = FakeDeviceAppSource(emptyList()), + smsEnabled = true, + permissionSnapshot = { snapshot }, + ) + + val payload = handler.handleDevicePermissions(null).payloadJson + + assertEquals("granted", permissionStatus(payload, "sms")) + assertEquals("denied", permissionStatus(payload, "contacts")) + assertEquals("granted", permissionStatus(payload, "calendar")) + val smsCapabilities = + parsePayload(payload) + .getValue("permissions") + .jsonObject + .getValue("sms") + .jsonObject + .getValue("capabilities") + .jsonObject + assertEquals( + "granted", + smsCapabilities + .getValue("send") + .jsonObject + .getValue("status") + .jsonPrimitive.content, + ) + assertEquals( + "denied", + smsCapabilities + .getValue("read") + .jsonObject + .getValue("status") + .jsonPrimitive.content, + ) + } + + @Test + fun smsTopLevelStatusTreatsSendOnlyPartialGrantAsGranted() { + assertTrue( + DeviceHandler.hasAnySmsCapability( + smsEnabled = true, + telephonyAvailable = true, + smsSendGranted = true, + smsReadGranted = false, + ), + ) + } + + @Test + fun smsTopLevelStatusTreatsReadOnlyPartialGrantAsGranted() { + assertTrue( + DeviceHandler.hasAnySmsCapability( + smsEnabled = true, + telephonyAvailable = true, + smsSendGranted = false, + smsReadGranted = true, + ), + ) + } + + @Test + fun smsTopLevelStatusTreatsNoSmsGrantAsDenied() { + assertTrue( + !DeviceHandler.hasAnySmsCapability( + smsEnabled = true, + telephonyAvailable = true, + smsSendGranted = false, + smsReadGranted = false, + ), + ) + } + + @Test + fun smsTopLevelStatusTreatsDisabledSmsAsDenied() { + assertTrue( + !DeviceHandler.hasAnySmsCapability( + smsEnabled = false, + telephonyAvailable = true, + smsSendGranted = true, + smsReadGranted = true, + ), + ) + } + + @Test + fun smsTopLevelStatusTreatsMissingTelephonyAsDenied() { + assertTrue( + !DeviceHandler.hasAnySmsCapability( + smsEnabled = true, + telephonyAvailable = false, + smsSendGranted = true, + smsReadGranted = true, + ), + ) + } + + @Test + fun smsTopLevelPromptableStaysTrueUntilBothSmsPermissionsAreGranted() { + assertTrue( + DeviceHandler.isSmsPromptable( + smsEnabled = true, + telephonyAvailable = true, + smsSendGranted = true, + smsReadGranted = false, + ), + ) + assertTrue( + !DeviceHandler.isSmsPromptable( + smsEnabled = true, + telephonyAvailable = true, + smsSendGranted = true, + smsReadGranted = true, + ), + ) + } + + @Test + fun smsTopLevelPromptableIsFalseWhenSmsCannotExist() { + assertTrue( + !DeviceHandler.isSmsPromptable( + smsEnabled = false, + telephonyAvailable = true, + smsSendGranted = false, + smsReadGranted = false, + ), + ) + assertTrue( + !DeviceHandler.isSmsPromptable( + smsEnabled = true, + telephonyAvailable = false, + smsSendGranted = false, + smsReadGranted = false, + ), + ) + } + + @Test + fun handleDevicePermissions_marksCallLogUnpromptableWhenFeatureDisabled() { + val handler = DeviceHandler(appContext(), callLogEnabled = false) + + val result = handler.handleDevicePermissions(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val callLog = + payload + .getValue("permissions") + .jsonObject + .getValue("callLog") + .jsonObject + assertEquals("denied", callLog.getValue("status").jsonPrimitive.content) + assertTrue(!callLog.getValue("promptable").jsonPrimitive.boolean) + } + + @Test + fun handleDevicePermissions_requiresReadAndWritePermissionPairs() { + val app = appContext() + val handler = DeviceHandler(app) + val permissionPairs = + listOf( + Triple("contacts", Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), + Triple("calendar", Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR), + ) + + for ((key, readPermission, writePermission) in permissionPairs) { + shadowOf(app).denyPermissions(readPermission, writePermission) + + shadowOf(app).grantPermissions(readPermission) + assertEquals("$key read-only", "denied", permissionStatus(handler.handleDevicePermissions(null).payloadJson, key)) + + shadowOf(app).denyPermissions(readPermission) + shadowOf(app).grantPermissions(writePermission) + assertEquals("$key write-only", "denied", permissionStatus(handler.handleDevicePermissions(null).payloadJson, key)) + + shadowOf(app).grantPermissions(readPermission) + assertEquals("$key read-write", "granted", permissionStatus(handler.handleDevicePermissions(null).payloadJson, key)) + } + } + + private fun emptyPermissionSnapshot(): AndroidPermissionSnapshot = + AndroidPermissionSnapshot( + camera = false, + microphone = false, + location = false, + locationPrecise = false, + locationBackground = false, + smsSend = false, + smsRead = false, + notificationListener = false, + notifications = false, + photos = false, + contactsRead = false, + contactsWrite = false, + calendarRead = false, + calendarWrite = false, + callLog = false, + motion = false, + ) + + @Test + fun handleDeviceHealth_returnsExpectedShape() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDeviceHealth(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val memory = payload.getValue("memory").jsonObject + val battery = payload.getValue("battery").jsonObject + val power = payload.getValue("power").jsonObject + val system = payload.getValue("system").jsonObject + + val pressure = memory.getValue("pressure").jsonPrimitive.content + assertTrue(pressure in setOf("normal", "moderate", "high", "critical", "unknown")) + val totalRamBytes = + memory + .getValue("totalRamBytes") + .jsonPrimitive.content + .toLong() + val availableRamBytes = + memory + .getValue("availableRamBytes") + .jsonPrimitive.content + .toLong() + val usedRamBytes = + memory + .getValue("usedRamBytes") + .jsonPrimitive.content + .toLong() + assertTrue(totalRamBytes >= 0L) + assertTrue(availableRamBytes >= 0L) + assertTrue(usedRamBytes >= 0L) + memory.getValue("lowMemory").jsonPrimitive.boolean + + val batteryState = battery.getValue("state").jsonPrimitive.content + assertTrue(batteryState in setOf("unknown", "unplugged", "charging", "full")) + val chargingType = battery.getValue("chargingType").jsonPrimitive.content + assertTrue(chargingType in setOf("none", "ac", "usb", "wireless", "dock")) + battery["temperatureC"]?.jsonPrimitive?.double + battery["currentMa"]?.jsonPrimitive?.double + + power.getValue("dozeModeEnabled").jsonPrimitive.boolean + power.getValue("lowPowerModeEnabled").jsonPrimitive.boolean + system["securityPatchLevel"]?.jsonPrimitive?.content + } + + @Test + fun handleDeviceApps_filtersAndLimitsVisibleApps() { + val handler = + DeviceHandler.forTesting( + appContext = appContext(), + appSource = + FakeDeviceAppSource( + listOf( + DeviceAppEntry( + label = "Calendar", + packageName = "com.google.android.calendar", + system = false, + enabled = true, + launchable = true, + ), + DeviceAppEntry( + label = "Android System", + packageName = "android", + system = true, + enabled = true, + launchable = false, + ), + DeviceAppEntry( + label = "Disabled App", + packageName = "com.example.disabled", + system = false, + enabled = false, + launchable = true, + ), + DeviceAppEntry( + label = "Gmail", + packageName = "com.google.android.gm", + system = false, + enabled = true, + launchable = true, + ), + ), + ), + ) + + val result = handler.handleDeviceApps("""{"query":"google","limit":1}""") + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + assertEquals("1", payload.getValue("count").jsonPrimitive.content) + assertEquals("2", payload.getValue("totalMatched").jsonPrimitive.content) + assertTrue(payload.getValue("truncated").jsonPrimitive.boolean) + assertEquals("launcher", payload.getValue("visibility").jsonPrimitive.content) + val apps = payload.getValue("apps").jsonArray + assertEquals(1, apps.size) + val app = apps.first().jsonObject + assertEquals("Calendar", app.getValue("label").jsonPrimitive.content) + assertEquals("com.google.android.calendar", app.getValue("packageName").jsonPrimitive.content) + assertTrue(!app.getValue("system").jsonPrimitive.boolean) + assertTrue(app.getValue("enabled").jsonPrimitive.boolean) + assertTrue(app.getValue("launchable").jsonPrimitive.boolean) + } + + @Test + fun handleDeviceApps_canIncludeSystemAndNonLaunchableApps() { + val source = + FakeDeviceAppSource( + listOf( + DeviceAppEntry( + label = "Android System", + packageName = "android", + system = true, + enabled = true, + launchable = false, + ), + ), + ) + val handler = DeviceHandler.forTesting(appContext = appContext(), appSource = source) + + val result = handler.handleDeviceApps("""{"includeSystem":true,"includeNonLaunchable":true}""") + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + assertEquals("android-visible", payload.getValue("visibility").jsonPrimitive.content) + assertTrue(payload.getValue("includeSystem").jsonPrimitive.boolean) + val app = + payload + .getValue("apps") + .jsonArray + .first() + .jsonObject + assertEquals("android", app.getValue("packageName").jsonPrimitive.content) + assertTrue(app.getValue("system").jsonPrimitive.boolean) + assertTrue(!app.getValue("launchable").jsonPrimitive.boolean) + assertTrue(source.includeNonLaunchableRequests.single()) + } + + @Test + fun isSystemDeviceApp_treatsUpdatedBuiltInsAsSystemApps() { + val appInfo = + ApplicationInfo().apply { + flags = ApplicationInfo.FLAG_UPDATED_SYSTEM_APP + } + + assertTrue(isSystemDeviceApp(appInfo)) + } + + private fun appContext(): Application = RuntimeEnvironment.getApplication() + + private fun parsePayload(payloadJson: String?): JsonObject { + val jsonString = payloadJson ?: error("expected payload") + return Json.parseToJsonElement(jsonString).jsonObject + } + + private fun permissionStatus( + payloadJson: String?, + key: String, + ): String = + parsePayload(payloadJson) + .getValue("permissions") + .jsonObject + .getValue(key) + .jsonObject + .getValue("status") + .jsonPrimitive + .content +} + +private class FakeDeviceAppSource( + private val apps: List, +) : DeviceAppSource { + val includeNonLaunchableRequests = mutableListOf() + + override fun listApps(includeNonLaunchable: Boolean): List { + includeNonLaunchableRequests += includeNonLaunchable + return apps + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt b/app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt new file mode 100644 index 0000000..d196d2c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt @@ -0,0 +1,131 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.NotificationBurstLimiter +import ai.openclaw.app.NotificationForwardingPolicy +import ai.openclaw.app.NotificationPackageFilterMode +import ai.openclaw.app.isWithinQuietHours +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DeviceNotificationListenerServiceTest { + @Test + fun recentPackages_migratesLegacyPreferenceKey() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE) + prefs + .edit() + .clear() + .putString("notifications.recentPackages", "com.example.one, com.example.two") + .commit() + + val packages = DeviceNotificationListenerService.recentPackages(context) + + assertEquals(listOf("com.example.one", "com.example.two"), packages) + assertEquals( + "com.example.one, com.example.two", + prefs.getString("notifications.forwarding.recentPackages", null), + ) + assertFalse(prefs.contains("notifications.recentPackages")) + } + + @Test + fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE) + prefs + .edit() + .clear() + .putString("notifications.forwarding.recentPackages", "com.example.new") + .putString("notifications.recentPackages", "com.example.legacy") + .commit() + + val packages = DeviceNotificationListenerService.recentPackages(context) + + assertEquals(listOf("com.example.new"), packages) + assertNull(prefs.getString("notifications.recentPackages", null)) + } + + @Test + fun recentPackages_trimsDedupesAndPreservesRecencyOrder() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE) + prefs + .edit() + .clear() + .putString( + "notifications.forwarding.recentPackages", + " com.example.recent , ,com.example.other,com.example.recent, com.example.third ", + ).commit() + + val packages = DeviceNotificationListenerService.recentPackages(context) + + assertEquals( + listOf("com.example.recent", "com.example.other", "com.example.third"), + packages, + ) + } + + @Test + fun quietHoursAndRateLimitingUseWallClockTimeNotNotificationPostTime() { + val zone = java.time.ZoneId.systemDefault() + val now = java.time.ZonedDateTime.now(zone) + val quietStart = + now + .minusMinutes(5) + .toLocalTime() + .withSecond(0) + .withNano(0) + val quietEnd = + now + .plusMinutes(5) + .toLocalTime() + .withSecond(0) + .withNano(0) + val stalePostTime = + now + .minusHours(2) + .withMinute(0) + .withSecond(0) + .withNano(0) + .toInstant() + .toEpochMilli() + + val policy = + NotificationForwardingPolicy( + enabled = true, + mode = NotificationPackageFilterMode.Blocklist, + packages = emptySet(), + quietHoursEnabled = true, + quietStart = "%02d:%02d".format(quietStart.hour, quietStart.minute), + quietEnd = "%02d:%02d".format(quietEnd.hour, quietEnd.minute), + maxEventsPerMinute = 1, + sessionKey = null, + ) + + assertFalse(policy.isWithinQuietHours(nowEpochMs = stalePostTime, zoneId = zone)) + assertTrue(policy.isWithinQuietHours(nowEpochMs = System.currentTimeMillis(), zoneId = zone)) + + val limiter = NotificationBurstLimiter() + assertTrue(limiter.allow(nowEpochMs = stalePostTime, maxEventsPerMinute = 1)) + assertTrue(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1)) + assertFalse(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1)) + } + + @Test + fun burstLimiter_capsAnyForwardedNotificationEvent() { + val limiter = NotificationBurstLimiter() + val nowEpochMs = System.currentTimeMillis() + + assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2)) + assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2)) + assertFalse(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt new file mode 100644 index 0000000..5974c4a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt @@ -0,0 +1,316 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.protocol.OpenClawCalendarCommand +import ai.openclaw.app.protocol.OpenClawCallLogCommand +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawCapability +import ai.openclaw.app.protocol.OpenClawContactsCommand +import ai.openclaw.app.protocol.OpenClawDeviceCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawMobileUiCommand +import ai.openclaw.app.protocol.OpenClawMotionCommand +import ai.openclaw.app.protocol.OpenClawNotificationsCommand +import ai.openclaw.app.protocol.OpenClawPhotosCommand +import ai.openclaw.app.protocol.OpenClawSmsCommand +import ai.openclaw.app.protocol.OpenClawSystemCommand +import ai.openclaw.app.protocol.OpenClawTalkCommand +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvokeCommandRegistryTest { + private val coreCapabilities = + setOf( + OpenClawCapability.Canvas.rawValue, + OpenClawCapability.Device.rawValue, + OpenClawCapability.Notifications.rawValue, + OpenClawCapability.System.rawValue, + OpenClawCapability.Talk.rawValue, + OpenClawCapability.Contacts.rawValue, + OpenClawCapability.Calendar.rawValue, + ) + + private val optionalCapabilities = + setOf( + OpenClawCapability.Camera.rawValue, + OpenClawCapability.Location.rawValue, + OpenClawCapability.Sms.rawValue, + OpenClawCapability.CallLog.rawValue, + OpenClawCapability.Motion.rawValue, + OpenClawCapability.Photos.rawValue, + OpenClawCapability.VoiceWake.rawValue, + OpenClawCapability.MobileUI.rawValue, + ) + + private val coreCommands = + setOf( + OpenClawDeviceCommand.Status.rawValue, + OpenClawDeviceCommand.Info.rawValue, + OpenClawDeviceCommand.Permissions.rawValue, + OpenClawDeviceCommand.Health.rawValue, + OpenClawNotificationsCommand.List.rawValue, + OpenClawNotificationsCommand.Actions.rawValue, + OpenClawSystemCommand.Notify.rawValue, + OpenClawTalkCommand.PttStart.rawValue, + OpenClawTalkCommand.PttStop.rawValue, + OpenClawTalkCommand.PttCancel.rawValue, + OpenClawTalkCommand.PttOnce.rawValue, + OpenClawContactsCommand.Search.rawValue, + OpenClawContactsCommand.Add.rawValue, + OpenClawCalendarCommand.Events.rawValue, + OpenClawCalendarCommand.Add.rawValue, + ) + + private val optionalCommands = + setOf( + OpenClawCameraCommand.Snap.rawValue, + OpenClawCameraCommand.Clip.rawValue, + OpenClawCameraCommand.List.rawValue, + OpenClawLocationCommand.Get.rawValue, + OpenClawMotionCommand.Activity.rawValue, + OpenClawMotionCommand.Pedometer.rawValue, + OpenClawSmsCommand.Send.rawValue, + OpenClawSmsCommand.Search.rawValue, + OpenClawCallLogCommand.Search.rawValue, + OpenClawPhotosCommand.Latest.rawValue, + OpenClawMobileUiCommand.Observe.rawValue, + OpenClawMobileUiCommand.Act.rawValue, + ) + + private val debugCommands = setOf("debug.logs", "debug.ed25519") + + @Test + fun advertisedCapabilities_respectsFeatureAvailability() { + val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags()) + + assertContainsAll(capabilities, coreCapabilities) + assertMissingAll(capabilities, optionalCapabilities) + } + + @Test + fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() { + val capabilities = + InvokeCommandRegistry.advertisedCapabilities( + defaultFlags( + cameraEnabled = true, + locationEnabled = true, + sendSmsAvailable = true, + readSmsAvailable = true, + smsSearchPossible = true, + callLogAvailable = true, + photosAvailable = true, + motionActivityAvailable = true, + motionPedometerAvailable = true, + voiceWakeEnabled = true, + mobileUiAvailable = true, + ), + ) + + assertContainsAll(capabilities, coreCapabilities + optionalCapabilities) + } + + @Test + fun advertisedCommands_respectsFeatureAvailability() { + val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags()) + + assertContainsAll(commands, coreCommands) + assertMissingAll(commands, optionalCommands + debugCommands) + } + + @Test + fun advertisedCommands_includesDeviceAppsOnlyWhenUserOptedIn() { + val disabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = false)) + val enabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = true)) + + assertFalse(disabled.contains(OpenClawDeviceCommand.Apps.rawValue)) + assertTrue(enabled.contains(OpenClawDeviceCommand.Apps.rawValue)) + } + + @Test + fun advertisedCommands_includesFeatureCommandsWhenEnabled() { + val commands = + InvokeCommandRegistry.advertisedCommands( + defaultFlags( + cameraEnabled = true, + locationEnabled = true, + sendSmsAvailable = true, + readSmsAvailable = true, + smsSearchPossible = true, + callLogAvailable = true, + photosAvailable = true, + motionActivityAvailable = true, + motionPedometerAvailable = true, + debugBuild = true, + mobileUiAvailable = true, + ), + ) + + assertContainsAll(commands, coreCommands + optionalCommands + debugCommands) + } + + @Test + fun advertisedCommands_onlyIncludesSupportedMotionCommands() { + val commands = + InvokeCommandRegistry.advertisedCommands( + NodeRuntimeFlags( + cameraEnabled = false, + locationEnabled = false, + sendSmsAvailable = false, + readSmsAvailable = false, + smsSearchPossible = false, + callLogAvailable = false, + photosAvailable = false, + motionActivityAvailable = true, + motionPedometerAvailable = false, + installedAppsSharingEnabled = false, + debugBuild = false, + ), + ) + + assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue)) + assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) + } + + @Test + fun advertisedCommands_splitsSmsSendAndSearchAvailability() { + val readOnlyCommands = + InvokeCommandRegistry.advertisedCommands( + defaultFlags(readSmsAvailable = true, smsSearchPossible = true), + ) + val sendOnlyCommands = + InvokeCommandRegistry.advertisedCommands( + defaultFlags(sendSmsAvailable = true), + ) + val requestableSearchCommands = + InvokeCommandRegistry.advertisedCommands( + defaultFlags(smsSearchPossible = true), + ) + + assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue)) + assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue)) + assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue)) + assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue)) + assertTrue(requestableSearchCommands.contains(OpenClawSmsCommand.Search.rawValue)) + } + + @Test + fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() { + val readOnlyCapabilities = + InvokeCommandRegistry.advertisedCapabilities( + defaultFlags(readSmsAvailable = true), + ) + val sendOnlyCapabilities = + InvokeCommandRegistry.advertisedCapabilities( + defaultFlags(sendSmsAvailable = true), + ) + val requestableSearchCapabilities = + InvokeCommandRegistry.advertisedCapabilities( + defaultFlags(smsSearchPossible = true), + ) + + assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue)) + assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue)) + assertFalse(requestableSearchCapabilities.contains(OpenClawCapability.Sms.rawValue)) + } + + @Test + fun advertisedCommands_excludesCallLogWhenUnavailable() { + val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(callLogAvailable = false)) + + assertFalse(commands.contains(OpenClawCallLogCommand.Search.rawValue)) + } + + @Test + fun advertisedCapabilities_excludesCallLogWhenUnavailable() { + val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(callLogAvailable = false)) + + assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue)) + } + + @Test + fun advertisedPhotosSurface_respectsFeatureAvailability() { + val disabledFlags = defaultFlags(photosAvailable = false) + val enabledFlags = defaultFlags(photosAvailable = true) + + assertFalse(InvokeCommandRegistry.advertisedCapabilities(disabledFlags).contains(OpenClawCapability.Photos.rawValue)) + assertFalse(InvokeCommandRegistry.advertisedCommands(disabledFlags).contains(OpenClawPhotosCommand.Latest.rawValue)) + assertTrue(InvokeCommandRegistry.advertisedCapabilities(enabledFlags).contains(OpenClawCapability.Photos.rawValue)) + assertTrue(InvokeCommandRegistry.advertisedCommands(enabledFlags).contains(OpenClawPhotosCommand.Latest.rawValue)) + } + + @Test + fun find_returnsForegroundMetadataForCameraCommands() { + val list = InvokeCommandRegistry.find(OpenClawCameraCommand.List.rawValue) + val location = InvokeCommandRegistry.find(OpenClawLocationCommand.Get.rawValue) + val pttStart = InvokeCommandRegistry.find(OpenClawTalkCommand.PttStart.rawValue) + val pttStop = InvokeCommandRegistry.find(OpenClawTalkCommand.PttStop.rawValue) + val pttCancel = InvokeCommandRegistry.find(OpenClawTalkCommand.PttCancel.rawValue) + val pttOnce = InvokeCommandRegistry.find(OpenClawTalkCommand.PttOnce.rawValue) + + assertNotNull(list) + assertEquals(true, list?.requiresForeground) + assertNotNull(location) + assertEquals(false, location?.requiresForeground) + assertNotNull(pttStart) + assertEquals(false, pttStart?.requiresForeground) + assertNotNull(pttStop) + assertEquals(false, pttStop?.requiresForeground) + assertNotNull(pttCancel) + assertEquals(false, pttCancel?.requiresForeground) + assertNotNull(pttOnce) + assertEquals(true, pttOnce?.requiresForeground) + } + + @Test + fun find_returnsNullForUnknownCommand() { + assertNull(InvokeCommandRegistry.find("not.real")) + } + + private fun defaultFlags( + cameraEnabled: Boolean = false, + locationEnabled: Boolean = false, + sendSmsAvailable: Boolean = false, + readSmsAvailable: Boolean = false, + smsSearchPossible: Boolean = false, + callLogAvailable: Boolean = false, + photosAvailable: Boolean = false, + motionActivityAvailable: Boolean = false, + motionPedometerAvailable: Boolean = false, + installedAppsSharingEnabled: Boolean = false, + debugBuild: Boolean = false, + voiceWakeEnabled: Boolean = false, + mobileUiAvailable: Boolean = false, + ): NodeRuntimeFlags = + NodeRuntimeFlags( + cameraEnabled = cameraEnabled, + locationEnabled = locationEnabled, + sendSmsAvailable = sendSmsAvailable, + readSmsAvailable = readSmsAvailable, + smsSearchPossible = smsSearchPossible, + callLogAvailable = callLogAvailable, + photosAvailable = photosAvailable, + motionActivityAvailable = motionActivityAvailable, + motionPedometerAvailable = motionPedometerAvailable, + installedAppsSharingEnabled = installedAppsSharingEnabled, + debugBuild = debugBuild, + voiceWakeEnabled = voiceWakeEnabled, + mobileUiAvailable = mobileUiAvailable, + ) + + private fun assertContainsAll( + actual: List, + expected: Set, + ) { + expected.forEach { value -> assertTrue(actual.contains(value)) } + } + + private fun assertMissingAll( + actual: List, + forbidden: Set, + ) { + forbidden.forEach { value -> assertFalse(actual.contains(value)) } + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt b/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt new file mode 100644 index 0000000..017fbbc --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt @@ -0,0 +1,556 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.testDeviceIdentityStore +import ai.openclaw.app.protocol.OpenClawCallLogCommand +import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawCanvasCommand +import ai.openclaw.app.protocol.OpenClawDeviceCommand +import ai.openclaw.app.protocol.OpenClawLocationCommand +import ai.openclaw.app.protocol.OpenClawMobileUiCommand +import ai.openclaw.app.protocol.OpenClawMotionCommand +import ai.openclaw.app.protocol.OpenClawPhotosCommand +import ai.openclaw.app.protocol.OpenClawSmsCommand +import ai.openclaw.app.protocol.OpenClawTalkCommand +import android.content.Context +import android.content.pm.PackageManager +import android.webkit.WebView +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class InvokeDispatcherTest { + @Test + fun classifySmsSearchAvailability_returnsAvailable_whenReadSmsIsAvailable() { + assertEquals( + SmsSearchAvailabilityReason.Available, + classifySmsSearchAvailability( + readSmsAvailable = true, + smsFeatureEnabled = true, + smsTelephonyAvailable = true, + ), + ) + } + + @Test + fun classifySmsSearchAvailability_returnsUnavailable_whenSmsFeatureDisabled() { + assertEquals( + SmsSearchAvailabilityReason.Unavailable, + classifySmsSearchAvailability( + readSmsAvailable = false, + smsFeatureEnabled = false, + smsTelephonyAvailable = true, + ), + ) + } + + @Test + fun classifySmsSearchAvailability_returnsUnavailable_whenTelephonyUnavailable() { + assertEquals( + SmsSearchAvailabilityReason.Unavailable, + classifySmsSearchAvailability( + readSmsAvailable = false, + smsFeatureEnabled = true, + smsTelephonyAvailable = false, + ), + ) + } + + @Test + fun classifySmsSearchAvailability_returnsPermissionRequired_whenOnlyReadSmsPermissionIsMissing() { + assertEquals( + SmsSearchAvailabilityReason.PermissionRequired, + classifySmsSearchAvailability( + readSmsAvailable = false, + smsFeatureEnabled = true, + smsTelephonyAvailable = true, + ), + ) + } + + @Test + fun smsSearchAvailabilityError_returnsNull_whenReadSmsPermissionIsRequestable() { + assertNull( + smsSearchAvailabilityError( + readSmsAvailable = false, + smsFeatureEnabled = true, + smsTelephonyAvailable = true, + ), + ) + } + + @Test + fun smsSearchAvailabilityError_returnsUnavailable_whenSmsSearchIsImpossible() { + val result = + smsSearchAvailabilityError( + readSmsAvailable = false, + smsFeatureEnabled = false, + smsTelephonyAvailable = true, + ) + + assertEquals("SMS_UNAVAILABLE", result?.error?.code) + assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result?.error?.message) + } + + @Test + fun handleInvoke_allowsRequestableSmsSearchToReachHandler() = + runTest { + val result = + newDispatcher( + readSmsAvailable = false, + smsFeatureEnabled = true, + smsTelephonyAvailable = true, + ).handleInvoke(OpenClawSmsCommand.Search.rawValue, "not-json") + + assertEquals("SMS_PERMISSION_REQUIRED", result.error?.code) + assertEquals("grant READ_SMS permission", result.error?.message) + } + + @Test + fun handleInvoke_blocksSmsSearchWhenFeatureIsUnavailable() = + runTest { + val result = + newDispatcher( + readSmsAvailable = false, + smsFeatureEnabled = false, + smsTelephonyAvailable = true, + ).handleInvoke(OpenClawSmsCommand.Search.rawValue, "not-json") + + assertEquals("SMS_UNAVAILABLE", result.error?.code) + assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result.error?.message) + } + + @Test + fun handleInvoke_allowsAvailableSmsSendToReachHandler() = + runTest { + val result = + newDispatcher( + sendSmsAvailable = true, + smsFeatureEnabled = true, + smsTelephonyAvailable = true, + ).handleInvoke(OpenClawSmsCommand.Send.rawValue, """{"to":"+15551234567","message":"hi"}""") + + assertEquals("SMS_PERMISSION_REQUIRED", result.error?.code) + assertEquals("grant SMS permission", result.error?.message) + } + + @Test + fun handleInvoke_blocksSmsSendWhenUnavailable() = + runTest { + val result = + newDispatcher( + sendSmsAvailable = false, + smsFeatureEnabled = true, + smsTelephonyAvailable = true, + ).handleInvoke(OpenClawSmsCommand.Send.rawValue, """{"to":"+15551234567","message":"hi"}""") + + assertEquals("SMS_UNAVAILABLE", result.error?.code) + assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result.error?.message) + } + + @Test + fun handleInvoke_blocksCameraCommandsWhenCameraDisabled() = + runTest { + val result = newDispatcher(cameraEnabled = false).handleInvoke(OpenClawCameraCommand.List.rawValue, null) + + assertEquals("CAMERA_DISABLED", result.error?.code) + assertEquals("CAMERA_DISABLED: enable Camera in Settings", result.error?.message) + } + + @Test + fun handleInvoke_blocksLocationCommandWhenLocationDisabled() = + runTest { + val result = newDispatcher(locationEnabled = false).handleInvoke(OpenClawLocationCommand.Get.rawValue, null) + + assertEquals("LOCATION_DISABLED", result.error?.code) + assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message) + } + + @Test + fun handleInvoke_blocksDeviceAppsWhenSharingDisabled() = + runTest { + val result = + newDispatcher(installedAppsSharingEnabled = false) + .handleInvoke(OpenClawDeviceCommand.Apps.rawValue, """{"limit":1}""") + + assertEquals("INSTALLED_APPS_SHARING_DISABLED", result.error?.code) + assertEquals( + "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings", + result.error?.message, + ) + } + + @Test + fun handleInvoke_blocksMotionActivityWhenUnavailable() = + runTest { + val result = + newDispatcher(motionActivityAvailable = false) + .handleInvoke(OpenClawMotionCommand.Activity.rawValue, null) + + assertEquals("MOTION_UNAVAILABLE", result.error?.code) + assertEquals("MOTION_UNAVAILABLE: accelerometer not available", result.error?.message) + } + + @Test + fun handleInvoke_blocksMotionPedometerWhenUnavailable() = + runTest { + val result = + newDispatcher(motionPedometerAvailable = false) + .handleInvoke(OpenClawMotionCommand.Pedometer.rawValue, null) + + assertEquals("PEDOMETER_UNAVAILABLE", result.error?.code) + assertEquals("PEDOMETER_UNAVAILABLE: step counter not available", result.error?.message) + } + + @Test + fun handleInvoke_blocksCallLogWhenUnavailable() = + runTest { + val result = + newDispatcher(callLogAvailable = false).handleInvoke(OpenClawCallLogCommand.Search.rawValue, null) + + assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code) + assertEquals("CALL_LOG_UNAVAILABLE: call log not available on this build", result.error?.message) + } + + @Test + fun handleInvoke_blocksPhotosWhenUnavailable() = + runTest { + val result = newDispatcher(photosAvailable = false).handleInvoke(OpenClawPhotosCommand.Latest.rawValue, null) + + assertEquals("PHOTOS_UNAVAILABLE", result.error?.code) + assertEquals("PHOTOS_UNAVAILABLE: photos not available on this build", result.error?.message) + } + + @Test + fun handleInvoke_blocksMobileUiWhenServiceIsUnavailable() = + runTest { + val result = + newDispatcher(mobileUiAvailable = false) + .handleInvoke(OpenClawMobileUiCommand.Observe.rawValue, null) + + assertEquals("MOBILE_UI_UNAVAILABLE", result.error?.code) + assertEquals( + "MOBILE_UI_UNAVAILABLE: accessibility service is not connected", + result.error?.message, + ) + } + + @Test + fun handleInvoke_treatsDebugCommandsAsUnknownOutsideDebugBuilds() = + runTest { + val result = newDispatcher(debugBuild = false).handleInvoke("debug.logs", null) + + assertEquals("INVALID_REQUEST", result.error?.code) + assertEquals("INVALID_REQUEST: unknown command", result.error?.message) + } + + @Test + fun handleInvoke_routesTalkPttCommands() = + runTest { + val talk = InvokeDispatcherFakeTalkHandler() + val dispatcher = newDispatcher(talkHandler = talk) + + val start = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) + val stop = dispatcher.handleInvoke(OpenClawTalkCommand.PttStop.rawValue, null) + val cancel = dispatcher.handleInvoke(OpenClawTalkCommand.PttCancel.rawValue, null) + val once = dispatcher.handleInvoke(OpenClawTalkCommand.PttOnce.rawValue, null) + + assertEquals("""{"captureId":"start"}""", start.payloadJson) + assertEquals("""{"status":"stop"}""", stop.payloadJson) + assertEquals("""{"status":"cancel"}""", cancel.payloadJson) + assertEquals("""{"status":"once"}""", once.payloadJson) + assertEquals( + listOf("start", "stop", "cancel", "once"), + talk.calls, + ) + } + + @Test + fun handleInvoke_blocksTalkOnceButLeavesPttStartToRuntimeStateGateWhenBackgrounded() = + runTest { + val talk = InvokeDispatcherFakeTalkHandler() + val dispatcher = newDispatcher(isForeground = false, talkHandler = talk) + + val start = dispatcher.handleInvoke(OpenClawTalkCommand.PttStart.rawValue, null) + val once = dispatcher.handleInvoke(OpenClawTalkCommand.PttOnce.rawValue, null) + val stop = dispatcher.handleInvoke(OpenClawTalkCommand.PttStop.rawValue, null) + val cancel = dispatcher.handleInvoke(OpenClawTalkCommand.PttCancel.rawValue, null) + + assertEquals("""{"captureId":"start"}""", start.payloadJson) + assertEquals("NODE_BACKGROUND_UNAVAILABLE", once.error?.code) + assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", once.error?.message) + assertEquals("""{"status":"stop"}""", stop.payloadJson) + assertEquals("""{"status":"cancel"}""", cancel.payloadJson) + assertEquals(listOf("start", "stop", "cancel"), talk.calls) + } + + @Test + fun handleInvoke_presentAndHideDriveTheShellOwnedCanvasState() = + runTest { + val appContext = RuntimeEnvironment.getApplication() + val canvas = CanvasController() + val webView = WebView(appContext) + canvas.attach(webView) + val dispatcher = newDispatcher(canvas = canvas) + + val present = + dispatcher.handleInvoke( + OpenClawCanvasCommand.Present.rawValue, + """{"url":"https://example.com/canvas"}""", + ) + + assertNull(present.error) + assertEquals("https://example.com/canvas", canvas.currentUrl()) + assertEquals(CanvasController.PresentationState.Visible, canvas.presentationState.value) + + val hide = dispatcher.handleInvoke(OpenClawCanvasCommand.Hide.rawValue, null) + + assertNull(hide.error) + assertEquals(CanvasController.PresentationState.Hidden, canvas.presentationState.value) + canvas.releaseHost() + webView.destroy() + } + + @Test + fun handleInvoke_rejectsBackgroundCanvasPresentationBeforeMountingAHost() = + runTest { + val canvas = CanvasController() + val result = + newDispatcher(isForeground = false, canvas = canvas) + .handleInvoke(OpenClawCanvasCommand.Present.rawValue, """{"url":"https://example.com"}""") + + assertEquals("NODE_BACKGROUND_UNAVAILABLE", result.error?.code) + assertEquals(CanvasController.PresentationState.Unmounted, canvas.presentationState.value) + } + + @Test + fun handleInvoke_doesNotCommitNavigationWhenTheShellHostCannotAttach() = + runTest { + val canvas = CanvasController() + val result = + newDispatcher(canvas = canvas) + .handleInvoke(OpenClawCanvasCommand.Present.rawValue, """{"url":"https://example.com"}""") + + assertEquals("NODE_BACKGROUND_UNAVAILABLE", result.error?.code) + assertNull(canvas.currentUrl()) + assertEquals(CanvasController.PresentationState.Unmounted, canvas.presentationState.value) + } + + private fun newDispatcher( + isForeground: Boolean = true, + cameraEnabled: Boolean = false, + locationEnabled: Boolean = false, + sendSmsAvailable: Boolean = false, + readSmsAvailable: Boolean = false, + smsFeatureEnabled: Boolean = true, + smsTelephonyAvailable: Boolean = true, + callLogAvailable: Boolean = false, + photosAvailable: Boolean = true, + installedAppsSharingEnabled: Boolean = true, + debugBuild: Boolean = false, + motionActivityAvailable: Boolean = false, + motionPedometerAvailable: Boolean = false, + mobileUiAvailable: Boolean = false, + talkHandler: TalkHandler = InvokeDispatcherFakeTalkHandler(), + canvas: CanvasController = CanvasController(), + ): InvokeDispatcher { + val appContext = RuntimeEnvironment.getApplication() + shadowOf(appContext.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, smsTelephonyAvailable) + return InvokeDispatcher( + canvas = canvas, + cameraHandler = newCameraHandler(appContext), + locationHandler = + LocationHandler.forTesting( + appContext = appContext, + dataSource = InvokeDispatcherFakeLocationDataSource(), + ), + deviceHandler = DeviceHandler(appContext), + notificationsHandler = + NotificationsHandler.forTesting( + appContext = appContext, + stateProvider = InvokeDispatcherFakeNotificationsStateProvider(), + ), + systemHandler = SystemHandler.forTesting(InvokeDispatcherFakeSystemNotificationPoster()), + talkHandler = talkHandler, + photosHandler = PhotosHandler.forTesting(appContext, InvokeDispatcherFakePhotosDataSource()), + contactsHandler = ContactsHandler.forTesting(appContext, InvokeDispatcherFakeContactsDataSource()), + calendarHandler = CalendarHandler.forTesting(appContext, InvokeDispatcherFakeCalendarDataSource()), + motionHandler = MotionHandler.forTesting(appContext, InvokeDispatcherFakeMotionDataSource()), + smsHandler = SmsHandler(SmsManager(appContext)), + a2uiHandler = + A2UIHandler( + canvas = canvas, + json = Json { ignoreUnknownKeys = true }, + ), + debugHandler = DebugHandler(appContext, testDeviceIdentityStore(appContext)), + callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()), + mobileUiHandler = MobileUiHandler(), + isForeground = { isForeground }, + cameraEnabled = { cameraEnabled }, + locationEnabled = { locationEnabled }, + sendSmsAvailable = { sendSmsAvailable }, + readSmsAvailable = { readSmsAvailable }, + smsFeatureEnabled = { smsFeatureEnabled }, + smsTelephonyAvailable = { smsTelephonyAvailable }, + callLogAvailable = { callLogAvailable }, + photosAvailable = { photosAvailable }, + installedAppsSharingEnabled = { installedAppsSharingEnabled }, + debugBuild = { debugBuild }, + onCanvasA2uiPush = {}, + onCanvasA2uiReset = {}, + motionActivityAvailable = { motionActivityAvailable }, + motionPedometerAvailable = { motionPedometerAvailable }, + mobileUiAvailable = { mobileUiAvailable }, + ) + } + + private fun newCameraHandler(appContext: Context): CameraHandler = + CameraHandler( + appContext = appContext, + camera = CameraCaptureManager(appContext), + setCameraAudioCaptureActive = { true }, + showCameraHud = { _, _, _ -> }, + invokeErrorFromThrowable = { err -> "UNAVAILABLE" to (err.message ?: "camera failed") }, + ) +} + +private class InvokeDispatcherFakeLocationDataSource : LocationDataSource { + override fun hasFinePermission(context: Context): Boolean = false + + override fun hasCoarsePermission(context: Context): Boolean = false + + override fun hasBackgroundPermission(context: Context): Boolean = false + + override suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload { + error("unused in InvokeDispatcherTest") + } +} + +private class InvokeDispatcherFakeNotificationsStateProvider : NotificationsStateProvider { + override fun readSnapshot(context: Context): DeviceNotificationSnapshot = DeviceNotificationSnapshot(enabled = false, connected = false, notifications = emptyList()) + + override fun requestServiceRebind(context: Context) = Unit + + override fun executeAction( + context: Context, + request: NotificationActionRequest, + ): NotificationActionResult = NotificationActionResult(ok = true, code = null, message = null) +} + +private class InvokeDispatcherFakeSystemNotificationPoster : SystemNotificationPoster { + override fun isAuthorized(): Boolean = true + + override fun post(request: SystemNotifyRequest) = Unit +} + +private class InvokeDispatcherFakeTalkHandler : TalkHandler { + val calls = mutableListOf() + + override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult { + calls.add("start") + return GatewaySession.InvokeResult.ok("""{"captureId":"start"}""") + } + + override suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult { + calls.add("stop") + return GatewaySession.InvokeResult.ok("""{"status":"stop"}""") + } + + override suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult { + calls.add("cancel") + return GatewaySession.InvokeResult.ok("""{"status":"cancel"}""") + } + + override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult { + calls.add("once") + return GatewaySession.InvokeResult.ok("""{"status":"once"}""") + } +} + +private class InvokeDispatcherFakePhotosDataSource : PhotosDataSource { + override fun hasPermission(context: Context): Boolean = true + + override fun latest( + context: Context, + request: PhotosLatestRequest, + ): List = emptyList() +} + +private class InvokeDispatcherFakeContactsDataSource : ContactsDataSource { + override fun hasReadPermission(context: Context): Boolean = true + + override fun hasWritePermission(context: Context): Boolean = true + + override fun search( + context: Context, + request: ContactsSearchRequest, + ): List = emptyList() + + override fun add( + context: Context, + request: ContactsAddRequest, + ): ContactRecord { + error("unused in InvokeDispatcherTest") + } +} + +private class InvokeDispatcherFakeCalendarDataSource : CalendarDataSource { + override fun hasReadPermission(context: Context): Boolean = true + + override fun hasWritePermission(context: Context): Boolean = true + + override fun events( + context: Context, + request: CalendarEventsRequest, + ): List = emptyList() + + override fun add( + context: Context, + request: CalendarAddRequest, + ): CalendarEventRecord { + error("unused in InvokeDispatcherTest") + } +} + +private class InvokeDispatcherFakeMotionDataSource : MotionDataSource { + override fun isActivityAvailable(context: Context): Boolean = false + + override fun isPedometerAvailable(context: Context): Boolean = false + + override fun hasPermission(context: Context): Boolean = true + + override suspend fun activity( + context: Context, + request: MotionActivityRequest, + ): MotionActivityRecord { + error("unused in InvokeDispatcherTest") + } + + override suspend fun pedometer( + context: Context, + request: MotionPedometerRequest, + ): PedometerRecord { + error("unused in InvokeDispatcherTest") + } +} + +private class InvokeDispatcherFakeCallLogDataSource : CallLogDataSource { + override fun hasReadPermission(context: Context): Boolean = true + + override fun search( + context: Context, + request: CallLogSearchRequest, + ): List = emptyList() +} diff --git a/app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt b/app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt new file mode 100644 index 0000000..c808662 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/JpegSizeLimiterTest.kt @@ -0,0 +1,70 @@ +package ai.openclaw.app.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.min + +class JpegSizeLimiterTest { + @Test + fun compressesLargePayloadsUnderLimit() { + val maxBytes = 5 * 1024 * 1024 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 4000, + initialHeight = 3000, + startQuality = 95, + maxBytes = maxBytes, + encode = { width, height, quality -> + val estimated = (width.toLong() * height.toLong() * quality.toLong()) / 100 + val size = min(maxBytes.toLong() * 2, estimated).toInt() + ByteArray(size) + }, + ) + + assertTrue(result.bytes.size <= maxBytes) + assertTrue(result.width <= 4000) + assertTrue(result.height <= 3000) + assertTrue(result.quality <= 95) + } + + @Test + fun keepsSmallPayloadsAsIs() { + val maxBytes = 5 * 1024 * 1024 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 800, + initialHeight = 600, + startQuality = 90, + maxBytes = maxBytes, + encode = { _, _, _ -> ByteArray(120_000) }, + ) + + assertEquals(800, result.width) + assertEquals(600, result.height) + assertEquals(90, result.quality) + } + + @Test + fun triesFinalScaledImageBeforeFailing() { + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 1000, + initialHeight = 800, + startQuality = 90, + maxBytes = 100, + minSize = 1, + scaleStep = 0.5, + maxScaleAttempts = 1, + maxQualityAttempts = 1, + encode = { width, _, _ -> + if (width == 500) ByteArray(80) else ByteArray(120) + }, + ) + + assertEquals(500, result.width) + assertEquals(400, result.height) + assertEquals(90, result.quality) + assertEquals(80, result.bytes.size) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt new file mode 100644 index 0000000..94336ee --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt @@ -0,0 +1,271 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.LocationMode +import android.content.Context +import android.location.LocationManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class LocationHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = false, + coarseGranted = false, + ), + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleLocationGet_requiresForegroundBeforeLocationPermission() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + ), + isForeground = { false }, + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code) + } + + @Test + fun handleLocationGet_allowsBackgroundWhenThirdPartyAlwaysGrantIsEffective() = + runTest { + val source = + FakeLocationDataSource( + fineGranted = false, + coarseGranted = true, + backgroundGranted = true, + payload = LocationCaptureManager.Payload("""{"ok":true}"""), + ) + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = source, + isForeground = { false }, + locationMode = { LocationMode.Always }, + backgroundLocationEnabled = { true }, + ) + + val result = handler.handleLocationGet(null) + + assertTrue(result.ok) + } + + @Test + fun handleLocationGet_deniesBackgroundWhenFlavorDisablesAlwaysMode() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + backgroundGranted = true, + ), + isForeground = { false }, + locationMode = { LocationMode.Always }, + backgroundLocationEnabled = { false }, + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code) + } + + @Test + fun hasFineLocationPermission_reflectsDataSource() { + val denied = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true), + ) + assertFalse(denied.hasFineLocationPermission()) + assertTrue(denied.hasCoarseLocationPermission()) + + val granted = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false), + ) + assertTrue(granted.hasFineLocationPermission()) + assertFalse(granted.hasCoarseLocationPermission()) + } + + @Test + fun handleLocationGet_usesPreciseGpsFirstWhenFinePermissionAndPreciseEnabled() = + runTest { + val source = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + payload = LocationCaptureManager.Payload("""{"ok":true}"""), + ) + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = source, + locationPreciseEnabled = { true }, + ) + + val result = handler.handleLocationGet("""{"desiredAccuracy":"precise","maxAgeMs":1234,"timeoutMs":2000}""") + + assertTrue(result.ok) + assertEquals(listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER), source.lastDesiredProviders) + assertEquals(1234L, source.lastMaxAgeMs) + assertEquals(2000L, source.lastTimeoutMs) + assertTrue(source.lastIsPrecise) + } + + @Test + fun handleLocationGet_fallsBackToBalancedWhenPreciseUnavailable() = + runTest { + val source = + FakeLocationDataSource( + fineGranted = false, + coarseGranted = true, + payload = LocationCaptureManager.Payload("""{"ok":true}"""), + ) + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = source, + locationPreciseEnabled = { true }, + ) + + val result = handler.handleLocationGet("""{"desiredAccuracy":"precise"}""") + + assertTrue(result.ok) + assertEquals(listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER), source.lastDesiredProviders) + assertFalse(source.lastIsPrecise) + } + + @Test + fun handleLocationGet_mapsTimeoutToLocationTimeout() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + timeout = true, + ), + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_TIMEOUT", result.error?.code) + assertEquals("LOCATION_TIMEOUT: no fix in time", result.error?.message) + } + + @Test + fun handleLocationGet_mapsOtherFailuresToLocationUnavailable() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + failure = IllegalStateException("gps offline"), + ), + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_UNAVAILABLE", result.error?.code) + assertEquals("gps offline", result.error?.message) + } + + @Test + fun handleLocationGet_propagatesParentCancellation() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + failure = CancellationException("request retired"), + ), + ) + + try { + handler.handleLocationGet(null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("request retired", err.message) + } + } +} + +private class FakeLocationDataSource( + private val fineGranted: Boolean, + private val coarseGranted: Boolean, + private val backgroundGranted: Boolean = false, + private val payload: LocationCaptureManager.Payload? = null, + private val failure: Throwable? = null, + private val timeout: Boolean = false, +) : LocationDataSource { + var lastDesiredProviders: List = emptyList() + var lastMaxAgeMs: Long? = null + var lastTimeoutMs: Long? = null + var lastIsPrecise: Boolean = false + + override fun hasFinePermission(context: Context): Boolean = fineGranted + + override fun hasCoarsePermission(context: Context): Boolean = coarseGranted + + override fun hasBackgroundPermission(context: Context): Boolean = backgroundGranted + + override suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload { + lastDesiredProviders = desiredProviders + lastMaxAgeMs = maxAgeMs + lastTimeoutMs = timeoutMs + lastIsPrecise = isPrecise + if (timeout) { + kotlinx.coroutines.withTimeout(1) { + kotlinx.coroutines.delay(5) + } + } + failure?.let { throw it } + return payload ?: LocationCaptureManager.Payload(Json.encodeToString(mapOf("ok" to true))) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt new file mode 100644 index 0000000..ff0117f --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt @@ -0,0 +1,185 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class MotionHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleMotionActivity_requiresPermission() = + runTest { + val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = false)) + + val result = handler.handleMotionActivity(null) + + assertFalse(result.ok) + assertEquals("MOTION_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleMotionActivity_rejectsInvalidJson() = + runTest { + val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = true)) + + val result = handler.handleMotionActivity("[]") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handleMotionActivity_returnsActivityPayload() = + runTest { + val activity = + MotionActivityRecord( + startISO = "2026-02-28T10:00:00Z", + endISO = "2026-02-28T10:00:02Z", + confidence = "high", + isWalking = true, + isRunning = false, + isCycling = false, + isAutomotive = false, + isStationary = false, + isUnknown = false, + ) + val handler = + MotionHandler.forTesting( + appContext(), + FakeMotionDataSource(hasPermission = true, activityRecord = activity), + ) + + val result = handler.handleMotionActivity(null) + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val activities = payload.getValue("activities").jsonArray + assertEquals(1, activities.size) + assertEquals( + "high", + activities + .first() + .jsonObject + .getValue("confidence") + .jsonPrimitive.content, + ) + } + + @Test + fun handleMotionPedometer_mapsRangeUnsupportedError() = + runTest { + val handler = + MotionHandler.forTesting( + appContext(), + FakeMotionDataSource( + hasPermission = true, + pedometerError = IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: not supported"), + ), + ) + + val result = handler.handleMotionPedometer("""{"startISO":"2026-02-01T00:00:00Z"}""") + + assertFalse(result.ok) + assertEquals("MOTION_UNAVAILABLE", result.error?.code) + assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true) + } + + @Test + fun handleMotionActivity_propagatesParentCancellation() = + runTest { + val handler = + MotionHandler.forTesting( + appContext(), + FakeMotionDataSource( + hasPermission = true, + activityError = CancellationException("invoke retired"), + ), + ) + + try { + handler.handleMotionActivity(null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("invoke retired", err.message) + } + } + + @Test + fun handleMotionPedometer_propagatesParentCancellation() = + runTest { + val handler = + MotionHandler.forTesting( + appContext(), + FakeMotionDataSource( + hasPermission = true, + pedometerError = CancellationException("invoke retired"), + ), + ) + + try { + handler.handleMotionPedometer(null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("invoke retired", err.message) + } + } +} + +private class FakeMotionDataSource( + private val hasPermission: Boolean, + private val activityAvailable: Boolean = true, + private val pedometerAvailable: Boolean = true, + private val activityRecord: MotionActivityRecord = + MotionActivityRecord( + startISO = "2026-02-28T00:00:00Z", + endISO = "2026-02-28T00:00:02Z", + confidence = "medium", + isWalking = false, + isRunning = false, + isCycling = false, + isAutomotive = false, + isStationary = true, + isUnknown = false, + ), + private val pedometerRecord: PedometerRecord = + PedometerRecord( + startISO = "2026-02-28T00:00:00Z", + endISO = "2026-02-28T01:00:00Z", + steps = 1234, + distanceMeters = null, + floorsAscended = null, + floorsDescended = null, + ), + private val activityError: Throwable? = null, + private val pedometerError: Throwable? = null, +) : MotionDataSource { + override fun isActivityAvailable(context: Context): Boolean = activityAvailable + + override fun isPedometerAvailable(context: Context): Boolean = pedometerAvailable + + override fun hasPermission(context: Context): Boolean = hasPermission + + override suspend fun activity( + context: Context, + request: MotionActivityRequest, + ): MotionActivityRecord { + activityError?.let { throw it } + return activityRecord + } + + override suspend fun pedometer( + context: Context, + request: MotionPedometerRequest, + ): PedometerRecord { + pedometerError?.let { throw it } + return pedometerRecord + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt b/app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt new file mode 100644 index 0000000..d89a9b1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/NodeHandlerRobolectricTest.kt @@ -0,0 +1,11 @@ +package ai.openclaw.app.node + +import android.content.Context +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +abstract class NodeHandlerRobolectricTest { + protected fun appContext(): Context = RuntimeEnvironment.getApplication() +} diff --git a/app/src/test/java/ai/openclaw/app/node/NodePresenceAliveBeaconTest.kt b/app/src/test/java/ai/openclaw/app/node/NodePresenceAliveBeaconTest.kt new file mode 100644 index 0000000..21b218d --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/NodePresenceAliveBeaconTest.kt @@ -0,0 +1,116 @@ +package ai.openclaw.app.node + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NodePresenceAliveBeaconTest { + @Test + fun shouldSkipRecentSuccess_requiresFreshSuccess() { + assertTrue( + NodePresenceAliveBeacon.shouldSkipRecentSuccess( + nowMs = 2_000, + lastSuccessAtMs = 1_500, + minIntervalMs = 1_000, + ), + ) + assertFalse( + NodePresenceAliveBeacon.shouldSkipRecentSuccess( + nowMs = 2_000, + lastSuccessAtMs = null, + minIntervalMs = 1_000, + ), + ) + assertFalse( + NodePresenceAliveBeacon.shouldSkipRecentSuccess( + nowMs = 3_000, + lastSuccessAtMs = 1_500, + minIntervalMs = 1_000, + ), + ) + } + + @Test + fun makePayloadJson_includesAndroidPresenceMetadata() { + val payload = + Json + .parseToJsonElement( + NodePresenceAliveBeacon.makePayloadJson( + trigger = NodePresenceAliveBeacon.Trigger.Connect, + sentAtMs = 123, + displayName = "Pixel Node", + version = "2026.4.28", + platform = "Android 15 (SDK 35)", + deviceFamily = "Android", + modelIdentifier = "Google Pixel 9", + ), + ).jsonObject + + assertEquals("connect", payload["trigger"]?.jsonPrimitive?.content) + assertEquals("123", payload["sentAtMs"]?.jsonPrimitive?.content) + assertEquals("Pixel Node", payload["displayName"]?.jsonPrimitive?.content) + assertEquals("2026.4.28", payload["version"]?.jsonPrimitive?.content) + assertEquals("Android 15 (SDK 35)", payload["platform"]?.jsonPrimitive?.content) + assertEquals("Android", payload["deviceFamily"]?.jsonPrimitive?.content) + assertEquals("Google Pixel 9", payload["modelIdentifier"]?.jsonPrimitive?.content) + assertNull(payload["pushTransport"]) + } + + @Test + fun decodeResponse_leavesOldGatewayAckUnhandled() { + val response = NodePresenceAliveBeacon.decodeResponse("""{"ok":true}""") + + assertEquals(true, response?.ok) + assertNull(response?.handled) + } + + @Test + fun decodeResponse_readsHandledPresenceResult() { + val response = + NodePresenceAliveBeacon.decodeResponse( + """{"ok":true,"event":"node.presence.alive","handled":true,"reason":"persisted"}""", + ) + + assertEquals(true, response?.ok) + assertEquals("node.presence.alive", response?.event) + assertEquals(true, response?.handled) + assertEquals("persisted", response?.reason) + } + + @Test + fun decodeResponse_rejectsOversizedPayloadBeforeParsing() { + assertNull( + NodePresenceAliveBeacon.decodeResponse("""{"ok":true,"reason":"${"x".repeat(16 * 1024)}"}"""), + ) + } + + @Test + fun sanitizeReasonForLog_removesControlCharactersAndBoundsLength() { + val raw = "bad\nreason\t${"x".repeat(240)}" + val sanitized = NodePresenceAliveBeacon.sanitizeReasonForLog(raw) + + assertFalse(sanitized.contains("\n")) + assertFalse(sanitized.contains("\t")) + assertEquals(200, sanitized.length) + } + + @Test + fun sanitizeReasonForLog_preservesUtf16BoundariesAtLimit() { + val splitPairPrefix = "bad\n${"x".repeat(195)}" + assertEquals( + "bad ${"x".repeat(195)}", + NodePresenceAliveBeacon.sanitizeReasonForLog("$splitPairPrefix😀tail"), + ) + + val completePairPrefix = "bad\n${"x".repeat(194)}" + assertEquals( + "bad ${"x".repeat(194)}😀", + NodePresenceAliveBeacon.sanitizeReasonForLog("$completePairPrefix😀tail"), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt b/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt new file mode 100644 index 0000000..559d342 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt @@ -0,0 +1,65 @@ +package ai.openclaw.app.node + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NodeUtilsTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parseJsonBooleanFlag_acceptsCommonStringAliases() { + val cases = + linkedMapOf( + """{"enabled":"true"}""" to true, + """{"enabled":"false"}""" to false, + """{"enabled":"yes"}""" to true, + """{"enabled":"no"}""" to false, + """{"enabled":"1"}""" to true, + """{"enabled":"0"}""" to false, + """{"enabled":" YES "}""" to true, + ) + for ((source, expected) in cases) { + val params = json.parseToJsonElement(source) as JsonObject + assertEquals(source, expected, parseJsonBooleanFlag(params, "enabled")) + } + } + + @Test + fun parseJsonBooleanFlag_acceptsJsonBooleanLiterals() { + val params = + buildJsonObject { + put("enabled", true) + put("disabled", false) + } + + assertEquals(true, parseJsonBooleanFlag(params, "enabled")) + assertEquals(false, parseJsonBooleanFlag(params, "disabled")) + } + + @Test + fun parseJsonBooleanFlag_returnsNullForUnknownValues() { + val params = json.parseToJsonElement("""{"enabled":"maybe"}""") as JsonObject + + assertNull(parseJsonBooleanFlag(params, "enabled")) + assertNull(parseJsonBooleanFlag(params, "missing")) + } + + @Test + fun parseJsonBooleanFlag_parsesIncludeAudioAliasesForCameraClip() { + val cases = + linkedMapOf( + """{"includeAudio":"no"}""" to false, + """{"includeAudio":"0"}""" to false, + """{"includeAudio":"yes"}""" to true, + ) + for ((source, expected) in cases) { + val params = json.parseToJsonElement(source) as JsonObject + assertEquals(source, expected, parseJsonBooleanFlag(params, "includeAudio")) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt new file mode 100644 index 0000000..f083ed1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/NotificationsHandlerTest.kt @@ -0,0 +1,333 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import android.content.Context +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class NotificationsHandlerTest { + @Test + fun notificationsListReturnsStatusPayloadWhenDisabled() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertFalse(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(0, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, payload.getValue("notifications").jsonArray.size) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun notificationsListRequestsRebindWhenEnabledButDisconnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = false, + notifications = listOf(sampleEntry("n1")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(1, payload.getValue("notifications").jsonArray.size) + assertEquals(1, provider.rebindRequests) + } + + @Test + fun notificationsListDoesNotRequestRebindWhenConnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n2")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertTrue(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun notificationsActions_executesDismissAction() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n2")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n2","action":"dismiss"}""") + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("ok").jsonPrimitive.boolean) + assertEquals("n2", payload.getValue("key").jsonPrimitive.content) + assertEquals("dismiss", payload.getValue("action").jsonPrimitive.content) + assertEquals("n2", provider.lastAction?.key) + assertEquals(NotificationActionKind.Dismiss, provider.lastAction?.kind) + } + + @Test + fun notificationsActions_requiresReplyTextForReplyAction() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n3")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n3","action":"reply"}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + assertEquals(0, provider.actionRequests) + } + + @Test + fun notificationsActions_rejectsMissingKey() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n3")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"action":"open"}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + assertEquals(0, provider.actionRequests) + } + + @Test + fun notificationsActions_rejectsInvalidAction() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n3")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n3","action":"archive"}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + assertEquals(0, provider.actionRequests) + } + + @Test + fun notificationsActions_propagatesProviderError() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n4")), + ), + ).also { + it.actionResult = + NotificationActionResult( + ok = false, + code = "NOTIFICATION_NOT_FOUND", + message = "NOTIFICATION_NOT_FOUND: notification key not found", + ) + } + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""") + + assertFalse(result.ok) + assertEquals("NOTIFICATION_NOT_FOUND", result.error?.code) + assertEquals(1, provider.actionRequests) + } + + @Test + fun notificationsActions_fallsBackWhenProviderOmitsErrorDetails() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n4")), + ), + ).also { + it.actionResult = NotificationActionResult(ok = false) + } + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""") + + assertFalse(result.ok) + assertEquals("UNAVAILABLE", result.error?.code) + assertEquals("notification action failed", result.error?.message) + assertEquals(1, provider.actionRequests) + } + + @Test + fun notificationsActions_requestsRebindWhenEnabledButDisconnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = false, + notifications = listOf(sampleEntry("n5")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsActions("""{"key":"n5","action":"open"}""") + + assertTrue(result.ok) + assertEquals(1, provider.rebindRequests) + assertEquals(1, provider.actionRequests) + } + + @Test + fun sanitizeNotificationTextReturnsNullForBlankInput() { + assertNull(sanitizeNotificationText(null)) + assertNull(sanitizeNotificationText(" ")) + } + + @Test + fun sanitizeNotificationTextTrimsAndTruncates() { + val value = " ${"x".repeat(600)} " + val sanitized = sanitizeNotificationText(value) + + assertEquals(512, sanitized?.length) + assertTrue((sanitized ?: "").all { it == 'x' }) + } + + @Test + fun sanitizeNotificationTextPreservesUtf16BoundariesAtLimit() { + val splitPairPrefix = "a".repeat(511) + assertEquals(splitPairPrefix, sanitizeNotificationText("$splitPairPrefix🚀 trailing text")) + + val completePairPrefix = "a".repeat(510) + assertEquals( + "$completePairPrefix🚀", + sanitizeNotificationText("$completePairPrefix🚀 trailing text"), + ) + } + + @Test + fun notificationsActionClearablePolicy_onlyRequiresClearableForDismiss() { + assertTrue(actionRequiresClearableNotification(NotificationActionKind.Dismiss)) + assertFalse(actionRequiresClearableNotification(NotificationActionKind.Open)) + assertFalse(actionRequiresClearableNotification(NotificationActionKind.Reply)) + } + + private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject { + val payloadJson = result.payloadJson ?: error("expected payload") + return Json.parseToJsonElement(payloadJson).jsonObject + } + + private fun appContext(): Context = RuntimeEnvironment.getApplication() + + private fun sampleEntry(key: String): DeviceNotificationEntry = + DeviceNotificationEntry( + key = key, + packageName = "com.example.app", + title = "Title", + text = "Text", + subText = null, + category = null, + channelId = null, + postTimeMs = 123L, + isOngoing = false, + isClearable = true, + ) +} + +private class FakeNotificationsStateProvider( + private val snapshot: DeviceNotificationSnapshot, +) : NotificationsStateProvider { + var rebindRequests: Int = 0 + private set + var actionRequests: Int = 0 + private set + var actionResult: NotificationActionResult = NotificationActionResult(ok = true) + var lastAction: NotificationActionRequest? = null + + override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot + + override fun requestServiceRebind(context: Context) { + rebindRequests += 1 + } + + override fun executeAction( + context: Context, + request: NotificationActionRequest, + ): NotificationActionResult { + actionRequests += 1 + lastAction = request + return actionResult + } +} diff --git a/app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt new file mode 100644 index 0000000..5feff12 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/PhotosHandlerTest.kt @@ -0,0 +1,75 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhotosHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handlePhotosLatest_requiresPermission() { + val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false)) + + val result = handler.handlePhotosLatest(null) + + assertFalse(result.ok) + assertEquals("PHOTOS_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handlePhotosLatest_rejectsInvalidJson() { + val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = true)) + + val result = handler.handlePhotosLatest("[]") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handlePhotosLatest_returnsPayload() { + val source = + FakePhotosDataSource( + hasPermission = true, + latest = + listOf( + EncodedPhotoPayload( + format = "jpeg", + base64 = "abc123", + width = 640, + height = 480, + createdAt = "2026-02-28T00:00:00Z", + ), + ), + ) + val handler = PhotosHandler.forTesting(appContext(), source) + + val result = handler.handlePhotosLatest("""{"limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val photos = payload.getValue("photos").jsonArray + assertEquals(1, photos.size) + val first = photos.first().jsonObject + assertEquals("jpeg", first.getValue("format").jsonPrimitive.content) + assertEquals(640, first.getValue("width").jsonPrimitive.int) + } +} + +private class FakePhotosDataSource( + private val hasPermission: Boolean, + private val latest: List = emptyList(), +) : PhotosDataSource { + override fun hasPermission(context: Context): Boolean = hasPermission + + override fun latest( + context: Context, + request: PhotosLatestRequest, + ): List = latest +} diff --git a/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt b/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt new file mode 100644 index 0000000..460f9c8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt @@ -0,0 +1,143 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.MainActivity +import android.content.Context +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SystemHandlerTest { + @Test + fun handleSystemNotify_rejectsUnauthorized() { + val handler = SystemHandler.forTesting(poster = FakePoster(authorized = false)) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""") + + assertFalse(result.ok) + assertEquals("NOT_AUTHORIZED", result.error?.code) + } + + @Test + fun handleSystemNotify_rejectsEmptyNotification() { + val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true)) + + val result = handler.handleSystemNotify("""{"title":" ","body":" "}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handleSystemNotify_rejectsInvalidRequestObject() { + val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true)) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw"}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handleSystemNotify_postsNotification() { + val poster = FakePoster(authorized = true) + val handler = SystemHandler.forTesting(poster = poster) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done","priority":"active"}""") + + assertTrue(result.ok) + assertEquals(1, poster.posts) + } + + @Test + fun handleSystemNotify_trimsAndPassesOptionalFields() { + val poster = FakePoster(authorized = true) + val handler = SystemHandler.forTesting(poster = poster) + + val result = + handler.handleSystemNotify( + """{"title":" OpenClaw ","body":" done ","priority":" passive ","sound":" silent "}""", + ) + + assertTrue(result.ok) + assertEquals("OpenClaw", poster.lastRequest?.title) + assertEquals("done", poster.lastRequest?.body) + assertEquals("passive", poster.lastRequest?.priority) + assertEquals("silent", poster.lastRequest?.sound) + } + + @Test + fun buildSystemNotificationSetsImmutableAppLaunchIntent() { + val context: Context = RuntimeEnvironment.getApplication() + val notification = + buildSystemNotification( + appContext = context, + channelId = "test", + request = SystemNotifyRequest("OpenClaw", "done", sound = null, priority = null), + ) + + val pendingIntent = notification.contentIntent + assertNotNull(pendingIntent) + assertTrue(pendingIntent.isImmutable) + + val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent + assertEquals(MainActivity::class.java.name, savedIntent.component?.className) + val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + assertEquals(expectedFlags, savedIntent.flags and expectedFlags) + } + + @Test + fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() { + val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied"))) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""") + + assertFalse(result.ok) + assertEquals("NOT_AUTHORIZED", result.error?.code) + } + + @Test + fun handleSystemNotify_returnsUnavailableWhenPostFailsUnexpectedly() { + val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = IllegalStateException("boom"))) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""") + + assertFalse(result.ok) + assertEquals("UNAVAILABLE", result.error?.code) + assertEquals("NOTIFICATION_FAILED: boom", result.error?.message) + } +} + +private class FakePoster( + private val authorized: Boolean, +) : SystemNotificationPoster { + var posts: Int = 0 + private set + var lastRequest: SystemNotifyRequest? = null + private set + + override fun isAuthorized(): Boolean = authorized + + override fun post(request: SystemNotifyRequest) { + posts += 1 + lastRequest = request + } +} + +private class ThrowingPoster( + private val authorized: Boolean, + private val error: Throwable, +) : SystemNotificationPoster { + override fun isAuthorized(): Boolean = authorized + + override fun post(request: SystemNotifyRequest): Unit = throw error +} diff --git a/app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt b/app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt new file mode 100644 index 0000000..7d49843 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/protocol/OpenClawCanvasA2UIActionTest.kt @@ -0,0 +1,63 @@ +package ai.openclaw.app.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class OpenClawCanvasA2UIActionTest { + @Test + fun extractActionNameAcceptsNameOrAction() { + val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject + assertEquals("Hello", OpenClawCanvasA2UIAction.extractActionName(nameObj)) + + val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject + assertEquals("Wave", OpenClawCanvasA2UIAction.extractActionName(actionObj)) + + val fallbackObj = + Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject + assertEquals("Fallback", OpenClawCanvasA2UIAction.extractActionName(fallbackObj)) + } + + @Test + fun formatAgentMessageMatchesSharedSpec() { + val msg = + OpenClawCanvasA2UIAction.formatAgentMessage( + actionName = "Get Weather", + sessionKey = "main", + surfaceId = "main", + sourceComponentId = "btnWeather", + host = "Peter’s iPad", + instanceId = "ipad16,6", + contextJson = "{\"city\":\"Vienna\"}", + ) + + assertEquals( + "CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas", + msg, + ) + } + + @Test + fun jsDispatchA2uiStatusIsStable() { + val js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null) + assertEquals( + "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));", + js, + ) + } + + @Test + fun jsDispatchA2uiStatusQuotesControlCharacters() { + val js = + OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( + actionId = "a1\n\u2028\"", + ok = false, + error = "parse failed\n\t\u2029\\", + ) + assertEquals( + "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"a1\\n\\u2028\\\"\", ok: false, error: \"parse failed\\n\\t\\u2029\\\\\" } }));", + js, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt new file mode 100644 index 0000000..b9e63af --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt @@ -0,0 +1,39 @@ +package ai.openclaw.app.protocol + +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenClawProtocolConstantsTest { + @Test + fun generatedCapabilitiesAreUniqueProtocolIds() { + val values = OpenClawCapability.entries.map { it.rawValue } + + assertTrue(values.isNotEmpty()) + assertTrue(values.all { it.isNotBlank() && "." !in it }) + assertTrue(values.size == values.toSet().size) + } + + @Test + fun generatedCommandGroupsMatchTheirNamespaces() { + val groups = + listOf( + OpenClawCanvasCommand.NamespacePrefix to OpenClawCanvasCommand.entries.map { it.rawValue }, + OpenClawCanvasA2UICommand.NamespacePrefix to OpenClawCanvasA2UICommand.entries.map { it.rawValue }, + OpenClawCameraCommand.NamespacePrefix to OpenClawCameraCommand.entries.map { it.rawValue }, + OpenClawSmsCommand.NamespacePrefix to OpenClawSmsCommand.entries.map { it.rawValue }, + OpenClawTalkCommand.NamespacePrefix to OpenClawTalkCommand.entries.map { it.rawValue }, + OpenClawLocationCommand.NamespacePrefix to OpenClawLocationCommand.entries.map { it.rawValue }, + OpenClawDeviceCommand.NamespacePrefix to OpenClawDeviceCommand.entries.map { it.rawValue }, + OpenClawNotificationsCommand.NamespacePrefix to OpenClawNotificationsCommand.entries.map { it.rawValue }, + OpenClawSystemCommand.NamespacePrefix to OpenClawSystemCommand.entries.map { it.rawValue }, + OpenClawPhotosCommand.NamespacePrefix to OpenClawPhotosCommand.entries.map { it.rawValue }, + OpenClawContactsCommand.NamespacePrefix to OpenClawContactsCommand.entries.map { it.rawValue }, + OpenClawCalendarCommand.NamespacePrefix to OpenClawCalendarCommand.entries.map { it.rawValue }, + OpenClawMotionCommand.NamespacePrefix to OpenClawMotionCommand.entries.map { it.rawValue }, + OpenClawCallLogCommand.NamespacePrefix to OpenClawCallLogCommand.entries.map { it.rawValue }, + ) + + val commands = groups.flatMap { (prefix, values) -> values.onEach { assertTrue(it.startsWith(prefix)) } } + assertTrue(commands.size == commands.toSet().size) + } +} diff --git a/app/src/test/java/ai/openclaw/app/systemagent/SystemAgentChatControllerTest.kt b/app/src/test/java/ai/openclaw/app/systemagent/SystemAgentChatControllerTest.kt new file mode 100644 index 0000000..13b3f4b --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/systemagent/SystemAgentChatControllerTest.kt @@ -0,0 +1,501 @@ +package ai.openclaw.app.systemagent + +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SystemAgentChatControllerTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun chatIsGatedAndGreetingUsesOnlySettingsSessionParams() = + runTest { + val harness = Harness(this) + harness.responses += reply("Ready") + + harness.controller.refresh() + harness.access = harness.access.copy(connected = true, gatewayId = "gateway-a") + harness.controller.refresh() + harness.access = harness.access.copy(hasAdminScope = true) + harness.controller.refresh() + assertTrue(harness.requests.isEmpty()) + + harness.access = harness.access.copy(supportsMethod = true) + harness.controller.refresh() + advanceUntilIdle() + + val request = harness.requests.single() + assertEquals("openclaw.chat", request.method) + assertEquals(190_000, request.timeoutMs) + val params = json.parseToJsonElement(request.paramsJson).jsonObject + assertTrue( + params + .getValue("sessionId") + .jsonPrimitive.content + .startsWith("android-settings-openclaw-"), + ) + assertFalse("message" in params) + assertFalse("welcomeVariant" in params) + assertFalse("delegation" in params) + } + + @Test + fun gatewayAccessRefreshDoesNotStartConversationUntilScreenRequestsIt() = + runTest { + val harness = readyHarness(this) + harness.responses += reply("Ready") + + harness.controller.refresh(startIfNeeded = false) + advanceUntilIdle() + assertEquals(SystemAgentChatAccess.Ready, harness.controller.state.value.access) + assertTrue(harness.requests.isEmpty()) + + harness.controller.refresh() + advanceUntilIdle() + assertEquals(1, harness.requests.size) + } + + @Test + fun sensitiveReplyIsSentVerbatimAndRedactedLocally() = + runTest { + val harness = readyHarness(this) + harness.responses += reply("Enter the token", sensitive = true) + harness.responses += reply("Saved") + + harness.controller.refresh() + advanceUntilIdle() + assertTrue(harness.controller.state.value.expectsSensitiveReply) + + harness.controller.setInput(" nonpublic test text ") + harness.controller.sendInput() + advanceUntilIdle() + + val params = json.parseToJsonElement(harness.requests[1].paramsJson).jsonObject + assertEquals(" nonpublic test text ", params.getValue("message").jsonPrimitive.content) + assertTrue( + harness.controller.state.value.messages.any { + it.role == SystemAgentChatMessage.Role.User && it.text == "" + }, + ) + assertFalse( + harness.controller.state.value.messages + .any { "nonpublic test text" in it.text }, + ) + } + + @Test + fun typedOptionSendsCanonicalReplyAndRetiresQuestion() = + runTest { + val harness = readyHarness(this) + harness.responses += questionReply() + harness.responses += reply("Applied") + + harness.controller.refresh() + advanceUntilIdle() + val questionMessage = + harness.controller.state.value.messages + .single() + + harness.controller.answerQuestion(questionMessage.id, "Use Tailscale") + advanceUntilIdle() + + val params = json.parseToJsonElement(harness.requests[1].paramsJson).jsonObject + assertEquals("tailscale", params.getValue("message").jsonPrimitive.content) + assertTrue( + harness.controller.state.value.messages + .any { it.text == "Use Tailscale" }, + ) + assertTrue(questionMessage.id in harness.controller.state.value.retiredQuestionIds) + } + + @Test + fun skipSendsExplicitReplyAndDismissesQuestion() = + runTest { + val harness = readyHarness(this) + harness.responses += questionReply() + harness.responses += reply("Skipped") + + harness.controller.refresh() + advanceUntilIdle() + val questionMessage = + harness.controller.state.value.messages + .single() + + harness.controller.skipQuestion(questionMessage.id) + advanceUntilIdle() + + val params = json.parseToJsonElement(harness.requests[1].paramsJson).jsonObject + assertEquals("Skip for now", params.getValue("message").jsonPrimitive.content) + assertTrue(questionMessage.id in harness.controller.state.value.dismissedQuestionIds) + } + + @Test + fun staleRouteReplyIsRejectedAndRequiresRestart() = + runTest { + val requestStarted = CompletableDeferred() + val response = CompletableDeferred() + val harness = readyHarness(this) + harness.handler = { + requestStarted.complete(Unit) + response.await() + } + + harness.controller.refresh() + runCurrent() + requestStarted.await() + harness.routeCurrent = false + response.complete(reply("stale reply")) + advanceUntilIdle() + + assertTrue( + harness.controller.state.value.messages + .isEmpty(), + ) + assertEquals( + "The Gateway connection changed. Restart OpenClaw to reconnect.", + harness.controller.state.value.errorText, + ) + } + + @Test + fun gatewaySwitchDuringAdmissionCannotPolluteReplacementConversation() = + runTest { + var access = + SystemAgentGatewayAccess( + connected = true, + hasAdminScope = true, + supportsMethod = true, + gatewayId = "gateway-a", + ) + var currentRoute = "gateway-a" + var switchDuringCapture = false + var requestCount = 0 + lateinit var controller: SystemAgentChatController + controller = + SystemAgentChatController( + scope = this, + access = { access }, + captureLease = { gatewayId -> + val capturedRoute = gatewayId.orEmpty() + val lease = + GatewaySession.RequestLease(capturedRoute, { currentRoute == capturedRoute }, null) { _, _, _ -> + requestCount += 1 + reply("Welcome") + } + if (switchDuringCapture) { + currentRoute = "gateway-b" + access = access.copy(gatewayId = currentRoute) + controller.refresh(startIfNeeded = false) + } + lease + }, + json = json, + ) + + controller.refresh() + advanceUntilIdle() + assertEquals( + listOf("Welcome"), + controller.state.value.messages + .map { it.text }, + ) + + switchDuringCapture = true + controller.setInput("stale message") + controller.sendInput() + advanceUntilIdle() + + assertEquals(SystemAgentChatAccess.Ready, controller.state.value.access) + assertTrue( + controller.state.value.messages + .isEmpty(), + ) + assertFalse(controller.state.value.sending) + assertNull(controller.state.value.errorText) + assertEquals(1, requestCount) + } + + @Test + fun staleFailureCannotCommitOutsideCapturedRoute() = + runTest { + val harness = readyHarness(this) + var commitCount = 0 + harness.commitIfCurrent = { block -> + commitCount += 1 + if (commitCount == 1) { + block() + true + } else { + false + } + } + harness.handler = { + throw GatewayRequestRejected(GatewaySession.ErrorShape("FAILED", "old route failure")) + } + + harness.controller.refresh() + advanceUntilIdle() + + assertEquals( + "The Gateway connection changed. Restart OpenClaw to reconnect.", + harness.controller.state.value.errorText, + ) + assertFalse( + harness.controller.state.value.errorText + ?.contains("old route failure") == true, + ) + } + + @Test + fun gatewayIdentityChangeRotatesConversationAndSession() = + runTest { + val harness = readyHarness(this) + harness.responses += questionReply() + harness.responses += reply("New gateway") + + harness.controller.refresh() + advanceUntilIdle() + val originalSessionId = harness.controller.state.value.sessionId + harness.controller.setInput("discard-me") + + harness.access = harness.access.copy(gatewayId = "gateway-b") + harness.controller.refresh(startIfNeeded = false) + assertNotEquals(originalSessionId, harness.controller.state.value.sessionId) + assertTrue( + harness.controller.state.value.messages + .isEmpty(), + ) + assertEquals("", harness.controller.state.value.input) + assertEquals(1, harness.requests.size) + harness.controller.refresh() + advanceUntilIdle() + assertEquals( + listOf("New gateway"), + harness.controller.state.value.messages + .map { it.text }, + ) + } + + @Test + fun reconnectOnSameGatewayRetainsTranscriptButRequiresFreshSession() = + runTest { + val harness = readyHarness(this) + harness.responses += reply("Welcome") + harness.responses += reply("Recovered") + + harness.controller.refresh() + advanceUntilIdle() + val originalSessionId = harness.controller.state.value.sessionId + + harness.access = harness.access.copy(connected = false, supportsMethod = false) + harness.controller.refresh() + assertEquals(SystemAgentChatAccess.Disconnected, harness.controller.state.value.access) + assertEquals( + listOf("Welcome"), + harness.controller.state.value.messages + .map { it.text }, + ) + + harness.access = harness.access.copy(connected = true, supportsMethod = true) + harness.controller.refresh() + assertEquals(SystemAgentChatAccess.Ready, harness.controller.state.value.access) + assertTrue(harness.controller.state.value.errorText != null) + assertEquals(1, harness.requests.size) + + harness.controller.restart() + advanceUntilIdle() + assertNotEquals(originalSessionId, harness.controller.state.value.sessionId) + assertEquals( + listOf("Recovered"), + harness.controller.state.value.messages + .map { it.text }, + ) + } + + @Test + fun pendingSupportCheckClearsDraftButKeepsSecureConversationState() = + runTest { + val harness = readyHarness(this) + harness.responses += reply("Enter a secret", sensitive = true) + + harness.controller.refresh() + advanceUntilIdle() + harness.controller.setInput("discard-me") + harness.access = harness.access.copy(supportsMethod = null) + harness.controller.refresh() + + assertEquals(SystemAgentChatAccess.CheckingGateway, harness.controller.state.value.access) + assertEquals("", harness.controller.state.value.input) + assertTrue(harness.controller.state.value.expectsSensitiveReply) + assertNull(harness.controller.state.value.errorText) + + harness.access = harness.access.copy(supportsMethod = true) + harness.controller.refresh() + assertEquals(1, harness.requests.size) + assertEquals( + listOf("Enter a secret"), + harness.controller.state.value.messages + .map { it.text }, + ) + } + + @Test + fun backgroundCleanupClearsDraftWithoutCancelingInFlightGreeting() = + runTest { + val requestStarted = CompletableDeferred() + val response = CompletableDeferred() + val harness = readyHarness(this) + harness.handler = { + requestStarted.complete(Unit) + response.await() + } + + harness.controller.refresh() + runCurrent() + requestStarted.await() + harness.controller.setInput("discard-me") + harness.controller.clearInputForBackground() + response.complete(reply("Welcome")) + advanceUntilIdle() + + assertEquals("", harness.controller.state.value.input) + assertEquals( + listOf("Welcome"), + harness.controller.state.value.messages + .map { it.text }, + ) + assertNull(harness.controller.state.value.errorText) + } + + @Test + fun handoffWaitsForExplicitActionAndBlocksFurtherMessages() = + runTest { + val harness = readyHarness(this) + harness.responses += reply("Continue in chat", action = "open-agent", agentId = " reviewer ") + + harness.controller.refresh() + advanceUntilIdle() + harness.controller.setInput("must-not-send") + harness.controller.sendInput() + advanceUntilIdle() + + assertEquals(1, harness.requests.size) + assertEquals( + " reviewer ", + harness.controller.state.value.handoff + ?.agentId, + ) + assertEquals(" reviewer ", harness.controller.openHandoff()?.agentId) + assertNull(harness.controller.state.value.handoff) + } + + private fun readyHarness(scope: CoroutineScope): Harness = + Harness(scope).also { + it.access = + SystemAgentGatewayAccess( + connected = true, + hasAdminScope = true, + supportsMethod = true, + gatewayId = "gateway-a", + ) + } + + private fun reply( + text: String, + action: String = "none", + sensitive: Boolean? = null, + agentId: String? = null, + ): String = + buildJsonObject { + put("sessionId", JsonPrimitive("system-session")) + put("reply", JsonPrimitive(text)) + put("action", JsonPrimitive(action)) + sensitive?.let { put("sensitive", JsonPrimitive(it)) } + agentId?.let { put("agentId", JsonPrimitive(it)) } + }.toString() + + private fun questionReply(): String = + """ + { + "sessionId": "system-session", + "reply": "Choose a connection", + "action": "none", + "question": { + "id": "connection", + "header": "Connection", + "question": "How should OpenClaw connect?", + "options": [ + { + "label": "Use Tailscale", + "description": "Private network", + "recommended": true, + "reply": "tailscale" + }, + { + "label": "Use LAN", + "description": "Local network", + "reply": "lan" + } + ] + } + } + """.trimIndent() + + private data class RecordedRequest( + val method: String, + val paramsJson: String, + val timeoutMs: Long, + ) + + private class Harness( + scope: CoroutineScope, + ) { + var access = + SystemAgentGatewayAccess( + connected = false, + hasAdminScope = false, + supportsMethod = null, + gatewayId = null, + ) + var routeCurrent = true + var commitIfCurrent: ((block: () -> Unit) -> Boolean)? = null + val requests = mutableListOf() + val responses = ArrayDeque() + var handler: suspend (RecordedRequest) -> String = { responses.removeFirst() } + val controller = + SystemAgentChatController( + scope = scope, + access = { access }, + captureLease = { gatewayId -> + if (!access.connected) { + null + } else { + GatewaySession.RequestLease(gatewayId.orEmpty(), { routeCurrent }, commitIfCurrent) { method, paramsJson, timeoutMs -> + val request = RecordedRequest(method, paramsJson.orEmpty(), timeoutMs) + requests += request + handler(request) + } + } + }, + json = Json { ignoreUnknownKeys = true }, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/tools/ToolDisplayRegistryTest.kt b/app/src/test/java/ai/openclaw/app/tools/ToolDisplayRegistryTest.kt new file mode 100644 index 0000000..1354f19 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/tools/ToolDisplayRegistryTest.kt @@ -0,0 +1,41 @@ +package ai.openclaw.app.tools + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class ToolDisplayRegistryTest { + @Test + @Config(sdk = [34]) + fun resolvePreservesUtf16BoundariesAtDetailPreviewLimit() { + val context = RuntimeEnvironment.getApplication() + + val splitPairPrefix = "a".repeat(156) + val splitSummary = + ToolDisplayRegistry.resolve( + context = context, + name = "bash", + args = JsonObject(mapOf("command" to JsonPrimitive("$splitPairPrefix😀tail"))), + ) + assertEquals("$splitPairPrefix…", splitSummary.detail) + assertFalse(Character.isHighSurrogate(splitSummary.detail!!.last())) + + val completePairPrefix = "a".repeat(155) + val completeSummary = + ToolDisplayRegistry.resolve( + context = context, + name = "bash", + args = JsonObject(mapOf("command" to JsonPrimitive("$completePairPrefix😀tail"))), + ) + assertEquals("$completePairPrefix😀…", completeSummary.detail) + assertTrue(completeSummary.detail!!.contains("😀")) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/CanvasA2UIActionBridgeTest.kt b/app/src/test/java/ai/openclaw/app/ui/CanvasA2UIActionBridgeTest.kt new file mode 100644 index 0000000..c9b12fc --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/CanvasA2UIActionBridgeTest.kt @@ -0,0 +1,50 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CanvasA2UIActionBridgeTest { + @Test + fun forwardsTrimmedPayloadFromTrustedPage() { + val forwarded = mutableListOf() + val bridge = + CanvasA2UIActionBridge( + isTrustedPage = { true }, + onMessage = { forwarded += it }, + ) + + bridge.postMessage(" {\"ok\":true} ") + + assertEquals(listOf("{\"ok\":true}"), forwarded) + } + + @Test + fun rejectsPayloadFromUntrustedPage() { + val forwarded = mutableListOf() + val bridge = + CanvasA2UIActionBridge( + isTrustedPage = { false }, + onMessage = { forwarded += it }, + ) + + bridge.postMessage("{\"ok\":true}") + + assertTrue(forwarded.isEmpty()) + } + + @Test + fun rejectsBlankPayloadBeforeForwarding() { + val forwarded = mutableListOf() + val bridge = + CanvasA2UIActionBridge( + isTrustedPage = { true }, + onMessage = { forwarded += it }, + ) + + bridge.postMessage(" ") + bridge.postMessage(null) + + assertTrue(forwarded.isEmpty()) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt b/app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt new file mode 100644 index 0000000..2301fe1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/CommandPaletteLogicTest.kt @@ -0,0 +1,84 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.i18n.verbatimText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CommandPaletteLogicTest { + @Test + fun localizedCopyDrivesRenderingAndSearchWithoutChangingActionIdentity() { + val item = + CommandItem( + action = CommandAction.Chat, + title = verbatimText("Ouvrir le chat"), + subtitle = verbatimText("Démarrer ou poursuivre une conversation"), + icon = Icons.Outlined.ChatBubbleOutline, + onClick = {}, + ) + + assertEquals("Ouvrir le chat", item.title.resolveNativeText()) + assertEquals("Démarrer ou poursuivre une conversation", item.subtitle.resolveNativeText()) + assertTrue(item.matches("ouvrir")) + assertTrue(item.matches("OUVRIR")) + assertTrue(item.matches("conversation")) + assertFalse(item.matches("open chat")) + assertTrue(item.copy(title = verbatimText("İletişim")).matches("iletişim")) + assertEquals(CommandAction.Chat, item.action) + } + + @Test + fun sessionSearchIgnoresQueryCase() { + assertTrue(commandSessionMatches(title = "Incident Review", query = "INCIDENT")) + assertTrue(commandSessionMatches(title = "Incident Review", query = "review")) + assertFalse(commandSessionMatches(title = "Incident Review", query = "deployment")) + } + + @Test + fun accessibilityDescriptionUsesLocalizedActionCopyWithoutDuplicateVerbs() { + val chatDescription = + commandActionAccessibilityDescription(CommandAction.Chat, "Ouvrir le chat") { _, _ -> + error("verb-led commands should use their localized title directly") + } + val settingsDescription = + commandActionAccessibilityDescription(CommandAction.Settings, "Paramètres") { source, title -> + assertEquals("Open \${row.title}", source) + "Ouvrir $title" + } + + assertEquals("Ouvrir le chat", chatDescription) + assertEquals("Ouvrir Paramètres", settingsDescription) + } + + @Test + fun stableActionDispatchDoesNotDependOnLocalizedCopy() { + val calls = mutableListOf() + val item = + CommandItem( + action = CommandAction.Voice, + title = verbatimText("Démarrer la voix"), + subtitle = verbatimText("Parler avec OpenClaw"), + icon = Icons.Outlined.ChatBubbleOutline, + onClick = { calls += CommandAction.Voice }, + ) + + item.onClick() + + assertEquals(CommandAction.Voice, item.action) + assertEquals(listOf(CommandAction.Voice), calls) + } + + @Test + fun relativeTimeUsesCatalogBackedCompactLabels() { + val now = 10_000_000L + + assertEquals("now", commandRelativeTime(updatedAtMs = now, nowMs = now)) + assertEquals("5m", commandRelativeTime(updatedAtMs = now - 5 * 60_000L, nowMs = now)) + assertEquals("3h", commandRelativeTime(updatedAtMs = now - 3 * 60 * 60_000L, nowMs = now)) + assertEquals("2d", commandRelativeTime(updatedAtMs = now - 2 * 24 * 60 * 60_000L, nowMs = now)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/ControlUiWebViewTest.kt b/app/src/test/java/ai/openclaw/app/ui/ControlUiWebViewTest.kt new file mode 100644 index 0000000..478225a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/ControlUiWebViewTest.kt @@ -0,0 +1,56 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.security.MessageDigest + +@RunWith(RobolectricTestRunner::class) +class ControlUiWebViewTest { + @Test + fun pinnedSslError_proceedsOnlyForExactCertificateAtGatewayOrigin() { + val certificate = "accepted gateway certificate".toByteArray() + val fingerprint = sha256Hex(certificate) + + assertTrue( + shouldProceedForPinnedControlUiSslError( + pageBaseUrl = "https://gateway.example.com:8443/openclaw/", + expectedFingerprint = fingerprint, + errorUrl = "https://gateway.example.com:8443/openclaw/assets/app.js", + encodedCertificate = certificate, + ), + ) + assertFalse( + shouldProceedForPinnedControlUiSslError( + pageBaseUrl = "https://gateway.example.com:8443/openclaw/", + expectedFingerprint = "00".repeat(32), + errorUrl = "https://gateway.example.com:8443/openclaw/assets/app.js", + encodedCertificate = certificate, + ), + ) + assertFalse( + shouldProceedForPinnedControlUiSslError( + pageBaseUrl = "https://gateway.example.com:8443/openclaw/", + expectedFingerprint = fingerprint, + errorUrl = "https://attacker.example.com:8443/openclaw/assets/app.js", + encodedCertificate = certificate, + ), + ) + assertFalse( + shouldProceedForPinnedControlUiSslError( + pageBaseUrl = "https://gateway.example.com:8443/openclaw/", + expectedFingerprint = null, + errorUrl = "https://gateway.example.com:8443/openclaw/assets/app.js", + encodedCertificate = certificate, + ), + ) + } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/CronJobManagementPanelTest.kt b/app/src/test/java/ai/openclaw/app/ui/CronJobManagementPanelTest.kt new file mode 100644 index 0000000..3a17cee --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/CronJobManagementPanelTest.kt @@ -0,0 +1,48 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayCronRunSummary +import org.junit.Assert.assertEquals +import org.junit.Test + +class CronJobManagementPanelTest { + @Test + fun deliveryStatusLabelsCoverClosedCodesAndPreserveFutureCodes() { + val expected = + mapOf( + "delivered" to "Delivered", + "not-delivered" to "Not delivered", + "unknown" to "Unknown", + "not-requested" to "Not requested", + "future-status" to "future-status", + ) + + expected.forEach { (status, label) -> + assertEquals(label, cronDeliveryStatusLabel(status)) + } + } + + @Test + fun runSubtitleUsesTheDeliveryStatusPresentation() { + val run = + GatewayCronRunSummary( + ts = 0, + runId = "run-1", + status = "ok", + summary = "Complete", + error = null, + durationMs = 125, + deliveryStatus = "not-delivered", + sessionKey = null, + model = "openai/gpt-5.6", + ) + + assertEquals( + "125ms · Not delivered · openai/gpt-5.6 · Complete", + cronRunSubtitle(run), + ) + assertEquals( + "125ms · future-status · openai/gpt-5.6 · Complete", + cronRunSubtitle(run.copy(deliveryStatus = "future-status")), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt new file mode 100644 index 0000000..ac12945 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -0,0 +1,1251 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Base64 + +@RunWith(RobolectricTestRunner::class) +class GatewayConfigResolverTest { + @Test + fun insecureRemoteGuidanceRetainsTheCompleteSecurityRuleAndFix() { + val message = + gatewayEndpointValidationMessage( + GatewayEndpointValidationError.INSECURE_REMOTE_URL, + GatewayEndpointInputSource.MANUAL, + ) + + assertEquals( + "Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs. " + + "Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access.", + message, + ) + } + + @Test + fun manualTransportForcesSecureConnectionForRemoteHosts() { + val presentation = + gatewayManualTransportPresentation( + hostInput = "gateway.example.com", + requestedTls = false, + ) + + assertEquals(true, presentation.requiresTls) + assertEquals(true, presentation.effectiveTls) + assertEquals("Secure connection is required for this host.", presentation.helperText) + } + + @Test + fun manualTransportAllowsUnencryptedPrivateLanConnections() { + val presentation = + gatewayManualTransportPresentation( + hostInput = "192.168.1.20", + requestedTls = false, + ) + + assertEquals(false, presentation.requiresTls) + assertEquals(false, presentation.effectiveTls) + assertEquals("Use only on a trusted private network.", presentation.helperText) + } + + @Test + fun manualTransportDoesNotRepeatSelectedPrivateLanTlsState() { + val presentation = + gatewayManualTransportPresentation( + hostInput = "192.168.1.20", + requestedTls = true, + ) + + assertEquals(false, presentation.requiresTls) + assertEquals(true, presentation.effectiveTls) + assertNull(presentation.helperText) + } + + @Test + fun manualTransportClassifiesTheHostFromPastedAuthorities() { + val cases = + listOf( + "192.168.1.20:18790" to false, + "gateway.local:18790" to false, + "GATEWAY.LOCAL.:18790" to false, + "[::1]:18790" to false, + "gateway.example:443" to true, + "gateway.local.evil.com:18790" to true, + "100.64.0.9:18790" to true, + "[2001:db8::1]:443" to true, + ) + + for ((hostInput, requiresTls) in cases) { + val presentation = + gatewayManualTransportPresentation( + hostInput = hostInput, + requestedTls = false, + ) + + assertEquals(hostInput, requiresTls, presentation.requiresTls) + assertEquals(hostInput, requiresTls, presentation.effectiveTls) + assertEquals( + hostInput, + if (requiresTls) { + "Secure connection is required for this host." + } else { + "Use only on a trusted private network." + }, + presentation.helperText, + ) + } + } + + @Test + fun parseGatewayEndpointUsesDefaultTlsPortForBareWssUrls() { + val parsed = parseGatewayEndpoint("wss://gateway.example") + + assertEquals( + GatewayEndpointConfig( + host = "gateway.example", + port = 443, + tls = true, + displayUrl = "https://gateway.example", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://gateway.example") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsTailnetCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://100.64.0.9:18789") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() { + val parsed = parseGatewayEndpoint("https://gateway.example:443") + + assertEquals( + GatewayEndpointConfig( + host = "gateway.example", + port = 443, + tls = true, + displayUrl = "https://gateway.example", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsLoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://127.0.0.1") + + assertEquals( + GatewayEndpointConfig( + host = "127.0.0.1", + port = 18789, + tls = false, + displayUrl = "http://127.0.0.1:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsLocalhostCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://localhost:18789") + + assertEquals( + GatewayEndpointConfig( + host = "localhost", + port = 18789, + tls = false, + displayUrl = "http://localhost:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsAndroidEmulatorCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://10.0.2.2:18789") + + assertEquals( + GatewayEndpointConfig( + host = "10.0.2.2", + port = 18789, + tls = false, + displayUrl = "http://10.0.2.2:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789") + + assertEquals( + GatewayEndpointConfig( + host = "192.168.1.20", + port = 18789, + tls = false, + displayUrl = "http://192.168.1.20:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsMdnsCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://gateway.local:18789") + + assertEquals( + GatewayEndpointConfig( + host = "gateway.local", + port = 18789, + tls = false, + displayUrl = "http://gateway.local:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsNormalizedMdnsCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://GATEWAY.LOCAL.:18789") + + assertEquals("GATEWAY.LOCAL.", parsed?.host) + assertEquals(18789, parsed?.port) + assertEquals(false, parsed?.tls) + } + + @Test + fun parseGatewayEndpointRejectsMdnsSuffixAndLabelBypasses() { + val rejected = + listOf( + "ws://gateway.local.evil.com:18789", + "ws://gatewaylocal:18789", + "ws://local:18789", + "ws://.local:18789", + "ws://gateway..local:18789", + "ws://gateway.local%25wlan0:18789", + ) + + for (url in rejected) { + assertNull(url, parseGatewayEndpoint(url)) + } + } + + @Test + fun parseGatewayEndpointAllowsIpv6LoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[::1]") + + assertEquals("::1", parsed?.host) + assertEquals(18789, parsed?.port) + assertEquals(false, parsed?.tls) + assertEquals("http://[::1]:18789", parsed?.displayUrl) + } + + @Test + fun parseGatewayEndpointAllowsIpv4MappedIpv6LoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[::ffff:127.0.0.1]") + + assertEquals("::ffff:127.0.0.1", parsed?.host) + assertEquals(18789, parsed?.port) + assertEquals(false, parsed?.tls) + assertEquals("http://[::ffff:127.0.0.1]:18789", parsed?.displayUrl) + } + + @Test + fun parseGatewayEndpointRejectsCleartextLoopbackPrefixBypassHost() { + val parsed = parseGatewayEndpoint("http://127.attacker.example:80") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsNonLoopbackIpv6CleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[2001:db8::1]") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointReportsUnsupportedIpv6ZoneIds() { + listOf( + "ws://[fe80::1%25eth0]", + "wss://[fe80::1%25wlan0]:443", + ).forEach { url -> + val parsed = parseGatewayEndpointResult(url) + assertNull(url, parsed.config) + assertEquals(url, GatewayEndpointValidationError.IPV6_ZONE_ID_UNSUPPORTED, parsed.error) + } + } + + @Test + fun parseGatewayEndpointRejectsUnspecifiedIpv4CleartextHttpUrls() { + val parsed = parseGatewayEndpoint("http://0.0.0.0:80") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsUnspecifiedIpv6CleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[::]") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointAllowsLoopbackCleartextHttpUrls() { + val parsed = parseGatewayEndpoint("http://localhost:80") + + assertEquals( + GatewayEndpointConfig( + host = "localhost", + port = 80, + tls = false, + displayUrl = "http://localhost:80", + ), + parsed, + ) + } + + @Test + fun resolveScannedSetupCodeResultAcceptsRawSetupCode() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertEquals(setupCode, resolved.setupCode) + assertNull(resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultAcceptsEmulatorSetupCode() { + val setupCode = + encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertEquals(setupCode, resolved.setupCode) + assertNull(resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + val qrJson = + """ + { + "setupCode": "$setupCode", + "gatewayUrl": "wss://gateway.example:18789", + "auth": "password", + "urlSource": "gateway.remote.url" + } + """.trimIndent() + + val resolved = resolveScannedSetupCodeResult(qrJson) + + assertEquals(setupCode, resolved.setupCode) + assertNull(resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultRejectsInvalidInput() { + val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code") + assertNull(resolved.setupCode) + assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() { + val qrJson = """{"setupCode":"invalid"}""" + val resolved = resolveScannedSetupCodeResult(qrJson) + assertNull(resolved.setupCode) + assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() { + val qrJson = """{"setupCode":{"nested":"value"}}""" + val resolved = resolveScannedSetupCodeResult(qrJson) + assertNull(resolved.setupCode) + assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() { + val setupCode = + encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertNull(resolved.setupCode) + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() { + val setupCode = + encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertEquals(setupCode, resolved.setupCode) + assertNull(resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultAcceptsMdnsCleartextGateway() { + val setupCode = + encodeSetupCode("""{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertEquals(setupCode, resolved.setupCode) + assertNull(resolved.error) + } + + @Test + fun resolveScannedSetupCodeResultPreservesIpv6ZoneError() { + val setupCode = + encodeSetupCode("""{"url":"wss://[fe80::1%25wlan0]:443","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertNull(resolved.setupCode) + assertEquals(GatewayEndpointValidationError.IPV6_ZONE_ID_UNSUPPORTED, resolved.error) + } + + @Test + fun gatewayEndpointValidationMessageExplainsIpv6ZoneReplacement() { + val error = GatewayEndpointValidationError.IPV6_ZONE_ID_UNSUPPORTED + + assertEquals( + "IPv6 zone IDs are not supported. Use an unscoped IPv6 address or a LAN hostname.", + gatewayEndpointValidationMessage(error, GatewayEndpointInputSource.MANUAL), + ) + assertEquals( + "Setup code uses an IPv6 zone ID. Use an unscoped IPv6 address or a LAN hostname.", + gatewayEndpointValidationMessage(error, GatewayEndpointInputSource.SETUP_CODE), + ) + assertEquals( + "QR code uses an IPv6 zone ID. Use an unscoped IPv6 address or a LAN hostname.", + gatewayEndpointValidationMessage(error, GatewayEndpointInputSource.QR_SCAN), + ) + } + + @Test + fun parseGatewayEndpointResultFlagsInsecureRemoteGateway() { + val parsed = parseGatewayEndpointResult("ws://gateway.example:18789") + + assertNull(parsed.config) + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error) + } + + @Test + fun parseGatewayEndpointResultRejectsUnsupportedSchemes() { + val parsed = parseGatewayEndpointResult("ftp://gateway.example:21") + + assertNull(parsed.config) + assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error) + } + + @Test + fun parseGatewayEndpointResultRejectsInvalidExplicitPort() { + val parsed = parseGatewayEndpointResult("wss://gateway.example:70000") + + assertNull(parsed.config) + assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error) + } + + @Test + fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() { + val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789") + + assertEquals( + GatewayEndpointConfig( + host = "192.168.1.20", + port = 18789, + tls = false, + displayUrl = "http://192.168.1.20:18789", + ), + parsed.config, + ) + assertNull(parsed.error) + } + + @Test + fun parseGatewayEndpointResultAllowsMdnsCleartextGateway() { + val parsed = parseGatewayEndpointResult("ws://gateway.local:18789") + + assertEquals( + GatewayEndpointConfig( + host = "gateway.local", + port = 18789, + tls = false, + displayUrl = "http://gateway.local:18789", + ), + parsed.config, + ) + assertNull(parsed.error) + } + + @Test + fun decodeGatewaySetupCodeParsesBootstrapToken() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + + val decoded = decodeGatewaySetupCode(setupCode) + + assertEquals("wss://gateway.example:18789", decoded?.url) + assertEquals("bootstrap-1", decoded?.bootstrapToken) + assertNull(decoded?.token) + assertNull(decoded?.password) + } + + @Test + fun manualTokenDetectsSetupCodePayloads() { + val setupCode = + encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""") + val qrPayload = """{"setupCode":"$setupCode"}""" + + assertEquals(true, manualTokenLooksLikeSetupCode(setupCode)) + assertEquals(true, manualTokenLooksLikeSetupCode(qrPayload)) + assertEquals(false, manualTokenLooksLikeSetupCode("local-mobile-test")) + assertEquals(false, manualTokenLooksLikeSetupCode("")) + } + + @Test + fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() { + val setupCode = + encodeSetupCode( + """{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""", + ) + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHostInput = "", + manualPortInput = "", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "shared-token", + passwordInput = "shared-password", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("bootstrap-1", resolved?.bootstrapToken) + assertEquals("", resolved?.token) + assertEquals("", resolved?.password) + } + + @Test + fun resolveGatewayConnectConfigAcceptsQrJsonSetupCodePayload() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + val qrPayload = """{"setupCode":"$setupCode"}""" + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = qrPayload, + manualHostInput = "", + manualPortInput = "", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "shared-token", + passwordInput = "shared-password", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("bootstrap-1", resolved?.bootstrapToken) + assertEquals("", resolved?.token) + assertEquals("", resolved?.password) + } + + @Test + fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() { + val setupCode = + encodeSetupCode( + """{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""", + ) + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHostInput = "", + manualPortInput = "", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(443, resolved?.port) + assertEquals(true, resolved?.tls) + } + + @Test + fun resolveGatewayConnectConfigAllowsMdnsCleartextSetupCode() { + val setupCode = + encodeSetupCode( + """{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""", + ) + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHostInput = "", + manualPortInput = "", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("gateway.local", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(false, resolved?.tls) + } + + @Test + fun resolveGatewayConnectPlanPreservesRuntimeOwnedAuthForUnchangedEndpoint() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction) + assertEquals("", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.token) + assertEquals("", plan?.config?.password) + } + + @Test + fun resolveGatewayConnectPlanReplacesAuthWhenEndpointChanges() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "127.0.0.2", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction) + assertEquals("127.0.0.2", plan?.config?.host) + } + + @Test + fun resolveGatewayConnectPlanTreatsMissingSavedEndpointAsReplacement() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "", + savedManualPort = "", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction) + } + + @Test + fun resolveGatewayConnectPlanMarksSetupCodeAsExplicitReplacement() { + val setupCode = + encodeSetupCode( + """{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""", + ) + + val plan = + resolveGatewayConnectPlan( + useSetupCode = true, + setupCode = setupCode, + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction) + assertEquals("bootstrap-1", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.token) + } + + @Test + fun resolveGatewayConnectPlanUsesOneExplicitCredentialFamily() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "bootstrap", + tokenInput = "token", + passwordInput = "password", + ) + + assertEquals("token", plan?.config?.token) + assertEquals("", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.password) + assertEquals(GatewaySavedAuthAction.REPLACE_CREDENTIALS, plan?.savedAuthAction) + } + + @Test + fun resolveGatewayConnectPlanReplacesStalePairingForExplicitBootstrapAuth() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "gateway.local", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "gateway.local", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "replacement-bootstrap", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction) + assertEquals("replacement-bootstrap", plan?.config?.bootstrapToken) + } + + @Test + fun resolveGatewayConnectPlanPreservesAuthForHostnameCaseOnlyEdit() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "Gateway.Local", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "gateway.local", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction) + } + + @Test + fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() { + val resolved = + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = "192.168.31.100", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "bootstrap-1", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("192.168.31.100", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(false, resolved?.tls) + } + + @Test + fun resolveGatewayConnectConfigAllowsMdnsManualCleartextEndpoint() { + val resolved = + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = "gateway.local", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "bootstrap-1", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("gateway.local", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(false, resolved?.tls) + } + + @Test + fun composeGatewayManualUrlRejectsBareScheme() { + assertNull(composeGatewayManualUrl("ws://", "18789", tls = false)) + } + + @Test + fun composeGatewayManualUrlPreservesCompleteEndpoint() { + val cleartextUrl = composeGatewayManualUrl("ws://192.168.178.57:18790", "18789", tls = true) + val tlsUrl = composeGatewayManualUrl("wss://gateway.example:443", "18789", tls = false) + + assertEquals("ws://192.168.178.57:18790", cleartextUrl) + assertEquals("wss://gateway.example:443", tlsUrl) + assertEquals("http://192.168.178.57:18790", parseGatewayEndpoint(cleartextUrl!!)?.displayUrl) + assertEquals("https://gateway.example", parseGatewayEndpoint(tlsUrl!!)?.displayUrl) + } + + @Test + fun composeGatewayManualUrlPreservesAllCompleteEndpointSchemes() { + val cases = + listOf( + "ws://gateway.local:18790" to true, + "http://192.168.1.20:18790/gateway?mode=manual" to true, + "wss://gateway.example:8443" to false, + "https://gateway.example/gateway?mode=manual" to false, + "HTTPS://gateway.example:443" to false, + "WS://GATEWAY.LOCAL.:18790" to true, + "ws://[::1]:18790" to true, + "wss://[2001:db8::1]:443" to false, + ) + + for ((hostInput, staleTls) in cases) { + assertEquals( + hostInput, + hostInput, + composeGatewayManualUrl(hostInput, "not-a-port", tls = staleTls), + ) + } + } + + @Test + fun composeGatewayManualUrlPreservesCompleteEndpointValidationError() { + val url = composeGatewayManualUrl("ws://gateway.example:18789", "18789", tls = false) + + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parseGatewayEndpointResult(url!!).error) + } + + @Test + fun resolveGatewayConnectConfigManualAcceptsCompleteLanEndpoint() { + val resolved = + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = "ws://192.168.178.57:18790", + manualPortInput = "18789", + manualTlsInput = true, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("192.168.178.57", resolved?.host) + assertEquals(18790, resolved?.port) + assertEquals(false, resolved?.tls) + } + + @Test + fun composeGatewayManualUrlPreservesPastedManualAuthorities() { + for (case in pastedManualAuthorityCases) { + val url = composeGatewayManualUrl(case.hostInput, case.portInput, case.tls) + + assertEquals(case.hostInput, case.expectedUrl, url) + assertEquals(case.hostInput, case.expectedEndpoint, url?.let(::parseGatewayEndpoint)) + } + } + + @Test + fun resolveGatewayConnectConfigPreservesPastedManualAuthorities() { + for (case in pastedManualAuthorityCases) { + val resolved = + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = case.hostInput, + manualPortInput = case.portInput, + manualTlsInput = case.tls, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals( + case.hostInput, + GatewayConnectConfig( + host = case.expectedHost, + port = case.expectedPort, + tls = case.tls, + bootstrapToken = "", + token = "", + password = "", + ), + resolved, + ) + } + } + + @Test + fun resolveGatewayConnectPlanPreservesSavedAuthForPastedManualAuthorities() { + for (case in pastedManualAuthorityCases) { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = case.expectedHost, + savedManualPort = case.expectedPort.toString(), + savedManualTls = case.tls, + manualHostInput = case.hostInput, + manualPortInput = case.portInput, + manualTlsInput = case.tls, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(case.hostInput, GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction) + } + } + + @Test + fun composeGatewayManualUrlRejectsInvalidPastedAuthorityPorts() { + val hosts = + listOf( + "192.168.1.20:", + "192.168.1.20:0", + "192.168.1.20:-1", + "192.168.1.20:65536", + "192.168.1.20:99999999999999999999", + "gateway.local:", + "gateway.local:not-a-port", + "gateway.local:65536", + "[::1]:", + "[::1]:0", + "[::1]:65536", + "[::1]:99999999999999999999", + "[::1]:not-a-port", + ) + + for (hostInput in hosts) { + assertNull(hostInput, composeGatewayManualUrl(hostInput, "18789", tls = false)) + assertNull( + hostInput, + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = hostInput, + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ), + ) + } + } + + @Test + fun composeGatewayManualUrlRejectsPastedAuthorityUserInfoQueriesAndFragments() { + val hosts = + listOf( + "gateway.local@evil.example:443", + "gateway.local:18789@evil.example:443", + "user:password@gateway.local:18789", + "gateway.local:18789?redirect=evil.example", + "gateway.local:18789#evil.example", + "[::1]:18789?redirect=evil.example", + "[::1]:18789#evil.example", + ) + + for (hostInput in hosts) { + assertNull(hostInput, composeGatewayManualUrl(hostInput, "18789", tls = true)) + assertNull( + hostInput, + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = hostInput, + manualPortInput = "18789", + manualTlsInput = true, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ), + ) + } + } + + @Test + fun composeGatewayManualUrlRejectsInvalidSeparatelyEnteredPorts() { + for (portInput in listOf("0", "-1", "65536", "99999999999999999999", "not-a-port")) { + assertNull(portInput, composeGatewayManualUrl("gateway.local", portInput, tls = false)) + } + } + + @Test + fun composeGatewayManualUrlPreservesIpv6Hosts() { + for (hostInput in listOf("::1", "[::1]")) { + assertEquals("http://[::1]:18789", composeGatewayManualUrl(hostInput, "18789", tls = false)) + } + } + + @Test + fun composeGatewayManualUrlTrimsTrailingSlashFromBareHost() { + assertEquals( + "http://192.168.1.20:20000", + composeGatewayManualUrl("192.168.1.20/", "20000", tls = false), + ) + } + + @Test + fun composeGatewayManualUrlDefaultsPortTo443WhenTlsAndPortBlank() { + val url = composeGatewayManualUrl("mydevice.tail1234.ts.net", "", tls = true) + + assertEquals("https://mydevice.tail1234.ts.net:443", url) + } + + @Test + fun composeGatewayManualUrlDefaultsPortTo18789ForNonTailnetTlsHostsWhenPortBlank() { + val url = composeGatewayManualUrl("gateway.example.com", "", tls = true) + + assertEquals("https://gateway.example.com:18789", url) + } + + @Test + fun composeGatewayManualUrlDefaultsPortTo443ForTailnetHostWithTrailingDotWhenPortBlank() { + val url = composeGatewayManualUrl("device.sample.ts.net.", "", tls = true) + + assertEquals("https://device.sample.ts.net.:443", url) + } + + @Test + fun composeGatewayManualUrlDoesNotTreatLookalikeTailnetSuffixAsTailnet() { + val url = composeGatewayManualUrl("gateway.ts.net.evil.com", "", tls = true) + + assertEquals("https://gateway.ts.net.evil.com:18789", url) + } + + @Test + fun composeGatewayManualUrlDefaultsBlankCleartextPortTo18789() { + val url = composeGatewayManualUrl("127.0.0.1", "", tls = false) + + assertEquals("http://127.0.0.1:18789", url) + } + + @Test + fun composeGatewayManualUrl_bracketsIpv6ForEndpointParsing() { + val cases = + listOf( + ManualAuthorityCase( + hostInput = "::1", + expectedHost = "::1", + expectedPort = 18789, + portInput = "18789", + ), + ManualAuthorityCase( + hostInput = "[::1]", + expectedHost = "::1", + expectedPort = 18789, + portInput = "18789", + ), + ManualAuthorityCase( + hostInput = "::ffff:127.0.0.1", + expectedHost = "::ffff:127.0.0.1", + expectedPort = 18789, + portInput = "18789", + ), + ManualAuthorityCase( + hostInput = "[::ffff:127.0.0.1]", + expectedHost = "::ffff:127.0.0.1", + expectedPort = 18789, + portInput = "18789", + ), + ManualAuthorityCase( + hostInput = "2001:db8::1", + expectedHost = "2001:db8::1", + expectedPort = 18789, + tls = true, + portInput = "18789", + ), + ManualAuthorityCase( + hostInput = "[2001:db8::1]", + expectedHost = "2001:db8::1", + expectedPort = 18789, + tls = true, + portInput = "18789", + ), + ) + + for (case in cases) { + val url = composeGatewayManualUrl(case.hostInput, case.portInput, case.tls) + + assertEquals(case.hostInput, case.expectedUrl, url) + assertEquals(case.hostInput, case.expectedEndpoint, url?.let(::parseGatewayEndpoint)) + } + } + + @Test + fun composeGatewayManualUrlPreservesIpv6ZoneValidationErrors() { + val hosts = + listOf( + "fe80::1%25eth0", + "[fe80::1%25eth0]", + "[fe80::1%25eth0]:18789", + ) + + for (hostInput in hosts) { + val url = composeGatewayManualUrl(hostInput, "18789", tls = false) + + assertEquals( + hostInput, + GatewayEndpointValidationError.IPV6_ZONE_ID_UNSUPPORTED, + url?.let(::parseGatewayEndpointResult)?.error, + ) + } + } + + @Test + fun resolveGatewayConnectConfigManualAcceptsTailscaleHostWithoutPort() { + val resolved = + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + manualHostInput = "mydevice.tail1234.ts.net", + manualPortInput = "", + manualTlsInput = true, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("mydevice.tail1234.ts.net", resolved?.host) + assertEquals(443, resolved?.port) + assertEquals(true, resolved?.tls) + } + + private val pastedManualAuthorityCases = + listOf( + ManualAuthorityCase( + hostInput = "192.168.178.57:18790", + expectedHost = "192.168.178.57", + expectedPort = 18790, + ), + ManualAuthorityCase( + hostInput = "192.168.178.57:1", + expectedHost = "192.168.178.57", + expectedPort = 1, + ), + ManualAuthorityCase( + hostInput = "gateway.local:18790", + expectedHost = "gateway.local", + expectedPort = 18790, + ), + ManualAuthorityCase( + hostInput = "gateway.local:65535", + expectedHost = "gateway.local", + expectedPort = 65535, + ), + ManualAuthorityCase( + hostInput = "GATEWAY.LOCAL.:18790", + expectedHost = "GATEWAY.LOCAL.", + expectedPort = 18790, + portInput = "65536", + ), + ManualAuthorityCase( + hostInput = "gateway.example:443", + expectedHost = "gateway.example", + expectedPort = 443, + tls = true, + ), + ManualAuthorityCase( + hostInput = "gateway.example:8443", + expectedHost = "gateway.example", + expectedPort = 8443, + tls = true, + ), + ManualAuthorityCase( + hostInput = "mydevice.tail1234.ts.net:8443", + expectedHost = "mydevice.tail1234.ts.net", + expectedPort = 8443, + tls = true, + portInput = "", + ), + ManualAuthorityCase( + hostInput = "[::1]:18790", + expectedHost = "::1", + expectedPort = 18790, + ), + ManualAuthorityCase( + hostInput = "[2001:db8::1]:8443", + expectedHost = "2001:db8::1", + expectedPort = 8443, + tls = true, + ), + ) + + private data class ManualAuthorityCase( + val hostInput: String, + val expectedHost: String, + val expectedPort: Int, + val tls: Boolean = false, + val portInput: String = "not-a-port", + ) { + val expectedUrl: String + get() { + val scheme = if (tls) "https" else "http" + val formattedHost = if (expectedHost.contains(':')) "[$expectedHost]" else expectedHost + return "$scheme://$formattedHost:$expectedPort" + } + + val expectedEndpoint: GatewayEndpointConfig + get() = + GatewayEndpointConfig( + host = expectedHost, + port = expectedPort, + tls = tls, + displayUrl = if (tls && expectedPort == 443) expectedUrl.removeSuffix(":443") else expectedUrl, + ) + } + + private fun encodeSetupCode(payloadJson: String): String = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/GatewayDiagnosticsTest.kt b/app/src/test/java/ai/openclaw/app/ui/GatewayDiagnosticsTest.kt new file mode 100644 index 0000000..1b496b5 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/GatewayDiagnosticsTest.kt @@ -0,0 +1,96 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayDiagnosticsTest { + @Test + fun authRecoveryLabelsComeFromStructuredProblemCodes() { + val labels = + mapOf( + "AUTH_BOOTSTRAP_TOKEN_INVALID" to "Setup code expired", + "AUTH_TOKEN_MISSING" to "Gateway token needed", + "AUTH_TOKEN_NOT_CONFIGURED" to "Gateway token not configured", + "AUTH_PASSWORD_MISSING" to "Gateway password needed", + "AUTH_PASSWORD_MISMATCH" to "Gateway password invalid", + "AUTH_PASSWORD_NOT_CONFIGURED" to "Gateway password not configured", + "AUTH_SCOPE_MISMATCH" to "Gateway access needs review", + "AUTH_TOKEN_MISMATCH" to "Saved auth invalid", + "AUTH_DEVICE_TOKEN_MISMATCH" to "Saved auth invalid", + "CONTROL_UI_DEVICE_IDENTITY_REQUIRED" to "Device identity required", + "DEVICE_IDENTITY_REQUIRED" to "Device identity required", + ) + + labels.forEach { (code, label) -> + assertEquals(label, gatewayAuthRecoveryLabel(authProblem(code))) + } + assertNull(gatewayAuthRecoveryLabel(authProblem("SOME_UNMAPPED_CODE"))) + assertNull(gatewayAuthRecoveryLabel(null)) + } + + @Test + fun endpointPrefersLiveRemoteAddress() { + assertEquals( + "wss://gateway.example.test", + gatewayDiagnosticsEndpoint( + remoteAddress = " wss://gateway.example.test ", + manualHost = "10.0.2.2", + manualPort = 18789, + manualTls = false, + ), + ) + } + + @Test + fun endpointFallsBackToManualConfig() { + assertEquals( + "http://10.0.2.2:18789", + gatewayDiagnosticsEndpoint( + remoteAddress = null, + manualHost = "10.0.2.2", + manualPort = 18789, + manualTls = false, + ), + ) + } + + @Test + fun endpointReportsMissingConfig() { + assertEquals( + "Not set", + gatewayDiagnosticsEndpoint( + remoteAddress = null, + manualHost = "", + manualPort = 18789, + manualTls = false, + ), + ) + } + + @Test + fun diagnosticsReportIncludesSupportContext() { + val report = + buildGatewayDiagnosticsReport( + screen = "chat composer", + gatewayAddress = "http://10.0.2.2:18789", + statusText = "connection refused", + ) + + assertTrue(report.contains("- screen: chat composer")) + assertTrue(report.contains("- gateway address: http://10.0.2.2:18789")) + assertTrue(report.contains("- status/error: connection refused")) + } + + private fun authProblem(code: String) = + ai.openclaw.app.GatewayConnectionProblem( + code = code, + message = "Authentication failed.", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = false, + retryable = false, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/HealthLogsSettingsScreenTest.kt b/app/src/test/java/ai/openclaw/app/ui/HealthLogsSettingsScreenTest.kt new file mode 100644 index 0000000..ae92b3b --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/HealthLogsSettingsScreenTest.kt @@ -0,0 +1,47 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.VoiceCaptureMode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HealthLogsSettingsScreenTest { + @Test + fun voiceReadinessUsesTypedCaptureMode() { + assertTrue( + voiceRuntimeReady( + voiceCaptureMode = VoiceCaptureMode.ManualMic, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + talkAwaitingAgent = false, + ), + ) + } + + @Test + fun voiceReadinessIncludesTransientTalkActivity() { + assertTrue( + voiceRuntimeReady( + voiceCaptureMode = VoiceCaptureMode.Off, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + talkAwaitingAgent = true, + ), + ) + } + + @Test + fun voiceReadinessIsFalseWhenTypedRuntimeIsInactive() { + assertFalse( + voiceRuntimeReady( + voiceCaptureMode = VoiceCaptureMode.Off, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + talkAwaitingAgent = false, + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/InitialOnboardingLayoutTest.kt b/app/src/test/java/ai/openclaw/app/ui/InitialOnboardingLayoutTest.kt new file mode 100644 index 0000000..2434286 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/InitialOnboardingLayoutTest.kt @@ -0,0 +1,198 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.ui.design.ClawDesignTheme +import ai.openclaw.app.ui.design.MascotMood +import android.content.Context +import android.provider.Settings +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private const val OnboardingViewportTag = "initial-onboarding-viewport" + +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w360dp-h720dp-420dpi") +class InitialOnboardingLayoutTest { + @get:Rule + val composeRule = createComposeRule() + + @Before + fun disableMascotAnimations() { + val context = ApplicationProvider.getApplicationContext() + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + } + + @Test + fun defaultFontKeepsWelcomeContentAndActionVisible() { + var connectClicked = false + setContent(fontScale = 1f, viewportHeight = 720.dp) { + WelcomeScreen(mascotMood = MascotMood.Idle, onConnect = { connectClicked = true }) + } + + composeRule.onNodeWithText("Security notice").assertIsDisplayed() + composeRule.onNodeWithText("Continue").assertIsDisplayed().performClick() + assertTrue(connectClicked) + } + + @Test + fun largeFontKeepsWelcomeActionFixedWhileContentScrolls() { + var connectClicked = false + setContent(fontScale = 1.3f, viewportHeight = 480.dp) { + WelcomeScreen(mascotMood = MascotMood.Idle, onConnect = { connectClicked = true }) + } + + val viewport = composeRule.onNodeWithTag(OnboardingViewportTag) + val content = composeRule.onNodeWithText("Security notice") + val action = composeRule.onNodeWithText("Continue").assertIsDisplayed() + val scrollable = composeRule.onNode(hasScrollAction()).assertExists() + val viewportBounds = viewport.getUnclippedBoundsInRoot() + val contentBeforeScroll = content.getUnclippedBoundsInRoot() + val actionBeforeScroll = action.getUnclippedBoundsInRoot() + assertFullyInside(actionBeforeScroll, viewportBounds, "Welcome action") + + scrollable.performTouchInput { swipeUp() } + composeRule.waitForIdle() + + val contentAfterScroll = content.getUnclippedBoundsInRoot() + val actionAfterScroll = action.getUnclippedBoundsInRoot() + assertTrue("Welcome content should move upward after a swipe", contentAfterScroll.top < contentBeforeScroll.top) + assertEquals("Welcome action should remain fixed while content scrolls", actionBeforeScroll, actionAfterScroll) + assertFullyInside(actionAfterScroll, viewportBounds, "Welcome action") + + content.performScrollTo().assertIsDisplayed() + composeRule.waitForIdle() + + val actionAfterContentReached = action.getUnclippedBoundsInRoot() + assertEquals("Welcome action should remain fixed when content is reached", actionBeforeScroll, actionAfterContentReached) + assertFullyInside(actionAfterContentReached, viewportBounds, "Welcome action") + action.assertIsDisplayed().performClick() + assertTrue(connectClicked) + } + + @Test + fun defaultFontKeepsGatewayContentAndActionsVisible() { + var manualSetupClicked = false + setContent(fontScale = 1f, viewportHeight = 720.dp) { + GatewaySetupScreen( + nearbyGateway = null, + onBack = {}, + onSetupCode = {}, + onManualSetup = { manualSetupClicked = true }, + ) + } + + composeRule.onNodeWithText("Android setup guide").assertIsDisplayed() + composeRule.onNodeWithText("Scan QR or setup code").assertIsDisplayed() + composeRule.onNodeWithText("Set up manually").assertIsDisplayed().performClick() + assertTrue(manualSetupClicked) + } + + @Test + fun largeFontKeepsGatewayActionsFixedWhileContentScrolls() { + var setupCodeClicked = false + setContent(fontScale = 1.3f, viewportHeight = 480.dp) { + GatewaySetupScreen( + nearbyGateway = null, + onBack = {}, + onSetupCode = { setupCodeClicked = true }, + onManualSetup = {}, + ) + } + + val viewport = composeRule.onNodeWithTag(OnboardingViewportTag) + val content = composeRule.onNodeWithText("Android setup guide") + val primaryAction = composeRule.onNodeWithText("Scan QR or setup code").assertIsDisplayed() + val secondaryAction = composeRule.onNodeWithText("Set up manually").assertIsDisplayed() + val scrollable = composeRule.onNode(hasScrollAction()).assertExists() + val viewportBounds = viewport.getUnclippedBoundsInRoot() + val contentBeforeScroll = content.getUnclippedBoundsInRoot() + val primaryActionBeforeScroll = primaryAction.getUnclippedBoundsInRoot() + val secondaryActionBeforeScroll = secondaryAction.getUnclippedBoundsInRoot() + assertFullyInside(primaryActionBeforeScroll, viewportBounds, "Gateway primary action") + assertFullyInside(secondaryActionBeforeScroll, viewportBounds, "Gateway secondary action") + + scrollable.performTouchInput { swipeUp() } + composeRule.waitForIdle() + + val contentAfterScroll = content.getUnclippedBoundsInRoot() + val primaryActionAfterScroll = primaryAction.getUnclippedBoundsInRoot() + val secondaryActionAfterScroll = secondaryAction.getUnclippedBoundsInRoot() + assertTrue("Gateway content should move upward after a swipe", contentAfterScroll.top < contentBeforeScroll.top) + assertEquals("Gateway primary action should remain fixed while content scrolls", primaryActionBeforeScroll, primaryActionAfterScroll) + assertEquals("Gateway secondary action should remain fixed while content scrolls", secondaryActionBeforeScroll, secondaryActionAfterScroll) + assertFullyInside(primaryActionAfterScroll, viewportBounds, "Gateway primary action") + assertFullyInside(secondaryActionAfterScroll, viewportBounds, "Gateway secondary action") + + content.performScrollTo().assertIsDisplayed() + composeRule.waitForIdle() + + val primaryActionAfterContentReached = primaryAction.getUnclippedBoundsInRoot() + val secondaryActionAfterContentReached = secondaryAction.getUnclippedBoundsInRoot() + assertEquals("Gateway primary action should remain fixed when content is reached", primaryActionBeforeScroll, primaryActionAfterContentReached) + assertEquals("Gateway secondary action should remain fixed when content is reached", secondaryActionBeforeScroll, secondaryActionAfterContentReached) + assertFullyInside(primaryActionAfterContentReached, viewportBounds, "Gateway primary action") + assertFullyInside(secondaryActionAfterContentReached, viewportBounds, "Gateway secondary action") + primaryAction.assertIsDisplayed().performClick() + assertTrue(setupCodeClicked) + } + + private fun setContent( + fontScale: Float, + viewportHeight: Dp, + content: @Composable () -> Unit, + ) { + composeRule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(fontScale)) { + ClawDesignTheme { + Box( + modifier = + Modifier + .size(width = 360.dp, height = viewportHeight) + .clipToBounds() + .testTag(OnboardingViewportTag), + ) { + content() + } + } + } + } + } + + private fun assertFullyInside( + child: DpRect, + parent: DpRect, + label: String, + ) { + assertTrue("$label should stay inside the viewport's left edge", child.left >= parent.left) + assertTrue("$label should stay inside the viewport's top edge", child.top >= parent.top) + assertTrue("$label should stay inside the viewport's right edge", child.right <= parent.right) + assertTrue("$label should stay inside the viewport's bottom edge", child.bottom <= parent.bottom) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/NodesDevicesSettingsScreenTest.kt b/app/src/test/java/ai/openclaw/app/ui/NodesDevicesSettingsScreenTest.kt new file mode 100644 index 0000000..64957b3 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/NodesDevicesSettingsScreenTest.kt @@ -0,0 +1,94 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayPendingDeviceSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NodesDevicesSettingsScreenTest { + @Test + fun deviceListSummariesPreserveValuesAndLocalizeControlledCounts() { + assertEquals(null, formatDeviceList(emptyList(), DeviceListKind.Role)) + assertEquals("operator", formatDeviceList(listOf("operator"), DeviceListKind.Role)) + assertEquals("2 roles", formatDeviceList(listOf("operator", "admin"), DeviceListKind.Role)) + assertEquals("read:messages", formatDeviceList(listOf("read:messages"), DeviceListKind.Scope)) + assertEquals( + "2 scopes", + formatDeviceList(listOf("read:messages", "write:messages"), DeviceListKind.Scope), + ) + } + + @Test + fun relativeDeviceTimeUsesLocalizedAgeTemplates() { + val nowMs = 10L * 24L * 60L * 60L * 1_000L + + assertEquals("now", relativeDeviceTime(timeMs = nowMs - 30_000L, nowMs = nowMs)) + assertEquals("2m ago", relativeDeviceTime(timeMs = nowMs - 2L * 60L * 1_000L, nowMs = nowMs)) + assertEquals("3h ago", relativeDeviceTime(timeMs = nowMs - 3L * 60L * 60L * 1_000L, nowMs = nowMs)) + assertEquals("4d ago", relativeDeviceTime(timeMs = nowMs - 4L * 24L * 60L * 60L * 1_000L, nowMs = nowMs)) + } + + @Test + fun approvalIdentityIncludesGatewayPairingFields() { + val lines = + pendingDeviceIdentityLines( + GatewayPendingDeviceSummary( + requestId = "request-1", + deviceId = "device-1", + publicKey = "public-key-1", + displayName = "Pixel", + platform = "android", + deviceFamily = "phone", + clientId = "openclaw-android", + clientMode = "ui", + browserOrigin = "https://gateway.example", + remoteIp = "192.0.2.10", + roles = listOf("operator"), + scopes = listOf("operator.read", "operator.pairing"), + requestedAtMs = 123L, + repair = false, + ), + ).toMap() + + assertEquals("Pixel", lines["Name"]) + assertEquals("device-1", lines["Device ID"]) + assertEquals("public-key-1", lines["Public key"]) + assertEquals("android · phone", lines["Platform"]) + assertEquals("openclaw-android · ui", lines["Client"]) + assertEquals("https://gateway.example", lines["Origin"]) + assertEquals("192.0.2.10", lines["Remote IP"]) + assertEquals("operator", lines["Roles"]) + assertEquals("operator.read, operator.pairing", lines["Scopes"]) + } + + @Test + fun approvalIdentityStripsLineAndBidiSpoofingAndBoundsLength() { + assertEquals( + "Pixel Device ID: fake", + pairingIdentityForDisplay("Pixel\n\u202EDevice ID: fake"), + ) + assertEquals("abc…", pairingIdentityForDisplay("abcdef", maxCodePoints = 4)) + assertEquals("…", pairingIdentityForDisplay("\u202E\n")) + } + + @Test + fun approvalIdentityShowsRequestedScopeAfterLongPrecedingScopes() { + val requestedScope = "operator.admin" + val longPrecedingScope = "operator." + "x".repeat(180) + val lines = + pendingDeviceIdentityLines( + GatewayPendingDeviceSummary( + requestId = "request-1", + deviceId = "device-1", + displayName = "Pixel", + remoteIp = null, + roles = listOf("operator"), + scopes = listOf(longPrecedingScope, requestedScope), + requestedAtMs = 123L, + repair = false, + ), + ).toMap() + + assertTrue(lines.getValue("Scopes").contains(requestedScope)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt b/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt new file mode 100644 index 0000000..dfaf6e2 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt @@ -0,0 +1,1294 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayConnectionProblem +import ai.openclaw.app.GatewayNodeCapabilityApproval +import ai.openclaw.app.LocationMode +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.ui.design.MascotMood +import android.Manifest +import androidx.compose.runtime.saveable.SaverScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class OnboardingFlowLogicTest { + @Test + fun mascotMoodTracksVisibleOnboardingState() { + assertEquals(MascotMood.Idle, onboardingMascotMood(OnboardingStep.Welcome)) + assertEquals(MascotMood.Curious, onboardingMascotMood(OnboardingStep.Permissions)) + assertEquals(MascotMood.Thinking, onboardingMascotMood(OnboardingStep.NodeApproval)) + assertEquals( + MascotMood.Working, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Finishing), + ) + assertEquals( + MascotMood.Working, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.TakingLonger), + ) + assertEquals( + MascotMood.Celebrating, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Connected), + ) + assertEquals( + MascotMood.Sad, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Failed), + ) + assertEquals( + MascotMood.Sad, + onboardingMascotMood( + step = OnboardingStep.EnterSetupCode, + setupErrorCode = OnboardingErrorCode.SetupCodeRejected, + ), + ) + assertEquals( + MascotMood.Sad, + onboardingMascotMood( + step = OnboardingStep.SetupCode, + setupScanErrorCode = OnboardingErrorCode.InvalidSetupQr, + ), + ) + } + + @Test + fun onboardingBackDestinationsMatchTheVisibleFlow() { + assertEquals(null, onboardingBackDestination(OnboardingStep.Welcome)) + assertEquals(OnboardingBackDestination(OnboardingStep.Welcome), onboardingBackDestination(OnboardingStep.Gateway)) + assertEquals(OnboardingBackDestination(OnboardingStep.Gateway), onboardingBackDestination(OnboardingStep.SetupCode)) + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode), + onboardingBackDestination(OnboardingStep.EnterSetupCode), + ) + assertEquals(OnboardingBackDestination(OnboardingStep.Gateway), onboardingBackDestination(OnboardingStep.Manual)) + assertEquals(OnboardingBackDestination(OnboardingStep.Recovery), onboardingBackDestination(OnboardingStep.NodeApproval)) + assertEquals(OnboardingBackDestination(OnboardingStep.NodeApproval), onboardingBackDestination(OnboardingStep.Permissions)) + } + + @Test + fun directPermissionsBackReturnsToRecovery() { + assertEquals( + OnboardingBackDestination(OnboardingStep.Recovery), + onboardingBackDestination( + step = OnboardingStep.Permissions, + accessStage = OnboardingAccessStage.DirectPermissions, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.Recovery), + onboardingBackStateAfterBack( + step = OnboardingStep.Permissions, + accessStage = OnboardingAccessStage.DirectPermissions, + ), + ) + } + + @Test + fun permissionReapprovalBackReturnsThroughPermissionsToRecovery() { + assertEquals( + OnboardingBackDestination(OnboardingStep.Permissions), + onboardingBackDestination( + step = OnboardingStep.NodeApproval, + accessStage = OnboardingAccessStage.PermissionReapproval, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.Permissions), + onboardingBackStateAfterBack( + step = OnboardingStep.NodeApproval, + accessStage = OnboardingAccessStage.PermissionReapproval, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.Recovery), + onboardingBackStateAfterBack( + step = OnboardingStep.Permissions, + accessStage = OnboardingAccessStage.PermissionReapproval, + ), + ) + } + + @Test + fun nodeApprovalSuccessUsesTheAccessStage() { + assertEquals( + OnboardingNodeApprovalSuccess.ShowPermissions, + OnboardingAccessStage.InitialApproval.nodeApprovalSuccess, + ) + assertEquals( + OnboardingNodeApprovalSuccess.CompleteOnboarding, + OnboardingAccessStage.PermissionReapproval.nodeApprovalSuccess, + ) + } + + @Test + fun setupCodeEntryBackRestoresInlineScannerOnlyWhenOpenedFromScanner() { + assertEquals( + OnboardingBackState(step = OnboardingStep.SetupCode, inlineQrScannerActive = true), + onboardingBackStateAfterBack( + step = OnboardingStep.EnterSetupCode, + setupCodeEntryOpenedFromScanner = true, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.SetupCode, inlineQrScannerActive = false), + onboardingBackStateAfterBack( + step = OnboardingStep.EnterSetupCode, + setupCodeEntryOpenedFromScanner = false, + ), + ) + } + + @Test + fun onboardingBackStateClearsScannerOriginAfterBack() { + assertEquals( + OnboardingBackState(step = OnboardingStep.SetupCode, inlineQrScannerActive = true, setupCodeEntryOpenedFromScanner = false), + onboardingBackStateAfterBack( + step = OnboardingStep.EnterSetupCode, + setupCodeEntryOpenedFromScanner = true, + ), + ) + } + + @Test + fun recoveryBackRestoresInlineScannerOnlyForScannerConnections() { + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = true), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.SetupScanner), + ) + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = false), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.SetupGallery), + ) + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = false), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.SetupEntry), + ) + } + + @Test + fun recoveryBackReturnsToManualFormAfterManualConnection() { + assertEquals( + OnboardingBackDestination(OnboardingStep.Manual), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.Manual), + ) + } + + @Test + fun standardPortraitWidthKeepsOnboardingFieldsInline() { + assertFalse(onboardingFormUsesStackedLayout(availableWidthDp = 342f, fontScale = 1f)) + } + + @Test + fun narrowWidthStacksOnboardingFields() { + assertTrue(onboardingFormUsesStackedLayout(availableWidthDp = 320f, fontScale = 1f)) + } + + @Test + fun largeFontScaleStacksOnboardingFields() { + assertTrue(onboardingFormUsesStackedLayout(availableWidthDp = 600f, fontScale = 1.3f)) + } + + @Test + fun cameraCapabilityStartsOffEvenWhenScannerPermissionWasGranted() { + assertFalse(initialCameraCapabilityEnabled(savedCapabilityEnabled = false, androidCameraPermissionGranted = false)) + assertFalse(initialCameraCapabilityEnabled(savedCapabilityEnabled = false, androidCameraPermissionGranted = true)) + assertFalse(initialCameraCapabilityEnabled(savedCapabilityEnabled = true, androidCameraPermissionGranted = false)) + assertTrue(initialCameraCapabilityEnabled(savedCapabilityEnabled = true, androidCameraPermissionGranted = true)) + } + + @Test + fun cameraPermissionRowDistinguishesAndroidPermissionFromCapabilityOptIn() { + assertEquals("Not allowed", cameraPermissionRowStatusText(capabilityEnabled = false, androidCameraPermissionGranted = false).resolveNativeText()) + assertEquals("Off", cameraPermissionRowStatusText(capabilityEnabled = false, androidCameraPermissionGranted = true).resolveNativeText()) + assertEquals("Enabled", cameraPermissionRowStatusText(capabilityEnabled = true, androidCameraPermissionGranted = true).resolveNativeText()) + } + + @Test + fun onboardingErrorCodeSaverRoundTripsTypedState() { + val saved = with(OnboardingErrorCodeSaver) { SaverScope { true }.save(OnboardingErrorCode.ManualInvalidUrl) } + + assertEquals("ManualInvalidUrl", saved) + assertEquals(OnboardingErrorCode.ManualInvalidUrl, OnboardingErrorCodeSaver.restore(requireNotNull(saved))) + } + + @Test + fun cameraPermissionRowTogglesCapabilityWhenAndroidPermissionAlreadyGranted() { + assertNull(cameraCapabilityAfterRowTap(currentCapabilityEnabled = false, androidCameraPermissionGranted = false)) + assertTrue(cameraCapabilityAfterRowTap(currentCapabilityEnabled = false, androidCameraPermissionGranted = true)!!) + assertFalse(cameraCapabilityAfterRowTap(currentCapabilityEnabled = true, androidCameraPermissionGranted = true)!!) + } + + @Test + fun permissionChangesRequireNodeApprovalWhenAdvertisedSurfaceChanges() { + assertTrue( + permissionChangesRequireNodeApproval( + currentCameraEnabled = false, + requestedCameraEnabled = true, + currentLocationMode = LocationMode.Off, + requestedLocationMode = LocationMode.Off, + currentSmsGranted = true, + requestedSmsGranted = true, + ), + ) + assertTrue( + permissionChangesRequireNodeApproval( + currentCameraEnabled = false, + requestedCameraEnabled = false, + currentLocationMode = LocationMode.Off, + requestedLocationMode = LocationMode.WhileUsing, + currentSmsGranted = true, + requestedSmsGranted = true, + ), + ) + assertTrue( + permissionChangesRequireNodeApproval( + currentCameraEnabled = false, + requestedCameraEnabled = false, + currentLocationMode = LocationMode.Off, + requestedLocationMode = LocationMode.Off, + currentSmsGranted = false, + requestedSmsGranted = true, + ), + ) + assertFalse( + permissionChangesRequireNodeApproval( + currentCameraEnabled = true, + requestedCameraEnabled = true, + currentLocationMode = LocationMode.WhileUsing, + requestedLocationMode = LocationMode.WhileUsing, + currentSmsGranted = true, + requestedSmsGranted = true, + ), + ) + } + + @Test + fun nearbyGatewayManualPortUsesResolvedDiscoveryEndpointPort() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Home", + name = "Home", + host = "192.168.1.12", + port = 53122, + gatewayPort = 18789, + ) + + assertEquals("53122", nearbyGatewayManualPort(endpoint)) + } + + @Test + fun nearbyGatewayManualTlsPreservesDiscoverySecurityPolicy() { + assertFalse( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Lan", + name = "Lan", + host = "192.168.1.12", + port = 18789, + ), + ), + ) + assertTrue( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Tls", + name = "Tls", + host = "192.168.1.12", + port = 18789, + tlsEnabled = true, + ), + ), + ) + assertTrue( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Pinned", + name = "Pinned", + host = "127.0.0.1", + port = 18789, + tlsFingerprintSha256 = "abc123", + ), + ), + ) + assertTrue( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Remote", + name = "Remote", + host = "gateway.example.com", + port = 443, + ), + ), + ) + assertFalse( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Loopback", + name = "Loopback", + host = "127.0.0.1", + port = 18789, + ), + ), + ) + } + + @Test + fun blocksFinishWhenGatewayHasNotReportedNodeConnected() { + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved)) + } + + @Test + fun blocksFinishWhenDisconnected() { + assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved)) + } + + @Test + fun blocksFinishWhenOnlyNodeIsConnected() { + assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved)) + } + + @Test + fun blocksFinishWhenNodeCapabilityApprovalIsPending() { + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null))) + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(null))) + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unapproved)) + } + + @Test + fun allowsFinishWhenOperatorNodeAndCapabilityApprovalAreReady() { + assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved)) + } + + @Test + fun blocksFinishWhileDelayedNodeListResolvesPendingApproval() = + runTest { + val delayedNodeList = CompletableDeferred() + var approvalState: GatewayNodeCapabilityApproval = GatewayNodeCapabilityApproval.Loading + val refresh = launch { approvalState = delayedNodeList.await() } + + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = approvalState)) + + delayedNodeList.complete(GatewayNodeCapabilityApproval.PendingApproval(null)) + refresh.join() + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = approvalState)) + } + + @Test + fun allowsFinishWhenSuccessfulLegacyNodeListOmitsApprovalState() { + assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported)) + } + + @Test + fun blocksFinishForLegacyNodeListUntilNodeConnects() { + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported)) + } + + @Test + fun splitSmsPermissionCallbacksMergePerPermissionGrantState() { + val requiredPermissions = listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS) + val afterSendOnly = + mergedRequiredPermissionGrantState( + permissions = mapOf(Manifest.permission.SEND_SMS to true), + requiredPermissions = requiredPermissions, + currentlyGranted = { false }, + ) + assertFalse(afterSendOnly) + + val afterReadOnly = + mergedRequiredPermissionGrantState( + permissions = mapOf(Manifest.permission.READ_SMS to true), + requiredPermissions = requiredPermissions, + currentlyGranted = { permission -> permission == Manifest.permission.SEND_SMS }, + ) + assertTrue(afterReadOnly) + + val deniedRead = + mergedRequiredPermissionGrantState( + permissions = mapOf(Manifest.permission.READ_SMS to false), + requiredPermissions = requiredPermissions, + currentlyGranted = { true }, + ) + assertFalse(deniedRead) + } + + @Test + fun contactAndCalendarPermissionGroupsRequireBothGrants() { + val permissionGroups = + listOf( + listOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), + listOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR), + ) + + for (requiredPermissions in permissionGroups) { + val readPermission = requiredPermissions.first() + val writePermission = requiredPermissions.last() + assertFalse( + mergedRequiredPermissionGrantState( + permissions = mapOf(readPermission to true), + requiredPermissions = requiredPermissions, + currentlyGranted = { false }, + ), + ) + assertTrue( + mergedRequiredPermissionGrantState( + permissions = mapOf(writePermission to true), + requiredPermissions = requiredPermissions, + currentlyGranted = { permission -> permission == readPermission }, + ), + ) + } + } + + @Test + fun recoveryGatewayNamePrefersServerThenAttemptedGateway() { + assertEquals("Server Gateway", recoveryGatewayName(serverName = "Server Gateway", attemptedGatewayName = "Discovered Gateway")) + assertEquals("Discovered Gateway", recoveryGatewayName(serverName = null, attemptedGatewayName = "Discovered Gateway")) + assertEquals("Home Gateway", recoveryGatewayName(serverName = " ", attemptedGatewayName = " ")) + } + + @Test + fun recoveryNodeApprovalCommandUsesRequestIdWhenAvailable() { + assertEquals("openclaw nodes approve request-1", recoveryNodeApprovalCommand(" request-1 ")) + assertEquals("openclaw nodes approve REQUEST_ID", recoveryNodeApprovalCommand(null)) + assertEquals("openclaw nodes approve REQUEST_ID", recoveryNodeApprovalCommand(" ")) + } + + @Test + fun nodeCapabilityApprovalNeedsUserActionOnlyForPendingStates() { + assertTrue(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.PendingApproval(null))) + assertTrue(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.PendingReapproval(null))) + assertTrue(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Unapproved)) + assertFalse(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Approved)) + assertFalse(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Loading)) + assertFalse(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Unsupported)) + } + + @Test + fun gatewayPairingContinueOnlyRoutesToNodeApprovalWhenApprovalNeedsUserAction() { + assertEquals( + OnboardingStep.Permissions, + gatewayPairingContinueDestination( + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null), + ), + ) + assertEquals( + OnboardingStep.NodeApproval, + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null), + ), + ) + assertEquals( + OnboardingStep.NodeApproval, + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(null), + ), + ) + assertEquals( + OnboardingStep.NodeApproval, + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unapproved, + ), + ) + assertNull( + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Loading, + ), + ) + assertNull( + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + assertNull( + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported, + ), + ) + } + + @Test + fun permissionContinueReturnsToNodeApprovalWhenApprovalIsStillPending() { + assertTrue( + permissionContinueNeedsNodeApproval( + ready = false, + requiresNodeApprovalAfterApply = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(null), + ), + ) + assertTrue( + permissionContinueNeedsNodeApproval( + ready = false, + requiresNodeApprovalAfterApply = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + assertTrue( + permissionContinueNeedsNodeApproval( + ready = true, + requiresNodeApprovalAfterApply = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + assertFalse( + permissionContinueNeedsNodeApproval( + ready = true, + requiresNodeApprovalAfterApply = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported, + ), + ) + assertFalse( + permissionContinueNeedsNodeApproval( + ready = true, + requiresNodeApprovalAfterApply = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + } + + @Test + fun nodeApprovalCheckingOnlyTracksActiveRefresh() { + assertTrue( + nodeApprovalCheckingInProgress( + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ), + ) + assertTrue( + nodeApprovalCheckingInProgress( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = true, + ), + ) + assertFalse( + nodeApprovalCheckingInProgress( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ), + ) + assertFalse( + nodeApprovalCheckingInProgress( + checkRequested = false, + refreshStarted = true, + nodesDevicesRefreshing = true, + ), + ) + } + + @Test + fun nodeApprovalCheckClearsUnobservedRefreshOnlyOnApprovalScreen() { + assertTrue( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.NodeApproval, + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ), + ) + assertFalse( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.NodeApproval, + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ), + ) + assertFalse( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.NodeApproval, + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = true, + ), + ) + assertFalse( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.Permissions, + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ), + ) + } + + @Test + fun nodeApprovalCheckContinuesWhenRequestedCheckFindsGatewayReady() { + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ready = true, + ), + ) + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = true, + ready = true, + ), + ) + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = true, + ready = true, + ), + ) + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ready = false, + ), + ) + assertTrue( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ready = true, + ), + ) + } + + @Test + fun nodeApprovalAutoContinuesWhenGatewayReportsReady() { + assertTrue( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.NodeApproval, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + autoContinueEnabled = true, + ), + ) + assertFalse( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.NodeApproval, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null), + autoContinueEnabled = true, + ), + ) + assertFalse( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.Permissions, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + autoContinueEnabled = true, + ), + ) + assertFalse( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.NodeApproval, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + autoContinueEnabled = false, + ), + ) + } + + @Test + fun gatewayPairingStopsAtConnectedEvenWhenNodeApprovalIsStillPending() { + assertEquals( + GatewayRecoveryUiState.Connected, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = true, + statusText = "Waiting for node approval", + connectSettling = false, + connectTimedOut = true, + ), + ) + } + + @Test + fun gatewayPairingContinueWinsOverStaleNodePairingRequiredProblem() { + assertEquals( + GatewayRecoveryUiState.Connected, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = true, + statusText = "Connected (node offline)", + connectSettling = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun gatewayPairingPrefersManualApprovalErrorOverPartialOperatorConnect() { + assertEquals( + GatewayRecoveryUiState.ApprovalRequired, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun gatewayPairingPrefersRetryableApprovalErrorOverPartialOperatorConnect() { + assertEquals( + GatewayRecoveryUiState.Pairing, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + retryable = true, + ), + ), + ) + } + + @Test + fun gatewayPairingWaitsWhenOperatorConnectedButNoContinueDestinationExists() { + assertEquals( + GatewayRecoveryUiState.Finishing, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + connectTimedOut = false, + ), + ) + assertEquals( + GatewayRecoveryUiState.TakingLonger, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + connectTimedOut = true, + ), + ) + } + + @Test + fun gatewayPairingShowsSlowConnectionWhenGatewayNeverPairs() { + assertEquals( + GatewayRecoveryUiState.Finishing, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Connecting…", + connectSettling = false, + connectTimedOut = false, + ), + ) + assertEquals( + GatewayRecoveryUiState.TakingLonger, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Connecting…", + connectSettling = false, + connectTimedOut = true, + ), + ) + } + + @Test + fun gatewayPairingPreservesExplicitFailureStatusText() { + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", + connectSettling = false, + connectTimedOut = false, + ), + ) + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", + connectSettling = false, + connectTimedOut = true, + ), + ) + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Gateway error: unauthorized: gateway token missing", + connectSettling = false, + connectTimedOut = false, + ), + ) + } + + @Test + fun recoveryGatewayDetailPreservesRetryablePairingGuidance() { + assertEquals( + "Gateway approval is in progress. OpenClaw will retry automatically.", + recoveryGatewayDetail( + ready = false, + remoteAddress = null, + statusText = "Connected (node offline)", + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + retryable = true, + ), + ), + ) + } + + @Test + fun recoveryGatewayDetailPrefersAuthProblemOverStaleAddressWhenNotReady() { + assertEquals( + "Saved authentication is invalid. Re-authenticate or reset this gateway connection.", + recoveryGatewayDetail( + ready = false, + remoteAddress = "wss://gateway.example.test", + statusText = "Connected (node offline)", + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "AUTH_DEVICE_TOKEN_MISMATCH", + message = "authentication needed", + reason = null, + requestId = null, + recommendedNextStep = "update_auth_credentials", + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun recoveryGatewayDetailPrefersAuthProblemWhileNodeApprovalIsLoading() { + assertEquals( + "Saved authentication is invalid. Re-authenticate or reset this gateway connection.", + recoveryGatewayDetail( + ready = false, + remoteAddress = "wss://gateway.example.test", + statusText = "Connected (node offline)", + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Loading, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "AUTH_DEVICE_TOKEN_MISMATCH", + message = "authentication needed", + reason = null, + requestId = null, + recommendedNextStep = "update_auth_credentials", + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun recoveryGatewayAuthDetailShowsSpecificAuthRecoveryActions() { + val cases = + listOf( + "AUTH_BOOTSTRAP_TOKEN_INVALID" to "The code may have expired or been generated for another Gateway.", + "AUTH_DEVICE_TOKEN_MISMATCH" to "Saved authentication is invalid. Re-authenticate or reset this gateway connection.", + "AUTH_PASSWORD_MISMATCH" to "Gateway password is invalid. Re-enter it or reset this gateway connection.", + "AUTH_TOKEN_MISSING" to "Gateway token is required. Enter it again or edit this connection.", + "DEVICE_IDENTITY_REQUIRED" to "Gateway requires this device identity. Re-authenticate or reset this gateway connection.", + ) + + cases.forEach { (code, expected) -> + assertEquals( + expected, + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = code, + message = "authentication needed", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + } + + @Test + fun recoveryGatewayAuthDetailPreservesProtocolMismatchGuidance() { + assertEquals( + "This app is older than the Gateway. Update OpenClaw on this device, then retry. (app protocol v4, gateway protocol v5).", + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = "PROTOCOL_MISMATCH", + message = "protocol mismatch", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + clientMinProtocol = 4, + clientMaxProtocol = 4, + expectedProtocol = 5, + ), + ), + ) + } + + @Test + fun recoveryGatewayAuthDetailExplainsOlderGatewayProtocolMismatch() { + assertEquals( + "The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry. (app protocol v6, gateway protocol v5).", + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = "PROTOCOL_MISMATCH", + message = "protocol mismatch", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + clientMinProtocol = 6, + clientMaxProtocol = 6, + expectedProtocol = 5, + ), + ), + ) + assertEquals( + "openclaw update", + recoveryGatewayProtocolMismatchCommand( + GatewayConnectionProblem( + code = "PROTOCOL_MISMATCH", + message = "protocol mismatch", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + clientMinProtocol = 6, + clientMaxProtocol = 6, + expectedProtocol = 5, + ), + ), + ) + } + + @Test + fun recoveryGatewayAuthDetailExplainsIncompatibleProtocolMismatch() { + assertEquals( + "The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry. (app protocols v4-v6).", + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = "PROTOCOL_MISMATCH", + message = "protocol mismatch", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + clientMinProtocol = 4, + clientMaxProtocol = 6, + expectedProtocol = null, + ), + ), + ) + } + + @Test + fun recoveryGatewayAuthDetailUsesRecommendedNextStepFallbacks() { + assertEquals( + "Gateway authentication is not configured. Edit this connection and try again.", + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = "UNKNOWN", + message = "authentication needed", + reason = null, + requestId = null, + recommendedNextStep = "update_auth_configuration", + pauseReconnect = true, + retryable = false, + ), + ), + ) + assertEquals( + "gateway says no", + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = "UNKNOWN", + message = "gateway says no", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun recoveryPrimaryActionOnlyAppearsForCompleteFailureOrSlowConnectionStates() { + assertEquals(GatewayRecoveryPrimaryAction.Finish, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.Connected)) + assertEquals(GatewayRecoveryPrimaryAction.Back, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.Failed)) + assertEquals(GatewayRecoveryPrimaryAction.Retry, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.TakingLonger)) + assertEquals(GatewayRecoveryPrimaryAction.Retry, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.ApprovalRequired)) + + listOf( + GatewayRecoveryUiState.NodeCapabilityApprovalPending, + GatewayRecoveryUiState.Pairing, + GatewayRecoveryUiState.Finishing, + ).forEach { state -> + assertEquals(null, gatewayRecoveryPrimaryAction(state)) + } + } + + @Test + fun recoveryDiagnosticActionAppearsForFailuresSlowStatesAndGatewayProblems() { + assertTrue(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.Failed, gatewayConnectionProblem = null)) + assertTrue(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.TakingLonger, gatewayConnectionProblem = null)) + assertTrue( + gatewayRecoveryShowsDiagnosticAction( + GatewayRecoveryUiState.Pairing, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + retryable = true, + ), + ), + ) + assertFalse(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.Finishing, gatewayConnectionProblem = null)) + assertFalse(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.Connected, gatewayConnectionProblem = null)) + } + + @Test + fun recoveryDiagnosticTextIncludesRecoveryStateWithoutCredentials() { + val diagnostic = + gatewayRecoveryDiagnosticText( + statusText = "Gateway closed: token mismatch", + gatewayName = "Home Gateway", + gatewayPaired = false, + gatewayPairingCanContinue = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "AUTH_TOKEN_MISMATCH", + message = "token mismatch", + reason = "bad-token", + requestId = "request-1", + recommendedNextStep = "update_auth_credentials", + pauseReconnect = true, + retryable = false, + ), + localizeLabel = { label -> "[$label]" }, + ) + + assertTrue(diagnostic.contains("[OpenClaw Android gateway diagnostic]")) + assertTrue(diagnostic.contains("[Gateway]: Home Gateway")) + assertTrue(diagnostic.contains("[Status]: Gateway closed: token mismatch")) + assertTrue(diagnostic.contains("[Gateway paired]: false")) + assertTrue(diagnostic.contains("[Ready to continue]: false")) + assertTrue(diagnostic.contains("[Error code]: AUTH_TOKEN_MISMATCH")) + assertTrue(diagnostic.contains("[Reason]: bad-token")) + assertTrue(diagnostic.contains("[Request ID]: request-1")) + assertTrue(diagnostic.contains("[Next step]: update_auth_credentials")) + assertFalse(diagnostic.contains("secret")) + } + + @Test + fun recoveryDiagnosticDoesNotInventOrTranslateStatusValues() { + val status = " gateway status\n" + val diagnostic = + gatewayRecoveryDiagnosticText( + statusText = status, + gatewayName = "Gateway A", + gatewayPaired = false, + gatewayPairingCanContinue = false, + gatewayConnectionProblem = null, + localizeLabel = { label -> "[$label]" }, + ) + + assertTrue(diagnostic.contains("[Status]: $status")) + assertFalse(diagnostic.contains("Offline")) + } + + @Test + fun recoveryProgressStartsAtGatewayEndpointWhileConnecting() { + assertEquals( + listOf( + GatewayRecoveryProgressItem(nativeText("Opening Gateway connection"), GatewayRecoveryProgressStatus.Current), + GatewayRecoveryProgressItem(nativeText("Checking pairing access"), GatewayRecoveryProgressStatus.Pending), + GatewayRecoveryProgressItem(nativeText("Checking node access"), GatewayRecoveryProgressStatus.Pending), + ), + gatewayRecoveryProgressItems( + state = GatewayRecoveryUiState.Finishing, + statusText = "Connecting…", + connectSettling = true, + ), + ) + } + + @Test + fun recoveryProgressDoesNotAdvanceToGatewayAccessJustBecauseSettlingEnds() { + assertEquals( + listOf( + GatewayRecoveryProgressItem(nativeText("Opening Gateway connection"), GatewayRecoveryProgressStatus.Current), + GatewayRecoveryProgressItem(nativeText("Checking pairing access"), GatewayRecoveryProgressStatus.Pending), + GatewayRecoveryProgressItem(nativeText("Checking node access"), GatewayRecoveryProgressStatus.Pending), + ), + gatewayRecoveryProgressItems( + state = GatewayRecoveryUiState.Finishing, + statusText = "Connecting…", + connectSettling = false, + ), + ) + } + + @Test + fun recoveryProgressMovesDownToNodeAccessAfterGatewayConnects() { + assertEquals( + listOf( + GatewayRecoveryProgressItem(nativeText("Opening Gateway connection"), GatewayRecoveryProgressStatus.Complete), + GatewayRecoveryProgressItem(nativeText("Checking pairing access"), GatewayRecoveryProgressStatus.Complete), + GatewayRecoveryProgressItem(nativeText("Checking node access"), GatewayRecoveryProgressStatus.Current), + ), + gatewayRecoveryProgressItems( + state = GatewayRecoveryUiState.Finishing, + statusText = "Connected (node offline)", + ), + ) + } + + @Test + fun resolvesOnboardingSetupCodeConnectConfigForScannedQr() { + val setupCode = + encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""") + val scanned = resolveScannedSetupCodeResult(setupCode) + + val plan = + resolveOnboardingGatewayConnectPlan( + setupCode = requireNotNull(scanned.setupCode), + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHost = "127.0.0.1", + manualPort = "18789", + manualTls = false, + token = "stale-shared-token", + password = "stale-shared-password", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction) + assertEquals("10.0.2.2", plan?.config?.host) + assertEquals(18789, plan?.config?.port) + assertEquals(false, plan?.config?.tls) + assertEquals("bootstrap-1", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.token) + assertEquals("", plan?.config?.password) + assertNull(scanned.error) + } + + @Test + fun resolvesOnboardingManualConnectConfigWhenSetupCodeIsBlank() { + val plan = + resolveOnboardingGatewayConnectPlan( + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHost = "127.0.0.1", + manualPort = "18789", + manualTls = false, + token = "shared-token", + password = "shared-password", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_CREDENTIALS, plan?.savedAuthAction) + assertEquals("127.0.0.1", plan?.config?.host) + assertEquals(18789, plan?.config?.port) + assertEquals(false, plan?.config?.tls) + assertEquals("", plan?.config?.bootstrapToken) + assertEquals("shared-token", plan?.config?.token) + assertEquals("", plan?.config?.password) + } + + @Test + fun onboardingManualEndpointChangeReplacesSavedGatewayAuth() { + val plan = + resolveOnboardingGatewayConnectPlan( + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHost = "10.0.2.2", + manualPort = "18790", + manualTls = false, + token = "replacement-token", + password = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction) + assertEquals("10.0.2.2", plan?.config?.host) + assertEquals("replacement-token", plan?.config?.token) + } + + private fun encodeSetupCode(payloadJson: String): String = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt b/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt new file mode 100644 index 0000000..4852b98 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt @@ -0,0 +1,240 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayModelProviderSummary +import ai.openclaw.app.GatewayModelSummary +import ai.openclaw.app.parseGatewayModels +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProviderModelStatusTest { + @Test + fun staticProviderStatusIsReady() { + assertTrue(modelProviderReady("static")) + } + + @Test + fun expiringProviderStatusIsNotFullyReady() { + assertFalse(modelProviderReady("expiring")) + } + + @Test + fun missingProviderStatusIsNotReady() { + assertFalse(modelProviderReady("missing")) + } + + @Test + fun providerRowsIncludeConfiguredModelProvidersWithoutAuthRows() { + val rows = + providerRows( + providers = + listOf( + GatewayModelProviderSummary( + id = "openai", + displayName = "OpenAI", + status = "ok", + profileCount = 1, + ), + ), + models = + listOf( + model(provider = "openai", id = "gpt-5.5"), + model(provider = "byteplus", id = "seed-1-8-251228"), + ), + ) + + assertEquals(listOf("openai", "byteplus"), rows.map { it.id }) + assertEquals(1, rows.first { it.id == "openai" }.modelCount) + assertEquals(1, rows.first { it.id == "byteplus" }.modelCount) + assertEquals(listOf("gpt-5.5"), rows.first { it.id == "openai" }.models.map { it.id }) + assertEquals(listOf("seed-1-8-251228"), rows.first { it.id == "byteplus" }.models.map { it.id }) + assertEquals(ProviderAvailability.Unknown, rows.first { it.id == "byteplus" }.availability) + assertFalse(rows.first { it.id == "byteplus" }.ready) + } + + @Test + fun providerRowsPreserveReadyAuthProvidersWithoutConfiguredModels() { + val rows = + providerRows( + providers = + listOf( + GatewayModelProviderSummary( + id = "openai", + displayName = "OpenAI", + status = "ok", + profileCount = 1, + ), + ), + models = emptyList(), + ) + + assertEquals(ProviderAvailability.Available, rows.single().availability) + assertTrue(rows.single().ready) + assertTrue(rows.single().models.isEmpty()) + } + + @Test + fun unknownModelAvailabilityIsNotUpgradedByProviderAuth() { + val rows = + providerRows( + providers = + listOf( + GatewayModelProviderSummary( + id = "openai", + displayName = "OpenAI", + status = "ok", + profileCount = 1, + ), + ), + models = listOf(model(provider = "openai", id = "gpt-5.5")), + ) + + assertEquals(ProviderAvailability.Unknown, rows.single().availability) + assertEquals("Unknown", rows.single().status) + assertFalse(rows.single().ready) + } + + @Test + fun unavailableModelsOverrideReadyProviderAuth() { + val rows = + providerRows( + providers = + listOf( + GatewayModelProviderSummary( + id = "custom", + displayName = "Custom", + status = "ok", + profileCount = 1, + ), + ), + models = listOf(model(provider = "custom", id = "offline-model", available = false)), + ) + + assertEquals(ProviderAvailability.Unavailable, rows.single().availability) + assertEquals("Needs attention", rows.single().status) + assertFalse(rows.single().ready) + } + + @Test + fun oneAvailableRouteMakesProviderReadyAndModelsSortByName() { + val rows = + providerRows( + providers = emptyList(), + models = + listOf( + model(provider = "custom", id = "zeta", name = "Zeta", available = null), + model(provider = "custom", id = "alpha", name = "Alpha", available = true), + ), + ) + + assertEquals(ProviderAvailability.Available, rows.single().availability) + assertEquals(listOf("alpha", "zeta"), rows.single().models.map { it.id }) + assertTrue(rows.single().ready) + } + + @Test + fun commandSubtitleDoesNotReportUnknownModelsAsReady() { + val providers = + listOf( + GatewayModelProviderSummary( + id = "openai", + displayName = "OpenAI", + status = "ok", + profileCount = 1, + ), + ) + + assertEquals( + "Provider availability unknown", + providerCommandSubtitle( + isConnected = true, + providers = providers, + models = listOf(model(provider = "openai", id = "gpt-5.5")), + ), + ) + } + + @Test + fun configuredModelCopyHandlesZeroOneAndMany() { + assertEquals("No configured models", configuredModelsCountText(0)) + assertEquals("1 configured model", configuredModelsCountText(1)) + assertEquals("2 configured models", configuredModelsCountText(2)) + assertEquals( + "No configured models. Refresh to recheck availability.", + configuredModelsOverviewText(0), + ) + assertEquals( + "1 configured model. Refresh to recheck availability.", + configuredModelsOverviewText(1), + ) + assertEquals( + "2 configured models. Refresh to recheck availability.", + configuredModelsOverviewText(2), + ) + } + + @Test + fun videoCapabilitySurvivesGatewayParsingAndRendering() { + val payload = + Json + .parseToJsonElement( + """[{"id":"video-model","name":"Video Model","provider":"openai","input":["text","video"]}]""", + ).jsonArray + val model = parseGatewayModels(payload).single() + + assertTrue(model.supportsVideo) + assertEquals("video", modelCapabilities(model)) + } + + @Test + fun modelCapabilitiesLocalizeControlledLabelsWithoutChangingGatewayMetadata() { + val model = + model( + provider = "custom-provider", + id = "model/internal-id", + name = "Model Display Name", + supportsReasoning = true, + supportsVision = true, + supportsAudio = true, + supportsVideo = true, + supportsDocuments = true, + contextTokens = 128_000, + ) + + assertEquals( + "reasoning / image / audio / video / document / 128k context", + modelCapabilities(model), + ) + assertEquals("custom-provider", model.provider) + assertEquals("model/internal-id", model.id) + assertEquals("Model Display Name", model.name) + } + + private fun model( + provider: String, + id: String, + name: String = id, + available: Boolean? = null, + supportsReasoning: Boolean = false, + supportsVision: Boolean = false, + supportsAudio: Boolean = false, + supportsVideo: Boolean = false, + supportsDocuments: Boolean = false, + contextTokens: Long? = null, + ): GatewayModelSummary = + GatewayModelSummary( + id = id, + name = name, + provider = provider, + supportsVision = supportsVision, + supportsAudio = supportsAudio, + supportsVideo = supportsVideo, + supportsDocuments = supportsDocuments, + supportsReasoning = supportsReasoning, + contextTokens = contextTokens, + available = available, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SessionDashboardScreenTest.kt b/app/src/test/java/ai/openclaw/app/ui/SessionDashboardScreenTest.kt new file mode 100644 index 0000000..20be0bf --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SessionDashboardScreenTest.kt @@ -0,0 +1,48 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SessionDashboardScreenTest { + @Test + fun dashboardUrlAppendsChatRouteAndEncodesSessionKey() { + val url = + sessionDashboardUrl( + baseUrl = "https://gateway.example.com:8443/", + sessionKey = "agent:main/phone & qa?x=1", + ) + + assertEquals( + "https://gateway.example.com:8443/chat?session=agent%3Amain%2Fphone%20%26%20qa%3Fx%3D1&face=dashboard", + url, + ) + } + + @Test + fun originRuleDropsBasePathAndKeepsPort() { + assertEquals( + "https://gateway.example.com:8443", + controlUiOriginRule("https://gateway.example.com:8443/openclaw"), + ) + assertEquals("http://[::1]:18789", controlUiOriginRule("http://[::1]:18789")) + } + + @Test + fun dashboardUrlKeepsConfiguredControlUiBasePath() { + val url = + sessionDashboardUrl( + baseUrl = "https://gateway.example.com:8443/openclaw", + sessionKey = "agent:main:qa", + ) + + assertEquals( + "https://gateway.example.com:8443/openclaw/chat?session=agent%3Amain%3Aqa&face=dashboard", + url, + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt b/app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt new file mode 100644 index 0000000..dddf2b1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SessionObserverDigestTest.kt @@ -0,0 +1,340 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.chat.ChatSessionAgentStatus +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.applySessionObserverDigest +import ai.openclaw.app.chat.mergeChatSessionEntry +import ai.openclaw.app.chat.reconcileGlobalObserverDigestOwner +import ai.openclaw.app.chat.reconcileSessionObserverProjectionOwner +import ai.openclaw.app.gateway.SessionObserverDigest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionObserverDigestTest { + @Test + fun observerEventsRequireTheServerRunAndAdvanceMonotonically() { + val running = + ChatSessionEntry( + key = "agent:main:work", + updatedAtMs = 100, + hasActiveRun = true, + activeRunIds = listOf(" run-1 "), + status = "running", + ) + var sessions = + applySessionObserverDigest( + listOf(running), + digest(runId = "run-1", revision = 2, updatedAt = 200, headline = "Second"), + ) + sessions = + applySessionObserverDigest( + sessions, + digest(runId = "run-1", revision = 1, updatedAt = 300, headline = "Stale"), + ) + sessions = + applySessionObserverDigest( + sessions, + digest(runId = "run-old", revision = 3, updatedAt = 400, headline = "Wrong run"), + ) + + assertEquals("Second", sessions.single().observerDigest?.headline) + assertEquals("Second", sessionListSubtitle(sessions.single(), fallback = "Work", nowMs = 1_000)) + + val projected = + ChatSessionEntry( + key = running.key, + updatedAtMs = 500, + observerDigest = digest(runId = "run-1", revision = 3, updatedAt = 500, headline = "Projected"), + hasObserverDigestMetadata = true, + hasActiveRun = true, + activeRunIds = listOf("run-1"), + hasActiveRunMetadata = true, + status = "running", + hasRunMetadata = true, + ) + assertEquals("Projected", mergeChatSessionEntry(sessions.single(), projected).observerDigest?.headline) + } + + @Test + fun sessionProjectionClearsDigestOnRunRollover() { + val existing = + ChatSessionEntry( + key = "agent:main:work", + updatedAtMs = 100, + hasActiveRun = true, + activeRunIds = listOf("run-1"), + status = "running", + observerDigest = digest(runId = "run-1", revision = 8, updatedAt = 800, headline = "Old run"), + ) + val replacement = + ChatSessionEntry( + key = existing.key, + updatedAtMs = 900, + hasActiveRun = true, + activeRunIds = listOf("run-2"), + hasActiveRunMetadata = true, + status = "running", + hasRunMetadata = true, + ) + + val rolled = mergeChatSessionEntry(existing, replacement) + + assertNull(rolled.observerDigest) + val accepted = + applySessionObserverDigest( + listOf(rolled), + digest(runId = "run-2", revision = 1, updatedAt = 901, headline = "New run"), + ) + assertEquals("New run", accepted.single().observerDigest?.headline) + } + + @Test + fun explicitNullProjectionClearsDigestWhileRunIsActive() { + val existing = + ChatSessionEntry( + key = "agent:main:work", + updatedAtMs = 100, + observerDigest = digest(runId = "run-1", revision = 4, updatedAt = 400, headline = "Stale"), + hasActiveRun = true, + activeRunIds = listOf("run-1"), + status = "running", + ) + val explicitClear = + ChatSessionEntry( + key = existing.key, + updatedAtMs = 500, + observerDigest = null, + hasObserverDigestMetadata = true, + hasActiveRun = true, + activeRunIds = listOf("run-1"), + hasActiveRunMetadata = true, + status = "running", + hasRunMetadata = true, + ) + + assertNull(mergeChatSessionEntry(existing, explicitClear).observerDigest) + } + + @Test + fun globalObserverEventsRequireTheSelectedAgent() { + val running = + ChatSessionEntry( + key = "global", + updatedAtMs = 100, + hasActiveRun = true, + activeRunIds = listOf("run-work"), + status = "running", + ) + val wrongOwner = + SessionObserverDigest( + sessionKey = "global", + agentId = "main", + runId = "run-work", + revision = 1, + updatedAt = 200, + headline = "Wrong owner", + health = "stuck", + ) + val selectedOwner = wrongOwner.copy(agentId = "work", headline = "Selected owner") + val missingOwner = wrongOwner.copy(agentId = null, headline = "Missing owner") + + val rejected = applySessionObserverDigest(listOf(running), wrongOwner, activeAgentId = "work") + val ownerless = + applySessionObserverDigest( + rejected, + missingOwner, + activeAgentId = "work", + ) + val disconnected = + applySessionObserverDigest( + listOf(running.copy(observerDigest = wrongOwner.copy(headline = "Last verified owner"))), + missingOwner, + activeAgentId = null, + ) + val accepted = applySessionObserverDigest(ownerless, selectedOwner, activeAgentId = "work") + + assertNull(rejected.single().observerDigest) + assertNull(ownerless.single().observerDigest) + assertEquals("Last verified owner", disconnected.single().observerDigest?.headline) + assertEquals("Selected owner", accepted.single().observerDigest?.headline) + } + + @Test + fun globalReconnectRejectsForeignAndStaleObserverDigests() { + val current = + SessionObserverDigest( + sessionKey = "global", + agentId = "work", + runId = "run-work", + revision = 4, + updatedAt = 400, + headline = "Current work status", + health = "grinding", + ) + val running = + ChatSessionEntry( + key = "global", + updatedAtMs = 100, + hasActiveRun = true, + activeRunIds = listOf("run-work"), + status = "running", + observerDigest = current, + ) + + val foreign = + applySessionObserverDigest( + listOf(running), + current.copy( + agentId = "main", + revision = 9, + updatedAt = 900, + headline = "Foreign status", + ), + activeAgentId = "work", + ) + val replayed = + applySessionObserverDigest( + foreign, + current.copy( + revision = 3, + updatedAt = 1_000, + headline = "Replayed work status", + ), + activeAgentId = "work", + ) + + assertEquals("Current work status", replayed.single().observerDigest?.headline) + assertEquals(4L, replayed.single().observerDigest?.revision) + } + + @Test + fun changingTheSelectedAgentClearsThePreviousGlobalDigest() { + val previous = + ChatSessionEntry( + key = "global", + updatedAtMs = 100, + hasActiveRun = true, + activeRunIds = listOf("run-main"), + status = "running", + observerDigest = + SessionObserverDigest( + sessionKey = "global", + agentId = "main", + runId = "run-main", + revision = 4, + updatedAt = 400, + headline = "Main owner", + health = "on-track", + ), + ) + + val switched = + reconcileGlobalObserverDigestOwner( + listOf(previous), + activeAgentId = "work", + adoptOwnerless = false, + ) + val ownerless = + reconcileGlobalObserverDigestOwner( + listOf(previous.copy(observerDigest = previous.observerDigest?.copy(agentId = null))), + activeAgentId = "work", + adoptOwnerless = false, + ) + val disconnected = + reconcileGlobalObserverDigestOwner( + listOf(previous), + activeAgentId = null, + adoptOwnerless = false, + ) + + assertNull(switched.single().observerDigest) + assertNull(ownerless.single().observerDigest) + assertEquals("Main owner", disconnected.single().observerDigest?.headline) + } + + @Test + fun globalSessionProjectionRequiresMatchingOuterAndDigestOwners() { + val projected = + ChatSessionEntry( + key = "global", + updatedAtMs = 900, + ownerAgentId = "work", + status = "running", + observerDigest = + SessionObserverDigest( + sessionKey = "global", + agentId = "main", + runId = "run-main", + revision = 9, + updatedAt = 900, + headline = "Foreign owner", + health = "stuck", + ), + ) + + val mismatched = reconcileSessionObserverProjectionOwner(projected, ownerAgentId = "work") + val legacy = + reconcileSessionObserverProjectionOwner( + projected.copy(observerDigest = projected.observerDigest?.copy(agentId = null)), + ownerAgentId = "work", + ) + + assertNull(mismatched.observerDigest) + assertEquals("running", mismatched.status) + assertEquals("work", legacy.observerDigest?.agentId) + + val scopedLegacy = + reconcileGlobalObserverDigestOwner( + listOf(projected.copy(observerDigest = projected.observerDigest?.copy(agentId = null))), + activeAgentId = "work", + ) + assertEquals("work", scopedLegacy.single().observerDigest?.agentId) + } + + @Test + fun subtitlePrecedenceAndUnreadFinalRuleMatchTheWebSidebar() { + val liveDigest = digest(runId = "run-1", revision = 1, updatedAt = 200, headline = "Observer") + val agentStatus = ChatSessionAgentStatus(note = "Agent note", expiresAt = 10_000) + val failed = + ChatSessionEntry( + key = "work", + updatedAtMs = 500, + lastReadAt = 100, + agentStatus = agentStatus, + observerDigest = liveDigest, + hasActiveRun = true, + activeRunIds = listOf("run-1"), + status = "failed", + lastRunError = "Needs approval", + endedAt = 500, + ) + + assertEquals("Needs approval", sessionListSubtitle(failed, fallback = "Work", nowMs = 1_000)) + assertEquals( + "Agent note", + sessionListSubtitle(failed.copy(status = "running", lastRunError = null), fallback = "Work", nowMs = 1_000), + ) + + val finalDigest = digest(runId = null, revision = 2, updatedAt = 2_000, headline = "Finished", health = "done") + val idle = ChatSessionEntry(key = "work", updatedAtMs = 2_000, lastReadAt = 1_999, observerDigest = finalDigest) + assertEquals("Finished", sessionListSubtitle(idle, fallback = "Work", nowMs = 3_000)) + assertEquals("Work", sessionListSubtitle(idle.copy(lastReadAt = 2_000), fallback = "Work", nowMs = 3_000)) + } + + private fun digest( + runId: String?, + revision: Long, + updatedAt: Long, + headline: String, + health: String = "on-track", + ): SessionObserverDigest = + SessionObserverDigest( + sessionKey = "agent:main:work", + runId = runId, + revision = revision, + updatedAt = updatedAt, + headline = headline, + health = health, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt b/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt new file mode 100644 index 0000000..962aaef --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt @@ -0,0 +1,141 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionsScreenGroupingTest { + @Test + fun relativeTimeUsesCatalogBackedCompactLabels() { + val now = 10_000_000L + + assertEquals("now", relativeSessionTime(updatedAtMs = now, nowMs = now)) + assertEquals("5m", relativeSessionTime(updatedAtMs = now - 5 * 60_000L, nowMs = now)) + assertEquals("3h", relativeSessionTime(updatedAtMs = now - 3 * 60 * 60_000L, nowMs = now)) + assertEquals("2d", relativeSessionTime(updatedAtMs = now - 2 * 24 * 60 * 60_000L, nowMs = now)) + } + + @Test + fun sessionActionTargetKeepsTheOwnerCapturedWhenTheDialogOpened() { + val target = + ChatSessionEntry( + key = "custom", + updatedAtMs = null, + ownerAgentId = "agent-a", + label = "Original", + ).toActionTarget("gateway-a") + val refreshed = + ChatSessionEntry( + key = "custom", + updatedAtMs = null, + ownerAgentId = "agent-b", + label = "Replacement", + ) + + assertEquals("gateway-a", target.gatewayStableId) + assertEquals("agent-a", target.ownerAgentId) + assertEquals("Original", target.label) + assertEquals("gateway-a:agent-a:custom", target.stateKey) + assertEquals(true, target.matchesGateway("gateway-a")) + assertEquals(false, target.matchesGateway("gateway-b")) + assertEquals("agent-b", refreshed.ownerAgentId) + } + + @Test + fun sessionActionTargetSavedStatePreservesOwnerAndNullableLabels() { + val full = SessionActionTarget("gateway-a", "custom", "agent-a", "", "Display") + val sparse = SessionActionTarget(null, "agent:main:device", null, null, null) + + assertEquals(full, sessionActionTargetFromSavedState(full.toSavedState())) + assertEquals(sparse, sessionActionTargetFromSavedState(sparse.toSavedState())) + } + + @Test + fun sessionActionTargetSavedStateRejectsMissingIdentity() { + assertEquals(null, sessionActionTargetFromSavedState(emptyList())) + assertEquals( + null, + sessionActionTargetFromSavedState(listOf("1", "gateway-a", "", "0", "", "0", "", "0", "")), + ) + } + + @Test + fun groupsPinnedThenAlphabeticalCategoriesThenUngrouped() { + val sections = + groupSessionEntries( + listOf( + session("loose"), + session("zeta", category = "Zeta"), + session("pinned-grouped", category = "Alpha", pinned = true), + session("alpha", category = "Alpha"), + session("pinned", pinned = true), + ), + ) + + assertEquals(listOf("Pinned", "Alpha", "Zeta", "Ungrouped"), sections.map { it.title }) + assertEquals(listOf("pinned-grouped", "pinned"), sections[0].entries.map { it.key }) + assertEquals(listOf("alpha"), sections[1].entries.map { it.key }) + assertEquals(listOf("zeta"), sections[2].entries.map { it.key }) + assertEquals(listOf("loose"), sections[3].entries.map { it.key }) + } + + @Test + fun omitsUngroupedHeaderWhenNoCategoriesExist() { + val sections = groupSessionEntries(listOf(session("one"), session("two"))) + + assertEquals(listOf(null), sections.map { it.title }) + assertEquals(listOf("one", "two"), sections.single().entries.map { it.key }) + } + + @Test + fun pinnedSessionsAppearOnlyInPinnedSection() { + val sections = groupSessionEntries(listOf(session("pinned", category = "Work", pinned = true))) + + assertEquals(listOf("Pinned"), sections.map { it.title }) + assertEquals(listOf("pinned"), sections.single().entries.map { it.key }) + } + + @Test + fun knownGroupsRenderEmptyCategorySectionsInAlphabeticalMerge() { + val sections = + groupSessionEntries( + listOf(session("alpha", category = "Alpha"), session("loose")), + knownGroups = listOf(" Beta ", "beta", "alpha", "", " "), + ) + + // Blank names drop, "beta" dedupes against " Beta ", and "alpha" merges into the populated section. + assertEquals(listOf("Alpha", "Beta", "Ungrouped"), sections.map { it.title }) + assertEquals(listOf(true, true, false), sections.map { it.isCategory }) + assertEquals(listOf("alpha"), sections[0].entries.map { it.key }) + assertEquals(emptyList(), sections[1].entries.map { it.key }) + assertEquals(listOf("loose"), sections[2].entries.map { it.key }) + } + + @Test + fun knownGroupsAloneDoNotCreateSectionsWithoutSessions() { + assertEquals(emptyList(), groupSessionEntries(emptyList(), knownGroups = listOf("Beta"))) + } + + @Test + fun pinnedAndUngroupedSectionsAreNotCategories() { + val sections = + groupSessionEntries( + listOf(session("pinned", pinned = true), session("grouped", category = "Work"), session("loose")), + ) + + assertEquals(listOf("Pinned", "Work", "Ungrouped"), sections.map { it.title }) + assertEquals(listOf(false, true, false), sections.map { it.isCategory }) + } + + private fun session( + key: String, + category: String? = null, + pinned: Boolean? = null, + ): ChatSessionEntry = + ChatSessionEntry( + key = key, + updatedAtMs = null, + category = category, + pinned = pinned, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SessionsScreenSearchTest.kt b/app/src/test/java/ai/openclaw/app/ui/SessionsScreenSearchTest.kt new file mode 100644 index 0000000..3c6ce6f --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SessionsScreenSearchTest.kt @@ -0,0 +1,18 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionsScreenSearchTest { + @Test + fun blankSearchKeepsTheFilterSpecificEmptyState() { + assertEquals(SessionEmptyMode.Filter, sessionEmptyMode("", loading = false)) + assertEquals(SessionEmptyMode.Filter, sessionEmptyMode(" ", loading = true)) + } + + @Test + fun nonBlankSearchDistinguishesLoadingFromNoMatches() { + assertEquals(SessionEmptyMode.SearchLoading, sessionEmptyMode("zzproof", loading = true)) + assertEquals(SessionEmptyMode.SearchNoMatches, sessionEmptyMode("zzproof", loading = false)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SettingsScreensNotificationAppsTest.kt b/app/src/test/java/ai/openclaw/app/ui/SettingsScreensNotificationAppsTest.kt new file mode 100644 index 0000000..2a14db4 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SettingsScreensNotificationAppsTest.kt @@ -0,0 +1,77 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SettingsScreensNotificationAppsTest { + @Test + fun resolveNotificationCandidatePackages_keepsConfiguredPackagesVisible() { + val packages = + resolveNotificationCandidatePackages( + launcherPackages = setOf("com.example.launcher"), + recentPackages = listOf("com.example.recent", "com.example.launcher"), + configuredPackages = setOf("com.example.configured"), + appPackageName = "ai.openclaw.app", + ) + + assertEquals( + setOf("com.example.launcher", "com.example.recent", "com.example.configured"), + packages, + ) + } + + @Test + fun resolveNotificationCandidatePackages_filtersBlankAndSelfPackages() { + val packages = + resolveNotificationCandidatePackages( + launcherPackages = setOf(" ", "ai.openclaw.app"), + recentPackages = listOf("com.example.recent", " "), + configuredPackages = setOf("ai.openclaw.app", "com.example.configured"), + appPackageName = "ai.openclaw.app", + ) + + assertEquals(setOf("com.example.recent", "com.example.configured"), packages) + } + + @Test + fun filterNotificationAppsForPicker_keepsSelectedSystemPackagesVisible() { + val apps = + listOf( + InstalledApp(label = "Android System", packageName = "android", isSystemApp = true), + InstalledApp(label = "Phone Services", packageName = "com.android.phone", isSystemApp = true), + InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false), + ) + + val filtered = + filterNotificationAppsForPicker( + apps = apps, + selectedPackages = setOf("com.android.phone"), + query = "", + showSystemApps = false, + ) + + assertEquals( + listOf("com.android.phone", "com.google.android.gm"), + filtered.map { it.packageName }, + ) + } + + @Test + fun filterNotificationAppsForPicker_matchesLabelsAndPackageNames() { + val apps = + listOf( + InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false), + InstalledApp(label = "Calendar", packageName = "com.google.android.calendar", isSystemApp = false), + ) + + val filtered = + filterNotificationAppsForPicker( + apps = apps, + selectedPackages = emptySet(), + query = "gm", + showSystemApps = false, + ) + + assertEquals(listOf("com.google.android.gm"), filtered.map { it.packageName }) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt new file mode 100644 index 0000000..32581ba --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -0,0 +1,444 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayConnectionDisplay +import ai.openclaw.app.GatewayConnectionProblem +import ai.openclaw.app.GatewayCronJobSummary +import ai.openclaw.app.GatewayExecApprovalSummary +import ai.openclaw.app.GatewayNodeCapabilityApproval +import ai.openclaw.app.GatewayUsageProviderSummary +import ai.openclaw.app.GatewayUsageWindowSummary +import ai.openclaw.app.LocationMode +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.verbatimText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path +import java.util.Locale + +class SettingsScreensTest { + @Test + fun locationModes_hideAlwaysFromPlayAndMapThirdPartySelection() { + assertEquals(listOf("Off", "While Using"), locationModeLabels(backgroundLocationAvailable = false)) + assertEquals( + listOf("Off", "While Using", "Always"), + locationModeLabels(backgroundLocationAvailable = true), + ) + assertEquals(LocationMode.Always, locationModeForLabel("Always")) + } + + @Test + fun androidDistributionChannelUsesBuildFlavorLabels() { + assertEquals("Play", androidDistributionChannel("play")) + assertEquals("Third-party", androidDistributionChannel("thirdParty")) + assertEquals("Unknown", androidDistributionChannel("")) + assertEquals("enterpriseInternal", androidDistributionChannel("enterpriseInternal")) + } + + @Test + fun aboutAndPermissionFallbacksLocalizeOnlyControlledLabels() { + assertEquals("Website", aboutLinkTitle("Website")) + assertEquals("Docs", aboutLinkTitle("Docs")) + assertEquals("GitHub", aboutLinkTitle("GitHub")) + assertEquals("Custom", aboutLinkTitle("Custom")) + assertEquals("Allow all the time", resolvedBackgroundPermissionLabel(" ")) + assertEquals("Android system label", resolvedBackgroundPermissionLabel(" Android system label ")) + } + + @Test + fun aboutBuildIdentityFormatsVersionShortCommitAndUtcDate() { + val identity = + aboutBuildIdentity( + versionName = "2026.7.1", + versionCode = 2026070102, + gitCommit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + buildTimestamp = "2026-07-10T00:30:00.000Z", + locale = Locale.US, + unknownLabel = "Unknown", + ) + + assertEquals("2026.7.1 (2026070102)", identity.version) + assertEquals("abcdef012345", identity.commit) + assertEquals("abcdef0123456789abcdef0123456789abcdef01", identity.fullCommit) + assertEquals("Jul 10, 2026", identity.built) + assertEquals("2026-07-10T00:30:00.000Z", identity.buildTimestamp) + } + + @Test + fun aboutBuildIdentityKeepsUnknownFallbacksVisible() { + val identity = + aboutBuildIdentity( + versionName = "dev", + versionCode = 1, + gitCommit = "unknown", + buildTimestamp = "unknown", + locale = Locale.US, + unknownLabel = "Unbekannt", + ) + + assertEquals("dev (1)", identity.version) + assertEquals("Unbekannt", identity.commit) + assertEquals(null, identity.fullCommit) + assertEquals("Unbekannt", identity.built) + assertEquals(null, identity.buildTimestamp) + assertEquals("Unbekannt", aboutCommitAccessibilityValue(identity.fullCommit, "Unbekannt")) + } + + @Test + fun aboutCommitAccessibilityValueSpellsTheFullHash() { + val commit = "abcdef0123456789abcdef0123456789abcdef01" + + assertEquals( + commit.toCharArray().joinToString(" "), + aboutCommitAccessibilityValue(commit, "Unknown"), + ) + } + + @Test + fun gatewayStatusLabelReportsWhichAuthRecoveryAppliesInsteadOfGenericLabel() { + assertEquals( + "Setup code expired", + gatewayStatusLabel( + "Gateway error: unauthorized: bootstrap token invalid or expired", + isConnected = false, + gatewayConnectionProblem = authProblem("AUTH_BOOTSTRAP_TOKEN_INVALID"), + ), + ) + assertEquals( + "Device identity required", + gatewayStatusLabel( + "Gateway error: device identity required", + isConnected = false, + gatewayConnectionProblem = authProblem("DEVICE_IDENTITY_REQUIRED"), + ), + ) + } + + @Test + fun gatewayStatusLabelFallsBackToGenericAuthLabelWithoutAKnownReason() { + assertEquals("Authentication needed", gatewayStatusLabel("auth failed", isConnected = false, gatewayConnectionProblem = null)) + assertEquals( + "Authentication needed", + gatewayStatusLabel("auth failed", isConnected = false, gatewayConnectionProblem = authProblem("SOME_UNMAPPED_CODE")), + ) + } + + @Test + fun gatewayStatusLabelLeavesUnrelatedStatesUnaffectedByConnectionProblem() { + val problem = authProblem("AUTH_TOKEN_MISSING") + assertEquals("Ready", gatewayStatusLabel("auth failed", isConnected = true, gatewayConnectionProblem = authProblem("AUTH_TOKEN_MISSING"))) + assertEquals("Pairing needed", gatewayStatusLabel("Pairing in progress", isConnected = false, gatewayConnectionProblem = problem)) + assertEquals("Cannot reach gateway", gatewayStatusLabel("Connection failed", isConnected = false, gatewayConnectionProblem = problem)) + } + + @Test + fun gatewayStatusLabelPreservesPartialConnectivity() { + assertEquals( + "Connected (node offline)", + gatewayStatusLabel( + GatewayConnectionDisplay( + isConnected = true, + statusText = "Connected (node offline)", + problem = null, + ), + ), + ) + assertEquals( + "Connected (operator offline)", + gatewayStatusLabel( + GatewayConnectionDisplay( + isConnected = false, + statusText = "Connected (operator offline)", + problem = null, + ), + ), + ) + } + + @Test + fun gatewaySetupResetCopyExplainsCredentialAndApprovalImpact() { + val text = gatewaySettingsSetupResetConfirmationText() + + assertEquals(true, text.contains("saved setup credentials")) + assertEquals(true, text.contains("device tokens")) + assertEquals(true, text.contains("node capability approval")) + } + + @Test + fun gatewayAccessExplainsLimitedConnectionsAndUpgradePath() { + assertEquals("Not available", gatewayAccessLabel(isConnected = false, operatorAdminScopeAvailable = false)) + assertEquals("Limited", gatewayAccessLabel(isConnected = true, operatorAdminScopeAvailable = false)) + assertEquals("Full", gatewayAccessLabel(isConnected = true, operatorAdminScopeAvailable = true)) + assertTrue(gatewayLimitedAccessUpgradeText().contains("full-access setup code")) + assertTrue(gatewayLimitedAccessUpgradeText().contains("wss://")) + assertTrue(gatewayLimitedAccessUpgradeText().contains("Tailscale Serve")) + assertTrue(gatewayLimitedAccessUpgradeText().contains("settings and upgrades")) + } + + @Test + fun devicePairingAdminCopySeparatesPairingFromNodeApproval() { + val text = devicePairingAdminUnavailableText() + + assertEquals(true, text.contains("openclaw devices list")) + assertEquals(true, text.contains("Gateway host")) + assertEquals(true, text.contains("Node capability approval is separate")) + assertEquals(true, text.contains("nodes approve ")) + } + + @Test + fun nodeApprovalCommandUsesOnlyASafeExactRequestId() { + assertEquals( + "openclaw nodes approve request-1", + gatewayNodeApprovalCommand(GatewayNodeCapabilityApproval.PendingApproval("request-1")), + ) + assertEquals( + "openclaw nodes status", + gatewayNodeApprovalCommand(GatewayNodeCapabilityApproval.PendingReapproval("request-1; unsafe")), + ) + assertEquals(null, gatewayNodeApprovalCommand(GatewayNodeCapabilityApproval.Approved)) + } + + @Test + fun cronDetailRefreshRecoversWhenDirtyDraftHasNoLoadedJob() { + assertEquals( + true, + cronDetailRefreshEnabled( + isConnected = true, + loading = false, + hasCurrentJob = false, + draftRequiresResolution = true, + saveSucceeded = false, + ), + ) + assertEquals( + false, + cronDetailRefreshEnabled( + isConnected = true, + loading = false, + hasCurrentJob = true, + draftRequiresResolution = true, + saveSucceeded = false, + ), + ) + } + + @Test + fun cronDetailDisposalRetainsTransientStateOnlyForActivityRecreation() { + assertEquals(false, cronDetailDisposalClearsTransientState(isChangingConfigurations = true)) + assertEquals(true, cronDetailDisposalClearsTransientState(isChangingConfigurations = false)) + } + + @Test + fun automationListSearchAndStatusFiltersCompose() { + val active = + GatewayCronJobSummary( + id = "daily", + name = "Daily Brief", + enabled = true, + scheduleLabel = nativeText("Every day"), + promptPreview = nativeText("Summarize updates"), + nextRunAtMs = null, + lastRunStatus = "ok", + ) + val paused = active.copy(id = "weekly", name = "Weekly Review", enabled = false) + + assertEquals(listOf(active), filterCronJobs(listOf(active, paused), "brief", CronJobsListFilter.All)) + assertEquals(listOf(active), filterCronJobs(listOf(active, paused), "", CronJobsListFilter.Active)) + assertEquals(listOf(paused), filterCronJobs(listOf(active, paused), "", CronJobsListFilter.Paused)) + } + + @Test + fun cronDetailDeliveryStatusUsesLocalizedLabelsAndNullFallback() { + assertEquals("Delivered", cronJobDeliveryStatusText("delivered")) + assertEquals("future-status", cronJobDeliveryStatusText("future-status")) + assertEquals("None", cronJobDeliveryStatusText(null)) + } + + @Test + fun approvalActionsUseUnabridgedSafetyLabelsInLargeFontSafeOrder() { + assertEquals( + listOf( + ExecApprovalAction("allow-once", "Allow Once"), + ExecApprovalAction("allow-always", "Allow Always"), + ExecApprovalAction("deny", "Deny"), + ), + execApprovalActions(listOf("allow-once", "allow-always", "deny")), + ) + } + + @Test + fun approvalPresentationLocalizesControlledCopyAndPreservesGatewayValues() { + val approval = + GatewayExecApprovalSummary( + id = "approval-1", + commandText = verbatimText("echo ok"), + commandPreview = "echo", + warningText = null, + allowedDecisions = listOf("allow-once"), + host = "node", + nodeId = "node-123456", + agentId = "agent-123456", + createdAtMs = 0, + expiresAtMs = 3_660_000, + ) + + assertEquals( + "Node node-123 · Agent agent-12 · Waiting 1h · Expires 1m", + execApprovalMetadata(approval, nowMs = 3_600_000), + ) + assertEquals( + "ssh.EXAMPLE", + execApprovalMetadata( + approval.copy(host = "ssh.EXAMPLE", nodeId = null, agentId = null, createdAtMs = null, expiresAtMs = null), + nowMs = 0, + ), + ) + assertEquals( + "Node", + execApprovalMetadata( + approval.copy(host = "node", nodeId = null, agentId = null, createdAtMs = null, expiresAtMs = null), + nowMs = 0, + ), + ) + assertEquals( + "Gateway", + execApprovalMetadata( + approval.copy(host = "gateway", nodeId = null, agentId = null, createdAtMs = null, expiresAtMs = null), + nowMs = 0, + ), + ) + assertEquals("soon", formatApprovalDuration(0)) + assertEquals("Action Request", approvalActionName("")) + } + + @Test + fun cronSessionTargetsLocalizeClosedCodesAndPreserveCustomTargets() { + assertEquals("Main", cronSessionTargetLabel("main")) + assertEquals("Isolated", cronSessionTargetLabel("isolated")) + assertEquals("Current", cronSessionTargetLabel("current")) + assertEquals("session:custom", cronSessionTargetLabel("session:custom")) + } + + @Test + fun usageAndCronSummariesLocalizeOnlyControlledWords() { + val provider = + GatewayUsageProviderSummary( + displayName = "Provider", + plan = "Team Plan", + error = null, + windows = + listOf( + GatewayUsageWindowSummary( + label = "Custom Window", + usedPercent = 25.0, + resetAtMs = null, + ), + ), + ) + + assertEquals("Team Plan · 75% left Custom Window", usageProviderSubtitle(provider)) + assertEquals("provider error", usageProviderSubtitle(provider.copy(error = "provider error"))) + assertEquals("Never", formatUsageUpdated(updatedAtMs = null, nowMs = 60_000)) + assertEquals("Now", formatUsageUpdated(updatedAtMs = 59_999, nowMs = 60_000)) + assertEquals("None", formatCronWake(timeMs = null, nowMs = 60_000)) + assertEquals("Due", formatCronWake(timeMs = 60_000, nowMs = 60_000)) + assertEquals("Soon", formatCronWake(timeMs = 60_001, nowMs = 60_000)) + assertEquals("None", formatCronTimestamp(null)) + } + + @Test + fun approvalCardShowsTheWholeMonospacedCommandBeforeStackedActions() { + val source = settingsScreensSource() + val cardStart = source.indexOf("private fun ExecApprovalCard(") + val reviewCall = source.indexOf("ExecApprovalCommandReview(", cardStart) + val actionsCall = source.indexOf("execApprovalActions(approval.allowedDecisions)", reviewCall) + val reviewStart = source.indexOf("private fun ExecApprovalCommandReview(", actionsCall) + val reviewEnd = source.indexOf("internal data class ExecApprovalAction", reviewStart) + assertTrue(cardStart >= 0 && reviewCall > cardStart && actionsCall > reviewCall) + assertTrue(reviewStart > actionsCall && reviewEnd > reviewStart) + val reviewBody = source.substring(reviewStart, reviewEnd) + val actionBody = source.substring(reviewCall, reviewStart) + + assertTrue(reviewBody.contains("FontFamily.Monospace")) + assertFalse(reviewBody.contains("maxLines")) + assertFalse(reviewBody.contains("TextOverflow")) + assertTrue(actionBody.contains("Column(modifier = Modifier.fillMaxWidth()")) + assertFalse(actionBody.contains("Modifier.weight(1f)")) + } + + @Test + fun terminalNoticeRendersAsStandaloneDismissibleBannerRegardlessOfRemainingCards() { + val source = settingsScreensSource() + // Terminal outcomes retire their card before the notice publishes, so any + // card-scoped or empty-inbox-only rendering hides losing outcomes whenever + // another approval card remains visible. + assertFalse(source.contains("execApprovalNoticeForCard")) + assertFalse(source.contains("execApprovalEmptyInboxNotice")) + val screenStart = source.indexOf("private fun ApprovalsSettingsScreen(") + val bannerCall = source.indexOf("execApprovalsNotice?.let", screenStart) + val listPanelCall = source.indexOf("ExecApprovalsPanel(", screenStart) + assertTrue(screenStart >= 0 && bannerCall > screenStart && listPanelCall > bannerCall) + + val noticeStart = source.indexOf("private fun ExecApprovalNotice(") + val noticeEnd = source.indexOf("@Composable", noticeStart + 1) + val noticeBody = source.substring(noticeStart, noticeEnd) + assertTrue(noticeBody.contains("onDismiss: () -> Unit")) + assertTrue(noticeBody.contains("notice.approvalId")) + assertTrue( + noticeBody.contains( + "contentDescription = nativeString(\"Dismiss approval notice\")", + ), + ) + } + + @Test + fun gatewayPairingSurfacesStayProminentUntilPaired() { + assertTrue(gatewayShowsScanHero(pairedGatewayCount = 0)) + assertFalse(gatewayShowsScanHero(pairedGatewayCount = 1)) + + val endpoint = GatewayEndpoint(stableId = "gw", name = "Studio", host = "10.0.0.5", port = 18789) + assertEquals("10.0.0.5:18789", gatewayDiscoveredRowSubtitle(endpoint)) + } + + @Test + fun gatewayScreenOrdersPairingAheadOfManualSetup() { + val source = settingsScreensSource() + val screenStart = source.indexOf("private fun GatewaySettingsScreen(") + // Pairing stays reachable without scrolling: nav-bar scanner action plus a + // hero CTA while nothing is paired, then Add Gateway before manual plumbing. + val trailingScan = source.indexOf("trailingAction = {", screenStart) + val scanHero = source.indexOf("nativeString(\"Scan QR to Pair\")", screenStart) + val addPanel = source.indexOf("nativeString(\"Add Gateway\")", screenStart) + val pairedPanel = source.indexOf("nativeString(\"Gateways\")", screenStart) + val manualPanel = source.indexOf("nativeString(\"Manual Gateway\")", screenStart) + assertTrue(screenStart >= 0 && trailingScan > screenStart && scanHero > trailingScan) + assertTrue(addPanel > scanHero && pairedPanel > addPanel && manualPanel > pairedPanel) + // Discovered gateways surface inside Add Gateway with a per-row connect. + val discoveredRows = source.indexOf("discoveredGateways.forEachIndexed", screenStart) + assertTrue(discoveredRows > addPanel && discoveredRows < pairedPanel) + } + + private fun settingsScreensSource(): String { + val candidates = + listOf( + Path.of("src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"), + Path.of("apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"), + ) + val path = candidates.firstOrNull(Files::exists) ?: error("SettingsScreens.kt not found") + return Files.readString(path) + } + + private fun authProblem(code: String): GatewayConnectionProblem = + GatewayConnectionProblem( + code = code, + message = "Authentication failed.", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = false, + retryable = false, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt b/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt new file mode 100644 index 0000000..9200fae --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt @@ -0,0 +1,901 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.AppearanceThemeMode +import ai.openclaw.app.GatewayAgentSummary +import ai.openclaw.app.GatewayChannelSummary +import ai.openclaw.app.GatewayChannelsSummary +import ai.openclaw.app.GatewayConnectionDisplay +import ai.openclaw.app.GatewayConnectionProblem +import ai.openclaw.app.GatewayNodeApprovalState +import ai.openclaw.app.GatewayNodeSummary +import ai.openclaw.app.GatewayNodesDevicesSummary +import ai.openclaw.app.GatewayPendingDeviceSummary +import ai.openclaw.app.GatewaySkillWorkshopProposal +import ai.openclaw.app.GatewaySkillWorkshopSummary +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.i18n.verbatimText +import ai.openclaw.app.normalizeOperatorScopes +import ai.openclaw.app.ui.design.ClawStatus +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.saveable.SaverScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ShellScreenLogicTest { + @Test + fun bottomNavHidesForKeyboardAndCommandPalette() { + assertTrue(shellBottomNavVisible(keyboardVisible = false, commandOpen = false)) + assertFalse(shellBottomNavVisible(keyboardVisible = true, commandOpen = false)) + assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true)) + } + + @Test + fun localizedUppercaseUsesTheSelectedAppLocale() { + assertEquals("İLETİŞİM", localizedUppercase("iletişim", languageTag = "tr", fallbackLocale = Locale.US)) + } + + @Test + fun settingsDisclosureUsesTheLocalizedTitle() { + assertEquals("Open Nœuds et appareils", settingsRowDisclosureDescription("Nœuds et appareils", opensRoute = true)) + assertEquals("Nœuds et appareils", settingsRowDisclosureDescription("Nœuds et appareils", opensRoute = false)) + } + + @Test + fun appearanceThemeModeDefaultsToDarkForExistingInstalls() { + assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue(null)) + assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue("unknown")) + } + + @Test + fun appearanceThemeLabelsRoundTripFromSettingsOptions() { + assertEquals(listOf("System", "Dark", "Light"), appearanceThemeOptions()) + assertEquals(AppearanceThemeMode.System, appearanceThemeModeForLabel("System")) + assertEquals(AppearanceThemeMode.Dark, appearanceThemeModeForLabel("Dark")) + assertEquals(AppearanceThemeMode.Light, appearanceThemeModeForLabel("Light")) + } + + @Test + fun appearanceThemeModeResolvesAgainstSystemPreference() { + assertFalse(AppearanceThemeMode.System.isDark(systemDark = false)) + assertTrue(AppearanceThemeMode.System.isDark(systemDark = true)) + assertTrue(AppearanceThemeMode.Dark.isDark(systemDark = false)) + assertFalse(AppearanceThemeMode.Light.isDark(systemDark = true)) + } + + @Test + fun settingsRouteOpenedCrossTabReturnsToOriginTab() { + val nav = ShellNavigation() + nav.selectTab(Tab.Voice) + nav.openSettingsRoute(SettingsRoute.Gateway) + assertEquals(Tab.Settings, nav.activeTab) + assertEquals(SettingsRoute.Gateway, nav.settingsRoute) + + nav.back() + assertEquals(Tab.Chat, nav.activeTab) + assertEquals(SettingsRoute.Home, nav.settingsRoute) + + nav.back() + assertEquals(Tab.Overview, nav.activeTab) + } + + @Test + fun settingsRouteOpenedFromOverviewReturnsToOverview() { + val nav = ShellNavigation() + nav.openSettingsRoute(SettingsRoute.Approvals) + nav.back() + assertEquals(Tab.Overview, nav.activeTab) + assertEquals(SettingsRoute.Home, nav.settingsRoute) + } + + @Test + fun tabBarSettingsSelectionOpensHomeAndBacksToOverview() { + val nav = ShellNavigation() + nav.selectTab(Tab.Voice) + nav.openSettingsRoute(SettingsRoute.Voice) + nav.selectTab(Tab.Settings) + assertEquals(SettingsRoute.Home, nav.settingsRoute) + + nav.back() + assertEquals(Tab.Overview, nav.activeTab) + } + + @Test + fun settingsDetailOpenedFromHomeUnwindsToHomeBeforeLeavingSettings() { + val nav = ShellNavigation() + nav.selectTab(Tab.Voice) + nav.openSettingsRoute(SettingsRoute.Home) + nav.openSettingsRouteFromHome(SettingsRoute.Gateway) + + nav.back() + assertEquals(Tab.Settings, nav.activeTab) + assertEquals(SettingsRoute.Home, nav.settingsRoute) + + nav.back() + assertEquals(Tab.Chat, nav.activeTab) + } + + @Test + fun detailTabsReturnToTheTabThatOpenedThem() { + val nav = ShellNavigation() + nav.selectTab(Tab.Chat) + nav.openDetailTab(Tab.Sessions) + nav.back() + assertEquals(Tab.Chat, nav.activeTab) + + nav.selectTab(Tab.Voice) + nav.openDetailTab(Tab.ProvidersModels) + nav.back() + assertEquals(Tab.Chat, nav.activeTab) + } + + @Test + fun sessionDashboardRoutePreservesTheOpeningSessionAndReturnsToChat() { + val nav = ShellNavigation() + nav.selectTab(Tab.Chat) + + nav.openSessionDashboard("agent:main:phone") + + assertEquals(Tab.Dashboard, nav.activeTab) + assertEquals("agent:main:phone", nav.dashboardSessionKey) + nav.back() + assertEquals(Tab.Chat, nav.activeTab) + } + + @Test + fun tabBarSelectionClearsCrossTabReturnOrigin() { + val nav = ShellNavigation() + nav.selectTab(Tab.Chat) + nav.openDetailTab(Tab.Sessions) + nav.selectTab(Tab.Voice) + nav.back() + assertEquals(Tab.Overview, nav.activeTab) + } + + @Test + fun shellNavigationSaverRoundTripsCrossTabState() { + val nav = ShellNavigation() + nav.selectTab(Tab.Voice) + nav.openSettingsRoute(SettingsRoute.Gateway) + + val saveAnything = SaverScope { true } + val saved = with(ShellNavigation.Saver) { saveAnything.save(nav) }!! + val restored = ShellNavigation.Saver.restore(saved)!! + + assertEquals(Tab.Settings, restored.activeTab) + assertEquals(SettingsRoute.Gateway, restored.settingsRoute) + restored.back() + assertEquals(Tab.Chat, restored.activeTab) + } + + @Test + fun homeAttentionRowsSurfaceGatewayWhenDisconnected() { + val rows = + homeAttentionRows( + isConnected = false, + pendingApprovals = 0, + channelsSummary = emptyChannels(), + nodesDevicesSummary = emptyNodesDevices(), + readyProviderCount = 0, + ) + + assertEquals(listOf("Gateway"), rows.map { it.title }) + } + + @Test + fun homeAttentionRowsSurfaceOnlyActionableConnectedIssues() { + val rows = + homeAttentionRows( + isConnected = true, + pendingApprovals = 2, + channelsSummary = + GatewayChannelsSummary( + channels = + listOf( + GatewayChannelSummary( + id = "telegram", + label = "Telegram", + accountCount = 1, + enabled = true, + configured = true, + linked = true, + running = false, + connected = false, + error = "offline", + ), + ), + ), + nodesDevicesSummary = + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = + listOf( + GatewayPendingDeviceSummary( + requestId = "request-1", + deviceId = "device-1", + displayName = "Phone", + remoteIp = null, + roles = emptyList(), + scopes = emptyList(), + requestedAtMs = null, + repair = false, + ), + ), + pairedDevices = emptyList(), + ), + readyProviderCount = 0, + ) + + assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title }) + val providersRow = rows.single { it.title == "Providers" } + assertEquals(Tab.Settings, providersRow.tab) + assertEquals(SettingsRoute.ProvidersModels, providersRow.settingsRoute) + } + + @Test + fun homeAttentionRowsStayQuietWhenConnectedAndHealthy() { + val rows = + homeAttentionRows( + isConnected = true, + pendingApprovals = 0, + channelsSummary = emptyChannels(), + nodesDevicesSummary = emptyNodesDevices(), + readyProviderCount = 1, + ) + + assertEquals(emptyList(), rows.map { it.title }) + } + + @Test + fun homeAttentionRowsDoNotClaimUnknownProvidersAreUnavailable() { + val rows = + homeAttentionRows( + isConnected = true, + pendingApprovals = 0, + channelsSummary = emptyChannels(), + nodesDevicesSummary = emptyNodesDevices(), + readyProviderCount = 0, + unknownProviderCount = 1, + ) + + assertEquals(emptyList(), rows.map { it.title }) + } + + @Test + fun skillWorkshopSummaryPrioritizesPendingAndHeldProposals() { + assertEquals( + "2 pending", + skillWorkshopSummaryText( + GatewaySkillWorkshopSummary( + proposals = + listOf( + skillWorkshopProposal("one", "pending"), + skillWorkshopProposal("two", "pending"), + skillWorkshopProposal("three", "applied"), + ), + ), + ), + ) + assertEquals( + "1 held", + skillWorkshopSummaryText( + GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("held", "quarantined"))), + ), + ) + assertEquals(null, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = emptyList()))) + assertEquals(false, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("pending", "pending"))))) + assertEquals(true, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("applied", "applied"))))) + } + + @Test + fun skillWorkshopFilteringMatchesHeldAndSearchText() { + val proposals = + listOf( + skillWorkshopProposal("pending", "pending", title = "Browser Playbook", skillKey = "browser-playbook"), + skillWorkshopProposal("stale", "stale", title = "Old Draft", skillKey = "old-draft"), + skillWorkshopProposal("quarantine", "quarantined", title = "Risky Skill", skillKey = "risky-skill"), + ) + + assertEquals(listOf("stale", "quarantine"), skillWorkshopFilteredProposals(proposals, "held", "").map { it.id }) + assertEquals(listOf("pending"), skillWorkshopFilteredProposals(proposals, "all", "browser").map { it.id }) + assertTrue(skillWorkshopStatusMatchesFilter("stale", "held")) + assertFalse(skillWorkshopStatusMatchesFilter("applied", "held")) + } + + @Test + fun skillWorkshopStatusLabelsMapKnownCodesAndPreserveUnknownValues() { + assertEquals("Pending", skillWorkshopStatusLabel("pending")) + assertEquals("Held", skillWorkshopStatusLabel("quarantined")) + assertEquals("Held", skillWorkshopStatusLabel("stale")) + assertEquals("Applied", skillWorkshopStatusLabel("applied")) + assertEquals("Rejected", skillWorkshopStatusLabel("rejected")) + assertEquals("Loading", skillWorkshopStatusLabel("loading")) + assertEquals("future_status", skillWorkshopStatusLabel("future_status")) + } + + @Test + fun skillWorkshopVisibleProposalsAreKeyedBySelectedAgentScope() { + val mainProposal = skillWorkshopProposal("main-proposal", "pending") + val opsProposal = skillWorkshopProposal("ops-proposal", "pending") + + assertEquals( + listOf("main-proposal"), + skillWorkshopVisibleProposals( + GatewaySkillWorkshopSummary(agentId = "", proposals = listOf(mainProposal)), + selectedAgentId = null, + ).map { it.id }, + ) + assertEquals( + emptyList(), + skillWorkshopVisibleProposals( + GatewaySkillWorkshopSummary(agentId = "main", proposals = listOf(mainProposal)), + selectedAgentId = "ops", + ).map { it.id }, + ) + assertEquals( + listOf("ops-proposal"), + skillWorkshopVisibleProposals( + GatewaySkillWorkshopSummary(agentId = "ops", proposals = listOf(opsProposal)), + selectedAgentId = " ops ", + ).map { it.id }, + ) + } + + @Test + fun skillWorkshopProposalActionsRequireAdminScope() { + assertTrue( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = true, + busy = false, + status = "pending", + ), + ) + assertFalse( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = false, + busy = false, + status = "pending", + ), + ) + assertFalse( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = true, + busy = true, + status = "pending", + ), + ) + assertFalse( + skillWorkshopProposalActionEnabled( + isConnected = true, + operatorAdminScopeAvailable = true, + busy = false, + status = "applied", + ), + ) + } + + @Test + fun operatorScopesNormalizeForStableAdminChecks() { + assertEquals( + listOf("operator.admin", "operator.read", "operator.write"), + normalizeOperatorScopes( + listOf(" operator.write ", "operator.admin", "", "operator.write", "operator.read"), + ), + ) + } + + @Test + fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() { + val rows = + homeAttentionRows( + isConnected = true, + pendingApprovals = 0, + channelsSummary = emptyChannels(), + nodesDevicesSummary = + GatewayNodesDevicesSummary( + nodes = + listOf( + GatewayNodeSummary( + id = "android-node", + displayName = "Android", + remoteIp = null, + version = null, + deviceFamily = "Android", + paired = true, + connected = true, + approvalState = GatewayNodeApprovalState.PendingApproval, + pendingRequestId = null, + capabilities = emptyList(), + commands = emptyList(), + ), + ), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ), + readyProviderCount = 1, + ) + + assertEquals(listOf("Nodes & Devices"), rows.map { it.title }) + assertEquals("Node approval pending", rows.single().subtitle) + } + + @Test + fun overviewHeaderStateReflectsGatewayConnectionAndAttention() { + assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true)) + assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true)) + assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false)) + } + + @Test + fun overviewHeaderRouteUsesFirstAttentionDestination() { + assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList())) + assertEquals( + SettingsRoute.Approvals, + overviewHeaderRoute( + listOf( + HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals), + HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices), + ), + ), + ) + } + + @Test + fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() { + val cards = + overviewMetricCardSpecs( + isConnected = true, + hasAttention = true, + nodesDevicesSummary = + GatewayNodesDevicesSummary( + nodes = + listOf( + GatewayNodeSummary( + id = "android-node", + displayName = "Android", + remoteIp = null, + version = null, + deviceFamily = "Android", + paired = true, + connected = true, + approvalState = GatewayNodeApprovalState.PendingReapproval, + pendingRequestId = "node-request", + capabilities = emptyList(), + commands = emptyList(), + ), + ), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ), + pendingApprovals = 2, + sessionCount = 4, + ) + + assertEquals(listOf("Gateway", "Nodes", "Approvals", "Threads", "Files"), cards.map { it.title }) + assertEquals("Online", cards.single { it.title == "Gateway" }.value) + assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle) + assertEquals("1/1", cards.single { it.title == "Nodes" }.value) + assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle) + assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status) + assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f) + assertEquals("2", cards.single { it.title == "Approvals" }.value) + assertEquals("4", cards.single { it.title == "Threads" }.value) + assertEquals("Browse", cards.single { it.title == "Files" }.value) + assertEquals(Tab.Files, cards.single { it.title == "Files" }.tab) + } + + @Test + fun overviewRecentSessionCountIgnoresRetainedRowsOutsideTheRecentWindow() { + val sessions = + (1..51).map { index -> + ChatSessionEntry(key = "session-$index", updatedAtMs = index.toLong()) + } + + assertEquals(50, overviewRecentSessionCount(sessions)) + assertEquals((51 downTo 2).map { "session-$it" }, overviewRecentSessions(sessions).map { it.key }) + } + + @Test + fun overviewRecentSessionsSortByMostRecentTimestamp() { + val sessions = + listOf( + ChatSessionEntry(key = "cron", updatedAtMs = 2), + ChatSessionEntry(key = "main", updatedAtMs = 3), + ChatSessionEntry(key = "telegram", updatedAtMs = 1), + ) + + assertEquals(listOf("main", "cron", "telegram"), overviewRecentSessions(sessions).map { session -> session.key }) + } + + @Test + fun overviewRecentSessionsPreferLastActivityForRecency() { + val sessions = + listOf( + ChatSessionEntry(key = "main", updatedAtMs = 10, lastActivityAt = 10), + ChatSessionEntry(key = "cron", updatedAtMs = 50, lastActivityAt = 20), + ChatSessionEntry(key = "telegram", updatedAtMs = 1, lastActivityAt = 100), + ) + + assertEquals(listOf("telegram", "cron", "main"), overviewRecentSessions(sessions).map { session -> session.key }) + } + + @Test + fun overviewRecentSessionsDeduplicateByNewestEntry() { + val sessions = + overviewRecentSessions( + listOf( + ChatSessionEntry(key = "main", displayName = "Stale main", updatedAtMs = 10, lastActivityAt = 10), + ChatSessionEntry(key = "cron", displayName = "Cron", updatedAtMs = 2), + ChatSessionEntry(key = "main", displayName = "Fresh main", updatedAtMs = 3, lastActivityAt = 30), + ), + ) + + assertEquals(listOf("main", "cron"), sessions.map { session -> session.key }) + assertEquals("Fresh main", sessions.first().displayName) + } + + @Test + fun overviewRecentSessionsUseStableKeyOrderWhenTimestampsMatch() { + assertEquals( + listOf("cron", "main", "telegram"), + overviewRecentSessions( + listOf( + ChatSessionEntry(key = "telegram", updatedAtMs = 1), + ChatSessionEntry(key = "main", updatedAtMs = 1), + ChatSessionEntry(key = "cron", updatedAtMs = 1), + ), + ).map { session -> session.key }, + ) + } + + @Test + fun overviewRecentSessionRowsUseLastActivityForMetadata() { + val rows = + overviewRecentSessionRows( + sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = null, lastActivityAt = System.currentTimeMillis())), + channelsSummary = emptyChannels(), + ) + + assertTrue(rows.single().metadata.isNotBlank()) + } + + @Test + fun stableOverviewRecentRowsKeepPreviousMetadataDuringPartialRefresh() { + val rows = + stableOverviewRecentRows( + previousRows = + listOf( + RecentSessionListItem(key = "main", title = "Main session", source = "OpenClaw", metadata = "1h"), + ), + candidateRows = + listOf( + RecentSessionListItem(key = "main", title = "Main session", source = "OpenClaw", metadata = ""), + ), + ) + + assertEquals("1h", rows.single().metadata) + } + + @Test + fun stableOverviewRecentRowsFollowCandidateRows() { + val rows = + stableOverviewRecentRows( + previousRows = + listOf( + RecentSessionListItem(key = "main", title = "Main session", source = "OpenClaw", metadata = "1h"), + RecentSessionListItem(key = "discord", title = "Discord", source = "Discord", metadata = "2h"), + ), + candidateRows = + listOf( + RecentSessionListItem(key = "main", title = "Main session", source = "OpenClaw", metadata = "1h"), + RecentSessionListItem(key = "cron", title = "Cron", source = "Cron", metadata = "4h"), + ), + ) + + assertEquals(listOf("main", "cron"), rows.map { row -> row.key }) + } + + @Test + fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() { + val cards = + overviewMetricCardSpecs( + isConnected = true, + hasAttention = false, + nodesDevicesSummary = + GatewayNodesDevicesSummary( + nodes = + (1..3).map { index -> + GatewayNodeSummary( + id = "node-$index", + displayName = "Node $index", + remoteIp = null, + version = null, + deviceFamily = null, + paired = true, + connected = index <= 2, + approvalState = GatewayNodeApprovalState.Approved, + pendingRequestId = null, + capabilities = emptyList(), + commands = emptyList(), + ) + }, + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ), + pendingApprovals = 0, + sessionCount = 0, + ) + + val nodes = cards.single { it.title == "Nodes" } + assertEquals("2/3", nodes.value) + assertEquals("67% online", nodes.subtitle) + assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f) + } + + @Test + fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() { + val cards = + overviewMetricCardSpecs( + isConnected = true, + hasAttention = false, + nodesDevicesSummary = emptyNodesDevices(), + pendingApprovals = 0, + sessionCount = 0, + ) + + val gateway = cards.single { it.title == "Gateway" } + assertEquals("Healthy", gateway.value) + assertEquals("All systems nominal", gateway.subtitle) + assertEquals(ClawStatus.Success, gateway.status) + } + + @Test + fun overviewAgentNameUsesDefaultAgentWhenPresent() { + val agents = + listOf( + GatewayAgentSummary(id = "main", name = "Main", emoji = null), + GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"), + ) + + assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout")) + assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null)) + assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null)) + } + + @Test + fun overviewAgentBadgeUsesEmojiBeforeInitials() { + val agents = + listOf( + GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null), + GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"), + ) + + assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout")) + assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main")) + assertEquals( + "🧭S", + overviewAgentBadgeText( + agents = listOf(GatewayAgentSummary(id = "emoji", name = "🧭 Scout", emoji = null)), + defaultAgentId = "emoji", + ), + ) + assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null)) + } + + @Test + fun overviewAgentActivityTextUsesRealRuntimeCounts() { + assertEquals( + "Working · 2 active runs", + overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"), + ) + assertEquals( + "Monitoring · 50 threads", + overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"), + ) + assertEquals( + "Gateway offline", + overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"), + ) + } + + @Test + fun channelsSummaryTextUsesDistinctIssuePluralization() { + fun channel( + id: String, + error: String?, + ) = GatewayChannelSummary( + id = id, + label = id, + accountCount = 1, + enabled = true, + configured = true, + linked = true, + running = error == null, + connected = error == null, + error = error, + ) + + assertEquals( + "1 issue", + channelsSummaryText(GatewayChannelsSummary(channels = listOf(channel("one", "offline")))), + ) + assertEquals( + "2 issues", + channelsSummaryText( + GatewayChannelsSummary( + channels = listOf(channel("one", "offline"), channel("two", "unauthorized")), + ), + ), + ) + } + + @Test + fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() { + assertEquals("Telegram", sessionSourceLabel("telegram:8227096397")) + assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf")) + assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection")) + assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058")) + assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001")) + assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123")) + assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android")) + assertEquals("OpenClaw", sessionSourceLabel("agent:main:main")) + assertEquals("OpenClaw", sessionSourceLabel("Daily standup")) + } + + @Test + fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() { + val channels = + GatewayChannelsSummary( + channels = + listOf( + GatewayChannelSummary( + id = "matrix", + label = "Matrix", + accountCount = 1, + enabled = true, + configured = true, + linked = true, + running = true, + connected = true, + error = null, + ), + ), + ) + + assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels)) + } + + @Test + fun settingsSectionTitlesGroupPowerSettingsByMeaning() { + assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway).resolveNativeText()) + assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices).resolveNativeText()) + assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.SystemAgent).resolveNativeText()) + assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels).resolveNativeText()) + assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals).resolveNativeText()) + assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs).resolveNativeText()) + assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities).resolveNativeText()) + assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.Notifications).resolveNativeText()) + assertEquals("Profile & device", settingsSectionTitleForRoute(SettingsRoute.Appearance).resolveNativeText()) + assertEquals("Diagnostics", settingsSectionTitleForRoute(SettingsRoute.Health).resolveNativeText()) + } + + @Test + fun settingsSectionsPreserveMeaningfulOrder() { + val sections = + settingsSections( + listOf( + settingsRow(SettingsRoute.Voice), + settingsRow(SettingsRoute.Agents), + settingsRow(SettingsRoute.Gateway), + settingsRow(SettingsRoute.Appearance), + settingsRow(SettingsRoute.Health), + ), + ) + + assertEquals( + listOf( + "Connection", + "Agents & automation", + "Phone context & privacy", + "Profile & device", + "Diagnostics", + ), + sections.map { it.title.resolveNativeText() }, + ) + } + + @Test + fun gatewaySummaryUsesStructuredProblemForCurrentAuthFailure() { + assertEquals( + "Gateway token needed", + gatewaySummary( + "Gateway error: unauthorized: gateway token missing", + isConnected = false, + gatewayConnectionProblem = authProblem("AUTH_TOKEN_MISSING"), + ), + ) + assertEquals( + "Device identity required", + gatewaySummary( + "Gateway error: device identity required", + isConnected = false, + gatewayConnectionProblem = authProblem("DEVICE_IDENTITY_REQUIRED"), + ), + ) + } + + @Test + fun gatewaySummaryFallsBackToGenericAuthLabelWithoutAKnownReason() { + assertEquals("Authentication needed", gatewaySummary("auth failed", isConnected = false, gatewayConnectionProblem = null)) + assertEquals("Authentication needed", gatewaySummary("auth failed", isConnected = false, gatewayConnectionProblem = authProblem("SOME_UNMAPPED_CODE"))) + } + + @Test + fun gatewaySummaryLeavesUnrelatedStatesUnaffectedByConnectionProblem() { + val problem = authProblem("AUTH_TOKEN_MISSING") + assertEquals("Online and ready", gatewaySummary("auth failed", isConnected = true, gatewayConnectionProblem = authProblem("AUTH_TOKEN_MISSING"))) + assertEquals("Connecting...", gatewaySummary("Reconnecting", isConnected = false, gatewayConnectionProblem = problem)) + assertEquals("Waiting for pairing", gatewaySummary("Pairing in progress", isConnected = false, gatewayConnectionProblem = problem)) + assertEquals("Certificate review needed", gatewaySummary("TLS handshake failed", isConnected = false, gatewayConnectionProblem = problem)) + } + + @Test + fun gatewaySummaryUsesAtomicRetryDisplayAfterAuthFailure() { + val retrying = + GatewayConnectionDisplay( + isConnected = false, + statusText = "Reconnecting…", + problem = null, + ) + + assertEquals("Connecting...", gatewaySummary(retrying)) + } + + private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList()) + + private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList()) + + private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(verbatimText(route.name), verbatimText("Value"), Icons.Default.Settings, route = route) + + private fun authProblem(code: String): GatewayConnectionProblem = + GatewayConnectionProblem( + code = code, + message = "Authentication failed.", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = false, + retryable = false, + ) + + private fun skillWorkshopProposal( + id: String, + status: String, + title: String = id, + skillKey: String = id, + ): GatewaySkillWorkshopProposal = + GatewaySkillWorkshopProposal( + id = id, + kind = "create", + status = status, + title = title, + description = null, + skillName = title, + skillKey = skillKey, + createdAt = "2026-07-08T00:00:00.000Z", + updatedAt = "2026-07-08T00:00:00.000Z", + scanState = null, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt b/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt new file mode 100644 index 0000000..71aeffe --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt @@ -0,0 +1,185 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayAgentSummary +import ai.openclaw.app.chat.ChatSessionEntry +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SidebarShellLogicTest { + @Test + fun compactWidthUsesNavigationBarAcrossTheSixHundredDpBoundary() { + assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(599f, 800f)) + assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(600f, 800f)) + } + + @Test + fun expandedWidthUsesPermanentDrawerAcrossTheEightHundredFortyDpBoundary() { + assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(839f, 800f)) + assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(840f, 800f)) + } + + @Test + fun compactHeightUsesNavigationBarAcrossTheFourHundredEightyDpBoundary() { + assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(840f, 479f)) + assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(840f, 480f)) + } + + @Test + fun representativeAndroidWindowSizesMapToMaterialPatterns() { + assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(360f, 800f)) + assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(800f, 360f)) + assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(600f, 480f)) + assertEquals(AdaptiveNavigationMode.Rail, adaptiveNavigationMode(839f, 899f)) + assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(841f, 701f)) + assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(1024f, 640f)) + assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(1280f, 800f)) + assertEquals(AdaptiveNavigationMode.Drawer, adaptiveNavigationMode(1600f, 900f)) + } + + @Test + fun tabletopPostureAlwaysUsesReachableBottomNavigation() { + assertEquals(AdaptiveNavigationMode.Bar, adaptiveNavigationMode(1280f, 800f, tabletop = true)) + } + + @Test + fun hiddenCompactNavigationDoesNotHideRailOrPermanentDrawer() { + assertEquals( + NavigationSuiteType.None, + adaptiveNavigationSuiteType(AdaptiveNavigationMode.Bar, compactNavigationVisible = false), + ) + assertEquals( + NavigationSuiteType.NavigationBar, + adaptiveNavigationSuiteType(AdaptiveNavigationMode.Bar, compactNavigationVisible = true), + ) + assertEquals( + NavigationSuiteType.NavigationRail, + adaptiveNavigationSuiteType(AdaptiveNavigationMode.Rail, compactNavigationVisible = false), + ) + assertEquals( + NavigationSuiteType.NavigationDrawer, + adaptiveNavigationSuiteType(AdaptiveNavigationMode.Drawer, compactNavigationVisible = false), + ) + } + + @Test + fun compactNavigationUsesShortDistinctLabels() { + val labels = SidebarDestination.entries.map(SidebarDestination::compactLabelSource) + + assertEquals(listOf("Chat", "Status", "Usage", "Cron", "Threads"), labels) + assertEquals(labels.size, labels.distinct().size) + assertTrue(labels.all { it.length <= 7 }) + } + + @Test + fun compactNavigationOnlyShowsTheSelectedLabel() { + assertFalse(alwaysShowAdaptiveNavigationLabel(AdaptiveNavigationMode.Bar)) + assertTrue(alwaysShowAdaptiveNavigationLabel(AdaptiveNavigationMode.Rail)) + assertTrue(alwaysShowAdaptiveNavigationLabel(AdaptiveNavigationMode.Drawer)) + } + + @Test + fun agentRosterExcludesSystemAgentsAndKeepsTheSelectedAgentFirst() { + val roster = + sidebarAgentRoster( + agents = + listOf( + agent("main"), + agent("system", kind = "system"), + agent("ops"), + agent("main"), + ), + selectedAgentId = "ops", + ) + + assertEquals("ops", roster.selected?.id) + assertEquals(listOf("main"), roster.others.map(GatewayAgentSummary::id)) + } + + @Test + fun emptySelectableAgentRosterHasNoSyntheticSelection() { + val roster = sidebarAgentRoster(listOf(agent("system", kind = "system")), selectedAgentId = "main") + + assertNull(roster.selected) + assertEquals(emptyList(), roster.others.map(GatewayAgentSummary::id)) + } + + @Test + fun recentSessionsExcludeArchivedRowsAndPrioritizePinsThenActivity() { + val rows = + sidebarRecentSessions( + sessions = + listOf( + session("old-pinned", activity = 1, pinned = true), + session("fresh", activity = 30), + session("archived", activity = 50, archived = true), + session("fresh-pinned", activity = 20, pinned = true), + ), + query = "", + ) + + assertEquals(listOf("fresh-pinned", "old-pinned", "fresh"), rows.map(ChatSessionEntry::key)) + } + + @Test + fun recentSessionSearchCoversTitleLabelKeyAndOwnerBeforeApplyingLimit() { + val rows = + sidebarRecentSessions( + sessions = + listOf( + session("agent:ops:one", activity = 1, displayName = "Release planning", owner = "ops"), + session("agent:main:two", activity = 2, displayName = "Product notes", owner = "main"), + session("agent:main:three", activity = 3, label = "Ops handoff", owner = "main"), + ), + query = "ops", + limit = 1, + ) + + assertEquals(listOf("agent:main:three"), rows.map(ChatSessionEntry::key)) + } + + @Test + fun recentSessionsStayBoundedInsideTheSharedScrollableSidebar() { + val rows = + sidebarRecentSessions( + sessions = (1L..12L).map { activity -> session("session-$activity", activity = activity) }, + query = "", + ) + + assertEquals(8, rows.size) + } + + private fun agent( + id: String, + kind: String? = null, + ): GatewayAgentSummary = + GatewayAgentSummary( + id = id, + name = id, + emoji = null, + kind = kind, + ) + + private fun session( + key: String, + activity: Long, + pinned: Boolean = false, + archived: Boolean = false, + displayName: String? = null, + label: String? = null, + owner: String? = null, + ): ChatSessionEntry = + ChatSessionEntry( + key = key, + updatedAtMs = activity, + lastActivityAt = activity, + pinned = pinned, + archived = archived, + displayName = displayName, + label = label, + ownerAgentId = owner, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SkillsSettingsScreenTest.kt b/app/src/test/java/ai/openclaw/app/ui/SkillsSettingsScreenTest.kt new file mode 100644 index 0000000..5dc75a0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SkillsSettingsScreenTest.kt @@ -0,0 +1,25 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SkillsSettingsScreenTest { + @Test + fun missingItemCopyHandlesZeroOneAndMany() { + assertEquals("No missing items", skillMissingItemsText(0)) + assertEquals("1 missing item", skillMissingItemsText(1)) + assertEquals("2 missing items", skillMissingItemsText(2)) + } + + @Test + fun missingSetupCopyUsesExplicitSingularAndPluralForms() { + assertEquals( + "This skill needs 1 setup item. Android shows what is installed; setup/config changes stay on desktop or CLI.", + skillMissingConfigurationText(1), + ) + assertEquals( + "This skill needs 2 setup items. Android shows what is installed; setup/config changes stay on desktop or CLI.", + skillMissingConfigurationText(2), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/SystemAnimationsTest.kt b/app/src/test/java/ai/openclaw/app/ui/SystemAnimationsTest.kt new file mode 100644 index 0000000..52bcb87 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/SystemAnimationsTest.kt @@ -0,0 +1,116 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.ui.design.OpenClawMascot +import ai.openclaw.app.ui.design.TalkWaveform +import ai.openclaw.app.ui.design.TalkWaveformPhase +import android.os.Looper +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.SideEffect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class SystemAnimationsTest { + private val uri = Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE) + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Test + fun reflectsAnimatorDurationScaleChangesWhileComposed() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val resolver = RuntimeEnvironment.getApplication().contentResolver + val originalScale = Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + val observed = mutableListOf() + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + + try { + controller.get().setContent { + val enabled = rememberSystemAnimationsEnabled() + SideEffect { observed.add(enabled) } + } + idleMainLooper() + assertEquals(true, observed.last()) + + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + resolver.notifyChange(uri, null) + idleMainLooper() + assertEquals(false, observed.last()) + + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + resolver.notifyChange(uri, null) + idleMainLooper() + assertEquals(true, observed.last()) + } finally { + controller.pause().stop().destroy() + idleMainLooper() + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, originalScale) + } + } + + @Test + fun initialCompositionRespectsDisabledAnimations() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val resolver = RuntimeEnvironment.getApplication().contentResolver + val originalScale = Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + val observed = mutableListOf() + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + + try { + controller.get().setContent { + val enabled = rememberSystemAnimationsEnabled() + SideEffect { observed.add(enabled) } + } + idleMainLooper() + assertEquals(false, observed.first()) + assertEquals(false, observed.last()) + } finally { + controller.pause().stop().destroy() + idleMainLooper() + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, originalScale) + } + } + + @Test + fun unregistersObserverOnDispose() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val resolver = RuntimeEnvironment.getApplication().contentResolver + + controller.get().setContent { rememberSystemAnimationsEnabled() } + idleMainLooper() + assertTrue(shadowOf(resolver).getContentObservers(uri).isNotEmpty()) + + controller.pause().stop().destroy() + idleMainLooper() + assertTrue(shadowOf(resolver).getContentObservers(uri).isEmpty()) + } + + @Test + fun mascotAndWaveformObserveTheReducedMotionSetting() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val resolver = RuntimeEnvironment.getApplication().contentResolver + val originalScale = Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + + try { + controller.get().setContent { + OpenClawMascot() + TalkWaveform(phase = TalkWaveformPhase.Idle) + } + idleMainLooper() + + assertEquals(2, shadowOf(resolver).getContentObservers(uri).size) + } finally { + controller.pause().stop().destroy() + idleMainLooper() + Settings.Global.putFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, originalScale) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt b/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt new file mode 100644 index 0000000..6764ebd --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt @@ -0,0 +1,155 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.VoiceCaptureMode +import ai.openclaw.app.ui.design.TalkWaveformPhase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceScreenLogicTest { + @Test + fun voiceAttentionStatusKeepsFailedTalkStartVisibleAfterModeStops() { + val attention = + voiceAttentionStatus( + talkModeStatusText = "Start failed: Error: Realtime voice provider \"openai\" is not configured", + voiceCaptureMode = VoiceCaptureMode.Off, + micEnabled = false, + micIsSending = false, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + ) + + assertEquals("Realtime voice provider is not configured.", attention) + assertEquals( + attention, + voiceStatusLabel( + gatewayStatus = "Online", + voiceCaptureMode = VoiceCaptureMode.Off, + micStatusText = "Mic off", + micQueuedMessages = 0, + micIsSending = false, + talkModeListening = false, + talkModeSpeaking = false, + voiceAttentionStatus = attention, + ), + ) + } + + @Test + fun voiceAttentionStatusDoesNotOverrideActiveTalkState() { + assertNull( + voiceAttentionStatus( + talkModeStatusText = "Start failed: provider unavailable", + voiceCaptureMode = VoiceCaptureMode.TalkMode, + micEnabled = false, + micIsSending = false, + talkModeEnabled = true, + talkModeListening = false, + talkModeSpeaking = false, + ), + ) + } + + @Test + fun voiceAttentionStatusDoesNotOverrideDictationState() { + assertNull( + voiceAttentionStatus( + talkModeStatusText = "Start failed: provider unavailable", + voiceCaptureMode = VoiceCaptureMode.ManualMic, + micEnabled = true, + micIsSending = false, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + ), + ) + } + + @Test + fun voiceRuntimeAttentionStatusSanitizesTranscriptionProviderFailures() { + assertEquals( + "Realtime transcription provider is not configured.", + voiceRuntimeAttentionStatus("Transcription unavailable: UNAVAILABLE: Error: No realtime transcription provider registered"), + ) + } + + @Test + fun voiceRuntimeAttentionStatusPreservesUtf16BoundariesAtLimit() { + val splitPairPrefix = "failed: ${"x".repeat(78)}" + assertEquals( + "$splitPairPrefix...", + voiceRuntimeAttentionStatus("$splitPairPrefix😀${"y".repeat(10)}"), + ) + + val completePairPrefix = "failed: ${"x".repeat(77)}" + assertEquals( + "$completePairPrefix😀...", + voiceRuntimeAttentionStatus("$completePairPrefix😀${"y".repeat(10)}"), + ) + } + + @Test + fun talkSessionWaveformPhaseFollowsTalkState() { + assertEquals( + TalkWaveformPhase.Speaking(0.4f), + talkSessionWaveformPhase(speaking = true, listening = true, awaitingAgent = false, inputLevel = 0.2f, speechActive = true, outputLevel = 0.4f), + ) + // Awaiting the agent wins over the still-running capture loop. + assertEquals( + TalkWaveformPhase.Thinking, + talkSessionWaveformPhase(speaking = false, listening = true, awaitingAgent = true, inputLevel = 0.2f, speechActive = false, outputLevel = null), + ) + assertEquals( + TalkWaveformPhase.Listening(level = 0.2f, speechActive = true), + talkSessionWaveformPhase(speaking = false, listening = true, awaitingAgent = false, inputLevel = 0.2f, speechActive = true, outputLevel = null), + ) + assertEquals( + TalkWaveformPhase.Idle, + talkSessionWaveformPhase(speaking = false, listening = false, awaitingAgent = false, inputLevel = 0f, speechActive = false, outputLevel = null), + ) + } + + @Test + fun voiceHeroWaveformPhasePrefersTalkOverDictation() { + assertEquals( + TalkWaveformPhase.Speaking(null), + voiceHeroWaveformPhase( + micEnabled = true, + micInputLevel = 0.5f, + talkModeEnabled = true, + talkModeListening = true, + talkModeSpeaking = true, + talkInputLevel = 0.1f, + talkOutputLevel = null, + talkSpeechActive = false, + ), + ) + assertEquals( + TalkWaveformPhase.Listening(level = 0.5f, speechActive = false), + voiceHeroWaveformPhase( + micEnabled = true, + micInputLevel = 0.5f, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + talkInputLevel = 0f, + talkOutputLevel = null, + talkSpeechActive = false, + ), + ) + assertEquals( + TalkWaveformPhase.Thinking, + voiceHeroWaveformPhase( + micEnabled = false, + micInputLevel = 0f, + talkModeEnabled = true, + talkModeListening = false, + talkModeSpeaking = false, + talkInputLevel = 0f, + talkOutputLevel = null, + talkSpeechActive = false, + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatCommandControlsTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatCommandControlsTest.kt new file mode 100644 index 0000000..6919472 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatCommandControlsTest.kt @@ -0,0 +1,159 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatCommandEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatCommandControlsTest { + @Test + fun matchingSlashCommandsFiltersByNameAndAliasPrefixes() { + val commands = + listOf( + ChatCommandEntry( + name = "new", + description = "Start fresh", + category = "session", + textAliases = listOf("/new"), + ), + ChatCommandEntry( + name = "model", + description = "Switch models", + category = "model", + textAliases = listOf("/model"), + acceptsArgs = true, + ), + ChatCommandEntry( + name = "agent", + description = "Pick runtime", + category = "agent", + textAliases = listOf("/agent", "/delegate"), + acceptsArgs = true, + ), + ) + + assertEquals( + listOf("/new", "/model", "/agent"), + matchingSlashCommands(input = "/", commands = commands).map(::slashCommandText), + ) + assertEquals( + listOf("/new"), + matchingSlashCommands(input = "/n", commands = commands).map(::slashCommandText), + ) + assertEquals( + listOf("/model"), + matchingSlashCommands(input = "/mo", commands = commands).map(::slashCommandText), + ) + assertEquals( + listOf("/delegate"), + matchingSlashCommands(input = "/de", commands = commands).map(::slashCommandText), + ) + assertEquals(emptyList(), matchingSlashCommands(input = "/runtime", commands = commands)) + assertEquals(emptyList(), matchingSlashCommands(input = "/session", commands = commands)) + assertEquals(emptyList(), matchingSlashCommands(input = "hello", commands = commands)) + } + + @Test + fun matchingSlashCommandsKeepsGatewayAdvertisedAliases() { + val commands = + listOf( + ChatCommandEntry( + name = "new", + description = "Start fresh", + category = "session", + textAliases = listOf("/new", "/reset"), + ), + ChatCommandEntry( + name = "reset", + description = "Reset session", + category = "session", + textAliases = listOf("/reset"), + ), + ) + + assertEquals( + listOf("/new", "/reset"), + matchingSlashCommands(input = "/", commands = commands).map(::slashCommandText), + ) + assertEquals(listOf("/reset"), matchingSlashCommands(input = "/reset", commands = commands).map(::slashCommandText)) + } + + @Test + fun selectedNewSlashCommandCompletesGatewayCommandText() { + assertEquals( + "/new", + slashCommandCompletion( + ChatCommandEntry( + name = "new", + description = "Start fresh", + textAliases = listOf("/new", "/reset"), + ), + ), + ) + assertEquals( + "/model ", + slashCommandCompletion( + ChatCommandEntry( + name = "model", + description = "Switch model", + textAliases = listOf("/model"), + acceptsArgs = true, + ), + ), + ) + } + + @Test + fun matchingSlashCommandsKeepsGatewayAdvertisedNewCommandVisible() { + val commands = + listOf( + ChatCommandEntry( + name = "new", + description = "Start fresh", + textAliases = listOf("/new"), + ), + ChatCommandEntry( + name = "model", + description = "Switch model", + textAliases = listOf("/model"), + ), + ) + + assertEquals( + listOf("/new", "/model"), + matchingSlashCommands(input = "/", commands = commands).map(::slashCommandText), + ) + } + + @Test + fun canStartNewChatRequiresIdleRunAndQueue() { + assertEquals(true, canStartNewChat(pendingRunCount = 0, hasQueuedMessage = false, gatewayReady = true)) + assertEquals(false, canStartNewChat(pendingRunCount = 1, hasQueuedMessage = false, gatewayReady = true)) + assertEquals(false, canStartNewChat(pendingRunCount = 0, hasQueuedMessage = true, gatewayReady = true)) + assertEquals(false, canStartNewChat(pendingRunCount = 0, hasQueuedMessage = false, gatewayReady = false)) + } + + @Test + fun slashCommandCompletionKeepsArgumentCommandsOpen() { + assertEquals( + "/model ", + slashCommandCompletion( + ChatCommandEntry( + name = "model", + description = "Switch models", + textAliases = listOf("/model"), + acceptsArgs = true, + ), + ), + ) + assertEquals( + "/new", + slashCommandCompletion( + ChatCommandEntry( + name = "new", + description = "Start fresh", + textAliases = listOf("/new"), + ), + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt new file mode 100644 index 0000000..27bfb2e --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt @@ -0,0 +1,1038 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.ChatDraft +import ai.openclaw.app.ChatDraftPlacement +import ai.openclaw.app.ChatShareDraft +import ai.openclaw.app.SharedAttachment +import ai.openclaw.app.SharedAttachmentKind +import ai.openclaw.app.chat.ChatComposerOwner +import ai.openclaw.app.chat.GatewayDefaultAgentOwner +import ai.openclaw.app.chat.VoiceNoteRecorderState +import ai.openclaw.app.chat.resolveChatComposerOwner +import ai.openclaw.app.chat.resolveChatComposerRoutingOwner +import ai.openclaw.app.claimChatDraftForOwner +import android.net.Uri +import androidx.compose.runtime.saveable.SaverScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ChatComposerDraftTest { + @Test + fun dictationAppendsToTheCurrentDraftWithoutEatingSpacing() { + assertEquals("hello world", appendChatDictationTranscript("hello", " world ")) + assertEquals("hello world", appendChatDictationTranscript("hello ", " world ")) + assertEquals("hello", appendChatDictationTranscript("hello", " ")) + } + + @Test + fun dictationFillsAnEmptyDraft() { + assertEquals("hello world", appendChatDictationTranscript("", " hello world ")) + } + + @Test + fun textDraftsRemainKeyedToTheirComposerOwner() { + val store = ChatComposerTextDraftStore() + val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first") + val second = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:second") + + store[first] = "first draft" + store[second] = "second draft" + + assertEquals("first draft", store[first]) + assertEquals("second draft", store[second]) + } + + @Test + fun sendPayloadReadsCurrentOwnerStoresAfterEditsAndRemovals() { + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first") + val state = ChatComposerStateStore() + val removed = PendingAttachment("removed", "removed.jpg", "image/jpeg", "YQ==") + val retained = PendingAttachment("retained", "retained.jpg", "image/jpeg", "Yg==") + state.textDrafts[owner] = "old text" + state.addAttachments(owner, listOf(removed)) + + state.textDrafts[owner] = " edited text " + state.removeAttachments(owner, setOf(removed.id)) + state.addAttachments(owner, listOf(retained)) + + val request = requireNotNull(state.beginSend(owner).request) + + assertEquals(" edited text ", request.inputSnapshot) + assertEquals("edited text", request.message) + assertEquals(listOf(retained), request.attachments) + } + + @Test + fun restoredAttachmentsReplaceTheCurrentComposerSet() { + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first") + val state = ChatComposerStateStore() + val existing = PendingAttachment("existing", "existing.jpg", "image/jpeg", "YQ==") + val restored = PendingAttachment("restored", "image-1", "image/png", "Yg==") + state.addAttachments(owner, listOf(existing)) + + state.replaceAttachments(owner, listOf(restored)) + + assertEquals(listOf(restored), state.attachments.value[owner]) + } + + @Test + fun textDraftSnapshotRestoresEveryOwnerAfterProcessRecreation() { + var saved = arrayListOf() + val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first") + val second = ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "work", sessionKey = "agent:work:second") + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + store[first] = "first draft" + store[second] = "second draft" + + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved)) + + assertEquals("first draft", restored[first]) + assertEquals("second draft", restored[second]) + } + + @Test + fun processRecreationHidesPendingDraftUntilOutboxReconciliation() { + var saved = arrayListOf() + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + store[owner] = "send once" + store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once") + + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved)) + + assertEquals("", restored[owner]) + assertEquals(listOf("command-a"), restored.pendingAdmissions().map { it.commandId }) + restored.resolveAdmission("command-a", admitted = false) + assertEquals("send once", restored[owner]) + } + + @Test + fun oversizedPendingDraftIsNotHiddenWithoutACompleteCrashCheckpoint() { + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val oversized = "x".repeat(CHAT_COMPOSER_MAX_SEND_CHARS + 1) + val store = ChatComposerTextDraftStore() + store[owner] = oversized + + assertFalse(store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = oversized)) + + assertEquals(oversized, store[owner]) + assertTrue(store.pendingAdmissions().isEmpty()) + } + + @Test + fun pendingDraftBudgetIncludesOtherOwnersAdmissions() { + val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:first") + val second = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:second") + val third = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:third") + val fourth = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:fourth") + val store = ChatComposerTextDraftStore() + store[first] = "a".repeat(CHAT_COMPOSER_MAX_SEND_CHARS) + store[second] = "b".repeat(CHAT_COMPOSER_MAX_SEND_CHARS) + store[third] = "c".repeat(CHAT_COMPOSER_MAX_SEND_CHARS) + store[fourth] = "d".repeat(10_000) + + assertTrue(store.beginAdmission(commandId = "command-a", owner = first, inputSnapshot = store[first])) + assertTrue(store.beginAdmission(commandId = "command-b", owner = second, inputSnapshot = store[second])) + assertTrue(store.beginAdmission(commandId = "command-c", owner = third, inputSnapshot = store[third])) + assertFalse(store.beginAdmission(commandId = "command-d", owner = fourth, inputSnapshot = store[fourth])) + + assertEquals("", store[first]) + assertEquals("", store[second]) + assertEquals("", store[third]) + assertEquals("d".repeat(10_000), store[fourth]) + assertEquals( + listOf("command-a", "command-b", "command-c"), + store.pendingAdmissions().map(PendingChatComposerSend::commandId), + ) + } + + @Test + fun restoredPendingAdmissionMigratesWithoutAVisibleDraft() { + var saved = arrayListOf() + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + store[alias] = "send once" + store.beginAdmission(commandId = "command-a", owner = alias, inputSnapshot = "send once") + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved)) + + assertEquals("", restored[alias]) + assertEquals(setOf(alias), restored.migrateMatching(canonical, canonical.sessionKey)) + assertEquals(canonical, restored.pendingAdmissions().single().owner) + + restored.resolveAdmission("command-a", admitted = false) + assertEquals("", restored[alias]) + assertEquals("send once", restored[canonical]) + } + + @Test + fun acceptedAliasAdmissionKeepsTheCanonicalDraftMergedBeforeResolution() { + var saved = arrayListOf() + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + store[alias] = "already sent" + store[canonical] = "keep editing" + store.beginAdmission(commandId = "command-a", owner = alias, inputSnapshot = "already sent") + store.migrate(alias, canonical) + + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved)) + assertEquals("keep editing", restored[canonical]) + restored.resolveAdmission("command-a", admitted = true) + + assertEquals("keep editing", restored[canonical]) + } + + @Test + fun rejectedAliasAdmissionRestoresSentTextAfterTheCanonicalDraft() { + var saved = arrayListOf() + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + store[alias] = "retry me" + store[canonical] = "keep editing" + store.beginAdmission(commandId = "command-a", owner = alias, inputSnapshot = "retry me") + store.migrate(alias, canonical) + + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved)) + restored.resolveAdmission("command-a", admitted = false) + + assertEquals("retry me\n\nkeep editing", restored[canonical]) + } + + @Test + fun removingGatewayDraftsAlsoRemovesItsPendingAdmission() { + val removed = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val retained = ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "main", sessionKey = "main") + val store = ChatComposerTextDraftStore() + store[removed] = "private a" + store[retained] = "private b" + store.beginAdmission(commandId = "command-a", owner = removed, inputSnapshot = "private a") + + store.removeOwners { it.gatewayStableId == "gateway-a" } + + assertEquals("", store[removed]) + assertEquals("private b", store[retained]) + assertTrue(store.pendingAdmissions().isEmpty()) + } + + @Test + fun durablePendingSendStaysHiddenAndLaterEditsSurviveReconciliation() { + var saved = arrayListOf() + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + store[owner] = "send once" + store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once") + assertEquals("", store[owner]) + store[owner] = "new draft" + + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved)) + assertEquals("new draft", restored[owner]) + + restored.resolveAdmission("command-a", admitted = true) + assertEquals("new draft", restored[owner]) + assertTrue(restored.pendingAdmissions().isEmpty()) + } + + @Test + fun identicallyRetypedDraftSurvivesAcceptedAdmission() { + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val store = ChatComposerTextDraftStore() + store[owner] = "send once" + store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once") + store[owner] = "send once" + + store.resolveAdmission("command-a", admitted = true) + + assertEquals("send once", store[owner]) + } + + @Test + fun rejectedPendingSendRestoresOriginalBeforePostAdmissionEdits() { + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val store = ChatComposerTextDraftStore() + store[owner] = "send once" + store.beginAdmission(commandId = "command-a", owner = owner, inputSnapshot = "send once") + store[owner] = "new draft" + + store.resolveAdmission("command-a", admitted = false) + + assertEquals("send once\n\nnew draft", store[owner]) + } + + @Test + fun pendingReplyDraftClaimsTheCanonicalMainAliasOwner() { + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + val draft = ChatDraft(text = "reply", placement = ChatDraftPlacement.BeforeExisting, owner = alias) + + val claimed = claimChatDraftForOwner(draft, canonical, canonical.sessionKey) + + assertEquals(canonical, claimed?.owner) + assertEquals("reply", claimed?.text) + } + + @Test + fun textDraftStoreEvictsTheOldestOwnerAndBoundsProcessCheckpoint() { + var saved = arrayListOf() + val store = ChatComposerTextDraftStore(onSnapshotChanged = { saved = it }) + val owners = + (0..CHAT_COMPOSER_MAX_DRAFT_OWNERS).map { index -> + ChatComposerOwner( + gatewayStableId = "gateway-a", + agentId = "main", + sessionKey = "agent:main:$index", + ) + } + + val longDraft = "x".repeat(40_000) + owners.forEach { owner -> store[owner] = longDraft } + + assertEquals(CHAT_COMPOSER_MAX_DRAFT_OWNERS, store.size()) + assertEquals("", store[owners.first()]) + assertEquals(longDraft, store[owners.last()]) + assertTrue(saved.sumOf(String::length) <= CHAT_COMPOSER_DRAFT_SNAPSHOT_MAX_CHARS) + assertEquals(longDraft, ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(saved))[owners.last()]) + } + + @Test + fun mainAliasDraftMovesToCanonicalMainOwner() { + val store = ChatComposerTextDraftStore() + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + store[alias] = "typed while connecting" + + assertTrue(shouldMigrateComposerDraft(alias, canonical, canonical.sessionKey)) + store.migrate(from = alias, to = canonical) + + assertEquals("", store[alias]) + assertEquals("typed while connecting", store[canonical]) + } + + @Test + fun mainAliasDraftDoesNotCrossGatewayOrAgent() { + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + + assertFalse( + shouldMigrateComposerDraft( + alias, + ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "main", sessionKey = "agent:main:device"), + "agent:main:device", + ), + ) + assertFalse( + shouldMigrateComposerDraft( + alias, + ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "other", sessionKey = "agent:other:device"), + "agent:other:device", + ), + ) + } + + @Test + fun mainAliasMigrationPreservesAnExistingCanonicalDraft() { + val store = ChatComposerTextDraftStore() + val alias = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val canonical = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "agent:main:device") + store[alias] = "typed while connecting" + store[canonical] = "saved canonical draft" + + store.migrate(from = alias, to = canonical) + + assertEquals("saved canonical draft\n\ntyped while connecting", store[canonical]) + } + + @Test + fun aliasResolutionPreservesEveryActiveSendAndPendingAcknowledgement() { + val alias = ChatComposerOwner("gateway-a", "main", "main") + val provisional = ChatComposerOwner("gateway-a", "main", "main", routingVerified = false) + val canonical = ChatComposerOwner("gateway-a", "main", "agent:main:device") + val state = ChatComposerStateStore() + state.textDrafts[alias] = "manual send" + val manualRequest = requireNotNull(state.beginSend(alias).request) + state.completeSend(manualRequest, accepted = true) + val trackedSendId = requireNotNull(state.tryBeginTrackedSend(provisional)) + state.textDrafts[canonical] = "second manual send" + val activeManualRequest = requireNotNull(state.beginSend(canonical).request) + + state.resolveAliases(canonical, canonical.sessionKey) + + assertEquals( + ChatComposerSendState( + activeOperationIds = setOf(trackedSendId, activeManualRequest.commandId), + pendingAdmissionIds = setOf(manualRequest.commandId), + ), + state.sendStates.value[canonical], + ) + state.acknowledgeSendAdmission(canonical, manualRequest.commandId) + assertEquals( + ChatComposerSendState(activeOperationIds = setOf(trackedSendId, activeManualRequest.commandId)), + state.sendStates.value[canonical], + ) + assertNull(state.tryBeginTrackedSend(canonical)) + + state.finishTrackedSend(trackedSendId) + assertEquals( + ChatComposerSendState(activeOperationIds = setOf(activeManualRequest.commandId)), + state.sendStates.value[canonical], + ) + state.completeSend(activeManualRequest, accepted = true) + assertEquals( + ChatComposerSendState(pendingAdmissionIds = setOf(activeManualRequest.commandId)), + state.sendStates.value[canonical], + ) + state.acknowledgeSendAdmission(canonical, activeManualRequest.commandId) + assertNotNull(state.tryBeginTrackedSend(canonical)) + } + + @Test + fun gatewayBoundProvisionalDraftMovesToItsVerifiedOwner() { + val store = ChatComposerTextDraftStore() + val provisional = + ChatComposerOwner( + gatewayStableId = "gateway-a", + agentId = "main", + sessionKey = "main", + routingVerified = false, + ) + val verified = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "work", sessionKey = "agent:work:device") + store[provisional] = "typed before gateway hello" + + assertTrue(shouldMigrateComposerDraft(provisional, verified, verified.sessionKey)) + store.migrate(provisional, verified) + + assertEquals("", store[provisional]) + assertEquals("typed before gateway hello", store[verified]) + } + + @Test + fun provisionalOwnerCheckpointSurvivesRecreation() { + val provisional = + ChatComposerOwner( + gatewayStableId = null, + agentId = "main", + sessionKey = "main", + routingVerified = false, + ) + + val restored = chatComposerOwnerFromCheckpointValues(provisional.toCheckpointValues()) + + assertEquals(provisional, restored) + } + + @Test + fun ownerlessProvisionalDraftMovesWhenAGatewayIsSelected() { + val unresolvedGateway = + ChatComposerOwner( + gatewayStableId = null, + agentId = "main", + sessionKey = "custom", + ) + val resolvedGateway = unresolvedGateway.copy(gatewayStableId = "gateway-a", routingVerified = true) + + assertTrue(shouldMigrateComposerDraft(unresolvedGateway, resolvedGateway, "agent:main:device")) + } + + @Test + fun ownerlessProvisionalDraftWaitsForVerifiedGatewayRouting() { + val unresolvedGateway = + ChatComposerOwner( + gatewayStableId = null, + agentId = "main", + sessionKey = "custom", + ) + val selectedGateway = unresolvedGateway.copy(gatewayStableId = "gateway-a", agentId = "other") + + assertFalse(shouldMigrateComposerDraft(unresolvedGateway, selectedGateway, "agent:other:device")) + } + + @Test + fun verifiedOwnerlessDraftDoesNotCrossAgentsWhenAGatewayIsSelected() { + val captured = + ChatComposerOwner( + gatewayStableId = null, + agentId = "agent-a", + sessionKey = "custom", + routingVerified = true, + ) + val current = captured.copy(gatewayStableId = "gateway-a", agentId = "agent-b") + + assertFalse(shouldMigrateComposerDraft(captured, current, "agent:agent-b:device")) + } + + @Test + fun verifiedDraftDoesNotMoveWhenTheDefaultOwnerChanges() { + val first = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "first", sessionKey = "custom") + val second = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "second", sessionKey = "custom") + + assertFalse(shouldMigrateComposerDraft(first, second, "agent:second:device")) + } + + @Test + fun replyDraftPreservesExistingComposerText() { + val draft = ChatDraft(text = "> quoted\n\n", placement = ChatDraftPlacement.BeforeExisting) + + assertEquals("> quoted\n\nmy reply", mergeChatDraft(draft, "my reply")) + } + + @Test + fun replacementDraftReplacesExistingComposerText() { + val draft = ChatDraft(text = "repeat this", placement = ChatDraftPlacement.Replace) + + assertEquals("repeat this", mergeChatDraft(draft, "existing text")) + } + + @Test + fun guardedReplacementPreservesComposerEditsMadeWhileActionWasInFlight() { + val draft = + ChatDraft( + text = "rewound text", + placement = ChatDraftPlacement.Replace, + expectedExistingText = "before", + ) + + assertEquals(null, mergeChatDraft(draft, "typed while waiting")) + assertEquals("rewound text", mergeChatDraft(draft, "before")) + } + + @Test + fun rewindReplacementCanIntentionallyClearTheComposer() { + val draft = + ChatDraft( + text = "", + placement = ChatDraftPlacement.Replace, + expectedExistingText = "before", + acceptsEmptyText = true, + ) + + assertEquals("", mergeChatDraft(draft, "before")) + } + + @Test + fun replyDraftCanOnlyMergeIntoItsOriginatingOwner() { + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a") + val draft = ChatDraft(text = "> quoted\n\n", placement = ChatDraftPlacement.BeforeExisting, owner = owner) + + assertEquals( + null, + mergeChatDraft(draft = draft, currentInput = "wrong", currentOwner = owner.copy(sessionKey = "session-b")), + ) + assertEquals( + "> quoted\n\nreply", + mergeChatDraft(draft = draft, currentInput = "reply", currentOwner = owner), + ) + } + + @Test + fun sharedTextPreservesExistingComposerText() { + assertEquals( + "existing draft\n\nshared link", + mergeSharedChatText(sharedText = "shared link", currentInput = "existing draft"), + ) + } + + @Test + fun queuedSharedTextPreservesArrivalOrder() { + val first = mergeSharedChatText(sharedText = "first", currentInput = "") + + assertEquals("first\n\nsecond", mergeSharedChatText(sharedText = "second", currentInput = first)) + } + + @Test + fun imageOnlyShareLeavesExistingComposerTextUntouched() { + assertEquals( + "existing draft", + mergeSharedChatText(sharedText = null, currentInput = "existing draft"), + ) + } + + @Test + fun stagedSharePreservesComposerAndReportsDroppedImages() { + val owner = ChatComposerOwner("gateway", "main", "agent:main:device") + val store = ChatComposerAttachmentStore() + val existing = pendingAttachment("existing") + val shared = pendingAttachment("shared") + val staged = + StagedChatShare( + text = "shared link", + attachments = listOf(shared), + failedAttachmentCount = 0, + droppedAttachmentCount = 2, + ) + + store.add(owner, listOf(existing)) + val omitted = store.add(owner, staged.attachments) + + assertEquals("existing draft\n\nshared link", mergeSharedChatText(staged.text, "existing draft")) + assertEquals(listOf(existing, shared), store.get(owner)) + assertEquals(2, staged.failedAttachmentCount + staged.droppedAttachmentCount + omitted) + } + + @Test + fun unreadableSharedImageDoesNotDiscardOtherStagedContent() = + runBlocking { + val readable = Uri.parse("content://photos/readable") + val unreadable = Uri.parse("content://photos/unreadable") + val draft = + ChatShareDraft( + id = 1, + text = "caption", + attachments = listOf(sharedAttachment(readable), sharedAttachment(unreadable)), + droppedAttachmentCount = 0, + ) + + val staged = + stageChatShareDraft(draft) { attachment -> + if (attachment.uri == unreadable) error("provider read failed") + pendingAttachment(attachment.uri.toString()) + } + + assertEquals("caption", staged.text) + assertEquals(listOf(readable.toString()), staged.attachments.map { it.id }) + assertEquals(1, staged.failedAttachmentCount) + assertEquals(0, staged.droppedAttachmentCount) + } + + @Test + fun screenDisposalCancellationLeavesShareUnstaged() { + val draft = + ChatShareDraft( + id = 1, + text = null, + attachments = listOf(sharedAttachment(Uri.parse("content://photos/slow"))), + droppedAttachmentCount = 0, + ) + + assertThrows(CancellationException::class.java) { + runBlocking { + stageChatShareDraft(draft) { throw CancellationException("screen disposed") } + } + } + } + + @Test + fun repeatedSharesRespectExistingComposerAttachmentLimit() = + runBlocking { + val owner = ChatComposerOwner("gateway", "main", "agent:main:device") + val store = ChatComposerAttachmentStore() + val current = (1..7).map { pendingAttachment("existing-$it") } + val uris = (1..3).map { Uri.parse("content://photos/shared/$it") } + val draft = + ChatShareDraft( + id = 1, + text = null, + attachments = uris.map(::sharedAttachment), + droppedAttachmentCount = 0, + ) + + val staged = + stageChatShareDraft(draft) { attachment -> + pendingAttachment(attachment.uri.toString()) + } + + assertEquals(uris.map(Uri::toString), staged.attachments.map { it.id }) + assertEquals(0, staged.droppedAttachmentCount) + store.add(owner, current) + val omitted = store.add(owner, staged.attachments) + assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, store.get(owner).size) + assertEquals(2, staged.droppedAttachmentCount + omitted) + } + + @Test + fun mergeRechecksAttachmentBudgetAfterStaging() { + val owner = ChatComposerOwner("gateway", "main", "agent:main:device") + val store = ChatComposerAttachmentStore() + val staged = + StagedChatShare( + text = null, + attachments = listOf(pendingAttachment("one"), pendingAttachment("two")), + failedAttachmentCount = 0, + droppedAttachmentCount = 0, + ) + val current = (1..7).map { pendingAttachment("existing-$it") } + + store.add(owner, current) + val omitted = store.add(owner, staged.attachments) + + assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, store.get(owner).size) + assertEquals(1, staged.droppedAttachmentCount + omitted) + } + + @Test + fun sharedAttachmentsAtomicallyMergeWithAConcurrentPickerImport() { + val owner = ChatComposerOwner("gateway", "main", "agent:main:device") + val store = ChatComposerAttachmentStore() + val existing = pendingAttachment("existing") + val picker = pendingAttachment("picker") + val shared = pendingAttachment("shared") + store.add(owner, listOf(existing)) + + store.add(owner, listOf(picker)) + store.add(owner, listOf(shared)) + + assertEquals(listOf(existing, picker, shared), store.get(owner)) + } + + @Test + fun attachmentAdmissionEnforcesBase64AndDecodedBudgets() { + val candidates = listOf(pendingAttachment("one", base64 = "AAAA"), pendingAttachment("two", base64 = "AAAA")) + + val base64Bound = + admitChatAttachments( + currentAttachments = emptyList(), + candidates = candidates, + maxAttachmentCount = 8, + maxBase64Chars = 4, + maxDecodedBytes = 100, + ) + val decodedBound = + admitChatAttachments( + currentAttachments = emptyList(), + candidates = candidates, + maxAttachmentCount = 8, + maxBase64Chars = 100, + maxDecodedBytes = 3, + ) + + assertEquals(listOf(candidates.first()), base64Bound.accepted) + assertEquals(1, base64Bound.omittedCount) + assertEquals(listOf(candidates.first()), decodedBound.accepted) + assertEquals(1, decodedBound.omittedCount) + } + + @Test + fun attachmentAdmissionUsesPerKindDecodedBudgets() { + assertEquals(CHAT_COMPOSER_MAX_IMAGE_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("image/png")) + assertEquals(CHAT_COMPOSER_MAX_AUDIO_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("audio/mpeg")) + assertEquals(CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("video/mp4")) + assertEquals(CHAT_COMPOSER_MAX_DOCUMENT_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("application/pdf")) + assertEquals(20L * 1024L * 1024L, CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES) + } + + @Test + fun videoPositionDoesNotRelaxNonVideoAdmissionBudget() { + val video = PendingAttachment("video", "clip.mp4", "video/mp4", "AAAA") + val document = PendingAttachment("document", "report.pdf", "application/pdf", "AAAAAAAA") + + fun admit(candidates: List) = + admitChatAttachments( + currentAttachments = emptyList(), + candidates = candidates, + maxAttachmentCount = 8, + maxBase64Chars = 100, + maxDecodedBytes = 9, + maxNonVideoBase64Chars = 100, + maxNonVideoDecodedBytes = 3, + ) + + assertEquals(listOf(video), admit(listOf(video, document)).accepted) + assertEquals(listOf(video), admit(listOf(document, video)).accepted) + } + + @Test + fun stagedShareCommitsOnlyForMatchingQueueHead() { + val current = ChatShareDraft(id = 7, text = "current", attachments = emptyList(), droppedAttachmentCount = 0) + val replacement = ChatShareDraft(id = 8, text = "replacement", attachments = emptyList(), droppedAttachmentCount = 0) + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a") + + assertTrue(canCommitStagedChatShare(current.id, current, owner, owner)) + assertFalse(canCommitStagedChatShare(current.id, replacement, owner, owner)) + assertFalse(canCommitStagedChatShare(current.id, null, owner, owner)) + } + + @Test + fun pendingAttachmentsRemainKeyedAcrossComposerNavigationAndOwnerResolution() { + val ownerA = ChatComposerOwner(gatewayStableId = "gateway", agentId = "agent-a", sessionKey = "session-a") + val ownerB = ChatComposerOwner(gatewayStableId = "gateway", agentId = "agent-b", sessionKey = "session-b") + val resolvedA = ownerA.copy(sessionKey = "agent:agent-a:device") + val store = ChatComposerAttachmentStore() + val first = pendingAttachment("first") + val second = pendingAttachment("second") + val late = pendingAttachment("late") + val importId = store.beginImport(ownerA) + + store.add(ownerA, listOf(first)) + store.add(ownerB, listOf(second)) + assertEquals(listOf(first), store.attachments.value[ownerA]) + assertEquals(listOf(second), store.attachments.value[ownerB]) + + store.migrate(ownerA, resolvedA) + assertEquals(null, store.attachments.value[ownerA]) + assertEquals(listOf(first), store.attachments.value[resolvedA]) + assertEquals(listOf(second), store.attachments.value[ownerB]) + + // Only the decode that was already in flight follows the explicit owner migration. + store.completeImport(importId, listOf(late)) + assertEquals(listOf(first, late), store.attachments.value[resolvedA]) + + val reusedProvisional = pendingAttachment("reused") + store.add(ownerA, listOf(reusedProvisional)) + assertEquals(listOf(reusedProvisional), store.attachments.value[ownerA]) + + store.remove(resolvedA, setOf(first.id, late.id)) + assertEquals(null, store.attachments.value[resolvedA]) + } + + @Test + fun removingGatewayAttachmentsAlsoCancelsItsInFlightImports() { + val removed = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") + val retained = ChatComposerOwner(gatewayStableId = "gateway-b", agentId = "main", sessionKey = "main") + val store = ChatComposerAttachmentStore() + val removedAttachment = pendingAttachment("removed") + val retainedAttachment = pendingAttachment("retained") + val removedImport = store.beginImport(removed) + store.add(removed, listOf(removedAttachment)) + store.add(retained, listOf(retainedAttachment)) + + store.removeOwners { it.gatewayStableId == "gateway-a" } + + assertEquals(emptyList(), store.get(removed)) + assertEquals(listOf(retainedAttachment), store.get(retained)) + assertEquals(null, store.completeImport(removedImport, listOf(pendingAttachment("late")))) + } + + @Test + fun ownerResolutionMigratesParkedDraftsAttachmentsAndImportsAfterNavigation() { + val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false) + val unrelated = ChatComposerOwner("gateway", "other", "agent:other:device") + val resolved = ChatComposerOwner("gateway", "work", "agent:work:device") + val drafts = ChatComposerTextDraftStore() + val attachments = ChatComposerAttachmentStore() + val parked = pendingAttachment("parked") + val late = pendingAttachment("late") + val unrelatedAttachment = pendingAttachment("unrelated") + drafts[provisional] = "parked draft" + drafts[unrelated] = "other draft" + attachments.add(provisional, listOf(parked)) + attachments.add(unrelated, listOf(unrelatedAttachment)) + val importId = attachments.beginImport(provisional) + + assertEquals(setOf(provisional), drafts.migrateMatching(resolved, resolved.sessionKey)) + val migration = attachments.migrateMatching(resolved, resolved.sessionKey) + attachments.completeImport(importId, listOf(late)) + + assertEquals(setOf(provisional), migration.sources) + assertEquals(0, migration.omittedCount) + assertEquals("parked draft", drafts[resolved]) + assertEquals("other draft", drafts[unrelated]) + assertEquals(listOf(parked, late), attachments.get(resolved)) + assertEquals(listOf(unrelatedAttachment), attachments.get(unrelated)) + } + + @Test + fun pendingAttachmentsAreBoundedAcrossComposerOwners() { + val ownerA = ChatComposerOwner("gateway", "agent-a", "session-a") + val ownerB = ChatComposerOwner("gateway", "agent-b", "session-b") + val store = + ChatComposerAttachmentStore( + maxTotalAttachmentCount = 8, + maxTotalBase64Chars = 8, + maxTotalDecodedBytes = 5, + ) + val first = pendingAttachment("first", base64 = "AAAA") + val second = pendingAttachment("second", base64 = "BBBB") + + assertEquals(0, store.add(ownerA, listOf(first))) + assertEquals(1, store.add(ownerB, listOf(second))) + assertEquals(listOf(first), store.attachments.value[ownerA]) + assertEquals(null, store.attachments.value[ownerB]) + } + + @Test + fun ownerMigrationDropsAndReportsAttachmentsBeyondTheDestinationLimit() { + val from = ChatComposerOwner("gateway", "main", "main", routingVerified = false) + val to = ChatComposerOwner("gateway", "main", "agent:main:device") + val store = ChatComposerAttachmentStore() + val destination = (1..7).map { pendingAttachment("destination-$it") } + val source = listOf(pendingAttachment("source-1"), pendingAttachment("source-2")) + store.add(to, destination) + store.add(from, source) + + assertEquals(1, store.migrate(from, to)) + assertEquals(CHAT_COMPOSER_MAX_ATTACHMENTS, store.attachments.value[to]?.size) + assertEquals(null, store.attachments.value[from]) + store.remove(to, store.get(to).mapTo(mutableSetOf()) { it.id }) + assertEquals(0, store.migrate(from, to)) + assertEquals(null, store.attachments.value[to]) + } + + @Test + fun voiceNoteCompletionMustMatchTheRecordingThatStartedIt() { + val ownerA = ChatComposerOwner("gateway", "agent-a", "session-a") + val ownerB = ChatComposerOwner("gateway", "agent-b", "session-b") + val checkpoint = ChatComposerMediaCheckpoint() + + checkpoint.begin(ownerA, mediaAuthorizationId = "auth-a", requestId = "recording-a") + checkpoint.begin(ownerB, mediaAuthorizationId = "auth-b", requestId = "recording-b") + + assertEquals(null, checkpoint.consume("recording-a")) + assertEquals(ownerB, checkpoint.owner) + assertEquals(ChatComposerMediaLease(ownerB, "auth-b"), checkpoint.consume("recording-b")) + assertEquals(null, checkpoint.owner) + } + + @Test + fun imagePickerCheckpointCarriesTheCredentialGenerationThroughRecreation() { + val owner = ChatComposerOwner("gateway", "agent", "session") + val checkpoint = ChatComposerMediaCheckpoint() + checkpoint.begin(owner, mediaAuthorizationId = "media-auth") + val saverScope = SaverScope { true } + val saved = + with(ChatComposerMediaCheckpoint.Saver) { + saverScope.save(checkpoint) + } + val restored = requireNotNull(ChatComposerMediaCheckpoint.Saver.restore(requireNotNull(saved))) + + assertEquals(ChatComposerMediaLease(owner, "media-auth"), restored.consume()) + } + + @Test + fun voiceRecorderSurvivesOnlyCanonicalOwnerMigration() { + val provisional = ChatComposerOwner("gateway", "main", "main", routingVerified = false) + val canonical = ChatComposerOwner("gateway", "work", "agent:work:device") + val tracker = VoiceNoteRecorderOwnerTracker(provisional) + + assertTrue(tracker.moveTo(canonical, canonical.sessionKey)) + assertFalse(tracker.moveTo(canonical.copy(sessionKey = "agent:work:other"), canonical.sessionKey)) + } + + @Test + fun composerOwnerUsesTheSameSessionFallbackAsTheViewModel() { + assertEquals( + ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "alpha", sessionKey = "agent:alpha:main"), + resolveChatComposerOwner( + gatewayStableId = "gateway-a", + gatewayDefaultAgentId = "main", + sessionKey = " ", + mainSessionKey = "agent:alpha:main", + ), + ) + } + + @Test + fun composerOwnerRetainsVerifiedRoutingOnlyForTheSameGateway() { + val retained = GatewayDefaultAgentOwner(gatewayStableId = "gateway-a", agentId = "agent-a") + + assertEquals( + ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "main"), + resolveChatComposerOwner( + gatewayStableId = "gateway-a", + gatewayDefaultAgentId = null, + lastVerifiedOwner = retained, + sessionKey = "main", + mainSessionKey = "main", + ), + ) + assertFalse( + resolveChatComposerOwner( + gatewayStableId = "gateway-b", + gatewayDefaultAgentId = null, + lastVerifiedOwner = retained, + sessionKey = "main", + mainSessionKey = "main", + ).routingVerified, + ) + } + + @Test + fun routingOwnerRejectsABlankGatewayDefaultAgent() { + assertEquals( + null, + resolveChatComposerRoutingOwner( + gatewayStableId = "gateway-a", + gatewayDefaultAgentId = " ", + sessionKey = "main", + mainSessionKey = "main", + ), + ) + } + + @Test + fun stagedShareRejectsAReplacementComposerOwner() { + val share = ChatShareDraft(id = 7, text = "share", attachments = emptyList(), droppedAttachmentCount = 0) + val owner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "agent-a", sessionKey = "session-a") + + assertFalse( + canCommitStagedChatShare( + stagedId = share.id, + currentHead = share, + ownerSnapshot = owner, + currentOwner = owner.copy(sessionKey = "session-b"), + ), + ) + } + + @Test + fun sendIsDisabledWhileShareHeadStages() { + assertFalse( + chatComposerSendEnabled( + voiceNoteState = VoiceNoteRecorderState.Idle, + pendingRunCount = 0, + hasContent = true, + shareStaging = true, + sendInFlight = false, + ), + ) + assertTrue( + chatComposerSendEnabled( + voiceNoteState = VoiceNoteRecorderState.Idle, + pendingRunCount = 0, + hasContent = true, + shareStaging = false, + sendInFlight = false, + ), + ) + assertFalse( + chatComposerSendEnabled( + voiceNoteState = VoiceNoteRecorderState.Idle, + pendingRunCount = 0, + hasContent = true, + shareStaging = false, + sendInFlight = true, + ), + ) + } + + @Test + fun sendIsDisabledWhileDictationIsActive() { + assertFalse( + chatComposerSendEnabled( + voiceNoteState = VoiceNoteRecorderState.Idle, + pendingRunCount = 0, + hasContent = true, + shareStaging = false, + dictationActive = true, + ), + ) + } + + private fun pendingAttachment( + id: String, + base64: String = id, + ): PendingAttachment = + PendingAttachment( + id = id, + fileName = "$id.jpg", + mimeType = "image/jpeg", + base64 = base64, + ) + + private fun sharedAttachment(uri: Uri): SharedAttachment = + SharedAttachment( + uri = uri, + kind = SharedAttachmentKind.Image, + mimeType = "image/jpeg", + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt new file mode 100644 index 0000000..1193551 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt @@ -0,0 +1,159 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatThinkingLevelOption +import ai.openclaw.app.chat.ChatThinkingLevelSelection +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.resolveNativeText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatContextMeterTest { + @Test + fun starterPromptsKeepCatalogSourcesThroughTheSendBoundary() { + assertTrue(starterPrompts.all { it.title is NativeText.Resource }) + assertTrue(starterPrompts.all { it.subtitle is NativeText.Resource }) + assertTrue(starterPrompts.all { it.message is NativeText.Resource }) + assertEquals( + "Catch me up on my recent OpenClaw threads and suggest next steps.", + starterPrompts.first().message.resolveNativeText(), + ) + } + + @Test + fun contextMeterUsesActiveSessionTokenBudget() { + val sessions = + listOf( + ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L), + ChatSessionEntry( + key = "agent:main:mobile:test-device", + updatedAtMs = 2L, + displayName = "Phone", + totalTokens = 1_250L, + totalTokensFresh = true, + contextTokens = 5_000L, + ), + ) + + val usage = + resolveChatContextUsage( + sessionKey = "agent:main:mobile:test-device", + mainSessionKey = "main", + sessions = sessions, + ) + + assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage) + assertEquals(0.25f, contextMeterWidth(usage)) + assertEquals("Context 25% · High", contextMeterLabel(usage, "high")) + } + + @Test + fun contextMeterResolvesCanonicalMainAlias() { + val sessions = + listOf( + ChatSessionEntry( + key = "agent:main:node-phone", + updatedAtMs = 1L, + displayName = "Main", + totalTokens = 41_000L, + totalTokensFresh = true, + contextTokens = 100_000L, + ), + ) + + val usage = + resolveChatContextUsage( + sessionKey = "main", + mainSessionKey = "agent:main:node-phone", + sessions = sessions, + ) + + assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage) + assertEquals("Context 41% · Off", contextMeterLabel(usage, "off")) + } + + @Test + fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() { + val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null) + + assertNull(contextMeterWidth(usage)) + assertEquals("Context -- · Medium", contextMeterLabel(usage, "medium")) + } + + @Test + fun contextMeterClampsOverfullSessions() { + val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L) + + assertEquals(1.0f, contextMeterWidth(usage)) + assertEquals("Context 100% · Low", contextMeterLabel(usage, "low")) + } + + @Test + fun contextMeterDoesNotDisplayStaleTokenUsage() { + val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L) + + assertNull(contextMeterWidth(usage)) + assertEquals("Context -- · High", contextMeterLabel(usage, "high")) + } + + @Test + fun contextMeterHidesThinkingLabelWhenUnsupported() { + val usage = ChatContextUsage(totalTokens = 2_500L, totalTokensFresh = true, contextTokens = 10_000L) + + assertEquals("Context 25%", contextMeterLabel(usage, "high", thinkingSupported = false)) + } + + @Test + fun contextMeterPreservesGatewayThinkingLevelIds() { + val usage = ChatContextUsage(totalTokens = null, totalTokensFresh = null, contextTokens = null) + + assertEquals("Context -- · xhigh", contextMeterLabel(usage, "xhigh")) + assertEquals("Context -- · adaptive", contextMeterLabel(usage, "adaptive")) + assertEquals("Context -- · ultra", contextMeterLabel(usage, "ultra")) + } + + @Test + fun gatewayThinkingOptionsAreAuthoritativeForSupport() { + val offOnly = + ChatThinkingLevelSelection( + options = listOf(ChatThinkingLevelOption(id = "off", label = "off")), + isGatewayProvided = true, + ) + val max = + ChatThinkingLevelSelection( + options = + listOf( + ChatThinkingLevelOption(id = "off", label = "off"), + ChatThinkingLevelOption(id = "max", label = "max"), + ), + isGatewayProvided = true, + ) + val fallback = + ChatThinkingLevelSelection( + options = emptyList(), + isGatewayProvided = false, + ) + + assertFalse(chatThinkingSupported(offOnly, fallbackSupported = true)) + assertTrue(chatThinkingSupported(max, fallbackSupported = false)) + assertTrue(chatThinkingSupported(fallback, fallbackSupported = true)) + } + + @Test + fun largeThinkingProfilesSplitIntoBalancedInlineRows() { + val options = + listOf("off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max") + .map { ChatThinkingLevelOption(id = it, label = it) } + + val rows = chatThinkingOptionRows(options) + + assertEquals(listOf(4, 4), rows.map { it.size }) + assertEquals("Minimal", chatThinkingOptionLabel(options[1])) + assertEquals("Xhigh", chatThinkingOptionLabel(options[5])) + assertEquals("Adaptive", chatThinkingOptionLabel(options[6])) + assertEquals("Max", chatThinkingOptionLabel(options.last())) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatDictationControllerTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatDictationControllerTest.kt new file mode 100644 index 0000000..3ab95d2 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatDictationControllerTest.kt @@ -0,0 +1,308 @@ +package ai.openclaw.app.ui.chat + +import android.speech.SpeechRecognizer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatDictationControllerTest { + private class FakeRecognizer( + override var isAvailable: Boolean = true, + private val onCancel: () -> Unit = {}, + ) : ChatDictationRecognizer { + var listener: ((ChatDictationRecognitionEvent) -> Unit)? = null + var startCount = 0 + var finishCount = 0 + var cancelCount = 0 + var destroyCount = 0 + + override fun start(onEvent: (ChatDictationRecognitionEvent) -> Unit) { + startCount += 1 + listener = onEvent + } + + override fun finish() { + finishCount += 1 + } + + override fun cancel() { + cancelCount += 1 + onCancel() + listener = null + } + + override fun destroy() { + destroyCount += 1 + listener = null + } + + fun emit(event: ChatDictationRecognitionEvent) { + listener?.invoke(event) + } + } + + @Test + fun finalTranscriptCompletesAndReleasesTheMicrophone() = + runTest { + val recognizer = FakeRecognizer() + var acquired = 0 + var released = 0 + val controller = + controller( + recognizer = recognizer, + acquireMic = { + acquired += 1 + true + }, + releaseMic = { released += 1 }, + ) + + val result = async { controller.start() } + runCurrent() + assertEquals(ChatDictationState.Listening, controller.state.value) + + recognizer.emit(ChatDictationRecognitionEvent.Transcript(" hello world ")) + + assertEquals("hello world", result.await()) + assertEquals(ChatDictationState.Idle, controller.state.value) + assertEquals(1, acquired) + assertEquals(1, released) + } + + @Test + fun finalTranscriptRetiresTheRecognizerBeforeReleasingTheMicrophone() = + runTest { + val terminalOrder = mutableListOf() + val recognizer = FakeRecognizer(onCancel = { terminalOrder += "recognizer" }) + val controller = + controller( + recognizer = recognizer, + releaseMic = { terminalOrder += "microphone" }, + ) + val result = async { controller.start() } + runCurrent() + + recognizer.emit(ChatDictationRecognitionEvent.Transcript("done")) + + assertEquals("done", result.await()) + assertEquals(listOf("recognizer", "microphone"), terminalOrder) + } + + @Test + fun finishRequestsAPlatformFinalResult() = + runTest { + val recognizer = FakeRecognizer() + val controller = controller(recognizer) + val result = async { controller.start() } + runCurrent() + + controller.finish() + recognizer.emit(ChatDictationRecognitionEvent.Transcript("done")) + + assertEquals(1, recognizer.finishCount) + assertEquals("done", result.await()) + } + + @Test + fun finishDuringPermissionRequestCancelsBeforeRecognitionStarts() = + runTest { + val recognizer = FakeRecognizer() + val permission = CompletableDeferred() + val controller = + controller( + recognizer = recognizer, + requestPermission = { permission.await() }, + ) + val result = async { controller.start() } + runCurrent() + + controller.finish() + permission.complete(true) + runCurrent() + + assertNull(result.await()) + assertEquals(0, recognizer.startCount) + assertEquals(ChatDictationState.Idle, controller.state.value) + } + + @Test + fun cancelCompletesWithoutTranscriptAndReleasesTheMicrophone() = + runTest { + val recognizer = FakeRecognizer() + var released = 0 + val controller = controller(recognizer = recognizer, releaseMic = { released += 1 }) + val result = async { controller.start() } + runCurrent() + + controller.cancel() + + assertNull(result.await()) + assertEquals(ChatDictationState.Idle, controller.state.value) + assertEquals(1, released) + } + + @Test + fun ownerCancellationDuringPermissionRequestCannotStartRecognition() = + runTest { + val recognizer = FakeRecognizer() + val permission = CompletableDeferred() + val controller = + ChatDictationController( + recognizer = recognizer, + requestPermission = { permission.await() }, + acquireMic = { true }, + releaseMic = {}, + ) + val result = async { controller.start() } + runCurrent() + + controller.cancel() + permission.complete(true) + runCurrent() + + assertNull(result.await()) + assertEquals(0, recognizer.startCount) + assertEquals(ChatDictationState.Idle, controller.state.value) + } + + @Test + fun cancelledPermissionRequestCannotTakeOverARestartedAttempt() = + runTest { + val recognizer = FakeRecognizer() + val firstPermission = CompletableDeferred() + val secondPermission = CompletableDeferred() + var permissionRequestCount = 0 + val controller = + ChatDictationController( + recognizer = recognizer, + requestPermission = { + permissionRequestCount += 1 + if (permissionRequestCount == 1) firstPermission.await() else secondPermission.await() + }, + acquireMic = { true }, + releaseMic = {}, + ) + + val cancelledAttempt = async { controller.start() } + runCurrent() + controller.cancel() + val replacementAttempt = async { controller.start() } + runCurrent() + + firstPermission.complete(true) + runCurrent() + assertNull(cancelledAttempt.await()) + assertEquals(0, recognizer.startCount) + assertEquals(ChatDictationState.Starting, controller.state.value) + + secondPermission.complete(true) + runCurrent() + assertEquals(1, recognizer.startCount) + recognizer.emit(ChatDictationRecognitionEvent.Transcript("replacement")) + + assertEquals("replacement", replacementAttempt.await()) + assertEquals(ChatDictationState.Idle, controller.state.value) + } + + @Test + fun unavailableRecognizerFailsBeforeRequestingPermission() = + runTest { + val recognizer = FakeRecognizer(isAvailable = false) + var permissionRequests = 0 + val controller = + controller( + recognizer = recognizer, + requestPermission = { + permissionRequests += 1 + true + }, + ) + + assertNull(controller.start()) + assertEquals(ChatDictationState.Failure(ChatDictationFailure.Unavailable), controller.state.value) + assertEquals(0, permissionRequests) + assertEquals(0, recognizer.startCount) + } + + @Test + fun permissionDenialIsVisibleAndDoesNotAcquireTheMicrophone() = + runTest { + val recognizer = FakeRecognizer() + var acquireCount = 0 + val controller = + controller( + recognizer = recognizer, + requestPermission = { false }, + acquireMic = { + acquireCount += 1 + true + }, + ) + + assertNull(controller.start()) + assertEquals(ChatDictationState.Failure(ChatDictationFailure.PermissionRequired), controller.state.value) + assertEquals(0, acquireCount) + } + + @Test + fun microphoneContentionIsVisibleAndDoesNotStartRecognition() = + runTest { + val recognizer = FakeRecognizer() + val controller = controller(recognizer = recognizer, acquireMic = { false }) + + assertNull(controller.start()) + assertEquals(ChatDictationState.Failure(ChatDictationFailure.Busy), controller.state.value) + assertEquals(0, recognizer.startCount) + } + + @Test + fun platformErrorReleasesTheMicrophoneAndMapsTheFailure() = + runTest { + val recognizer = FakeRecognizer() + var released = 0 + val controller = controller(recognizer = recognizer, releaseMic = { released += 1 }) + val result = async { controller.start() } + runCurrent() + + recognizer.emit(ChatDictationRecognitionEvent.Error(SpeechRecognizer.ERROR_NETWORK)) + + assertNull(result.await()) + assertEquals(ChatDictationState.Failure(ChatDictationFailure.Network), controller.state.value) + assertEquals(1, released) + } + + @Test + fun destroyCancelsCaptureAndDestroysThePlatformRecognizer() = + runTest { + val recognizer = FakeRecognizer() + val controller = controller(recognizer) + val result = async { controller.start() } + runCurrent() + + controller.destroy() + + assertNull(result.await()) + assertTrue(recognizer.cancelCount > 0) + assertEquals(1, recognizer.destroyCount) + } + + private fun controller( + recognizer: FakeRecognizer, + requestPermission: suspend () -> Boolean = { true }, + acquireMic: () -> Boolean = { true }, + releaseMic: () -> Unit = {}, + ): ChatDictationController = + ChatDictationController( + recognizer = recognizer, + requestPermission = requestPermission, + acquireMic = acquireMic, + releaseMic = releaseMic, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatDurationFormatterRobolectricTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatDurationFormatterRobolectricTest.kt new file mode 100644 index 0000000..50c2640 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatDurationFormatterRobolectricTest.kt @@ -0,0 +1,23 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ChatDurationFormatterRobolectricTest { + @Test + fun fullDurationUsesLocalizedHumanReadableWording() { + assertEquals("4 hours, 2 minutes", formatChatDurationFull(14_520_000L, Locale.US)) + + val german = formatChatDurationFull(14_520_000L, Locale.GERMAN) + assertTrue(german.contains("4")) + assertTrue(german.contains("2")) + assertTrue(german != "4h 2m") + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatErrorTextTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatErrorTextTest.kt new file mode 100644 index 0000000..a0900f8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatErrorTextTest.kt @@ -0,0 +1,22 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatErrorTextTest { + @Test + fun notConnectedErrorPointsToFixActionsOnlyWhenGatewayIsOffline() { + assertEquals( + "Gateway is offline. Fix the connection below or copy diagnostics.", + userFacingChatError(error = "not connected", gatewayConnected = false), + ) + } + + @Test + fun notConnectedErrorDoesNotClaimGatewayOfflineDuringConnectedHealthBootstrap() { + assertEquals( + "Chat is still checking Gateway health.", + userFacingChatError(error = "not connected", gatewayConnected = true), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatHardwareKeyTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatHardwareKeyTest.kt new file mode 100644 index 0000000..93d1ea5 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatHardwareKeyTest.kt @@ -0,0 +1,212 @@ +package ai.openclaw.app.ui.chat + +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.text.input.TextFieldValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import android.view.KeyEvent as AndroidKeyEvent + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ChatHardwareKeyTest { + @Test + fun unmodifiedEnterOwnsFullSequenceAndSendsOnce() { + var sends = 0 + val handler = PhysicalChatSendKeyHandler() + + assertTrue(handler.handle(keyEvent(AndroidKeyEvent.KEYCODE_ENTER), sendEnabled = true, textEmpty = false, compositionActive = false) { sends += 1 }) + assertTrue( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER, repeatCount = 1, metaState = AndroidKeyEvent.META_SHIFT_ON), + sendEnabled = false, + textEmpty = true, + compositionActive = false, + ) { sends += 1 }, + ) + assertTrue( + handler.handle( + keyEvent( + AndroidKeyEvent.KEYCODE_ENTER, + action = AndroidKeyEvent.ACTION_UP, + metaState = AndroidKeyEvent.META_SHIFT_ON, + ), + sendEnabled = false, + textEmpty = true, + compositionActive = false, + ) { sends += 1 }, + ) + assertFalse( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER, action = AndroidKeyEvent.ACTION_UP), + sendEnabled = false, + textEmpty = true, + compositionActive = false, + ) { sends += 1 }, + ) + + assertEquals(1, sends) + } + + @Test + fun numpadEnterSends() { + var sends = 0 + val handler = PhysicalChatSendKeyHandler() + + assertTrue(handler.handle(keyEvent(AndroidKeyEvent.KEYCODE_NUMPAD_ENTER), sendEnabled = true, textEmpty = false, compositionActive = false) { sends += 1 }) + + assertEquals(1, sends) + } + + @Test + fun disabledEnterWithTextOwnsSequenceWithoutSending() { + var sent = false + val handler = PhysicalChatSendKeyHandler() + + assertTrue(handler.handle(keyEvent(AndroidKeyEvent.KEYCODE_ENTER), sendEnabled = false, textEmpty = false, compositionActive = false) { sent = true }) + assertTrue( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER, repeatCount = 1), + sendEnabled = false, + textEmpty = false, + compositionActive = false, + ) { sent = true }, + ) + assertTrue( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER, action = AndroidKeyEvent.ACTION_UP), + sendEnabled = false, + textEmpty = false, + compositionActive = false, + ) { sent = true }, + ) + + assertFalse(sent) + } + + @Test + fun blankEnterRemainsImeInputButFiltersInsertedNewline() { + val handler = PhysicalChatSendKeyHandler() + + assertFalse(handler.handle(keyEvent(AndroidKeyEvent.KEYCODE_ENTER), sendEnabled = false, textEmpty = true, compositionActive = false) {}) + assertEquals( + "", + handler + .filterTextFieldUpdate( + currentText = "", + nextTextFieldValue = TextFieldValue("\n"), + ).text, + ) + assertEquals( + "nihao", + handler + .filterTextFieldUpdate( + currentText = "", + nextTextFieldValue = TextFieldValue("nihao"), + ).text, + ) + assertFalse( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER, action = AndroidKeyEvent.ACTION_UP), + sendEnabled = false, + textEmpty = false, + compositionActive = false, + ) {}, + ) + } + + @Test + fun compositionAndModifiedEnterRemainImeInput() { + val modifiers = + listOf( + AndroidKeyEvent.META_SHIFT_ON, + AndroidKeyEvent.META_CTRL_ON, + AndroidKeyEvent.META_ALT_ON, + AndroidKeyEvent.META_META_ON, + ) + val handler = PhysicalChatSendKeyHandler() + + modifiers.forEach { metaState -> + assertFalse( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER, metaState = metaState), + sendEnabled = true, + textEmpty = false, + compositionActive = false, + onSend = {}, + ), + ) + } + assertFalse( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER), + sendEnabled = true, + textEmpty = false, + compositionActive = true, + onSend = {}, + ), + ) + } + + @Test + fun privateImeInputOwnsEnterUntilTextFieldUpdates() { + var sends = 0 + val handler = PhysicalChatSendKeyHandler() + + assertFalse( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_N), + sendEnabled = true, + textEmpty = false, + compositionActive = false, + onSend = { sends += 1 }, + ), + ) + assertFalse( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER), + sendEnabled = true, + textEmpty = false, + compositionActive = false, + onSend = { sends += 1 }, + ), + ) + + handler.filterTextFieldUpdate( + currentText = "prefix", + nextTextFieldValue = TextFieldValue("prefix"), + ) + + assertTrue( + handler.handle( + keyEvent(AndroidKeyEvent.KEYCODE_ENTER), + sendEnabled = true, + textEmpty = false, + compositionActive = false, + onSend = { sends += 1 }, + ), + ) + assertEquals(1, sends) + } + + private fun keyEvent( + keyCode: Int, + action: Int = AndroidKeyEvent.ACTION_DOWN, + repeatCount: Int = 0, + metaState: Int = 0, + ): KeyEvent = + KeyEvent( + AndroidKeyEvent( + 0L, + 0L, + action, + keyCode, + repeatCount, + metaState, + ), + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt new file mode 100644 index 0000000..8bd89a0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt @@ -0,0 +1,25 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.CHAT_IMAGE_MAX_BASE64_CHARS +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatImageCodecTest { + @Test + fun computeInSampleSizeCapsLongestEdge() { + assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600)) + assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600)) + } + + @Test + fun normalizeAttachmentFileNameForcesJpegExtension() { + assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png")) + assertEquals("image.jpg", normalizeAttachmentFileName("")) + } + + @Test + fun decodeBase64BitmapRejectsOversizedInputBeforeDecode() { + assertNull(decodeBase64Bitmap("A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1))) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt new file mode 100644 index 0000000..f0157e7 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt @@ -0,0 +1,599 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.ui.image.PublicOnlyDns +import ai.openclaw.app.ui.image.REMOTE_IMAGE_BODY_MAX_BYTES +import ai.openclaw.app.ui.image.REMOTE_IMAGE_MAX_DIMENSION +import ai.openclaw.app.ui.image.RemoteImageResult +import ai.openclaw.app.ui.image.SafeRemoteImageFetcher +import ai.openclaw.app.ui.image.SafeRemoteImageStore +import ai.openclaw.app.ui.image.SafeWebFetcher +import ai.openclaw.app.ui.image.decodeRemoteImageBitmap +import ai.openclaw.app.ui.image.isPubliclyRoutableHost +import ai.openclaw.app.ui.image.resolveRedirect +import android.graphics.Bitmap +import android.graphics.Color +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayOutputStream +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +class ChatLinkPreviewTest { + @Test + fun extractsFirstHttpLinkOutsideCode() { + assertEquals("https://example.com/path", extractFirstBareUrl("See https://example.com/path now")) + assertEquals("https://example.com/docs", extractFirstBareUrl("Read [the docs](https://example.com/docs)")) + assertEquals( + "https://after.example", + extractFirstBareUrl("`https://inline.example`\n```\nhttps://fenced.example\n```\nhttps://after.example"), + ) + } + + @Test + fun extractionSkipsMissingAndNonHttpLinks() { + assertNull(extractFirstBareUrl("No URL here")) + assertNull(extractFirstBareUrl("[mail](mailto:test@example.com) and ftp://example.com/file")) + } + + @Test + fun rejectsNonPublicLiteralAndLocalHosts() { + val rejected = + listOf( + "http://127.0.0.1/", + "http://10.0.0.5/", + "http://172.20.1.1/", + "http://192.168.1.1/", + "http://169.254.9.9/", + "http://100.64.0.1/", + "http://198.18.0.1/", + "http://192.0.2.1/", + "http://0.0.0.0/", + "http://224.0.0.1/", + "http://255.255.255.255/", + "http://[::1]/", + "http://[fe80::1]/", + "http://[fc00::1]/", + "http://[::]/", + "http://[ff02::1]/", + "http://[2001:db8::1]/", + "http://localhost/", + "http://foo.local/", + ) + rejected.forEach { url -> + assertTrue("Expected $url to be rejected", !isPubliclyRoutableHost(url.toHttpUrl())) + } + + assertTrue(isPubliclyRoutableHost("https://example.com/".toHttpUrl())) + assertTrue(isPubliclyRoutableHost("http://93.184.216.34/".toHttpUrl())) + assertTrue(isPubliclyRoutableHost("https://[2606:4700:4700::1111]/".toHttpUrl())) + } + + @Test + fun dnsRejectsMixedPublicAndPrivateAnswers() { + val answers = + listOf( + InetAddress.getByAddress(byteArrayOf(93, 184.toByte(), 216.toByte(), 34)), + InetAddress.getByAddress(byteArrayOf(10, 0, 0, 5)), + ) + val dns = PublicOnlyDns(Dns { answers }) + + assertTrue(runCatching { dns.lookup("example.com") }.exceptionOrNull() is UnknownHostException) + } + + @Test + fun privateLiteralInitialUrlFailsWithoutNetworkCall() = + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("Private")) + + assertSame(LinkPreviewResult.Failed, realPolicyFetcher().fetch(server.url("/private").toString())) + assertEquals(0, server.requestCount) + } + + @Test + fun redirectPolicyRejectsPrivateLiteralTarget() { + val target = resolveRedirect("https://example.com/start".toHttpUrl(), "http://127.0.0.1:1/private") + + assertNull(target) + } + + @Test + fun parsesOpenGraphWithAttributeOrderAndRelativeImage() { + val result = + parseOpenGraph( + html = + """ + + + + + + """.trimIndent(), + baseUrl = "https://example.com/articles/one", + ) as LinkPreviewResult.Loaded + + assertEquals("A title", result.metadata.title) + assertEquals("One & two", result.metadata.description) + assertEquals("https://example.com/images/card.png", result.metadata.imageUrl) + } + + @Test + fun fallsBackToTitleAndFailsWhenMetadataIsMissing() { + val fallback = parseOpenGraph("Fallback title", "https://example.com") + + assertEquals("Fallback title", (fallback as LinkPreviewResult.Loaded).metadata.title) + assertSame(LinkPreviewResult.Failed, parseOpenGraph("Nothing", "https://example.com")) + } + + @Test + fun stripsControlsAndCapsMetadataLengths() { + val title = "T\u0000" + "x".repeat(LINK_PREVIEW_TITLE_MAX_CHARS + 20) + val description = "D\u0007" + "y".repeat(LINK_PREVIEW_DESCRIPTION_MAX_CHARS + 20) + val result = + parseOpenGraph( + "", + "https://example.com", + ) as LinkPreviewResult.Loaded + + assertEquals(LINK_PREVIEW_TITLE_MAX_CHARS, result.metadata.title?.length) + assertEquals(LINK_PREVIEW_DESCRIPTION_MAX_CHARS, result.metadata.description?.length) + assertTrue( + result.metadata.title + .orEmpty() + .none(Character::isISOControl), + ) + assertTrue( + result.metadata.description + .orEmpty() + .none(Character::isISOControl), + ) + } + + @Test + fun metadataTruncationPreservesUtf16Boundaries() { + val titlePrefix = "t".repeat(LINK_PREVIEW_TITLE_MAX_CHARS - 1) + val descriptionPrefix = "d".repeat(LINK_PREVIEW_DESCRIPTION_MAX_CHARS - 2) + val result = + parseOpenGraph( + "" + + "", + "https://example.com", + ) as LinkPreviewResult.Loaded + + assertEquals(titlePrefix, result.metadata.title) + assertEquals("$descriptionPrefix\uD83D\uDE80", result.metadata.description) + } + + @Test + fun fetchesHtmlWithoutAmbientHeaders() = + withServer { server -> + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/html; charset=utf-8") + .setBody(""), + ) + + val result = fetcher().fetch(server.url("/page").toString()) as LinkPreviewResult.Loaded + + assertEquals("Fetched", result.metadata.title) + val request = server.takeRequest() + assertEquals("text/html, application/xhtml+xml;q=0.9", request.getHeader("Accept")) + assertNull(request.getHeader("Cookie")) + assertNull(request.getHeader("Authorization")) + } + + @Test + fun followsThreeRedirectsButRejectsAFourth() { + withServer { server -> + repeat(3) { index -> server.enqueue(redirect("/hop${index + 1}")) } + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/html") + .setBody("After three"), + ) + + val loaded = fetcher().fetch(server.url("/start").toString()) as LinkPreviewResult.Loaded + assertEquals("After three", loaded.metadata.title) + assertEquals(4, server.requestCount) + } + + withServer { server -> + repeat(4) { index -> server.enqueue(redirect("/hop${index + 1}")) } + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/html") + .setBody("Too far"), + ) + + assertSame(LinkPreviewResult.Failed, fetcher().fetch(server.url("/start").toString())) + assertEquals(4, server.requestCount) + } + } + + @Test + fun parsesOnlyTheFirst512KiB() = + withServer { server -> + val prefix = "" + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/html") + .setBody(prefix + "x".repeat(LINK_PREVIEW_BODY_MAX_BYTES + 1_024)), + ) + + val result = fetcher().fetch(server.url("/large").toString()) as LinkPreviewResult.Loaded + + assertEquals("Early", result.metadata.title) + } + + @Test + fun rejectsNonHtmlAndTimesOutQuickly() { + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + assertSame(LinkPreviewResult.Failed, fetcher().fetch(server.url("/json").toString())) + } + + withServer { server -> + server.enqueue( + MockResponse() + .setHeadersDelay(500, TimeUnit.MILLISECONDS) + .setHeader("Content-Type", "text/html") + .setBody("Late"), + ) + assertSame(LinkPreviewResult.Failed, fetcher(timeoutMillis = 75).fetch(server.url("/slow").toString())) + } + } + + @Test + fun responseBodyDisconnectReturnsFailed() = + withServer { server -> + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/html") + .setBody("x".repeat(16_384) + "Never complete") + .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY), + ) + + assertSame(LinkPreviewResult.Failed, fetcher().fetch(server.url("/disconnect").toString())) + } + + @Test + fun cancellationCancelsActiveMetadataAndImageCalls() { + withServer { server -> + coroutineScope { + server.enqueue( + MockResponse() + // Cancel before OkHttp produces a Response. + .setHeader("Content-Type", "text/html") + .setHeadersDelay(30, TimeUnit.SECONDS) + .setBody("Never delivered"), + ) + + val metadataFetch = async { fetcher(timeoutMillis = 60_000).fetch(server.url("/slow-page").toString()) } + assertTrue(withContext(Dispatchers.IO) { server.takeRequest(1, TimeUnit.SECONDS) } != null) + delay(100) + + withTimeout(1_000) { + metadataFetch.cancelAndJoin() + } + } + } + + withServer { server -> + coroutineScope { + server.enqueue( + MockResponse() + // Cancel while OkHttp is reading the response body. + .setHeader("Content-Type", "image/png") + .setBodyDelay(30, TimeUnit.SECONDS) + .setBody(Buffer().write(pngBytes(width = 10, height = 10))), + ) + + val imageFetch = async { imageFetcher(timeoutMillis = 60_000).fetch(server.url("/slow-image.png").toString()) } + assertTrue(withContext(Dispatchers.IO) { server.takeRequest(1, TimeUnit.SECONDS) } != null) + delay(100) + + withTimeout(1_000) { + imageFetch.cancelAndJoin() + } + } + } + } + + @Test + fun allowsHttpToHttpsRedirectAndRejectsFileRedirect() { + withServer { server -> + server.enqueue(redirect("https://secure.example/target")) + val client = + baseClient() + .addInterceptor { chain -> + val request = chain.request() + if (!request.url.isHttps) { + chain.proceed(request) + } else { + Response + .Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("Secure target".toResponseBody("text/html".toMediaType())) + .build() + } + }.build() + + val result = + LinkPreviewFetcher(client, hostPolicy = permissiveHostPolicy) + .fetch(server.url("/start").toString()) as LinkPreviewResult.Loaded + + assertEquals("Secure target", result.metadata.title) + assertEquals(1, server.requestCount) + } + + withServer { server -> + server.enqueue(redirect("file:///tmp/private")) + assertSame(LinkPreviewResult.Failed, fetcher().fetch(server.url("/start").toString())) + assertEquals(1, server.requestCount) + } + } + + @Test + fun cacheHitAvoidsSecondFetchIncludingFailures() = + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody("Cached")) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + val store = LinkPreviewStore(fetcher = fetcher()::fetch) + val loadedUrl = server.url("/loaded").toString() + val failedUrl = server.url("/failed").toString() + + assertTrue(store.get(loadedUrl) is LinkPreviewResult.Loaded) + assertTrue(store.get(loadedUrl) is LinkPreviewResult.Loaded) + assertEquals(1, server.requestCount) + + assertSame(LinkPreviewResult.Failed, store.get(failedUrl)) + assertSame(LinkPreviewResult.Failed, store.get(failedUrl)) + assertEquals(2, server.requestCount) + } + + @Test + fun imageFetchStartsOnlyWhenStoreIsRequestedAndCacheHitAvoidsSecondRequest() = + withServer { server -> + server.enqueue(imageResponse(pngBytes(width = 120, height = 80))) + val store = SafeRemoteImageStore(fetcher = imageFetcher()::fetch) + val imageUrl = server.url("/card.png").toString() + + assertEquals(0, server.requestCount) + assertTrue(store.get(imageUrl) is RemoteImageResult.Raster) + assertTrue(store.get(imageUrl) is RemoteImageResult.Raster) + assertEquals(1, server.requestCount) + assertEquals("image/*", server.takeRequest().getHeader("Accept")) + } + + @Test + fun imageCacheEvictsLeastRecentlyUsedBitmapByAllocatedBytes() = + runBlocking { + val first = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) + val second = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) + val fetchCounts = mutableMapOf() + val store = + SafeRemoteImageStore( + fetcher = { url -> + fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 + RemoteImageResult.Raster(if (url == "first") first else second) + }, + maxBytes = first.allocationByteCount, + ) + + try { + assertTrue(store.get("first") is RemoteImageResult.Raster) + assertTrue(store.get("second") is RemoteImageResult.Raster) + assertTrue(store.get("first") is RemoteImageResult.Raster) + + assertEquals(2, fetchCounts["first"]) + assertEquals(1, fetchCounts["second"]) + } finally { + first.recycle() + second.recycle() + } + } + + @Test + fun imageCacheBoundsNegativeResults() = + runBlocking { + val fetchCounts = mutableMapOf() + val store = + SafeRemoteImageStore( + fetcher = { url -> + fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 + RemoteImageResult.Failed + }, + maxBytes = 2, + ) + + assertSame(RemoteImageResult.Failed, store.get("first")) + assertSame(RemoteImageResult.Failed, store.get("second")) + assertSame(RemoteImageResult.Failed, store.get("third")) + assertSame(RemoteImageResult.Failed, store.get("first")) + + assertEquals(2, fetchCounts["first"]) + assertEquals(1, fetchCounts["second"]) + assertEquals(1, fetchCounts["third"]) + } + + @Test + fun imageCacheBoundsTinyLoadedResultsByEntryCount() = + runBlocking { + val tiny = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val maxEntries = 32 + val fetchCounts = mutableMapOf() + val store = + SafeRemoteImageStore( + fetcher = { url -> + fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 + RemoteImageResult.Raster(tiny) + }, + maxBytes = tiny.allocationByteCount * maxEntries * 2, + ) + + try { + repeat(maxEntries + 1) { index -> + assertTrue(store.get("image-$index") is RemoteImageResult.Raster) + } + assertTrue(store.get("image-0") is RemoteImageResult.Raster) + + assertEquals(2, fetchCounts["image-0"]) + } finally { + tiny.recycle() + } + } + + @Test + fun imageContentTypeAllowlistAndBodyCapAreEnforced() = + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "image/gif").setBody("GIF89a")) + server.enqueue(MockResponse().setHeader("Content-Type", "image/svg+xml").setBody("")) + server.enqueue(MockResponse().setHeader("Content-Type", "image/png").setBody("GIF89a")) + server.enqueue( + MockResponse() + .setHeader("Content-Type", "image/png") + .setBody(Buffer().write(ByteArray(REMOTE_IMAGE_BODY_MAX_BYTES + 1))), + ) + + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/animated.gif").toString())) + assertTrue(imageFetcher().fetch(server.url("/vector.svg").toString()) is RemoteImageResult.Svg) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/spoofed.png").toString())) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/oversized.png").toString())) + assertEquals(4, server.requestCount) + } + + @Test + fun privateLiteralImageUrlFailsWithoutNetworkCall() = + withServer { server -> + server.enqueue(imageResponse(pngBytes(width = 10, height = 10))) + + assertSame(RemoteImageResult.Failed, realPolicyImageFetcher().fetch(server.url("/private.png").toString())) + assertEquals(0, server.requestCount) + } + + @Test + fun imageRedirectsFollowThreeHopsAndRejectUnsafeOrFourthHop() { + withServer { server -> + repeat(3) { index -> server.enqueue(redirect("/image-hop${index + 1}")) } + server.enqueue(imageResponse(pngBytes(width = 12, height = 8))) + + assertTrue(imageFetcher().fetch(server.url("/image-start").toString()) is RemoteImageResult.Raster) + assertEquals(4, server.requestCount) + } + + withServer { server -> + repeat(4) { index -> server.enqueue(redirect("/image-hop${index + 1}")) } + server.enqueue(imageResponse(pngBytes(width = 12, height = 8))) + + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/image-start").toString())) + assertEquals(4, server.requestCount) + } + + withServer { server -> + server.enqueue(redirect("file:///tmp/private.png")) + + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/image-start").toString())) + assertEquals(1, server.requestCount) + } + } + + @Test + fun imageDecodeDownsamplesLargeSource() { + val decoded = decodeRemoteImageBitmap(pngBytes(width = 2_400, height = 1_200)) + + assertTrue(decoded != null) + assertTrue(checkNotNull(decoded).width <= REMOTE_IMAGE_MAX_DIMENSION) + assertTrue(decoded.height <= REMOTE_IMAGE_MAX_DIMENSION) + } + + @Test + fun corruptImageIsNegativeCachedWithoutRefetch() = + withServer { server -> + server.enqueue(MockResponse().setHeader("Content-Type", "image/webp").setBody("not an image")) + val store = SafeRemoteImageStore(fetcher = imageFetcher()::fetch) + val imageUrl = server.url("/corrupt.webp").toString() + + assertSame(RemoteImageResult.Failed, store.get(imageUrl)) + assertSame(RemoteImageResult.Failed, store.get(imageUrl)) + assertEquals(1, server.requestCount) + } + + private fun fetcher(timeoutMillis: Long = 6_000): LinkPreviewFetcher = LinkPreviewFetcher(baseClient().build(), timeoutMillis, permissiveHostPolicy) + + private fun realPolicyFetcher(): LinkPreviewFetcher = LinkPreviewFetcher(baseClient().build()) + + private fun imageFetcher(timeoutMillis: Long = 6_000): SafeRemoteImageFetcher = SafeRemoteImageFetcher(SafeWebFetcher(baseClient().build(), timeoutMillis, permissiveHostPolicy)) + + private fun realPolicyImageFetcher(): SafeRemoteImageFetcher = SafeRemoteImageFetcher(SafeWebFetcher(baseClient().build())) + + private fun baseClient(): OkHttpClient.Builder = + OkHttpClient + .Builder() + .followRedirects(false) + .followSslRedirects(false) + + private fun redirect(location: String): MockResponse = + MockResponse() + .setResponseCode(302) + .setHeader("Location", location) + + private fun imageResponse(bytes: ByteArray): MockResponse = + MockResponse() + .setHeader("Content-Type", "image/png") + .setBody(Buffer().write(bytes)) + + private fun pngBytes( + width: Int, + height: Int, + ): ByteArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + return try { + bitmap.eraseColor(Color.rgb(24, 96, 192)) + ByteArrayOutputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) + output.toByteArray() + } + } finally { + bitmap.recycle() + } + } + + private fun withServer(block: suspend (MockWebServer) -> Unit) { + MockWebServer().use { server -> + server.start() + runBlocking { block(server) } + } + } + + companion object { + private val permissiveHostPolicy: (okhttp3.HttpUrl) -> Boolean = { true } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatMarkdownTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMarkdownTest.kt new file mode 100644 index 0000000..73fb592 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMarkdownTest.kt @@ -0,0 +1,592 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.CHAT_IMAGE_MAX_BASE64_CHARS +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.HtmlBlock +import org.commonmark.node.Paragraph +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatMarkdownTest { + @Test + fun detailsFoldCollapsedAndExpandedBlocks() { + val collapsed = + parseChatMarkdownBlocks( + """ +
+ **Why** + + Body + +
+ """.trimIndent(), + ).single() as ChatMarkdownRenderBlock.Disclosure + val expanded = + parseChatMarkdownBlocks( + """ +
+ More + + Body + +
+ """.trimIndent(), + ).single() as ChatMarkdownRenderBlock.Disclosure + + assertEquals("**Why**", collapsed.summary) + assertEquals(false, collapsed.isExpanded) + assertTrue((collapsed.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node is Paragraph) + val renderedSummary = buildChatInlineMarkdown(checkNotNull(collapsed.summary)) + assertEquals("Why", renderedSummary.text) + assertTrue(renderedSummary.spanStyles.any { it.item.fontWeight == FontWeight.SemiBold }) + assertEquals("More", expanded.summary) + assertEquals(true, expanded.isExpanded) + } + + @Test + fun authoredDetailsSummaryDoesNotUseLocalizedFallback() { + val disclosure = + parseChatMarkdownBlocks("
\nDetails\n\nBody\n\n
") + .single() as ChatMarkdownRenderBlock.Disclosure + var fallbackEvaluated = false + val rendered = + chatMarkdownDisclosureSummarySource(disclosure.summary) { + fallbackEvaluated = true + "Localized details" + } + + assertEquals("Details", disclosure.summary) + assertEquals("Details", rendered) + assertEquals(false, fallbackEvaluated) + } + + @Test + fun detailsWithoutSummaryUseLocalizedDefaultLabel() { + val disclosure = + parseChatMarkdownBlocks("
\n\nBody\n\n
").single() as ChatMarkdownRenderBlock.Disclosure + + assertNull(disclosure.summary) + assertEquals( + "Localized details", + chatMarkdownDisclosureSummarySource(disclosure.summary) { "Localized details" }, + ) + } + + @Test + fun emptyDetailsSummaryAfterProseUsesLocalizedDefault() { + val blocks = + parseChatMarkdownBlocks( + "Intro\n\n
\n\n\nBody\n\n
", + ) + val intro = (blocks[0] as ChatMarkdownRenderBlock.CommonMark).node as Paragraph + val disclosure = blocks[1] as ChatMarkdownRenderBlock.Disclosure + + assertEquals("Intro", (intro.firstChild as org.commonmark.node.Text).literal) + assertNull(disclosure.summary) + assertEquals( + "Localized details", + chatMarkdownDisclosureSummarySource(disclosure.summary) { "Localized details" }, + ) + } + + @Test + fun detailsBodyKeepsNativeListAndFenceBlocks() { + val disclosure = + parseChatMarkdownBlocks( + """ +
+ Why + + - first + - second + + ```kotlin + val value = 1 + ``` + +
+ """.trimIndent(), + ).single() as ChatMarkdownRenderBlock.Disclosure + + assertTrue((disclosure.blocks[0] as ChatMarkdownRenderBlock.CommonMark).node is BulletList) + assertTrue((disclosure.blocks[1] as ChatMarkdownRenderBlock.CommonMark).node is FencedCodeBlock) + } + + @Test + fun type6HtmlBlockCannotAbsorbTheDetailsCloser() { + val blocks = + parseChatMarkdownBlocks( + """ +
+ X + +
body
+
+ + Following + """.trimIndent(), + ) + val disclosure = blocks[0] as ChatMarkdownRenderBlock.Disclosure + val html = (disclosure.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node as HtmlBlock + val following = (blocks[1] as ChatMarkdownRenderBlock.CommonMark).node as Paragraph + + assertTrue(html.literal.contains("body")) + assertEquals("Following", (following.firstChild as org.commonmark.node.Text).literal) + } + + @Test + fun unsupportedNestedDetailsBalanceWithoutClosingOuterDisclosure() { + val blocks = + parseChatMarkdownBlocks( + """ +
+ Outer + +
+ + unsupported body + +
+ + still outer + +
+ """.trimIndent(), + ) + val outer = blocks.single() as ChatMarkdownRenderBlock.Disclosure + val literals = outer.blocks.filterIsInstance().map { it.source } + + assertTrue(literals.contains("
")) + assertTrue(literals.contains("
")) + val paragraphText = + outer.blocks + .filterIsInstance() + .mapNotNull { it.node as? Paragraph } + .mapNotNull { it.firstChild as? org.commonmark.node.Text } + .mapNotNull { it.literal } + assertTrue(paragraphText.contains("still outer")) + } + + @Test + fun detailsTagsInFencedAndInlineCodeStayLiteral() { + val fenced = parseChatMarkdownBlocks("```html\n
\n
\n```").single() + val inline = parseChatMarkdownBlocks("`
`").single() + + assertTrue((fenced as ChatMarkdownRenderBlock.CommonMark).node is FencedCodeBlock) + assertTrue((inline as ChatMarkdownRenderBlock.CommonMark).node is Paragraph) + } + + @Test + fun detailsInRawHtmlContextsStayLiteral() { + val commented = "" + val preformatted = "
\n
\nExample\n
\n
" + + assertTrue((parseChatMarkdownBlocks(commented).single() as ChatMarkdownRenderBlock.CommonMark).node is HtmlBlock) + assertTrue((parseChatMarkdownBlocks(preformatted).single() as ChatMarkdownRenderBlock.CommonMark).node is HtmlBlock) + } + + @Test + fun detailsAfterHtmlCommentStillFold() { + val comment = "" + val blocks = + parseChatMarkdownBlocks( + "$comment\n
\nAfter\n\nBody\n\n
", + ) + + assertEquals(2, blocks.size) + assertTrue((blocks[0] as ChatMarkdownRenderBlock.CommonMark).node is HtmlBlock) + assertEquals("After", (blocks[1] as ChatMarkdownRenderBlock.Disclosure).summary) + } + + @Test + fun rawHtmlCloseMarkersInsideDetailsStayLiteral() { + listOf( + "
\nX\n
\n
\n\n
" to "", + "
\nX\n\n
" to "", + "
\nX\n\n?>\n
" to "
", + "
\nX\n\n]]>\n
" to "
", + "
\nX\n\n
" to "
", + ).forEach { (source, literalClose) -> + val blocks = parseChatMarkdownBlocks(source) + val disclosure = blocks.single() as ChatMarkdownRenderBlock.Disclosure + val rawBlock = (disclosure.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node as HtmlBlock + + assertTrue(rawBlock.literal.contains(literalClose)) + } + } + + @Test + fun midLineAndOverIndentedDetailsStayLiteral() { + val midLine = parseChatMarkdownBlocks("before
after").single() + val indented = parseChatMarkdownBlocks("
\n body\n
").single() + + assertTrue((midLine as ChatMarkdownRenderBlock.CommonMark).node is Paragraph) + val indentedNode = (indented as ChatMarkdownRenderBlock.CommonMark).node + assertTrue(indentedNode is org.commonmark.node.IndentedCodeBlock) + } + + @Test + fun unclosedStreamingDetailsFoldAvailableBody() { + val disclosure = + parseChatMarkdownBlocks("
\nProgress\n\n- first") + .single() as ChatMarkdownRenderBlock.Disclosure + + assertEquals("Progress", disclosure.summary) + assertEquals(true, disclosure.isExpanded) + assertTrue((disclosure.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node is BulletList) + } + + @Test + fun detailsNestingStopsAtDepthCap() { + val depth = CHAT_MARKDOWN_DISCLOSURE_MAX_DEPTH + 1 + val markdown = + List(depth) { "
" }.joinToString("\n") + + "\nbody\n" + + List(depth) { "
" }.joinToString("\n") + var blocks = parseChatMarkdownBlocks(markdown) + var structuralDepth = 0 + while (blocks.singleOrNull() is ChatMarkdownRenderBlock.Disclosure) { + structuralDepth += 1 + blocks = (blocks.single() as ChatMarkdownRenderBlock.Disclosure).blocks + } + + assertEquals(CHAT_MARKDOWN_DISCLOSURE_MAX_DEPTH, structuralDepth) + assertTrue(blocks.filterIsInstance().any { it.source == "
" }) + assertTrue(blocks.filterIsInstance().any { it.source == "
" }) + } + + @Test + fun displayMathSegmentsOwnLineAndSameLineDollarBlocks() { + val sameLine = segmentChatMarkdown("before\n$$ x^2 + y^2 $$\nafter", isStreaming = false) + val ownLine = segmentChatMarkdown("$$\nx + y\n$$", isStreaming = false) + + assertEquals( + listOf( + ChatMarkdownSourceBlock.Markdown("before"), + ChatMarkdownSourceBlock.Math("x^2 + y^2"), + ChatMarkdownSourceBlock.Markdown("after"), + ), + sameLine, + ) + assertEquals(listOf(ChatMarkdownSourceBlock.Math("x + y")), ownLine) + } + + @Test + fun displayMathSegmentsBracketBlocks() { + assertEquals( + listOf(ChatMarkdownSourceBlock.Math("\\frac{a}{b}")), + segmentChatMarkdown("\\[\\frac{a}{b}\\]", isStreaming = false), + ) + } + + @Test + fun displayMathIgnoresFencedAndInlineCode() { + val fenced = "```tex\n$$\nx + y\n$$\n```" + val inline = "`$$ x + y $$`" + + assertEquals(listOf(ChatMarkdownSourceBlock.Markdown(fenced)), segmentChatMarkdown(fenced, isStreaming = false)) + assertEquals(listOf(ChatMarkdownSourceBlock.Markdown(inline)), segmentChatMarkdown(inline, isStreaming = false)) + } + + @Test + fun fencedDelimiterCannotCloseStreamingDisplayMath() { + val source = "$$\nx\n```text\n$$\n```" + + assertEquals( + listOf(ChatMarkdownSourceBlock.Markdown(source)), + segmentChatMarkdown(source, isStreaming = true), + ) + } + + @Test + fun displayMathDoesNotSplitSpanningInlineMarkup() { + val emphasized = "*before\n$$ x + y $$\nafter*" + + assertEquals( + listOf(ChatMarkdownSourceBlock.Markdown(emphasized)), + segmentChatMarkdown(emphasized, isStreaming = false), + ) + } + + @Test + fun displayMathDoesNotExposeHardBreakEscape() { + val hardBreak = "before\\\n$$ x + y $$\nafter" + + assertEquals( + listOf(ChatMarkdownSourceBlock.Markdown(hardBreak)), + segmentChatMarkdown(hardBreak, isStreaming = false), + ) + } + + @Test + fun unclosedStreamingDisplayMathStaysMarkdown() { + val source = "before\n$$\nx + y" + + assertEquals( + listOf(ChatMarkdownSourceBlock.Markdown(source)), + segmentChatMarkdown(source, isStreaming = true), + ) + } + + @Test + fun oversizedDisplayMathUsesCodeFallback() { + val latex = "é".repeat(CHAT_MATH_MAX_BYTES / 2 + 1) + + assertEquals( + listOf(ChatMarkdownSourceBlock.MathFallback(latex)), + segmentChatMarkdown("$$\n$latex\n$$", isStreaming = false), + ) + } + + @Test + fun bareUrlsCarryClickableUrlAnnotations() { + val url = "https://www.amazon.it/GAZEBO-CANOPY-ACCIAIO-BIANCO-IMPERMEABILE/dp/B01G5R9FCK" + + val annotated = buildChatInlineMarkdown("Open $url") + + assertEquals("Open $url", annotated.text) + val links = annotated.getLinkAnnotations(0, annotated.length) + assertEquals(1, links.size) + assertEquals(5, links.single().start) + assertEquals(5 + url.length, links.single().end) + assertEquals(url, (links.single().item as LinkAnnotation.Url).url) + } + + @Test + fun markdownLinksUseLabelTextAndDestinationUrl() { + val annotated = buildChatInlineMarkdown("Open [docs](https://docs.openclaw.ai/help/testing) now") + + assertEquals("Open docs now", annotated.text) + val links = annotated.getLinkAnnotations(0, annotated.length) + assertEquals(1, links.size) + assertEquals(5, links.single().start) + assertEquals(9, links.single().end) + assertEquals("https://docs.openclaw.ai/help/testing", (links.single().item as LinkAnnotation.Url).url) + } + + @Test + fun markdownLinksDropUnsafeDestinations() { + listOf( + "intent://example/#Intent;scheme=openclaw;end", + "file:///sdcard/Download/x", + "content://downloads/public_downloads/1", + "tel:+15551234567", + "javascript:alert(1)", + ).forEach { destination -> + val annotated = buildChatInlineMarkdown("Open [settings]($destination)") + + assertEquals("Open settings", annotated.text) + assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty()) + } + } + + @Test + fun plainTextDoesNotAddLinkAnnotations() { + val annotated = buildChatInlineMarkdown("No link here") + + assertEquals("No link here", annotated.text) + assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty()) + } + + @Test + fun leadingListsAndQuotesParseAsBlockMarkdown() { + assertTrue(parseChatMarkdown("- first\n- second").firstChild is BulletList) + assertTrue(parseChatMarkdown("> quoted").firstChild is BlockQuote) + } + + @Test + fun underscoreEmphasisRendersAsItalicText() { + val document = parseChatMarkdown("_important_") + val paragraph = document.firstChild as Paragraph + + assertTrue(paragraph.firstChild is Emphasis) + val annotated = buildChatInlineMarkdown("_important_") + assertEquals("important", annotated.text) + val emphasis = + annotated.spanStyles + .single() + .item + assertEquals( + FontStyle.Italic, + emphasis.fontStyle, + ) + } + + @Test + fun parseDataImageDestinationAcceptsBoundedPayloads() { + val parsed = parseDataImageDestination("data:image/png;base64,QUJD") + + assertEquals(ParsedDataImage(mimeType = "image/png", base64 = "QUJD"), parsed) + } + + @Test + fun parseDataImageDestinationRejectsOversizedPayloads() { + val oversized = "A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1) + + val parsed = parseDataImageDestination("data:image/png;base64,$oversized") + + assertNull(parsed) + } + + @Test + fun kotlinCodeTokenizesKeywordStringCommentAndNumber() { + val code = "// greet\nfun main() {\n val count = 42\n println(\"hi\")\n}\n" + + val tokens = codeHighlightTokens(code, "kotlin") + + fun assertToken( + snippet: String, + kind: CodeTokenKind, + ) { + val start = code.indexOf(snippet) + assertTrue( + "expected $kind token for $snippet", + tokens.any { it.start == start && it.end == start + snippet.length && it.kind == kind }, + ) + } + assertToken("// greet", CodeTokenKind.COMMENT) + assertToken("fun", CodeTokenKind.KEYWORD) + assertToken("val", CodeTokenKind.KEYWORD) + assertToken("42", CodeTokenKind.NUMBER) + assertToken("\"hi\"", CodeTokenKind.STRING) + } + + @Test + fun highlightedCodeAppliesThemeTokenColors() { + val colors = CodeTokenColors(keyword = Color.Red, string = Color.Green, comment = Color.Gray, number = Color.Blue) + + val annotated = buildHighlightedCode("val x = 1", "kotlin", colors) + + assertEquals("val x = 1", annotated.text) + val keyword = annotated.spanStyles.single { it.start == 0 && it.end == 3 } + assertEquals(Color.Red, keyword.item.color) + val number = annotated.spanStyles.single { it.start == 8 && it.end == 9 } + assertEquals(Color.Blue, number.item.color) + } + + @Test + fun unknownOrMissingLanguageRendersPlain() { + val code = "fun main() {}" + val colors = CodeTokenColors(keyword = Color.Red, string = Color.Green, comment = Color.Gray, number = Color.Blue) + + assertTrue(codeHighlightTokens(code, "brainfuck").isEmpty()) + assertTrue(codeHighlightTokens(code, null).isEmpty()) + assertTrue(buildHighlightedCode(code, "brainfuck", colors).spanStyles.isEmpty()) + } + + @Test + fun openFencedBlockParsesWithoutClosingFence() { + val open = parseChatMarkdown("```kotlin\nval x = 1\n").firstChild as FencedCodeBlock + val closed = parseChatMarkdown("```kotlin\nval x = 1\n```\n").firstChild as FencedCodeBlock + + // While streaming, the renderer keeps fences without a closing marker plain; finalized + // messages highlight regardless because CommonMark allows fences to end at EOF. + assertNull(open.closingFenceLength) + assertNotNull(closed.closingFenceLength) + } + + @Test + fun blocksOverTheLineOrCharBoundSkipHighlighting() { + val overLineBound = buildString { repeat(CODE_HIGHLIGHT_MAX_LINES + 1) { append("val v$it = $it\n") } } + // Fenced literals keep a trailing newline; a block of exactly MAX lines must still highlight. + val atLineBound = buildString { repeat(CODE_HIGHLIGHT_MAX_LINES) { append("val v$it = $it\n") } } + // A minified one-line payload must hit the char bound even though it has no newlines. + val overCharBound = "{\"k\": \"" + "a".repeat(CODE_HIGHLIGHT_MAX_CHARS) + "\"}" + + assertTrue(codeHighlightTokens(overLineBound, "kotlin").isEmpty()) + assertTrue(codeHighlightTokens(atLineBound, "kotlin").isNotEmpty()) + assertTrue(codeHighlightTokens(overCharBound, "json").isEmpty()) + } + + @Test + fun jsonAndBashTokenizeStringsCommentsAndLiterals() { + val json = "{\"enabled\": true, \"count\": 3}" + val jsonTokens = codeHighlightTokens(json, "json") + assertTrue(jsonTokens.any { it.kind == CodeTokenKind.STRING }) + assertTrue(jsonTokens.any { it.kind == CodeTokenKind.KEYWORD && json.substring(it.start, it.end) == "true" }) + assertTrue(jsonTokens.any { it.kind == CodeTokenKind.NUMBER && json.substring(it.start, it.end) == "3" }) + + val bash = "# list\nfor f in *.txt; do echo \"\$f\"; done\n" + val bashTokens = codeHighlightTokens(bash, "bash") + assertTrue(bashTokens.any { it.kind == CodeTokenKind.COMMENT && it.start == 0 }) + assertTrue(bashTokens.any { it.kind == CodeTokenKind.KEYWORD && bash.substring(it.start, it.end) == "done" }) + } + + @Test + fun escapedSingleQuotesAndBashHashesTokenizeCorrectly() { + // TS single-quoted strings keep backslash escapes: the literal is one token and code after it is not a string. + val ts = "const m = 'don\\'t'; call()" + val literal = "'don\\'t'" + val tsTokens = codeHighlightTokens(ts, "typescript") + val start = ts.indexOf(literal) + assertTrue(tsTokens.any { it.kind == CodeTokenKind.STRING && it.start == start && it.end == start + literal.length }) + assertTrue(tsTokens.none { it.kind == CodeTokenKind.STRING && it.start > start }) + + // Template literals span newlines; code after the closing backtick is not a string. + val template = "const t = `a\nb`; call()" + val templateTokens = codeHighlightTokens(template, "typescript") + val backtick = template.indexOf('`') + assertTrue( + templateTokens.any { it.kind == CodeTokenKind.STRING && it.start == backtick && it.end == template.indexOf("`;") + 1 }, + ) + assertTrue(templateTokens.none { it.kind == CodeTokenKind.STRING && it.start > backtick }) + + // Bash '#' inside parameter expansion is not a comment; whitespace- or operator-adjacent '#' is. + val bash = "echo \${#items[@]} # count\n" + val bashTokens = codeHighlightTokens(bash, "bash") + val comments = bashTokens.filter { it.kind == CodeTokenKind.COMMENT } + assertEquals(1, comments.size) + assertEquals(bash.indexOf("# count"), comments.single().start) + + val compact = "echo ok;# if true\n" + val compactComments = codeHighlightTokens(compact, "bash").filter { it.kind == CodeTokenKind.COMMENT } + assertEquals(listOf(compact.indexOf("#")), compactComments.map { it.start }) + } + + @Test + fun nestedBlockCommentsAndMultilineShellStringsStayOneToken() { + // Kotlin block comments nest: the outer comment ends at the outer close, not the inner one. + val kotlin = "/* outer /* inner */ tail */\nval x = 1" + val kotlinTokens = codeHighlightTokens(kotlin, "kotlin") + val comment = kotlinTokens.single { it.kind == CodeTokenKind.COMMENT } + assertEquals(0, comment.start) + assertEquals(kotlin.lastIndexOf("*/") + 2, comment.end) + assertTrue(kotlinTokens.any { it.kind == CodeTokenKind.KEYWORD && kotlin.substring(it.start, it.end) == "val" }) + + // Shell strings span newlines: one token, and code after the closing quote is not a string. + val bash = "msg='a\nb'\nif true; then echo hi; fi\n" + val bashTokens = codeHighlightTokens(bash, "bash") + val string = bashTokens.single { it.kind == CodeTokenKind.STRING } + assertEquals(bash.indexOf('\''), string.start) + assertEquals(bash.indexOf("'\n", string.start + 1) + 1, string.end) + assertTrue(bashTokens.any { it.kind == CodeTokenKind.KEYWORD && bash.substring(it.start, it.end) == "if" }) + } + + @Test + fun escapedTripleQuotesDoNotEndPythonOrSwiftStrings() { + val samples = + listOf( + "python" to "message = \"\"\"before \\\"\"\" after\"\"\"\nreturn 1\n", + "swift" to "let message = \"\"\"\nbefore \\\"\"\" after\n\"\"\"\nreturn 1\n", + ) + + samples.forEach { (language, code) -> + val tokens = codeHighlightTokens(code, language) + val string = tokens.single { it.kind == CodeTokenKind.STRING } + + assertEquals(code.indexOf("\"\"\""), string.start) + assertEquals(code.lastIndexOf("\"\"\"") + 3, string.end) + assertTrue(tokens.any { it.kind == CodeTokenKind.KEYWORD && code.substring(it.start, it.end) == "return" }) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatMathAssetsTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMathAssetsTest.kt new file mode 100644 index 0000000..2b6a794 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMathAssetsTest.kt @@ -0,0 +1,20 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ChatMathAssetsTest { + @Test + fun rendererReportsMalformedKatexAsFailure() { + val assets = RuntimeEnvironment.getApplication().assets + val renderer = assets.open("katex/renderer.js").bufferedReader().use { reader -> reader.readText() } + + assertTrue(Regex("""throwOnError:\s*true""").containsMatchIn(renderer)) + assertFalse(Regex("""throwOnError:\s*false""").containsMatchIn(renderer)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatMathRendererTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMathRendererTest.kt new file mode 100644 index 0000000..7af36c9 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMathRendererTest.kt @@ -0,0 +1,234 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatMathRendererTest { + @Test + fun queuePreservesOrderAndDeduplicatesMatchingJobs() { + val harness = RenderHarness() + val results = mutableListOf() + val first = request("first") + val second = request("second") + + harness.coordinator.render(first) { result -> results.add("a:${result.value()}") } + harness.coordinator.render(first) { result -> results.add("b:${result.value()}") } + harness.coordinator.render(second) { result -> results.add("c:${result.value()}") } + + assertEquals(listOf(first), harness.backend.requests) + harness.backend.complete(ChatMathRenderResult.Success("one")) + assertEquals(listOf(first, second), harness.backend.requests) + assertEquals(listOf("a:one", "b:one"), results) + harness.backend.complete(ChatMathRenderResult.Success("two")) + assertEquals(listOf("a:one", "b:one", "c:two"), results) + } + + @Test + fun cacheKeyBucketsWidthAndIncludesDarkMode() { + val lightA = request("x", widthPx = 321, darkMode = false) + val lightB = request("x", widthPx = 350, darkMode = false) + val dark = request("x", widthPx = 321, darkMode = true) + + assertEquals(lightA.key, lightB.key) + assertNotEquals(lightA.key, dark.key) + } + + @Test + fun presentationChangeWithSameKeyRendersAgain() { + val harness = RenderHarness() + val first = request("x") + val recolored = first.copy(textColor = 0xffffffff.toInt()) + + harness.coordinator.render(first) {} + harness.backend.complete(ChatMathRenderResult.Success("first")) + harness.coordinator.render(recolored) {} + + assertEquals(first.key, recolored.key) + assertEquals(listOf(first, recolored), harness.backend.requests) + } + + @Test + fun negativeResultsAreCachedWithoutAnotherBackendJob() { + val harness = RenderHarness() + val request = request("bad") + val results = mutableListOf>() + + harness.coordinator.render(request, results::add) + harness.backend.complete(ChatMathRenderResult.Failure) + harness.coordinator.render(request, results::add) + + assertEquals(1, harness.backend.requests.size) + assertEquals(listOf(ChatMathRenderResult.Failure, ChatMathRenderResult.Failure), results) + } + + @Test + fun transientFailuresCanRetryTheSameKey() { + val harness = RenderHarness() + val request = request("retry") + + harness.coordinator.render(request) {} + harness.backend.complete(ChatMathRenderResult.TransientFailure) + harness.coordinator.render(request) {} + + assertEquals(listOf(request, request), harness.backend.requests) + } + + @Test + fun cancelDropsQueuedJobWithoutInterruptingActiveCacheWarmup() { + val harness = RenderHarness() + val first = request("first") + val canceled = request("canceled") + val last = request("last") + + harness.coordinator.render(first) {} + harness.coordinator.render(canceled) {}.cancel() + harness.coordinator.render(last) {} + harness.backend.complete(ChatMathRenderResult.Success("one")) + + assertEquals(listOf(first, last), harness.backend.requests) + } + + @Test + fun timeoutFailsCurrentJobAndAdvancesQueue() { + val harness = RenderHarness() + val results = mutableListOf>() + val first = request("first") + val second = request("second") + + harness.coordinator.render(first, results::add) + harness.coordinator.render(second, results::add) + harness.scheduler.fire() + + assertEquals(listOf(ChatMathRenderResult.TransientFailure), results) + assertEquals(listOf(first, second), harness.backend.requests) + } + + @Test + fun staleCompletionAfterTimeoutCannotCompleteRetry() { + val harness = RenderHarness() + val firstResults = mutableListOf>() + val retryResults = mutableListOf>() + val request = request("retry") + + harness.coordinator.render(request, firstResults::add) + val staleCompletion = harness.backend.completions.removeAt(0) + harness.scheduler.fire() + harness.coordinator.render(request, retryResults::add) + staleCompletion(ChatMathRenderResult.Success("stale")) + + assertEquals(listOf(ChatMathRenderResult.TransientFailure), firstResults) + assertEquals(emptyList>(), retryResults) + harness.backend.complete(ChatMathRenderResult.Success("fresh")) + assertEquals(listOf(ChatMathRenderResult.Success("fresh")), retryResults) + } + + @Test + fun parsesStructuredRenderCompletionMessages() { + assertEquals( + ChatMathRenderMessage( + id = "7", + widthCssPx = 12.5, + heightCssPx = 8.0, + success = true, + ), + parseChatMathRenderMessage( + """{"id":"7","widthCssPx":12.5,"heightCssPx":8,"success":true}""", + ), + ) + assertNull(parseChatMathRenderMessage("""{"id":"7"}""")) + } + + @Test + fun bitmapDimensionsRejectNonFiniteNonPositiveAndOversizedValues() { + assertEquals(25, bitmapDimension(cssPixels = 12.5, density = 2f)) + assertNull(bitmapDimension(cssPixels = Double.NaN, density = 1f)) + assertNull(bitmapDimension(cssPixels = Double.POSITIVE_INFINITY, density = 1f)) + assertNull(bitmapDimension(cssPixels = 0.0, density = 1f)) + assertNull(bitmapDimension(cssPixels = 1.0, density = Float.NaN)) + assertNull(bitmapDimension(cssPixels = 8193.0, density = 1f)) + assertNull(bitmapDimension(cssPixels = 4097.0, density = 2f)) + } + + private class RenderHarness { + val backend = FakeBackend() + val cache = FakeCache() + val scheduler = FakeScheduler() + val coordinator = ChatMathRenderCoordinator(backend, cache, scheduler) + } + + private class FakeBackend : ChatMathRenderBackend { + val requests = mutableListOf() + val completions = mutableListOf<(ChatMathRenderResult) -> Unit>() + + override fun render( + request: ChatMathRenderRequest, + completion: (ChatMathRenderResult) -> Unit, + ) { + requests.add(request) + completions.add(completion) + } + + fun complete(result: ChatMathRenderResult) { + completions.removeAt(0).invoke(result) + } + } + + private class FakeCache : ChatMathRenderCache { + private val entries = mutableMapOf>() + + override fun get(request: ChatMathRenderRequest): ChatMathCacheEntry = entries[request] ?: ChatMathCacheEntry.Missing + + override fun put( + request: ChatMathRenderRequest, + result: ChatMathRenderResult, + ) { + entries[request] = + when (result) { + is ChatMathRenderResult.Success -> ChatMathCacheEntry.Success(result.value) + ChatMathRenderResult.Failure -> ChatMathCacheEntry.Failure + ChatMathRenderResult.TransientFailure -> ChatMathCacheEntry.Missing + } + } + } + + private class FakeScheduler : ChatMathTimeoutScheduler { + private var action: (() -> Unit)? = null + + override fun schedule( + delayMs: Long, + action: () -> Unit, + ): ChatMathTimeout { + this.action = action + return ChatMathTimeout { if (this.action === action) this.action = null } + } + + fun fire() { + val pending = action + action = null + checkNotNull(pending).invoke() + } + } + + private fun ChatMathRenderResult.value(): String = + when (this) { + is ChatMathRenderResult.Success -> value + ChatMathRenderResult.Failure -> "failure" + ChatMathRenderResult.TransientFailure -> "transient failure" + } + + private fun request( + latex: String, + widthPx: Int = 321, + darkMode: Boolean = false, + ): ChatMathRenderRequest = + ChatMathRenderRequest.create( + latex = latex, + widthPx = widthPx, + darkMode = darkMode, + textColor = 0xff000000.toInt(), + fontSizePx = 16f, + density = 1f, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt new file mode 100644 index 0000000..53ffd14 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt @@ -0,0 +1,170 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatMessageContent +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.media3.common.Player +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ChatMediaPlayerTest { + private class FakePlayer( + var positionMs: Long = 0L, + ) { + var paused = false + var released = false + var playCount = 0 + + fun pause() { + paused = true + } + + fun play() { + paused = false + playCount += 1 + } + + fun release() { + released = true + } + } + + private class FakeSession { + var released = false + } + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun claimHandoffReleasesPreviousPlaybackInstance() { + val first = FakePlayer() + val second = FakePlayer() + val claims = ChatMediaPlaybackClaims(FakePlayer::pause, FakePlayer::release) + + claims.claim(first) + claims.claim(second) + + assertTrue(first.released) + assertFalse(second.released) + assertSame(second, claims.active) + } + + @Test + fun pauseThenPlayResumesPositionWithoutRedownload() { + var downloadCount = 0 + val player = FakePlayer(positionMs = 4_200L).also { downloadCount += 1 } + val claims = ChatMediaPlaybackClaims(FakePlayer::pause, FakePlayer::release) + + claims.claim(player) + claims.pauseIf { it === player } + claims.claim(player) + player.play() + + assertEquals(1, downloadCount) + assertEquals(4_200L, player.positionMs) + assertEquals(1, player.playCount) + assertFalse(player.paused) + assertFalse(player.released) + assertSame(player, claims.active) + } + + @Test + fun playbackClaimsCreateAndReleaseOnlyOneMediaSession() { + val first = FakePlayer() + val second = FakePlayer() + val sessions = ChatMediaSessionLifecycle { it.released = true } + val claims = + ChatMediaPlaybackClaims( + pause = { player -> sessions.release(player) }, + release = { player -> sessions.release(player) }, + ) + + claims.claim(first) + val firstSession = sessions.activate(first) { FakeSession() } + assertSame(firstSession, sessions.activate(first) { error("duplicate session") }) + + claims.claim(second) + val secondSession = sessions.activate(second) { FakeSession() } + assertTrue(firstSession.released) + assertFalse(secondSession.released) + + claims.pauseIf { it === second } + assertTrue(secondSession.released) + } + + @Test + fun mediaSessionControllersCannotReplaceInlineMediaItem() { + val commands = + inlineMediaSessionPlayerCommands( + Player.Commands + .Builder() + .addAllCommands() + .build(), + ) + + assertTrue(commands.contains(Player.COMMAND_PLAY_PAUSE)) + assertTrue(commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)) + assertFalse(commands.contains(Player.COMMAND_SET_MEDIA_ITEM)) + assertFalse(commands.contains(Player.COMMAND_CHANGE_MEDIA_ITEMS)) + assertFalse(commands.contains(Player.COMMAND_STOP)) + } + + @Test + fun legacyMediaPartsRenderLabelsWithoutPlayControlsOrClaims() { + val audio = + ChatMessageContent( + type = "audio", + mimeType = "audio/mpeg", + fileName = "legacy.mp3", + durationMs = 4_000, + ) + val video = + ChatMessageContent( + type = "video", + mimeType = "video/mp4", + fileName = "legacy.mp4", + durationMs = 9_000, + ) + var loadCount = 0 + + composeRule.setContent { + ChatMessageBubble( + message = + ChatMessage( + id = "legacy-media", + role = "assistant", + content = listOf(audio, video), + timestampMs = 1, + ), + loadMediaArtifact = { _, _, _ -> + loadCount += 1 + null + }, + ) + } + + composeRule.onNodeWithText("legacy.mp3").assertIsDisplayed() + composeRule.onNodeWithText("legacy.mp4").assertIsDisplayed() + composeRule.onNodeWithText("0:04").assertIsDisplayed() + composeRule.onNodeWithText("0:09").assertIsDisplayed() + composeRule.onAllNodesWithContentDescription("Play audio").assertCountEquals(0) + composeRule.onAllNodesWithContentDescription("Play video").assertCountEquals(0) + composeRule.runOnIdle { + assertEquals(0, loadCount) + assertFalse(audio.hasPlayableMediaArtifact()) + assertFalse(video.hasPlayableMediaArtifact()) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt new file mode 100644 index 0000000..1c72607 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageActionsTest.kt @@ -0,0 +1,33 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessageContent +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatMessageActionsTest { + @Test + fun plainTextJoinsTextPartsAndIgnoresAttachments() { + val content = + listOf( + ChatMessageContent(type = "text", text = "First paragraph"), + ChatMessageContent(type = "image", fileName = "photo.png", base64 = "AAAA"), + ChatMessageContent(type = "text", text = "Second paragraph"), + ) + + assertEquals("First paragraph\n\nSecond paragraph", chatMessagePlainText(content)) + } + + @Test + fun replyQuotesEveryLineAndLeavesComposerSpace() { + assertEquals("> first\n>\n> second\n\n", quoteChatMessage("first\n\nsecond")) + } + + @Test + fun copyAndReplyPreserveWhitespaceSensitiveContent() { + val text = " indented code\nnext line " + val content = listOf(ChatMessageContent(type = "text", text = text)) + + assertEquals(text, chatMessagePlainText(content)) + assertEquals("> indented code\n> next line \n\n", quoteChatMessage(text)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageViewsTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageViewsTest.kt new file mode 100644 index 0000000..8a35250 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatMessageViewsTest.kt @@ -0,0 +1,56 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatMessageContent +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class ChatMessageViewsTest { + @Test + fun managedImageCompositionRequestsItsArtifact() { + val artifactId = "artifact_managed_image_11111111-1111-4111-8111-111111111111" + val requested = mutableListOf() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + + try { + controller.get().setContent { + ChatMessageBubble( + message = + ChatMessage( + id = "managed-image", + role = "assistant", + content = + listOf( + ChatMessageContent( + type = "image", + mimeType = "image/png", + artifactId = artifactId, + alt = "Managed image", + ), + ), + timestampMs = 1, + ), + imageResolverReady = true, + loadImageArtifact = { requestedArtifactId -> + requested += requestedArtifactId + null + }, + ) + } + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf(artifactId), requested) + } finally { + controller.pause().stop().destroy() + shadowOf(Looper.getMainLooper()).idle() + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt new file mode 100644 index 0000000..f7ebaf1 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt @@ -0,0 +1,69 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.GatewayModelSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatModelPickerTest { + @Test + fun providerQualifiedRefAddsProviderOnlyWhenNeeded() { + assertEquals("anthropic/claude-opus-4", model(id = "claude-opus-4", provider = "anthropic").providerQualifiedRef()) + assertEquals("anthropic/claude-opus-4", model(id = "anthropic/claude-opus-4", provider = "anthropic").providerQualifiedRef()) + } + + @Test + fun sectionsPreservePinAndRecentOrderAndKeepRemainingCatalogOrder() { + val catalog = + listOf( + model(id = "a", provider = "one"), + model(id = "b", provider = "two"), + model(id = "c", provider = "one"), + model(id = "d", provider = "three"), + ) + + val sections = + chatModelPickerSections( + catalog = catalog, + favorites = listOf("one/c", "missing/model", "one/a"), + recents = listOf("one/a", "three/d", "missing/recent"), + ) + + assertEquals(listOf("one/c", "one/a"), sections.pinned.map { it.providerQualifiedRef() }) + assertEquals(listOf("three/d"), sections.recent.map { it.providerQualifiedRef() }) + assertEquals(listOf("two/b"), sections.remaining.map { it.providerQualifiedRef() }) + } + + @Test + fun thinkingSupportFailsOpenUnlessMatchedModelDisablesReasoning() { + val catalog = + listOf( + model(id = "reasoning", provider = "openai", supportsReasoning = true), + model(id = "plain", provider = "openai", supportsReasoning = false), + ) + + assertTrue(thinkingSupportedForSelection(selectedModelRef = null, catalog = catalog)) + assertTrue(thinkingSupportedForSelection(selectedModelRef = "openai/unknown", catalog = catalog)) + assertTrue(thinkingSupportedForSelection(selectedModelRef = "openai/reasoning", catalog = catalog)) + assertFalse(thinkingSupportedForSelection(selectedModelRef = "openai/plain", catalog = catalog)) + } + + private fun model( + id: String, + provider: String, + supportsReasoning: Boolean = false, + ): GatewayModelSummary = + GatewayModelSummary( + id = id, + name = id.substringAfterLast('/'), + provider = provider, + available = true, + supportsVision = false, + supportsAudio = false, + supportsVideo = false, + supportsDocuments = false, + supportsReasoning = supportsReasoning, + contextTokens = null, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt new file mode 100644 index 0000000..40b11fc --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollControllerTest.kt @@ -0,0 +1,393 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatMessageContent +import ai.openclaw.app.chat.ChatQuestionPrompt +import ai.openclaw.app.gateway.QuestionAnswers +import ai.openclaw.app.gateway.QuestionRecord +import androidx.compose.runtime.saveable.SaverScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatReaderScrollControllerTest { + @Test + fun initialHistoryRestoresLatestUserAsReaderAnchor() { + val timeline = timeline(user("user-1"), assistant("assistant-1")) + + val transition = initialChatReaderTransition(timeline) + + assertEquals(1, transition.scrollIndex) + assertFalse(transition.animated) + assertEquals(ChatScrollFollowTarget.ReadAnchor, transition.state.followTarget) + assertTrue(transition.state.hasNewerContent) + assertEquals("user-1", transition.state.latestUserMessageId) + } + + @Test + fun userAtLiveEdgeRemainsReaderAnchorWhenRemoteReplyArrives() { + val initial = initialChatReaderTransition(timeline(user("user-1"))) + val replied = timeline(user("user-1"), assistant("assistant-1")) + + val transition = initial.state.onTimelineChanged(replied) + + assertEquals(ChatScrollFollowTarget.ReadAnchor, initial.state.followTarget) + assertFalse(initial.state.hasNewerContent) + assertEquals(replied.readAnchorIndex, transition.scrollIndex) + assertEquals(ChatScrollFollowTarget.ReadAnchor, transition.state.followTarget) + assertTrue(transition.state.hasNewerContent) + } + + @Test + fun contentAfterManualDeparturePreservesPositionAndOffersJump() { + val before = initialChatReaderTransition(timeline(user("user-1"), assistant("assistant-1"))).state + val readerMoved = before.onViewportChanged(index = 3, offset = 50, timeline = timeline(user("user-1")), targetTolerancePx = 24) + + val transition = readerMoved.onTimelineChanged(timeline(user("user-1"), assistant("assistant-2"))) + + assertNull(transition.scrollIndex) + assertTrue(transition.state.hasNewerContent) + } + + @Test + fun newUserTurnFollowsLatestContentWhileStreaming() { + val previous = initialChatReaderTransition(timeline(assistant("assistant-1"))).state + val active = activeTimeline(user("user-1"), stream = null) + + val newTurn = previous.onTimelineChanged(active) + val streaming = activeTimeline(user("user-1"), stream = "reply") + val streamUpdate = newTurn.state.onTimelineChanged(streaming) + + assertEquals(ChatScrollFollowTarget.LatestContent, newTurn.state.followTarget) + assertEquals(active.latestContentIndex, newTurn.scrollIndex) + assertTrue(newTurn.animated) + assertFalse(newTurn.state.hasNewerContent) + assertEquals(streaming.latestContentIndex, streamUpdate.scrollIndex) + assertFalse(streamUpdate.state.hasNewerContent) + } + + @Test + fun completedReplyKeepsPromptAnchoredAndOffersLatestJump() { + val active = activeTimeline(user("user-1"), stream = "reply") + val followingPrompt = initialChatReaderTransition(active).state + val finished = timeline(user("user-1"), assistant("assistant-1")) + + val transition = followingPrompt.onTimelineChanged(finished) + + assertEquals(finished.readAnchorIndex, transition.scrollIndex) + assertTrue(transition.state.hasNewerContent) + assertEquals(ChatScrollFollowTarget.ReadAnchor, transition.state.followTarget) + } + + @Test + fun removedOptimisticPromptPreservesPositionWithoutOfferingJump() { + val active = + buildChatTimeline( + messages = listOf(user("user-old"), assistant("assistant-old"), user("user-optimistic")), + pendingRunCount = 1, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + val followingPrompt = initialChatReaderTransition(active).state + val rejected = timeline(user("user-old"), assistant("assistant-old")) + + val transition = followingPrompt.onTimelineChanged(rejected) + + assertNull(transition.scrollIndex) + assertNull(transition.state.followTarget) + assertFalse(transition.state.hasNewerContent) + assertEquals("user-old", transition.state.latestUserMessageId) + } + + @Test + fun firstUserTurnAfterAssistantOnlyHistoryFollowsLatestContent() { + val previous = initialChatReaderTransition(timeline(assistant("assistant-1"))).state + val active = activeTimeline(user("user-1"), stream = null) + + val transition = previous.onTimelineChanged(active) + + assertEquals(active.latestContentIndex, transition.scrollIndex) + assertEquals(ChatScrollFollowTarget.LatestContent, transition.state.followTarget) + assertEquals("user-1", transition.state.latestUserMessageId) + } + + @Test + fun liveEdgeClearsNewerContentAndJumpFollowsLatest() { + val timeline = activeTimeline(user("user-1"), stream = "reply") + val waiting = ChatReaderState(initialized = true, hasNewerContent = true, latestUserMessageId = "user-1") + + val atLiveEdge = waiting.onViewportChanged(index = 0, offset = 20, timeline = timeline, targetTolerancePx = 24) + val jump = waiting.jumpToLatest(timeline) + + assertFalse(atLiveEdge.hasNewerContent) + assertEquals(0, jump.scrollIndex) + assertTrue(jump.animated) + assertFalse(jump.state.hasNewerContent) + } + + @Test + fun manualDepartureOffersJumpWithoutResumingFollowing() { + val timeline = activeTimeline(user("user-1"), stream = "reply") + val following = + ChatReaderState( + initialized = true, + followTarget = ChatScrollFollowTarget.ReadAnchor, + hasNewerContent = false, + latestUserMessageId = "user-1", + ) + + val moved = + following.onViewportChanged( + index = checkNotNull(timeline.readAnchorIndex), + offset = 0, + timeline = timeline, + targetTolerancePx = 24, + ) + + assertNull(moved.followTarget) + assertTrue(moved.hasNewerContent) + } + + @Test + fun stateStartsFreshForEachSession() { + val oldSession = ChatReaderState(initialized = true, hasNewerContent = true, latestUserMessageId = "old") + + val nextSession = initialChatReaderTransition(timeline(user("new"))) + + assertTrue(oldSession.hasNewerContent) + assertFalse(nextSession.state.hasNewerContent) + assertEquals("new", nextSession.state.latestUserMessageId) + } + + @Test + fun emptyTimelineCanResetReaderStateBeforeSameSessionReload() { + val previous = ChatReaderState(initialized = true, hasNewerContent = true, latestUserMessageId = "old") + + val reset = previous.onTimelineChanged(emptyTimeline()).state + val reloaded = initialChatReaderTransition(timeline(user("new"))) + + assertFalse(reset.initialized) + assertFalse(reset.hasNewerContent) + assertEquals("new", reloaded.state.latestUserMessageId) + } + + @Test + fun emptyBootstrapTimelinePreservesRestoredReaderState() { + val restored = + ChatReaderState( + initialized = true, + hasNewerContent = true, + latestUserMessageId = "old", + latestContentVersion = "old-version", + ) + + val loading = restored.onTimelineChanged(emptyTimeline(), historyLoading = true) + + assertEquals(restored, loading.state) + assertNull(loading.scrollIndex) + } + + @Test + fun savedReaderStateRestoresViewportIntent() { + val timeline = timeline(user("user-1"), assistant("assistant-1")) + val state = + ChatReaderState( + initialized = true, + followTarget = ChatScrollFollowTarget.ReadAnchor, + hasNewerContent = true, + latestUserMessageId = "user-1", + latestContentVersion = timeline.latestContentVersion, + ) + val saved = with(ChatReaderStateSaver) { SaverScope { true }.save(state) } + + val restored = ChatReaderStateSaver.restore(requireNotNull(saved)) + + assertEquals(state, restored) + } + + @Test + fun savedReaderStateDoesNotRestoreIntoAnotherSession() { + val state = + ChatReaderState( + ownerSessionKey = "session-old", + initialized = true, + followTarget = ChatScrollFollowTarget.LatestContent, + ) + val saved = with(ChatReaderStateSaver) { SaverScope { true }.save(state) } + + val restored = createChatReaderStateSaver("session-new").restore(requireNotNull(saved)) + + assertNull(restored) + } + + @Test + fun restoredReaderRebindsRegeneratedMessageIds() { + val before = + timeline( + user("user-before", text = "original prompt", timestampMs = 1000L, idempotencyKey = "run-1:user"), + assistant("assistant-before", text = "same reply"), + ) + val savedState = + ChatReaderState( + initialized = true, + followTarget = ChatScrollFollowTarget.LatestContent, + latestUserMessageId = before.latestUserMessageId, + latestUserMessageVersion = before.latestUserMessageVersion, + latestContentVersion = before.latestContentVersion, + ) + val saved = with(ChatReaderStateSaver) { SaverScope { true }.save(savedState) } + val restored = requireNotNull(ChatReaderStateSaver.restore(requireNotNull(saved))) + val after = + timeline( + user("user-after", text = "rewritten prompt", timestampMs = 2000L, idempotencyKey = "run-1:user"), + assistant("assistant-after", text = "same reply"), + ) + + val transition = restored.onTimelineChanged(after) + + assertEquals(ChatScrollFollowTarget.LatestContent, transition.state.followTarget) + assertEquals(after.latestContentIndex, transition.scrollIndex) + assertEquals("user-after", transition.state.latestUserMessageId) + assertEquals(after.latestUserMessageVersion, transition.state.latestUserMessageVersion) + } + + @Test + fun restoredReaderRecognizesRegeneratedPromptBeforeNewerUserTurn() { + val before = + timeline( + user("user-before", text = "original prompt", timestampMs = 1000L, idempotencyKey = "run-1:user"), + assistant("assistant-before", text = "original reply"), + ) + val restored = + ChatReaderState( + initialized = true, + followTarget = ChatScrollFollowTarget.LatestContent, + latestUserMessageId = before.latestUserMessageId, + latestUserMessageVersion = before.latestUserMessageVersion, + latestContentVersion = before.latestContentVersion, + ) + val after = + timeline( + user("user-restored", text = "original prompt", timestampMs = 2000L, idempotencyKey = "run-1:user"), + assistant("assistant-restored", text = "original reply"), + user("user-new", text = "new prompt", timestampMs = 3000L, idempotencyKey = "run-2:user"), + ) + + val transition = restored.onTimelineChanged(after) + + assertEquals(ChatScrollFollowTarget.LatestContent, transition.state.followTarget) + assertEquals(after.latestContentIndex, transition.scrollIndex) + assertTrue(transition.animated) + assertEquals("user-new", transition.state.latestUserMessageId) + assertEquals(after.latestUserMessageVersion, transition.state.latestUserMessageVersion) + } + + @Test + fun restoredReaderTreatsCurrentTimelineAsBaseline() { + val timeline = timeline(user("user-1"), assistant("assistant-1")) + val restored = + ChatReaderState( + initialized = true, + hasNewerContent = false, + latestUserMessageId = "user-1", + latestContentVersion = timeline.latestContentVersion, + ) + + val transition = restored.onTimelineChanged(timeline) + + assertEquals(restored, transition.state) + assertNull(transition.scrollIndex) + } + + @Test + fun questionTerminalStateAndHydratedAnswersChangeContentVersion() { + val pending = + ChatQuestionPrompt( + QuestionRecord( + id = "ask-1", + questions = emptyList(), + createdAtMs = 1_000, + expiresAtMs = Long.MAX_VALUE, + status = "pending", + ), + ) + val pendingTimeline = questionTimeline(pending) + val unavailableTimeline = questionTimeline(pending.copy(recoveryUnavailable = true)) + val answered = pending.copy(record = pending.record.copy(status = "answered")) + val answeredWithoutValues = questionTimeline(answered) + val answeredWithValues = + questionTimeline( + answered.copy( + record = + answered.record.copy( + answers = + QuestionAnswers( + mapOf("choice" to listOf("Yes")), + ), + ), + ), + ) + + assertNotEquals(pendingTimeline.latestContentVersion, unavailableTimeline.latestContentVersion) + assertNotEquals(answeredWithoutValues.latestContentVersion, answeredWithValues.latestContentVersion) + } + + private fun timeline(vararg messages: ChatMessage): ChatTimeline = + buildChatTimeline( + messages = messages.toList(), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + private fun emptyTimeline(): ChatTimeline = timeline() + + private fun questionTimeline(question: ChatQuestionPrompt): ChatTimeline = + buildChatTimeline( + messages = emptyList(), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + questions = listOf(question), + ) + + private fun activeTimeline( + message: ChatMessage, + stream: String?, + ): ChatTimeline = + buildChatTimeline( + messages = listOf(message), + pendingRunCount = 1, + pendingToolCalls = emptyList(), + streamingAssistantText = stream, + ) + + private fun user( + id: String, + text: String = id, + timestampMs: Long? = null, + idempotencyKey: String? = null, + ) = message(id, "user", text, timestampMs, idempotencyKey) + + private fun assistant( + id: String, + text: String = id, + ) = message(id, "assistant", text, timestampMs = null, idempotencyKey = null) + + private fun message( + id: String, + role: String, + text: String, + timestampMs: Long?, + idempotencyKey: String?, + ) = ChatMessage( + id = id, + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = timestampMs, + idempotencyKey = idempotencyKey, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt new file mode 100644 index 0000000..188b3fd --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt @@ -0,0 +1,186 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.GatewayAgentSummary +import ai.openclaw.app.PendingAssistantAutoSend +import ai.openclaw.app.chat.ChatComposerOwner +import ai.openclaw.app.chat.ChatMessageContent +import ai.openclaw.app.chat.SessionBranch +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatScreenTest { + @Test + fun jumpToLatestReservesItsTouchTargetBelowMessages() { + assertEquals(0.dp, chatReaderListBottomInset(showJumpToLatest = false)) + assertEquals(56.dp, chatReaderListBottomInset(showJumpToLatest = true)) + } + + @Test + fun branchMessageCountUsesCountNeutralCopy() { + assertEquals("Messages: 1", branchMessageCountText(1)) + assertEquals("Messages: 2", branchMessageCountText(2)) + assertEquals( + "Messages: 2", + branchMetadataText(SessionBranch("leaf", "", 2, updatedAt = null, active = false)), + ) + } + + @Test + fun longUserMessagesProduceABoundedPlainTextPreview() { + assertNull(ChatUserMessageDisclosurePolicy.collapsedPreview("Short prompt")) + assertNull(ChatUserMessageDisclosurePolicy.collapsedPreview(List(12) { "line" }.joinToString("\n"))) + assertNull(ChatUserMessageDisclosurePolicy.collapsedPreview("a".repeat(700))) + assertEquals( + List(12) { "line" }.joinToString("\n") + "…", + ChatUserMessageDisclosurePolicy.collapsedPreview(List(13) { "line" }.joinToString("\n")), + ) + assertEquals( + "a".repeat(700) + "…", + ChatUserMessageDisclosurePolicy.collapsedPreview("a".repeat(701)), + ) + } + + @Test + fun disclosureDoesNotReorderMixedUserContent() { + val mixedContent = + listOf( + ChatMessageContent(type = "text", text = "a".repeat(701)), + ChatMessageContent(type = "image", fileName = "photo.png", base64 = "AAAA"), + ChatMessageContent(type = "text", text = "caption"), + ) + + assertFalse(shouldUseUserMessageDisclosure(isUser = true, content = mixedContent)) + } + + @Test + fun realtimeTalkLaunchRequestsPermissionBeforeSetupOrStart() { + assertEquals( + ChatRealtimeTalkLaunch.RequestPermission, + resolveChatRealtimeTalkLaunch(hasMicPermission = false, requiresSetup = true), + ) + assertEquals( + ChatRealtimeTalkLaunch.ShowSetupMessage, + resolveChatRealtimeTalkLaunch(hasMicPermission = true, requiresSetup = true), + ) + assertEquals( + ChatRealtimeTalkLaunch.StartTalk, + resolveChatRealtimeTalkLaunch(hasMicPermission = true, requiresSetup = false), + ) + } + + @Test + fun activeTalkAlwaysKeepsTheStopControlVisible() { + assertEquals( + ChatComposerTrailingAction.StopTalk, + resolveChatComposerTrailingAction(talkActive = true, sendEnabled = true), + ) + assertEquals( + ChatComposerTrailingAction.Send, + resolveChatComposerTrailingAction(talkActive = false, sendEnabled = true), + ) + assertEquals( + ChatComposerTrailingAction.StartTalk, + resolveChatComposerTrailingAction(talkActive = false, sendEnabled = false), + ) + } + + @Test + fun agentChipUsesEmojiAndFallsBackToId() { + assertEquals( + "🦾 Scout", + chatAgentChipText(GatewayAgentSummary(id = "scout", name = "Scout", emoji = " 🦾 ")), + ) + assertEquals( + "ops", + chatAgentChipText(GatewayAgentSummary(id = "ops", name = " ", emoji = null)), + ) + } + + @Test + fun agentSelectorUsesCanonicalMainSession() { + assertEquals("scout", selectedChatAgentId("agent:scout:node-phone", "main")) + assertEquals("main", selectedChatAgentId("main", "main")) + } + + @Test + fun resolvesPendingAssistantAutoSendOnlyWhenChatIsReady() { + val owner = ChatComposerOwner(gatewayStableId = "gateway", agentId = "main", sessionKey = "agent:main:device") + val pending = PendingAssistantAutoSend(prompt = " summarize mail ", owner = owner) + assertNull( + resolvePendingAssistantAutoSend( + pending = pending, + currentOwner = owner, + healthOk = false, + pendingRunCount = 0, + ), + ) + assertNull( + resolvePendingAssistantAutoSend( + pending = pending, + currentOwner = owner, + healthOk = true, + pendingRunCount = 1, + ), + ) + assertNull( + resolvePendingAssistantAutoSend( + pending = pending, + currentOwner = owner.copy(sessionKey = "agent:main:other"), + healthOk = true, + pendingRunCount = 0, + ), + ) + assertEquals( + pending, + resolvePendingAssistantAutoSend( + pending = pending, + currentOwner = owner, + healthOk = true, + pendingRunCount = 0, + ), + ) + } + + @Test + fun initialChatLoadUsesMainWhenNoSessionIsSelected() { + assertEquals( + "agent:ops:device", + resolveInitialChatLoadSessionKey( + sessionKey = "main", + mainSessionKey = "agent:ops:device", + ), + ) + } + + @Test + fun initialChatLoadPreservesSelectedSession() { + assertNull( + resolveInitialChatLoadSessionKey( + sessionKey = "session:history", + mainSessionKey = "agent:ops:device", + ), + ) + } + + @Test + fun healthyEmptyChatShowsStarterStateInsteadOfLoadingPlaceholder() { + assertFalse( + showChatLoadingPlaceholder( + historyLoading = true, + healthOk = true, + gatewayOffline = false, + ), + ) + assertTrue( + showChatLoadingPlaceholder( + historyLoading = true, + healthOk = false, + gatewayOffline = false, + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt new file mode 100644 index 0000000..5ad3769 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt @@ -0,0 +1,305 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatMessageContent +import ai.openclaw.app.chat.ChatOutboxItem +import ai.openclaw.app.chat.ChatOutboxStatus +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.OUTBOX_OWNER_CHANGED_ERROR +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatTimelineTest { + @Test + fun activeRunAnchorsNewestUserPromptInsteadOfThinkingRow() { + val user = textMessage(id = "user-1", role = "user", text = "hello") + + val timeline = + buildChatTimeline( + messages = listOf(user), + pendingRunCount = 1, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + assertEquals(listOf("thinking", "message:user-1"), timeline.items.map(::chatTimelineItemKey)) + assertEquals(1, timeline.readAnchorIndex) + assertEquals(0, timeline.latestContentIndex) + assertEquals("user-1", timeline.latestUserMessageId) + } + + @Test + fun activeRunAnchorsNewestUserPromptWhileAssistantStreams() { + val olderAssistant = textMessage(id = "assistant-1", role = "assistant", text = "previous") + val user = textMessage(id = "user-1", role = "user", text = "next") + val tool = + ChatPendingToolCall( + toolCallId = "tool-1", + name = "memory.search", + startedAtMs = 1000L, + ) + + val timeline = + buildChatTimeline( + messages = listOf(olderAssistant, user), + pendingRunCount = 1, + pendingToolCalls = listOf(tool), + streamingAssistantText = "streaming", + ) + + assertEquals( + listOf("stream", "tools", "thinking", "message:user-1", "message:assistant-1"), + timeline.items.map(::chatTimelineItemKey), + ) + assertEquals(3, timeline.readAnchorIndex) + assertEquals(0, timeline.latestContentIndex) + assertEquals("user-1", timeline.latestUserMessageId) + } + + @Test + fun finishedRunKeepsLatestUserPromptAsReaderAnchor() { + val user = textMessage(id = "user-1", role = "user", text = "hello") + val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done") + + val timeline = + buildChatTimeline( + messages = listOf(user, assistant), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + assertEquals(listOf("message:assistant-1", "message:user-1"), timeline.items.map(::chatTimelineItemKey)) + assertEquals(1, timeline.readAnchorIndex) + assertEquals(0, timeline.latestContentIndex) + assertEquals("user-1", timeline.latestUserMessageId) + } + + @Test + fun finishedTurnRecapUsesNewestSlotWithoutChangingReaderAnchorRow() { + val user = textMessage(id = "user-1", role = "user", text = "hello") + val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done") + val timeline = + buildChatTimeline( + messages = listOf(user, assistant), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + val withRecap = timeline.withTurnRecap(TurnRecap(runtimeMs = 2_000L, outputTokens = 10L)) + + assertEquals( + listOf("turn-recap", "message:assistant-1", "message:user-1"), + withRecap.items.map(::chatTimelineItemKey), + ) + assertEquals(0, withRecap.latestContentIndex) + assertEquals(2, withRecap.readAnchorIndex) + assertEquals("user-1", withRecap.latestUserMessageId) + } + + @Test + fun emptyTimelineHasNoScrollTarget() { + val timeline = + buildChatTimeline( + messages = emptyList(), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + assertEquals(emptyList(), timeline.items.map(::chatTimelineItemKey)) + assertEquals(null, timeline.readAnchorIndex) + assertEquals(null, timeline.latestContentIndex) + assertEquals(null, timeline.latestUserMessageId) + } + + @Test + fun outboxRowsHideOnceTheirUserTurnIsVisibleAsAMessage() { + val visible = + ChatOutboxItem( + id = "visible-row", + sessionKey = "main", + text = "still queued", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ) + val consumed = + visible.copy( + id = "consumed-row", + status = ChatOutboxStatus.Accepted, + createdAtMs = 2, + ) + val optimisticCopy = + textMessage(id = "m1", role = "user", text = "sent already") + .copy(idempotencyKey = "consumed-row:user") + + val filtered = + outboxItemsForSession( + items = listOf(visible, consumed), + sessionKey = "main", + mainSessionKey = "agent:work:main", + ownerAgentId = "main", + messages = listOf(optimisticCopy), + ) + + // A row whose turn already renders as a message never shows a second bubble. + assertEquals(listOf("visible-row"), filtered.map { it.id }) + } + + @Test + fun outboxRowsStayWithTheirAgentOwner() { + val mainOwner = + ChatOutboxItem( + id = "main-row", + sessionKey = "shared", + text = "main", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "main", + ) + val otherOwner = mainOwner.copy(id = "other-row", text = "other", ownerAgentId = "other") + val migratedOwnerless = mainOwner.copy(id = "legacy-row", text = "legacy", ownerAgentId = null) + + val filtered = + outboxItemsForSession( + items = listOf(mainOwner, otherOwner, migratedOwnerless), + sessionKey = "shared", + mainSessionKey = "agent:main:device", + ownerAgentId = "main", + ) + + assertEquals(listOf("main-row"), filtered.map { it.id }) + } + + @Test + fun unreachableRowsRenderOnlyInTheNeutralRecoverySection() { + val ownerless = + ChatOutboxItem( + id = "legacy-row", + sessionKey = "shared", + text = "legacy private text", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = "owner unknown", + ownerAgentId = null, + ) + assertEquals(listOf(ownerless), outboxItemsForRecovery(listOf(ownerless))) + + val timeline = + buildChatTimeline( + messages = emptyList(), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + recoveryOutboxItems = listOf(ownerless), + ) + + assertEquals( + listOf("outbox-recovery:legacy-row", "outbox-recovery-header"), + timeline.items.map(::chatTimelineItemKey), + ) + } + + @Test + fun parkedMainAliasRowRemainsReachableForRecovery() { + val captured = + ChatOutboxItem( + id = "captured-main", + sessionKey = "main", + text = "park me", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = OUTBOX_OWNER_CHANGED_ERROR, + ownerAgentId = "agent-a", + ) + + assertEquals(listOf(captured), outboxItemsForRecovery(listOf(captured))) + assertTrue( + outboxItemsForSession( + items = listOf(captured), + sessionKey = "main", + mainSessionKey = "agent:agent-a:device", + ownerAgentId = "agent-a", + ).isEmpty(), + ) + } + + @Test + fun validForeignMainAliasRowStaysHiddenUntilItsCapturedOwnerIsCurrent() { + val captured = + ChatOutboxItem( + id = "captured-main", + sessionKey = "main", + text = "keep private", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ownerAgentId = "agent-a", + ) + + assertTrue(outboxItemsForRecovery(listOf(captured)).isEmpty()) + } + + @Test + fun foreignCustomAliasRowStaysHiddenUntilItsCapturedOwnerIsCurrent() { + val captured = + ChatOutboxItem( + id = "captured-custom", + sessionKey = "custom-alias", + text = "park me", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = "owner changed", + ownerAgentId = "agent-a", + ) + + assertTrue(outboxItemsForRecovery(listOf(captured)).isEmpty()) + } + + @Test + fun agentQualifiedRowMovesToRecoveryWhenItsCapturedOwnerDisagrees() { + val mismatched = + ChatOutboxItem( + id = "mismatched-owner", + sessionKey = "agent:agent-b:device", + text = "park me", + thinkingLevel = "off", + createdAtMs = 1, + status = ChatOutboxStatus.Failed, + retryCount = 0, + lastError = "owner changed", + ownerAgentId = "agent-a", + ) + + assertEquals(listOf(mismatched), outboxItemsForRecovery(listOf(mismatched))) + } + + private fun textMessage( + id: String, + role: String, + text: String, + ): ChatMessage = + ChatMessage( + id = id, + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = null, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt new file mode 100644 index 0000000..7b44e07 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatTurnRecapResolverTest.kt @@ -0,0 +1,314 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Locale + +class ChatTurnRecapResolverTest { + private val session = "agent:main:main" + private val previousEndedAt = 900_000L + private val runEndedAt = 1_000_000L + + private fun row( + status: String, + endedAt: Long? = null, + runtimeMs: Long? = null, + outputTokens: Long? = null, + ): ChatSessionEntry = + ChatSessionEntry( + key = session, + updatedAtMs = endedAt, + status = status, + endedAt = endedAt, + runtimeMs = runtimeMs, + outputTokens = outputTokens, + hasRunMetadata = true, + ) + + private fun done( + endedAt: Long, + runtimeMs: Long? = 51_000L, + outputTokens: Long? = null, + ): ChatSessionEntry = row("done", endedAt, runtimeMs, outputTokens) + + @Test + fun resolvesOnceAFreshTerminalStampLandsThenSticks() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + + val terminal = done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L) + val expected = TurnRecap(runtimeMs = 51_000L, outputTokens = 485L) + assertEquals(expected, resolver.resolve(session, false, terminal)) + assertEquals(expected, resolver.resolve(session, false, terminal)) + } + + @Test + fun rejectsPreviousTurnAndRegressedStamps() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt - 5_000L))) + } + + @Test + fun expiresAnUnresolvedWatchInsteadOfMatchingALaterRun() { + var nowMs = 1_000_000L + val resolver = TurnRecapResolver { nowMs } + resolver.resolve(session, true, done(previousEndedAt)) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + + nowMs += 31_000L + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + } + + @Test + fun freshDoneWithoutRuntimeConsumesTheWatch() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + + assertNull(resolver.resolve(session, false, done(runEndedAt, runtimeMs = null))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + + @Test + fun clearedRunStartBaselineIsStaleFree() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, row(status = "running"))) + assertEquals( + TurnRecap(runtimeMs = 2_000L, outputTokens = null), + resolver.resolve(session, false, done(runEndedAt, runtimeMs = 2_000L)), + ) + } + + @Test + fun neverResolvesWithoutWatchingAnIndicator() { + assertNull(TurnRecapResolver().resolve(session, false, done(runEndedAt))) + } + + @Test + fun consumesAWatchWhoseBaselineRowWasNeverObserved() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, null)) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + } + + @Test + fun adoptsTheFirstRowObservedMidWatchAsBaseline() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, null)) + assertNull(resolver.resolve(session, true, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertEquals( + TurnRecap(runtimeMs = 6_000L, outputTokens = null), + resolver.resolve(session, false, done(runEndedAt, runtimeMs = 6_000L)), + ) + } + + @Test + fun forfeitsWhenATerminalStampChangesMidWatch() { + val resolver = TurnRecapResolver() + assertNull(resolver.resolve(session, true, row(status = "running"))) + assertNull(resolver.resolve(session, true, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt, runtimeMs = 4_000L))) + } + + @Test + fun forfeitsAFailedTurnWhoseTerminalRacedTheIndicator() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, row(status = "running")) + resolver.resolve(session, true, row(status = "failed", endedAt = runEndedAt)) + + assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 60_000L))) + } + + @Test + fun freezesTheFirstRecapAgainstLaterUnwatchedTerminals() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + val settled = resolver.resolve(session, false, done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L)) + + assertNotNull(settled) + assertEquals(settled, resolver.resolve(session, false, done(runEndedAt + 90_000L, runtimeMs = 7_000L, outputTokens = 42L))) + } + + @Test + fun settledRecapSticksOnlyWhileItsTranscriptAnchorIsNewest() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + val terminal = done(runEndedAt, runtimeMs = 51_000L, outputTokens = 485L) + val expected = TurnRecap(runtimeMs = 51_000L, outputTokens = 485L) + + assertNull( + resolver.resolve(session, false, terminal, transcript("assistant-tool")), + ) + assertEquals( + expected, + resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + assertEquals( + expected, + resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + assertNull( + resolver.resolve( + session, + false, + terminal, + transcript("assistant-2", completedEndedAt = runEndedAt + 1_000L), + ), + ) + } + + @Test + fun emptyTranscriptWaitsForTheCompletedItemBeforeSettling() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript(null)) + val terminal = done(runEndedAt, runtimeMs = 2_000L) + + assertNull(resolver.resolve(session, false, terminal, transcript(null))) + assertEquals( + TurnRecap(runtimeMs = 2_000L, outputTokens = null), + resolver.resolve(session, false, terminal, transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + } + + @Test + fun newerContentAlreadyPresentWhenHistoryCompletesDropsTheRecap() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + val terminal = done(runEndedAt, runtimeMs = 2_000L) + + assertNull( + resolver.resolve( + session, + false, + terminal, + transcript( + newestItemId = "user-2", + completedEndedAt = runEndedAt, + completedNewestItemId = "assistant-1", + ), + ), + ) + } + + @Test + fun changedTerminalWhileWaitingForHistoryDestroysAttribution() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + assertNull( + resolver.resolve(session, false, done(runEndedAt), transcript("user-1")), + ) + + assertNull( + resolver.resolve( + session, + false, + done(runEndedAt + 1_000L, runtimeMs = 9_000L), + transcript("assistant-2", completedEndedAt = runEndedAt + 1_000L), + ), + ) + } + + @Test + fun terminalWaitingForHistoryStillExpires() { + var nowMs = 1_000_000L + val resolver = TurnRecapResolver { nowMs } + resolver.resolve(session, true, done(previousEndedAt), transcript("user-1")) + assertNull( + resolver.resolve(session, false, done(runEndedAt), transcript("user-1")), + ) + + nowMs += TURN_RECAP_SETTLE_WINDOW_MS + 1L + assertNull( + resolver.resolve(session, false, done(runEndedAt), transcript("assistant-1", completedEndedAt = runEndedAt)), + ) + } + + @Test + fun hidesTheRecapAsSoonAsTheNextIndicatorAppears() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + assertNotNull(resolver.resolve(session, false, done(runEndedAt))) + + assertNull(resolver.resolve(session, true, done(runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + } + + @Test + fun ignoresAStaleFailedRowThenResolvesAFreshDone() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, row(status = "failed", endedAt = previousEndedAt)) + + assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = previousEndedAt))) + assertEquals( + TurnRecap(runtimeMs = 3_000L, outputTokens = null), + resolver.resolve(session, false, done(runEndedAt, runtimeMs = 3_000L)), + ) + } + + @Test + fun freshFailedRowConsumesTheWatch() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + + assertNull(resolver.resolve(session, false, row(status = "failed", endedAt = runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + + @Test + fun leavingTheSessionAbandonsUnsettledButKeepsSettled() { + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + assertNull(resolver.resolve(session, false, done(previousEndedAt))) + resolver.abandonActiveWatch(session) + assertNull(resolver.resolve(session, false, done(runEndedAt))) + + resolver.resolve(session, true, done(previousEndedAt)) + val settled = resolver.resolve(session, false, done(runEndedAt)) + resolver.abandonActiveWatch(session) + assertEquals(settled, resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + + @Test + fun everyNonDoneTerminalConsumesQuietly() { + listOf("failed", "killed", "timeout").forEach { status -> + val resolver = TurnRecapResolver() + resolver.resolve(session, true, done(previousEndedAt)) + assertNull(resolver.resolve(session, false, row(status = status, endedAt = runEndedAt))) + assertNull(resolver.resolve(session, false, done(runEndedAt + 1_000L))) + } + } + + @Test + fun formatsZeroOneAndCompactTokenCounts() { + assertEquals(TurnRecapTokenFormat(singular = false, count = "0"), turnRecapTokenFormat(0L)) + assertEquals(TurnRecapTokenFormat(singular = true, count = "1"), turnRecapTokenFormat(1L)) + assertEquals("1.2k", formatCompactTokenCount(1_234L, Locale.US)) + assertEquals("1.3k", formatCompactTokenCount(1_250L, Locale.US)) + assertEquals("1,2k", formatCompactTokenCount(1_234L, Locale.GERMANY)) + assertEquals("١M", formatCompactTokenCount(999_999L, Locale.forLanguageTag("ar"))) + } + + private fun transcript( + newestItemId: String?, + completedEndedAt: Long? = null, + transcriptSessionKey: String? = session, + completedNewestItemId: String? = newestItemId.takeIf { completedEndedAt != null }, + ): TurnRecapTranscriptState = + TurnRecapTranscriptState( + sessionKey = transcriptSessionKey, + newestItemId = newestItemId, + completedEndedAt = completedEndedAt, + completedNewestItemId = completedNewestItemId, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatWidgetExportTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatWidgetExportTest.kt new file mode 100644 index 0000000..d43d71d --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatWidgetExportTest.kt @@ -0,0 +1,87 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatWidgetExportTest { + @Test + fun sanitizesWidgetTitleForPngFileName() { + assertEquals("Quarterly report 2026.png", widgetExportFileName(" Quarterly/report:*2026? ")) + assertEquals("Résumé 📈.png", widgetExportFileName("Résumé 📈")) + } + + @Test + fun fallsBackWhenWidgetTitleHasNoSafeCharacters() { + assertEquals("widget.png", widgetExportFileName(null)) + assertEquals("widget.png", widgetExportFileName(" .. /:*? ")) + } + + @Test + fun boundsWidgetTitleToCodePointAndUtf8ByteLimits() { + val fileName = widgetExportFileName("📈".repeat(100)) + val stem = fileName.removeSuffix(".png") + + assertEquals(30, stem.codePointCount(0, stem.length)) + assertTrue(fileName.toByteArray(Charsets.UTF_8).size <= 255) + assertTrue(!Character.isHighSurrogate(stem.last())) + assertTrue(fileName.endsWith(".png")) + } + + @Test + fun keepsAsciiFileNameBehaviorWithinTheUtf8Budget() { + assertEquals("a".repeat(80) + ".png", widgetExportFileName("a".repeat(100))) + } + + @Test + fun requiresACompleteGlobalVisibleRectForPixelCopy() { + assertTrue( + canCaptureWithPixelCopy( + hasGlobalVisibleRect = true, + visibleWidth = 320, + visibleHeight = 180, + viewWidth = 320, + viewHeight = 180, + ), + ) + assertTrue( + !canCaptureWithPixelCopy( + hasGlobalVisibleRect = true, + visibleWidth = 319, + visibleHeight = 180, + viewWidth = 320, + viewHeight = 180, + ), + ) + assertTrue( + !canCaptureWithPixelCopy( + hasGlobalVisibleRect = true, + visibleWidth = 320, + visibleHeight = 179, + viewWidth = 320, + viewHeight = 180, + ), + ) + assertTrue( + !canCaptureWithPixelCopy( + hasGlobalVisibleRect = false, + visibleWidth = 320, + visibleHeight = 180, + viewWidth = 320, + viewHeight = 180, + ), + ) + } + + @Test + fun prunesOnlyExpiredExportsOlderThanTheNewestExport() { + val nowMillis = 2 * 24 * 60 * 60 * 1000L + val expiredMillis = nowMillis - 24 * 60 * 60 * 1000L - 1 + val newestMillis = nowMillis - 60 * 60 * 1000L + + assertTrue(shouldPruneWidgetExportDirectory(expiredMillis, newestMillis, nowMillis)) + assertTrue(!shouldPruneWidgetExportDirectory(newestMillis, newestMillis, nowMillis)) + assertTrue(!shouldPruneWidgetExportDirectory(expiredMillis, expiredMillis, nowMillis)) + assertTrue(!shouldPruneWidgetExportDirectory(nowMillis - 24 * 60 * 60 * 1000L, nowMillis, nowMillis)) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt new file mode 100644 index 0000000..9df92b3 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkingIndicatorTest.kt @@ -0,0 +1,175 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatWorkingIndicatorTest { + @Test + fun stanceSelectionIsDeterministicForRunAndSalt() { + val first = pickWorkingClawStance("run-123", salt = 42) + + repeat(20) { + assertEquals(first, pickWorkingClawStance("run-123", salt = 42)) + } + } + + @Test + fun stanceSelectionUsesConfiguredWeights() { + val key = "run-weight-check" + val hash = workingClawHash(key) + val counts = mutableMapOf() + + repeat(1_000) { bucket -> + val stance = pickWorkingClawStance(key, salt = hash xor bucket) + counts[stance] = counts.getOrDefault(stance, 0) + 1 + } + + assertEquals(1_000, counts.values.sum()) + assertEquals(WorkingClawStance.entries.toSet(), counts.keys) + assertEquals(630, counts[WorkingClawStance.Default]) + assertEquals(190, counts[WorkingClawStance.Southpaw]) + assertEquals(50, counts[WorkingClawStance.Flurry]) + assertEquals(40, counts[WorkingClawStance.Spin]) + assertEquals(30, counts[WorkingClawStance.Shadowbox]) + assertEquals(20, counts[WorkingClawStance.Backflip]) + assertEquals(20, counts[WorkingClawStance.Zen]) + assertEquals(10, counts[WorkingClawStance.Drummer]) + assertEquals(10, counts[WorkingClawStance.Peekaboo]) + } + + @Test + fun newStancesUseSpecifiedCyclesAndKeyframePoses() { + assertEquals(6_000L, workingClawCycleMs(WorkingClawStance.Zen)) + assertPose( + workingClawPose(WorkingClawStance.Zen, 0.30f), + scale = 1.08f, + jawRotation = -10f, + ) + assertPose( + workingClawPose(WorkingClawStance.Zen, 0.70f), + jawRotation = -24f, + ) + assertPose( + workingClawPose(WorkingClawStance.Zen, 0.76f), + scale = 1f, + jawRotation = 2f, + ) + + assertEquals(1_200L, workingClawCycleMs(WorkingClawStance.Drummer)) + assertEquals(-20f, workingClawPose(WorkingClawStance.Drummer, 0.10f).jawRotation, 0.001f) + assertPose( + workingClawPose(WorkingClawStance.Drummer, 0.15f), + rotationZ = -8f, + jawRotation = 2f, + ) + assertEquals(-20f, workingClawPose(WorkingClawStance.Drummer, 0.50f).jawRotation, 0.001f) + assertPose( + workingClawPose(WorkingClawStance.Drummer, 0.55f), + rotationZ = 8f, + jawRotation = 2f, + ) + + assertEquals(2_400L, workingClawCycleMs(WorkingClawStance.Peekaboo)) + assertPose( + workingClawPose(WorkingClawStance.Peekaboo, 0.62f), + translationYDp = 5f, + scale = 0.72f, + jawRotation = -2f, + ) + assertPose( + workingClawPose(WorkingClawStance.Peekaboo, 0.78f), + translationYDp = -1.5f, + scale = 1.06f, + jawRotation = -28f, + ) + } + + @Test + fun phraseStrideVisitsEveryPhraseWithoutAdjacentRepeats() { + val indexes = (0L until 19L).map { bucket -> workingPhraseIndex("run-phrase", bucket) } + + assertEquals(19, indexes.toSet().size) + indexes.zipWithNext().forEach { (previous, next) -> assertNotEquals(previous, next) } + assertNotEquals(indexes.last(), workingPhraseIndex("run-phrase", 19L)) + } + + @Test + fun phraseWaitsThirtySecondsAndRotatesEveryFortyFive() { + assertEquals(null, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS - 1L)) + val first = workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS) + assertEquals(first, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS + 44_999L)) + assertNotEquals(first, workingPhraseIndexForElapsed("run-phrase", WORKING_PHRASE_SHOW_AFTER_MS + 45_000L)) + } + + @Test + fun compactDurationClampsToOneSecond() { + assertEquals("1s", formatChatDurationCompact(0L)) + assertEquals("1m 30s", formatChatDurationCompact(90_000L)) + assertTrue(formatChatDurationCompact(3_600_000L).startsWith("1h")) + } + + @Test + fun ackRekeyKeepsOptimisticClockAndLocalStart() { + val tracker = ChatWorkingRunTracker("agent:main:main") + val provisional = + requireNotNull( + tracker.resolve( + indicatorVisible = true, + clockKey = "message-1", + authoritativeRunId = "client-run", + nowElapsedMs = 5_000L, + outputTokens = null, + ), + ) + val authoritative = + requireNotNull( + tracker.resolve( + indicatorVisible = true, + clockKey = "message-1", + authoritativeRunId = "server-run", + nowElapsedMs = 8_000L, + outputTokens = 40L, + ), + ) + + assertEquals(provisional.clockKey, authoritative.clockKey) + assertEquals(5_000L, authoritative.observedAtElapsedMs) + assertEquals("server-run", authoritative.authoritativeRunId) + assertEquals(40L, authoritative.outputTokens) + } + + @Test + fun serverRunReplacementGetsANewClock() { + val tracker = ChatWorkingRunTracker("agent:main:main") + val first = + requireNotNull( + tracker.resolve(true, "run-1", "run-1", 7_000L, null), + ) + val replacement = + requireNotNull( + tracker.resolve(true, "run-2", "run-2", 9_000L, null), + ) + + assertEquals("run-1", first.clockKey) + assertEquals("run-2", replacement.clockKey) + assertEquals(9_000L, replacement.observedAtElapsedMs) + } + + private fun assertPose( + pose: WorkingClawPose, + rotationZ: Float = 0f, + translationXDp: Float = 0f, + translationYDp: Float = 0f, + scale: Float = 1f, + jawRotation: Float = -10f, + ) { + assertEquals(rotationZ, pose.rotationZ, 0.001f) + assertEquals(0f, pose.rotationY, 0.001f) + assertEquals(translationXDp, pose.translationXDp, 0.001f) + assertEquals(translationYDp, pose.translationYDp, 0.001f) + assertEquals(scale, pose.scale, 0.001f) + assertEquals(jawRotation, pose.jawRotation, 0.001f) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt b/app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt new file mode 100644 index 0000000..03d8f31 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/chat/SessionFiltersTest.kt @@ -0,0 +1,138 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionFiltersTest { + @Test + fun sessionChoicesPreferMainAndRecent() { + val now = 1_700_000_000_000L + val recent1 = now - 2 * 60 * 60 * 1000L + val recent2 = now - 5 * 60 * 60 * 1000L + val stale = now - 26 * 60 * 60 * 1000L + val sessions = + listOf( + ChatSessionEntry(key = "recent-1", updatedAtMs = recent1), + ChatSessionEntry(key = "main", updatedAtMs = stale), + ChatSessionEntry(key = "old-1", updatedAtMs = stale), + ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), + ) + + val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key } + assertEquals(listOf("main", "recent-1", "recent-2"), result) + } + + @Test + fun sessionChoicesIncludeCurrentWhenMissing() { + val now = 1_700_000_000_000L + val recent = now - 10 * 60 * 1000L + val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) + + val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key } + assertEquals(listOf("main", "custom"), result) + } + + @Test + fun compactChoicesKeepMainAndCurrentWhileCappingRecentSessions() { + val now = 1_700_000_000_000L + val sessions = + listOf( + ChatSessionEntry(key = "recent-1", updatedAtMs = now - 1), + ChatSessionEntry(key = "recent-2", updatedAtMs = now - 2), + ChatSessionEntry(key = "recent-3", updatedAtMs = now - 3), + ChatSessionEntry(key = "recent-4", updatedAtMs = now - 4), + ChatSessionEntry(key = "main", updatedAtMs = now - 5), + ChatSessionEntry(key = "active-old", updatedAtMs = now - 30 * 60 * 60 * 1000L), + ) + + val result = + resolveCompactSessionChoices( + currentSessionKey = "active-old", + sessions = sessions, + mainSessionKey = "main", + nowMs = now, + maxOptions = 4, + ).map { it.key } + + assertEquals(listOf("main", "active-old", "recent-1", "recent-2"), result) + } + + @Test + fun sessionChoicesFilterAgentDeviceAndInternalSessions() { + val now = 1_700_000_000_000L + val recent = now - 10 * 60 * 1000L + val sessions = + listOf( + ChatSessionEntry(key = "agent:main:node-android", updatedAtMs = recent), + ChatSessionEntry(key = "agent:main:slack:channel:C1", updatedAtMs = recent), + ChatSessionEntry(key = "agent:main:main", updatedAtMs = recent), + ChatSessionEntry(key = "main", updatedAtMs = recent), + ) + + val result = + resolveSessionChoices( + "agent:main:node-current", + sessions, + mainSessionKey = "main", + nowMs = now, + ).map { it.key } + + assertEquals(listOf("main", "agent:main:slack:channel:C1"), result) + } + + @Test + fun additionalChoicesIgnoreHiddenSessionsButIncludeStaleChats() { + val displayed = listOf(ChatSessionEntry(key = "main", updatedAtMs = null)) + val hiddenOnly = + listOf( + ChatSessionEntry(key = "main", updatedAtMs = null), + ChatSessionEntry(key = "agent:main:node-android", updatedAtMs = null), + ChatSessionEntry(key = "agent:main:onboarding", updatedAtMs = null), + ) + assertFalse(hasAdditionalSessionChoices(hiddenOnly, displayed, mainSessionKey = "main")) + + val withStaleChat = hiddenOnly + ChatSessionEntry(key = "old-channel", updatedAtMs = 1L) + assertTrue(hasAdditionalSessionChoices(withStaleChat, displayed, mainSessionKey = "main")) + } + + @Test + fun isSelectableChatSession_matchesIosRecentSessionFilter() { + val hidden = + listOf( + "main" to "main", + "agent:main:main" to "main", + "agent:rust-claw:main" to "main", + "agent:main:node-0b88d67b7e42" to "main", + "agent:main:work" to "work", + "main" to "agent:rust-claw:work", + "global" to "agent:rust-claw:work", + "node-0b88d67b7e42" to "agent:rust-claw:work", + "work" to "agent:rust-claw:work", + "agent:main:work" to "agent:rust-claw:work", + "agent:main:main:thread:42" to "main", + "agent:support:main:thread:1234:42" to "main", + "agent:main:node-0b88d67b7e42:thread:42" to "main", + "agent:main:work:thread:42" to "work", + "agent:main:work:thread:42" to "agent:rust-claw:work", + "onboarding" to "main", + "agent:main:onboarding" to "main", + ) + for ((key, mainKey) in hidden) { + assertFalse("expected hidden session: $key (main: $mainKey)", isSelectableChatSession(key, mainKey)) + } + + val selectable = + listOf( + "agent:main:signal:direct:+15555550123", + "agent:rust-claw:mattermost:channel:abc123", + "agent:rust-claw:cron:3cd2eb6f-b8a5-4db7-b74a-f6a3f7eab3d3", + "agent:main:slack:channel:c1:thread:123", + ) + for (key in selectable) { + assertTrue("expected selectable session: $key", isSelectableChatSession(key, "main")) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt b/app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt new file mode 100644 index 0000000..cb25deb --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt @@ -0,0 +1,82 @@ +package ai.openclaw.app.ui.design + +import ai.openclaw.app.GatewayAgentSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AgentAvatarTest { + @Test + fun prefersResolvedAvatarUrl() { + val agent = agent(avatar = dataUrl("image/png", "raw"), avatarUrl = dataUrl("image/jpeg", "resolved")) + + assertEquals( + AgentAvatarSource.Data(mimeType = "image/jpeg", base64 = "resolved"), + agentAvatarSource(agent), + ) + } + + @Test + fun fallsBackToRawAvatarOnlyWhenResolvedAvatarIsMissing() { + val raw = AgentAvatarSource.Data(mimeType = "image/png", base64 = "raw") + + assertEquals(raw, agentAvatarSource(agent(avatar = dataUrl("image/png", "raw")))) + assertEquals(raw, agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"), avatarUrl = " "))) + assertNull(agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"), avatarUrl = "not an image"))) + } + + @Test + fun preservesRasterAndSvgMimeTypes() { + assertEquals( + AgentAvatarSource.Data(mimeType = "image/png", base64 = "body"), + agentAvatarSource(agent(avatarUrl = "DATA:IMAGE/PNG;BASE64, body ")), + ) + assertEquals( + AgentAvatarSource.Data(mimeType = "image/svg+xml", base64 = "PHN2Zy8+"), + agentAvatarSource(agent(avatarUrl = dataUrl("image/svg+xml", "PHN2Zy8+"))), + ) + } + + @Test + fun recognizesRemoteHttpSources() { + assertEquals( + AgentAvatarSource.Remote("https://example.com/avatar.png"), + agentAvatarSource(agent(avatarUrl = "https://example.com/avatar.png")), + ) + assertEquals( + AgentAvatarSource.Remote("HTTP://example.com/avatar.svg"), + agentAvatarSource(agent(avatarUrl = "HTTP://example.com/avatar.svg")), + ) + } + + @Test + fun rejectsMalformedOrUnsupportedAvatarValues() { + assertNull(agentAvatarSource(agent(avatarUrl = "data:image/png,raw"))) + assertNull(agentAvatarSource(agent(avatarUrl = "data:text/plain;base64,dGV4dA=="))) + assertNull(agentAvatarSource(agent(avatarUrl = "data:image/png;base64,"))) + assertNull(agentAvatarSource(agent(avatar = "avatars/openclaw.png"))) + assertNull(agentAvatarSource(agent(avatar = "🦞"))) + } + + @Test + fun rejectsMissingAvatarValues() { + assertNull(agentAvatarSource(agent())) + assertNull(agentAvatarSource(agent(avatar = " ", avatarUrl = "\n"))) + } + + private fun agent( + avatar: String? = null, + avatarUrl: String? = null, + ) = GatewayAgentSummary( + id = "main", + name = "Main", + emoji = null, + avatar = avatar, + avatarUrl = avatarUrl, + ) + + private fun dataUrl( + mimeType: String, + body: String, + ): String = "data:$mimeType;base64,$body" +} diff --git a/app/src/test/java/ai/openclaw/app/ui/design/ClawComponentsTest.kt b/app/src/test/java/ai/openclaw/app/ui/design/ClawComponentsTest.kt new file mode 100644 index 0000000..aeded2a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/design/ClawComponentsTest.kt @@ -0,0 +1,46 @@ +package ai.openclaw.app.ui.design + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ClawComponentsTest { + @Test + fun emptySegmentedOptionsProduceNoRows() { + assertEquals(emptyList>(), segmentedControlRows(emptyList())) + } + + @Test + fun segmentedOptionsStayOnOneRowByDefault() { + val options = listOf("One", "Two", "Three", "Four", "Five") + + assertEquals(listOf(options), segmentedControlRows(options)) + } + + @Test + fun optedInSmallSegmentedOptionSetsStayOnOneRow() { + val options = listOf("One", "Two", "Three", "Four") + + assertEquals(listOf(options), segmentedControlRows(options, maxOptionsPerRow = 4)) + } + + @Test + fun fiveSegmentedOptionsSplitIntoBalancedRows() { + val options = listOf("Pending", "Held", "Applied", "Rejected", "All") + + assertEquals( + listOf( + listOf("Pending", "Held", "Applied"), + listOf("Rejected", "All"), + ), + segmentedControlRows(options, maxOptionsPerRow = 4), + ) + } + + @Test + fun largerSegmentedOptionSetsKeepRowsBalancedAndBounded() { + val rows = segmentedControlRows((1..10).map(Int::toString), maxOptionsPerRow = 4) + + assertEquals(listOf(4, 3, 3), rows.map { it.size }) + assertEquals((1..10).map(Int::toString), rows.flatten()) + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt b/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt new file mode 100644 index 0000000..3e40239 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt @@ -0,0 +1,194 @@ +package ai.openclaw.app.ui.design + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MascotAnimatorTest { + @Test + fun allMoodChannelsStayInsideBoundsForThirtySeconds() { + MascotMood.entries.forEachIndexed { index, mood -> + val animator = MascotAnimator(seed = (index + 1).toULong()) + animator.setMood(mood, 0.0) + + repeat(3_001) { frame -> + val pose = animator.poseAt(frame / 100.0) + assertInBounds(pose, mood, frame) + } + } + } + + @Test + fun sameSeedProducesIdenticalPoses() { + val first = MascotAnimator(seed = 0xCAFE_BABEuL) + val second = MascotAnimator(seed = 0xCAFE_BABEuL) + first.setMood(MascotMood.Curious, 0.0) + second.setMood(MascotMood.Curious, 0.0) + + repeat(1_500) { frame -> + val time = frame * 0.023 + if (frame == 400) { + first.setMood(MascotMood.Thinking, time) + second.setMood(MascotMood.Thinking, time) + } + if (frame == 900) { + first.setMood(MascotMood.Happy, time) + second.setMood(MascotMood.Happy, time) + } + assertEquals(first.poseAt(time), second.poseAt(time)) + } + } + + @Test + fun workingCycleSeatsHatSwingsClawAndShowsWorkEffects() { + val animator = MascotAnimator(seed = 7uL) + animator.setMood(MascotMood.Working, 0.0) + var minRightClaw = Double.POSITIVE_INFINITY + var maxRightClaw = Double.NEGATIVE_INFINITY + var seatedHat = false + var sawSparks = false + var sawSweat = false + + repeat(2_001) { frame -> + val time = frame / 100.0 + val pose = animator.poseAt(time) + minRightClaw = minOf(minRightClaw, pose.rightClawDegrees) + maxRightClaw = maxOf(maxRightClaw, pose.rightClawDegrees) + seatedHat = seatedHat || (time >= 1.0 && pose.hardHat >= 0.99) + sawSparks = sawSparks || pose.effect == MascotEffect.Sparks + sawSweat = sawSweat || pose.effect == MascotEffect.Sweat + } + + assertTrue("hard hat never seated", seatedHat) + assertTrue("hammer swing was ${maxRightClaw - minRightClaw}°", maxRightClaw - minRightClaw > 25.0) + assertTrue("impact sparks never appeared", sawSparks) + assertTrue("wipe-brow sweat never appeared", sawSweat) + } + + @Test + fun moodChangeCancelsQueuedAndActiveGestures() { + val animator = MascotAnimator(seed = 11uL) + animator.poseAt(0.0) + animator.poseAt(0.95) + animator.setMood(MascotMood.Thinking, 0.95) + + val afterWaveCancellation = animator.poseAt(1.0) + assertEquals(0.0, afterWaveCancellation.rightClawDegrees, 0.000_001) + + animator.setMood(MascotMood.Working, 1.1) + assertTrue(animator.poseAt(1.3).hardHat < 1.0) + animator.setMood(MascotMood.Sad, 1.3) + + val afterHatCancellation = animator.poseAt(1.31) + assertEquals(0.0, afterHatCancellation.hardHat, 0.000_001) + } + + @Test + fun staticPoseSignaturesMatchMoodContract() { + assertEquals(MascotPose(), staticPose(MascotMood.Idle)) + assertEquals(MascotGaze(x = 0.3, y = -0.5), staticPose(MascotMood.Thinking).gaze) + + val working = staticPose(MascotMood.Working) + assertEquals(1.0, working.hardHat, 0.0) + assertEquals(-28.0, working.rightClawDegrees, 0.0) + + val celebrating = staticPose(MascotMood.Celebrating) + assertEquals(0.8, celebrating.happyEyes, 0.0) + assertEquals(30.0, celebrating.leftClawDegrees, 0.0) + assertEquals(-30.0, celebrating.rightClawDegrees, 0.0) + + val sad = staticPose(MascotMood.Sad) + assertEquals(0.75, sad.antennaDroop, 0.0) + assertEquals(-0.55, sad.mouthCurve, 0.0) + + val sleepy = staticPose(MascotMood.Sleepy) + assertEquals(0.25, sleepy.leftEyeOpenness, 0.0) + assertEquals(0.5, sleepy.eyeGlowAlpha, 0.0) + } + + @Test + fun clampCoversEveryBoundedChannel() { + val pose = + MascotPose( + floatOffset = 100.0, + antennaDegrees = -100.0, + antennaDroop = 2.0, + leftClawDegrees = -100.0, + rightClawDegrees = 100.0, + eyeGlowAlpha = -1.0, + glowScale = 9.0, + leftEyeOpenness = -1.0, + rightEyeOpenness = 2.0, + happyEyes = 2.0, + gaze = MascotGaze(x = -9.0, y = 9.0), + mouthCurve = -9.0, + mouthOpen = 9.0, + mouthRound = -9.0, + blush = 9.0, + hardHat = -9.0, + bodyTilt = 90.0, + bodyStretch = 9.0, + effect = MascotEffect.Sweat, + effectPhase = 0.75, + ).clamp() + + assertEquals(2.0, pose.floatOffset, 0.0) + assertEquals(-14.0, pose.antennaDegrees, 0.0) + assertEquals(1.0, pose.antennaDroop, 0.0) + assertEquals(-45.0, pose.leftClawDegrees, 0.0) + assertEquals(45.0, pose.rightClawDegrees, 0.0) + assertEquals(0.0, pose.eyeGlowAlpha, 0.0) + assertEquals(1.6, pose.glowScale, 0.0) + assertEquals(0.0, pose.leftEyeOpenness, 0.0) + assertEquals(1.0, pose.rightEyeOpenness, 0.0) + assertEquals(1.0, pose.happyEyes, 0.0) + assertEquals(MascotGaze(x = -1.2, y = 1.2), pose.gaze) + assertEquals(-1.0, pose.mouthCurve, 0.0) + assertEquals(1.0, pose.mouthOpen, 0.0) + assertEquals(0.0, pose.mouthRound, 0.0) + assertEquals(1.0, pose.blush, 0.0) + assertEquals(0.0, pose.hardHat, 0.0) + assertEquals(8.0, pose.bodyTilt, 0.0) + assertEquals(1.05, pose.bodyStretch, 0.0) + assertEquals(MascotEffect.Sweat, pose.effect) + assertEquals(0.75, pose.effectPhase, 0.0) + } + + private fun assertInBounds( + pose: MascotPose, + mood: MascotMood, + frame: Int, + ) { + val location = "$mood frame $frame" + assertTrue(location, pose.floatOffset in -12.0..2.0) + assertTrue(location, pose.antennaDegrees in -14.0..14.0) + assertTrue(location, pose.antennaDroop in 0.0..1.0) + assertTrue(location, pose.leftClawDegrees in -45.0..45.0) + assertTrue(location, pose.rightClawDegrees in -45.0..45.0) + assertTrue(location, pose.eyeGlowAlpha in 0.0..1.0) + assertTrue(location, pose.glowScale in 0.5..1.6) + assertTrue(location, pose.leftEyeOpenness in 0.0..1.0) + assertTrue(location, pose.rightEyeOpenness in 0.0..1.0) + assertTrue(location, pose.happyEyes in 0.0..1.0) + assertTrue(location, pose.gaze.x in -1.2..1.2) + assertTrue(location, pose.gaze.y in -1.2..1.2) + assertTrue(location, pose.mouthCurve in -1.0..1.0) + assertTrue(location, pose.mouthOpen in 0.0..1.0) + assertTrue(location, pose.mouthRound in 0.0..1.0) + assertTrue(location, pose.blush in 0.0..1.0) + assertTrue(location, pose.hardHat in 0.0..1.0) + assertTrue(location, pose.bodyTilt in -8.0..8.0) + assertTrue(location, pose.bodyStretch in 0.86..1.05) + assertTrue(location, pose.effectPhase in 0.0..1.0) + } +} + +class EffectiveMascotMoodTest { + @Test + fun `tinted mascots stay ambient regardless of requested mood`() { + for (mood in MascotMood.entries) { + assertEquals(MascotMood.Idle, effectiveMascotMood(mood = mood, tinted = true)) + assertEquals(mood, effectiveMascotMood(mood = mood, tinted = false)) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt b/app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt new file mode 100644 index 0000000..daac28a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt @@ -0,0 +1,81 @@ +package ai.openclaw.app.ui.design + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI + +/** Guards the exact constants ported from TalkWaveformView.swift. */ +class TalkWaveformMathTest { + @Test + fun idlePowerIsStaticRegardlessOfTime() { + assertEquals(0.05, TalkWaveformMath.power(TalkWaveformPhase.Idle, 0.0), 1e-9) + assertEquals(0.05, TalkWaveformMath.power(TalkWaveformPhase.Idle, 42.5), 1e-9) + } + + @Test + fun thinkingBreathesInsideItsBand() { + assertEquals(0.21, TalkWaveformMath.power(TalkWaveformPhase.Thinking, 0.0), 1e-9) + var time = 0.0 + while (time < 10.0) { + val power = TalkWaveformMath.power(TalkWaveformPhase.Thinking, time) + assertTrue(power in 0.16..0.26) + time += 0.1 + } + } + + @Test + fun listeningClampsLevelAndSpeechRaisesFloor() { + assertEquals(0.30, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = -0.5f, speechActive = false), 0.0), 1e-9) + assertEquals(0.95, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 1.5f, speechActive = false), 0.0), 1e-9) + assertEquals(0.56, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 0.4f, speechActive = false), 0.0), 1e-6) + assertEquals(0.73, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 0.4f, speechActive = true), 0.0), 1e-6) + assertEquals(1.0, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 2f, speechActive = true), 0.0), 1e-9) + } + + @Test + fun speakingClampsMeteredLevel() { + assertEquals(0.25, TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = -1f), 0.0), 1e-9) + assertEquals(1.0, TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = 2f), 0.0), 1e-9) + assertEquals(0.70, TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = 0.6f), 0.0), 1e-6) + } + + @Test + fun speakingWithoutEnvelopePulsesSynthetically() { + val trough = TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = null), 0.0) + val peak = TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = null), PI / 10.0) + assertEquals(0.70 * 0.55, trough, 1e-9) + assertEquals(0.70, peak, 1e-9) + var time = 0.0 + while (time < 5.0) { + val power = TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = null), time) + assertTrue(power in trough..peak) + time += 0.05 + } + } + + @Test + fun lobesAreDeterministicWithFixedShapeConstants() { + val lobes = TalkWaveformMath.lobes(time = 1.25, seed = 7.31) + assertEquals(lobes, TalkWaveformMath.lobes(time = 1.25, seed = 7.31)) + assertEquals(3, lobes.size) + assertEquals(0.62, lobes[0].k, 1e-9) + assertEquals(0.73, lobes[1].k, 1e-9) + assertEquals(0.84, lobes[2].k, 1e-9) + for (lobe in lobes) { + assertTrue(lobe.amplitude in 0.30..1.0) + assertTrue(lobe.t in -2.8..2.8) + } + } + + @Test + fun attenuatedSinePeaksAtFullAmplitudeAndStaysNonNegative() { + // At kx − t = −π/2 the bell envelope is exactly 1 and |sin| is 1. + assertEquals(3.0, TalkWaveformMath.attenuatedSine(x = -PI / 2, amplitude = 3.0, k = 1.0, t = 0.0), 1e-9) + var x = -9.0 + while (x <= 9.0) { + assertTrue(TalkWaveformMath.attenuatedSine(x = x, amplitude = 1.0, k = 0.73, t = 1.4) >= 0.0) + x += 0.25 + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/AndroidAudioInputSessionTest.kt b/app/src/test/java/ai/openclaw/app/voice/AndroidAudioInputSessionTest.kt new file mode 100644 index 0000000..ea7a56e --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/AndroidAudioInputSessionTest.kt @@ -0,0 +1,246 @@ +package ai.openclaw.app.voice + +import android.Manifest +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.AudioRecord +import android.os.Looper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +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.shadows.AudioDeviceInfoBuilder +import org.robolectric.shadows.ShadowAudioManager +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AndroidAudioInputSessionTest { + private val context = RuntimeEnvironment.getApplication() + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val shadowAudioManager: ShadowAudioManager = shadowOf(audioManager) + private var nextDeviceId = 1 + + @Before + fun setUp() { + shadowOf(context).grantPermissions(Manifest.permission.RECORD_AUDIO) + } + + @After + fun tearDown() { + shadowAudioManager.setInputDevices(emptyList()) + shadowAudioManager.setAvailableCommunicationDevices(emptyList()) + audioManager.clearCommunicationDevice() + } + + @Test + fun prefersBleHeadsetInputAndCommunicationRoute() { + val sco = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val scoOutput = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.setInputDevices(listOf(sco, ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(scoOutput, bleOutput)) + + val session = AndroidAudioInputSession.open(context, sampleRateHz = 24_000, frameBytes = 4_800) + + assertEquals(AudioDeviceInfo.TYPE_BLE_HEADSET, session.requestedInputType) + assertEquals(AudioDeviceInfo.TYPE_BLE_HEADSET, audioManager.communicationDevice?.type) + session.close() + } + + @Test + fun removalFallsBackToClassicBluetoothInput() { + val sco = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val scoOutput = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.setInputDevices(listOf(sco, ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(scoOutput, bleOutput)) + val session = AndroidAudioInputSession.open(context, sampleRateHz = 24_000, frameBytes = 4_800) + + shadowAudioManager.setAvailableCommunicationDevices(listOf(scoOutput)) + shadowAudioManager.removeInputDevice(ble, true) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, session.requestedInputType) + assertEquals(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, audioManager.communicationDevice?.type) + session.close() + } + + @Test + fun presentPreferredInputResolvesByStableKey() { + val sco = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + + val resolved = resolvePreferredAudioInput(listOf(ble, sco), audioInputDeviceKey(sco)) + + assertEquals(sco.id, resolved?.id) + assertEquals(sco.type, resolved?.type) + } + + @Test + fun rejectedPreferredInputRestoresAutomaticBluetoothRouting() { + val sco = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val scoOutput = audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.setInputDevices(listOf(sco, ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(scoOutput, bleOutput)) + + val session = + AndroidAudioInputSession.open( + context, + sampleRateHz = 24_000, + frameBytes = 4_800, + preferredDeviceKey = audioInputDeviceKey(sco), + ) + + assertEquals(ble.type, session.requestedInputType) + assertNull(session.appliedPreferredDeviceKey) + assertEquals(AudioDeviceInfo.TYPE_BLE_HEADSET, audioManager.communicationDevice?.type) + session.close() + } + + @Test + fun unresolvedPreferredInputKeepsAutomaticBluetoothRouting() { + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.setInputDevices(listOf(ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(bleOutput)) + + val session = + AndroidAudioInputSession.open( + context, + sampleRateHz = 24_000, + frameBytes = 4_800, + preferredDeviceKey = "missing", + ) + + assertEquals(AudioDeviceInfo.TYPE_BLE_HEADSET, session.requestedInputType) + assertEquals(AudioDeviceInfo.TYPE_BLE_HEADSET, audioManager.communicationDevice?.type) + session.close() + } + + @Test + fun stableInputKeyUsesDeviceAttributesInsteadOfRuntimeId() { + val key = audioInputDeviceKey(type = 26, address = "usb:1", productName = "Desk Mic") + + assertEquals("26|usb%3A1|Desk+Mic", key) + assertEquals(AudioInputDeviceOption(key, "Desk Mic", 26), audioInputDeviceOptionFromKey(key)) + } + + @Test + fun unavailablePreferredInputIsRetainedWhenItAppearsLater() { + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val wired = audioDevice(AudioDeviceInfo.TYPE_WIRED_HEADSET) + val preferredDeviceKey = audioInputDeviceKey(wired) + shadowAudioManager.setInputDevices(listOf(ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(bleOutput)) + val session = + AndroidAudioInputSession.open( + context, + sampleRateHz = 24_000, + frameBytes = 4_800, + preferredDeviceKey = preferredDeviceKey, + setPreferredDevice = { true }, + ) + + shadowAudioManager.addInputDevice(wired, true) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(wired.type, session.requestedInputType) + session.close() + } + + @Test + fun deviceObserverTracksHotPlugAndStopsAfterClose() { + val builtIn = audioDevice(AudioDeviceInfo.TYPE_BUILTIN_MIC) + shadowAudioManager.setInputDevices(listOf(builtIn)) + val snapshots = mutableListOf>() + + val observer = AndroidAudioInputSession.observeAvailableDevices(context, snapshots::add) + assertEquals(listOf(builtIn.type), snapshots.last().map(AudioInputDeviceOption::type)) + + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.addInputDevice(ble, true) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(setOf(builtIn.type, ble.type), snapshots.last().mapTo(mutableSetOf(), AudioInputDeviceOption::type)) + + observer.close() + val snapshotCount = snapshots.size + shadowAudioManager.addInputDevice(audioDevice(AudioDeviceInfo.TYPE_BLUETOOTH_SCO), true) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(snapshotCount, snapshots.size) + } + + @Test + fun closeRestoresDefaultInputAndUnregistersDeviceCallback() { + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.setInputDevices(listOf(ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(bleOutput)) + val session = AndroidAudioInputSession.open(context, sampleRateHz = 8_000, frameBytes = 1_600) + + session.close() + + assertNull(session.requestedInputType) + assertNull(audioManager.communicationDevice) + shadowAudioManager.addInputDevice(audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET), true) + shadowOf(Looper.getMainLooper()).idle() + assertNull(session.requestedInputType) + } + + @Test + fun delayedOldCloseDoesNotClearNewerCommunicationRoute() { + val ble = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + val bleOutput = audioDevice(AudioDeviceInfo.TYPE_BLE_HEADSET) + shadowAudioManager.setInputDevices(listOf(ble)) + shadowAudioManager.setAvailableCommunicationDevices(listOf(bleOutput)) + val oldSession = AndroidAudioInputSession.open(context, sampleRateHz = 24_000, frameBytes = 4_800) + val newSession = AndroidAudioInputSession.open(context, sampleRateHz = 24_000, frameBytes = 4_800) + + oldSession.close() + + assertEquals(AudioDeviceInfo.TYPE_BLE_HEADSET, audioManager.communicationDevice?.type) + newSession.close() + assertNull(audioManager.communicationDevice) + } + + @Test + fun audioRecordErrorsFailTheSharedCaptureSession() { + assertEquals(0, checkAudioRecordReadResult(0)) + assertEquals(32, checkAudioRecordReadResult(32)) + + val deadObject = + runCatching { checkAudioRecordReadResult(AudioRecord.ERROR_DEAD_OBJECT) } + .exceptionOrNull() + assertTrue(deadObject is IllegalStateException) + assertEquals("microphone read failed: ERROR_DEAD_OBJECT", deadObject?.message) + + val unknown = runCatching { checkAudioRecordReadResult(-99) }.exceptionOrNull() + assertTrue(unknown is IllegalStateException) + assertEquals("microphone read failed: code=-99", unknown?.message) + } + + private fun audioDevice(type: Int): AudioDeviceInfo { + val device = + AudioDeviceInfoBuilder + .newBuilder() + .setType(type) + .build() + val port = ReflectionHelpers.getField(device, "mPort") + val handle = ReflectionHelpers.getField(port, "mHandle") + ReflectionHelpers.setField(handle, "mId", nextDeviceId++) + return device + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt b/app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt new file mode 100644 index 0000000..711444a --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt @@ -0,0 +1,72 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioLevelsTest { + @Test + fun silenceMetersToZero() { + val silence = ByteArray(640) + assertEquals(0.0, TalkAudioLevel.pcm16Rms(silence, silence.size), 0.0) + assertEquals(0f, TalkAudioLevel.pcm16Level(silence, silence.size), 0f) + } + + @Test + fun fullScaleMetersToOne() { + val frame = pcm16Frame(samples = 160, sample = Short.MAX_VALUE) + assertEquals(1.0, TalkAudioLevel.pcm16Rms(frame, frame.size), 1e-6) + assertEquals(1f, TalkAudioLevel.pcm16Level(frame, frame.size), 1e-6f) + } + + @Test + fun negativeFullScaleClampsToOne() { + // abs(-32768) exceeds Short.MAX_VALUE by one; the level must stay in 0..1. + val frame = pcm16Frame(samples = 160, sample = Short.MIN_VALUE) + assertEquals(1f, TalkAudioLevel.pcm16Level(frame, frame.size), 0f) + } + + @Test + fun midScaleFollowsTheSharedDecibelCurve() { + // Same 50 dB window as OpenClawKit's TalkAudioLevel: half amplitude is + // -6.02 dBFS, so the normalized level is (50 - 6.02) / 50 = 0.8796. + val frame = pcm16Frame(samples = 160, sample = (Short.MAX_VALUE / 2).toShort()) + assertEquals(0.5, TalkAudioLevel.pcm16Rms(frame, frame.size), 1e-3) + assertEquals(0.8796f, TalkAudioLevel.pcm16Level(frame, frame.size), 1e-3f) + } + + @Test + fun quietSignalsStayVisibleOnTheDecibelCurve() { + // -40 dBFS (1% amplitude) still reads at 0.2 instead of vanishing, which is + // what makes the wave feel alive at conversational distance on iOS/macOS. + assertEquals(0.2f, TalkAudioLevel.normalized(rms = 0.01), 1e-3f) + assertEquals(0f, TalkAudioLevel.normalized(rms = 0.0), 0f) + assertEquals(1f, TalkAudioLevel.normalized(rms = 1.0), 0f) + } + + @Test + fun trailingOddByteAndEmptyLengthAreIgnored() { + val frame = pcm16Frame(samples = 2, sample = Short.MAX_VALUE) + byteArrayOf(0x7F) + assertEquals(1f, TalkAudioLevel.pcm16Level(frame, frame.size), 1e-6f) + assertEquals(0f, TalkAudioLevel.pcm16Level(frame, 0), 0f) + assertEquals(0f, TalkAudioLevel.pcm16Level(frame, 1), 0f) + } + + @Test + fun smoothingMatchesIosWeighting() { + assertEquals(0.2f, TalkAudioLevel.smoothed(previous = 0f, raw = 1f), 1e-6f) + assertEquals(0.36f, TalkAudioLevel.smoothed(previous = 0.2f, raw = 1f), 1e-6f) + assertEquals(0.8f, TalkAudioLevel.smoothed(previous = 1f, raw = 0f), 1e-6f) + } + + private fun pcm16Frame( + samples: Int, + sample: Short, + ): ByteArray { + val frame = ByteArray(samples * 2) + for (index in 0 until samples) { + frame[index * 2] = (sample.toInt() and 0xff).toByte() + frame[index * 2 + 1] = ((sample.toInt() shr 8) and 0xff).toByte() + } + return frame + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/ChatEventTextTest.kt b/app/src/test/java/ai/openclaw/app/voice/ChatEventTextTest.kt new file mode 100644 index 0000000..77fd4f9 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/ChatEventTextTest.kt @@ -0,0 +1,106 @@ +package ai.openclaw.app.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatEventTextTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun extractsAssistantTextParts() { + val payload = + payload( + """ + { + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "hello" }, + { "type": "text", "text": "world" } + ] + } + } + """, + ) + + assertEquals("hello\nworld", ChatEventText.assistantTextFromPayload(payload)) + } + + @Test + fun extractsPlainStringContent() { + val payload = + payload( + """ + { + "message": { + "role": "assistant", + "content": "plain reply" + } + } + """, + ) + + assertEquals("plain reply", ChatEventText.assistantTextFromPayload(payload)) + } + + @Test + fun ignoresUserMessages() { + val payload = + payload( + """ + { + "message": { + "role": "user", + "content": [ + { "type": "text", "text": "do not speak" } + ] + } + } + """, + ) + + assertNull(ChatEventText.assistantTextFromPayload(payload)) + } + + @Test + fun ignoresMessagesWithMissingRole() { + val payload = + payload( + """ + { + "message": { + "content": [ + { "type": "text", "text": "do not speak" } + ] + } + } + """, + ) + + assertNull(ChatEventText.assistantTextFromPayload(payload)) + } + + @Test + fun ignoresNonCanonicalAssistantRoles() { + for (role in listOf("ASSISTANT", " assistant ")) { + val payload = + payload( + """ + { + "message": { + "role": "$role", + "content": "do not speak" + } + } + """, + ) + + assertNull(ChatEventText.assistantTextFromPayload(payload)) + } + } + + private fun payload(source: String): JsonObject = json.parseToJsonElement(source.trimIndent()) as JsonObject +} diff --git a/app/src/test/java/ai/openclaw/app/voice/MicCaptureManagerTest.kt b/app/src/test/java/ai/openclaw/app/voice/MicCaptureManagerTest.kt new file mode 100644 index 0000000..9e1408e --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/MicCaptureManagerTest.kt @@ -0,0 +1,453 @@ +package ai.openclaw.app.voice + +import ai.openclaw.app.gateway.ChatSendAck +import android.Manifest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MicCaptureManagerTest { + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun transcriptionFinalQueuesGatewayMessage() = + runTest { + val sentMessages = mutableListOf() + val manager = + createManager( + scope = this, + sendToGateway = { message, onRunIdKnown -> + sentMessages += message + onRunIdKnown("run-1") + ChatSendAck(runId = "run-1", status = "started") + }, + ) + + setTranscriptionSession(manager, "transcription-1") + manager.onGatewayConnectionChanged(true) + manager.handleGatewayEvent( + "talk.event", + """{"transcriptionSessionId":"transcription-1","type":"partial","text":"hello"}""", + ) + manager.handleGatewayEvent( + "talk.event", + """{"transcriptionSessionId":"transcription-1","type":"transcript","text":"hello world","final":true}""", + ) + runCurrent() + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-1", text = "reply")) + advanceUntilIdle() + + assertNull(manager.liveTranscript.value) + assertEquals(listOf("hello world"), sentMessages) + val conversation = manager.conversation.value.first() + assertEquals(VoiceConversationRole.User, conversation.role) + assertEquals("hello world", conversation.text) + } + + @Test + fun transcriptionErrorDisablesMic() { + val manager = createManager() + + setTranscriptionSession(manager, "transcription-1") + manager.handleGatewayEvent( + "talk.event", + """{"transcriptionSessionId":"transcription-1","type":"error","message":"provider unavailable"}""", + ) + + assertEquals(false, manager.micEnabled.value) + assertEquals("Transcription failed: provider unavailable", manager.statusText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun punctuationOnlyTranscriptDoesNotSendTurn() = + runTest { + val sentMessages = mutableListOf() + val manager = + createManager( + scope = this, + sendToGateway = { message, onRunIdKnown -> + sentMessages += message + onRunIdKnown("run-1") + ChatSendAck(runId = "run-1", status = "started") + }, + ) + + setTranscriptionSession(manager, "transcription-1") + manager.onGatewayConnectionChanged(true) + manager.handleGatewayEvent( + "talk.event", + """{"transcriptionSessionId":"transcription-1","type":"transcript","text":".","final":true}""", + ) + advanceUntilIdle() + + assertEquals(emptyList(), sentMessages) + assertEquals(emptyList(), manager.conversation.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun submittedTranscribedMessageUsesGatewayTurnPath() = + runTest { + val sentMessages = mutableListOf() + val manager = + createManager( + scope = this, + sendToGateway = { message, onRunIdKnown -> + sentMessages += message + onRunIdKnown("run-voice-e2e") + ChatSendAck(runId = "run-voice-e2e", status = "started") + }, + ) + + manager.onGatewayConnectionChanged(true) + manager.submitTranscribedMessage("voice e2e message") + runCurrent() + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-voice-e2e", text = "voice e2e reply")) + advanceUntilIdle() + + assertEquals(listOf("voice e2e message"), sentMessages) + assertEquals( + listOf(VoiceConversationRole.User, VoiceConversationRole.Assistant), + manager.conversation.value.map { it.role }, + ) + assertEquals( + "voice e2e reply", + manager.conversation.value + .last() + .text, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() = + runTest { + val manager = + createManager( + scope = this, + sendToGateway = { _, onRunIdKnown -> + onRunIdKnown("run-terminal") + ChatSendAck(runId = "run-terminal", status = "timeout") + }, + ) + + manager.onGatewayConnectionChanged(true) + manager.submitTranscribedMessage("terminal ack message") + runCurrent() + + assertNull(privateField(manager, "pendingRunId")) + assertEquals(false, manager.isSending.value) + assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value) + + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply")) + advanceUntilIdle() + + assertEquals( + listOf(VoiceConversationRole.User), + manager.conversation.value.map { it.role }, + ) + assertEquals( + "terminal ack message", + manager.conversation.value + .single() + .text, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() = + runTest { + val manager = + createManager( + scope = this, + sendToGateway = { _, onRunIdKnown -> + onRunIdKnown("run-error") + ChatSendAck(runId = "run-error", status = "error") + }, + ) + + manager.onGatewayConnectionChanged(true) + manager.submitTranscribedMessage("terminal error message") + runCurrent() + + assertNull(privateField(manager, "pendingRunId")) + assertEquals(false, manager.isSending.value) + assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun chatFailureRetainsLocalizedSourceForLaterLocaleChanges() = + runTest { + val manager = + createManager( + scope = this, + sendToGateway = { _, onRunIdKnown -> + onRunIdKnown("run-localized-error") + ChatSendAck(runId = "run-localized-error", status = "started") + }, + ) + + manager.onGatewayConnectionChanged(true) + manager.submitTranscribedMessage("trigger failure") + runCurrent() + manager.handleGatewayEvent( + "chat", + """{"runId":"run-localized-error","state":"error"}""", + ) + advanceUntilIdle() + + val failure = manager.conversation.value.last() + assertEquals(VoiceConversationRole.Assistant, failure.role) + assertEquals("Voice request failed", failure.text) + assertEquals("Voice request failed", failure.localizedSource) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() = + runTest { + var refreshCalls = 0 + val manager = + createManager( + scope = this, + sendToGateway = { _, onRunIdKnown -> + onRunIdKnown("run-ok") + ChatSendAck(runId = "run-ok", status = "ok") + }, + refreshAfterTerminalSuccess = { refreshCalls += 1 }, + ) + + manager.onGatewayConnectionChanged(true) + manager.submitTranscribedMessage("terminal ok message") + runCurrent() + + assertNull(privateField(manager, "pendingRunId")) + assertEquals(false, manager.isSending.value) + assertEquals(1, refreshCalls) + } + + @Test + fun pcm16FramesAreEncodedAsPcmuFrames() { + val manager = createManager() + val method = manager.javaClass.getDeclaredMethod("pcm16ToPcmu", ByteArray::class.java) + method.isAccessible = true + + val encoded = method.invoke(manager, byteArrayOf(0, 0, 0, 0)) as ByteArray + + assertEquals(2, encoded.size) + assertEquals(0xff.toByte(), encoded[0]) + assertEquals(0xff.toByte(), encoded[1]) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun disablingMicDuringSessionCreateClosesReturnedSession() = + runTest { + val createdSession = CompletableDeferred() + val closedSessions = mutableListOf() + val manager = + createManager( + scope = this, + createTranscriptionSession = { createdSession.await() }, + closeTranscriptionSession = { sessionId -> closedSessions += sessionId }, + ) + + manager.onGatewayConnectionChanged(true) + manager.setMicEnabled(true) + manager.setMicEnabled(false) + createdSession.complete("transcription-1") + advanceUntilIdle() + + assertEquals(listOf("transcription-1"), closedSessions) + assertEquals(false, manager.isListening.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun disablingMicKeepsSessionOpenForFinalTranscript() = + runTest { + val manager = createManager(scope = this) + + setPrivateMutableStateFlowValue(manager, "_micEnabled", true) + setTranscriptionSession(manager, "transcription-1") + manager.setMicEnabled(false) + manager.handleGatewayEvent( + "talk.event", + """{"transcriptionSessionId":"transcription-1","type":"transcript","text":"testing testing 1 2 3","final":true}""", + ) + runCurrent() + + assertEquals( + "testing testing 1 2 3", + manager.conversation.value + .single() + .text, + ) + assertEquals("transcription-1", privateField(manager, "transcriptionSession")?.id) + privateField(manager, "transcriptionDrainJob")?.cancel() + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun gatewayScopeChangeDropsQueuedVoiceBeforeReconnect() = + runTest { + val sentMessages = mutableListOf() + val manager = + createManager( + scope = this, + sendToGateway = { message, onRunIdKnown -> + sentMessages += message + onRunIdKnown("run-b") + ChatSendAck(runId = "run-b", status = "started") + }, + ) + + manager.submitTranscribedMessage("gateway A only") + assertEquals(listOf("gateway A only"), manager.queuedMessages.value) + + manager.onGatewayScopeChanging() + manager.onGatewayConnectionChanged(true) + runCurrent() + + assertEquals(emptyList(), manager.queuedMessages.value) + assertEquals(emptyList(), sentMessages) + assertEquals(emptyList(), manager.conversation.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun reconnectRestartsAfterPendingCreateCancellation() = + runTest { + val firstCreate = CompletableDeferred() + val secondCreate = CompletableDeferred() + var createCalls = 0 + val manager = + createManager( + scope = this, + createTranscriptionSession = { + createCalls += 1 + if (createCalls == 1) firstCreate.await() else secondCreate.await() + }, + ) + + manager.onGatewayConnectionChanged(true) + manager.setMicEnabled(true) + runCurrent() + manager.onGatewayConnectionChanged(false) + manager.onGatewayConnectionChanged(true) + firstCreate.completeExceptionally(CancellationException("connection closed")) + runCurrent() + + assertEquals(2, createCalls) + assertEquals(true, manager.micEnabled.value) + manager.setMicEnabled(false) + secondCreate.completeExceptionally(CancellationException("test complete")) + runCurrent() + } + + private fun createManager( + scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined), + createTranscriptionSession: suspend () -> String = { "transcription-1" }, + closeTranscriptionSession: suspend (String) -> Unit = { _ -> }, + sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown -> + onRunIdKnown("run-1") + ChatSendAck(runId = "run-1", status = "started") + }, + refreshAfterTerminalSuccess: suspend () -> Unit = {}, + ): MicCaptureManager = + MicCaptureManager( + context = + RuntimeEnvironment.getApplication().also { app -> + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + }, + scope = scope, + createTranscriptionSession = { + GatewayTranscriptionSession( + id = createTranscriptionSession(), + gatewayId = "gateway-a", + ) + }, + appendTranscriptionAudio = { _, _, _ -> }, + closeTranscriptionSession = { session -> closeTranscriptionSession(session.id) }, + sendToGateway = sendToGateway, + refreshAfterTerminalSuccess = refreshAfterTerminalSuccess, + ) + + private fun setTranscriptionSession( + manager: MicCaptureManager, + id: String, + ) { + setPrivateField( + manager, + "transcriptionSession", + GatewayTranscriptionSession(id = id, gatewayId = "gateway-a"), + ) + } + + private fun setPrivateField( + target: Any, + name: String, + value: Any?, + ) { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + field.set(target, value) + } + + @Suppress("UNCHECKED_CAST") + private fun setPrivateMutableStateFlowValue( + target: Any, + name: String, + value: Boolean, + ) { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + (field.get(target) as MutableStateFlow).value = value + } + + @Suppress("UNCHECKED_CAST") + private fun privateField( + target: Any, + name: String, + ): T { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + return field.get(target) as T + } + + private fun chatFinalPayload( + runId: String, + text: String, + ): String = + """ + { + "runId": "$runId", + "state": "final", + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "$text" } + ] + } + } + """.trimIndent() +} diff --git a/app/src/test/java/ai/openclaw/app/voice/PushToTalkRecognitionLadderTest.kt b/app/src/test/java/ai/openclaw/app/voice/PushToTalkRecognitionLadderTest.kt new file mode 100644 index 0000000..80bb640 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/PushToTalkRecognitionLadderTest.kt @@ -0,0 +1,56 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PushToTalkRecognitionLadderTest { + @Test + fun api33StartsWithRawAudioThenFallsThroughInOrder() { + assertEquals( + listOf( + PushToTalkRecognitionCandidate.RawAudioSegmented, + PushToTalkRecognitionCandidate.SilenceSegmented, + PushToTalkRecognitionCandidate.RestartingSingleSession, + ), + pushToTalkRecognitionCandidates(supportsSegmentedRecognition = true, first = null), + ) + } + + @Test + fun olderApisUseRestartingSingleSessionOnly() { + assertEquals( + listOf(PushToTalkRecognitionCandidate.RestartingSingleSession), + pushToTalkRecognitionCandidates(supportsSegmentedRecognition = false, first = null), + ) + } + + @Test + fun degradedHoldNeverClimbsBackUpTheLadder() { + assertEquals( + listOf( + PushToTalkRecognitionCandidate.SilenceSegmented, + PushToTalkRecognitionCandidate.RestartingSingleSession, + ), + pushToTalkRecognitionCandidates( + supportsSegmentedRecognition = true, + first = PushToTalkRecognitionCandidate.SilenceSegmented, + ), + ) + } + + @Test + fun rawSegmentedSessionEndAdvancesToSilenceButSilenceSessionEndRearms() { + assertEquals( + true, + shouldAdvancePushToTalkRungAfterSegmentedSession( + PushToTalkRecognitionCandidate.RawAudioSegmented, + ), + ) + assertEquals( + false, + shouldAdvancePushToTalkRungAfterSegmentedSession( + PushToTalkRecognitionCandidate.SilenceSegmented, + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/PushToTalkTranscriptMergerTest.kt b/app/src/test/java/ai/openclaw/app/voice/PushToTalkTranscriptMergerTest.kt new file mode 100644 index 0000000..bc3b587 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/PushToTalkTranscriptMergerTest.kt @@ -0,0 +1,90 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PushToTalkTranscriptMergerTest { + @Test + fun joinsMultipleFinalSegmentsInOrder() { + assertEquals( + "first thought. second thought. third thought", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("first thought", "second thought", "third thought"), + livePartial = null, + ), + ) + } + + @Test + fun preservesTerminalPunctuationWithoutDoublingIt() { + assertEquals( + "ready? yes! \"done.\" next", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("ready?", "yes!", "\"done.\"", "next"), + livePartial = null, + ), + ) + } + + @Test + fun preservesLocalePunctuationWithoutInjectingAsciiSeparators() { + assertEquals( + "你好。 世界", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("你好。", "世界"), + livePartial = null, + ), + ) + assertEquals( + "هل أنت جاهز؟ نعم", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("هل أنت جاهز؟", "نعم"), + livePartial = null, + ), + ) + } + + @Test + fun omitsTrailingPartialThatDuplicatesLastFinal() { + assertEquals( + "first. SAME words", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("first", "SAME words"), + livePartial = " same WORDS ", + ), + ) + } + + @Test + fun appendsDistinctTrailingPartial() { + assertEquals( + "first final. trailing words", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("first final"), + livePartial = "trailing words", + ), + ) + } + + @Test + fun returnsPartialForPartialOnlyHold() { + assertEquals( + "unfinished thought", + PushToTalkTranscriptMerger.merge( + finalSegments = emptyList(), + livePartial = " unfinished thought ", + ), + ) + } + + @Test + fun returnsEmptyForEmptyResults() { + assertEquals( + "", + PushToTalkTranscriptMerger.merge( + finalSegments = listOf("", " "), + livePartial = " ", + ), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/RealtimeAgentCoordinatorTest.kt b/app/src/test/java/ai/openclaw/app/voice/RealtimeAgentCoordinatorTest.kt new file mode 100644 index 0000000..aae5cd2 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/RealtimeAgentCoordinatorTest.kt @@ -0,0 +1,546 @@ +package ai.openclaw.app.voice + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class RealtimeAgentCoordinatorTest { + @Test + fun `consult correlates the active run and submits its final text`() = + runTest { + val calls = mutableListOf() + val working = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") """{"runId":"run-1"}""" else "{}" }, + onWorking = working::add, + ) + val session = RealtimeAgentSession("relay-1", "session-1") + coordinator.beginSession(session) + + assertTrue( + coordinator.handleToolCall( + callId = "call-1", + name = "openclaw_agent_consult", + args = null, + forced = false, + ), + ) + runCurrent() + + assertEquals(listOf(session), working) + assertFalse( + coordinator.handleChatEvent( + sessionKey = "other-session", + runId = "run-1", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"wrong"}"""), + ), + ) + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-1", + runId = "run-1", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"done"}"""), + ), + ) + runCurrent() + + val consult = calls.single { it.method == "talk.client.toolCall" } + assertEquals(15_000L, consult.timeoutMs) + assertTrue(consult.params.contains("\"name\":\"openclaw_agent_consult\"")) + val result = calls.single { it.method == "talk.session.submitToolResult" } + assertTrue(result.params.contains("\"sessionId\":\"relay-1\"")) + assertTrue(result.params.contains("\"callId\":\"call-1\"")) + assertTrue(result.params.contains("\"text\":\"done\"")) + } + + @Test + fun `early completion waits for run metadata`() = + runTest { + val calls = mutableListOf() + val response = CompletableDeferred() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") response.await() else "{}" }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-1", "session-1")) + coordinator.handleToolCall("call-1", "openclaw_agent_consult", null, forced = false) + runCurrent() + + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-1", + runId = "run-early", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"early"}"""), + ), + ) + response.complete("""{"runId":"run-early"}""") + runCurrent() + + assertTrue( + calls + .single { it.method == "talk.session.submitToolResult" } + .params + .contains("\"text\":\"early\""), + ) + } + + @Test + fun `validates tool names and dispatches control without a consult`() = + runTest { + val calls = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> + when (method) { + "talk.session.steer" -> """{"status":"steered"}""" + else -> "{}" + } + }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-1", "session-1")) + + coordinator.handleToolCall( + callId = "control-1", + name = "openclaw_agent_control", + args = Json.parseToJsonElement("""{"text":"stop","mode":"cancel"}"""), + forced = false, + ) + coordinator.handleToolCall( + callId = "unknown-1", + name = "other_tool", + args = null, + forced = false, + ) + runCurrent() + + assertTrue(calls.none { it.method == "talk.client.toolCall" }) + val steer = calls.single { it.method == "talk.session.steer" } + assertTrue(steer.params.contains("\"mode\":\"cancel\"")) + val results = calls.filter { it.method == "talk.session.submitToolResult" } + assertTrue(results.any { it.params.contains("\"status\":\"steered\"") }) + assertTrue(results.any { it.params.contains("unsupported realtime Talk tool: other_tool") }) + } + + @Test + fun `forced consult reports working then returns gateway errors`() = + runTest { + val calls = mutableListOf() + val errors = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> + if (method == "talk.client.toolCall") error("gateway offline") else "{}" + }, + onError = errors::add, + ) + coordinator.beginSession(RealtimeAgentSession("relay-1", "session-1")) + + coordinator.handleToolCall( + callId = "call-1", + name = "openclaw_agent_consult", + args = null, + forced = true, + ) + runCurrent() + + val results = calls.filter { it.method == "talk.session.submitToolResult" } + assertEquals(2, results.size) + assertTrue(results[0].params.contains("\"status\":\"working\"")) + assertTrue(results[0].params.contains("\"willContinue\":true")) + assertTrue(results[1].params.contains("\"error\":\"gateway offline\"")) + assertEquals(listOf("realtime toolCall failed: gateway offline"), errors) + } + + @Test + fun `session replacement quarantines the old run while a call id is reused`() = + runTest { + val calls = mutableListOf() + val oldResponse = CompletableDeferred() + val newResponse = CompletableDeferred() + var requestCount = 0 + val coordinator = + coordinator( + calls = calls, + responses = { method -> + if (method != "talk.client.toolCall") { + "{}" + } else if (requestCount++ == 0) { + oldResponse.await() + } else { + newResponse.await() + } + }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-shared", "openclaw_agent_consult", null, forced = false) + runCurrent() + + coordinator.beginSession(RealtimeAgentSession("relay-new", "session-main")) + coordinator.handleToolCall("call-shared", "openclaw_agent_consult", null, forced = false) + runCurrent() + + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "run-new", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"fresh"}"""), + ), + ) + newResponse.complete("""{"runId":"run-new"}""") + runCurrent() + + val result = calls.single { it.method == "talk.session.submitToolResult" } + assertTrue(result.params.contains("\"sessionId\":\"relay-new\"")) + assertTrue(result.params.contains("\"text\":\"fresh\"")) + + oldResponse.complete("""{"runId":"run-old"}""") + runCurrent() + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "run-old", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"stale"}"""), + ), + ) + assertEquals(1, calls.count { it.method == "talk.session.submitToolResult" }) + } + + @Test + fun `transport reset cancels an old consult before a new gateway session`() = + runTest { + val calls = mutableListOf() + val oldResponse = CompletableDeferred() + val unhandled = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") oldResponse.await() else "{}" }, + onUnhandledCompletion = unhandled::add, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + coordinator.handleToolCall("call-old-2", "openclaw_agent_consult", null, forced = false) + runCurrent() + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "cached-old-run", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"stale cached"}"""), + ), + ) + + coordinator.resetTransport() + coordinator.beginSession(RealtimeAgentSession("relay-new", "session-main")) + oldResponse.complete("""{"runId":"run-old"}""") + runCurrent() + + assertTrue(calls.none { it.method == "talk.session.submitToolResult" }) + assertTrue(unhandled.isEmpty()) + assertFalse( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "run-old", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"stale"}"""), + ), + ) + } + + @Test + fun `transport reset rejects a reused retired run id without stranding the call`() = + runTest { + val calls = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") """{"runId":"shared-run"}""" else "{}" }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + runCurrent() + + coordinator.resetTransport() + coordinator.beginSession(RealtimeAgentSession("relay-new", "session-main")) + coordinator.handleToolCall("call-new", "openclaw_agent_consult", null, forced = false) + runCurrent() + + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "shared-run", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"late"}"""), + ), + ) + runCurrent() + + val result = calls.single { it.method == "talk.session.submitToolResult" } + assertTrue(result.params.contains("\"sessionId\":\"relay-new\"")) + assertTrue(result.params.contains("\"callId\":\"call-new\"")) + assertTrue(result.params.contains("tool call returned a duplicate run id")) + } + + @Test + fun `old session request does not buffer a new session completion`() = + runTest { + val calls = mutableListOf() + val oldResponse = CompletableDeferred() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") oldResponse.await() else "{}" }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-old")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + runCurrent() + + coordinator.beginSession(RealtimeAgentSession("relay-new", "session-new")) + + assertFalse( + coordinator.handleChatEvent( + sessionKey = "session-new", + runId = "ordinary-run", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"ordinary"}"""), + ), + ) + oldResponse.complete("""{"runId":"run-old"}""") + runCurrent() + assertTrue(calls.none { it.method == "talk.session.submitToolResult" }) + } + + @Test + fun `session replacement consumes a known old run with the same session key`() = + runTest { + val calls = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") """{"runId":"run-old"}""" else "{}" }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + runCurrent() + + coordinator.beginSession(RealtimeAgentSession("relay-new", "session-main")) + + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "run-old", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"stale"}"""), + ), + ) + runCurrent() + + assertTrue(calls.none { it.method == "talk.session.submitToolResult" }) + } + + @Test + fun `session end retains unresolved correlation and quarantines its final`() = + runTest { + val calls = mutableListOf() + val response = CompletableDeferred() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") response.await() else "{}" }, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + runCurrent() + + coordinator.endSession("relay-old") + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "run-old", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"stale"}"""), + ), + ) + response.complete("""{"runId":"run-old"}""") + runCurrent() + + assertTrue(calls.none { it.method == "talk.session.submitToolResult" }) + } + + @Test + fun `session end suppresses a late gateway failure`() = + runTest { + val calls = mutableListOf() + val response = CompletableDeferred() + val errors = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") response.await() else "{}" }, + onError = errors::add, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + runCurrent() + + coordinator.endSession("relay-old") + response.completeExceptionally(IllegalStateException("late failure")) + runCurrent() + + assertTrue(errors.isEmpty()) + assertTrue(calls.none { it.method == "talk.session.submitToolResult" }) + } + + @Test + fun `session cleanup releases uncertain early completions`() = + runTest { + val calls = mutableListOf() + val response = CompletableDeferred() + val unhandled = mutableListOf() + val coordinator = + coordinator( + calls = calls, + responses = { method -> if (method == "talk.client.toolCall") response.await() else "{}" }, + onUnhandledCompletion = unhandled::add, + ) + coordinator.beginSession(RealtimeAgentSession("relay-old", "session-main")) + coordinator.handleToolCall("call-old", "openclaw_agent_consult", null, forced = false) + runCurrent() + assertTrue( + coordinator.handleChatEvent( + sessionKey = "session-main", + runId = "uncertain-run", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"ordinary"}"""), + ), + ) + + coordinator.beginSession(RealtimeAgentSession("relay-new", "session-main")) + response.complete("""{"runId":"old-tool-run"}""") + runCurrent() + + assertEquals(listOf("uncertain-run"), unhandled.map { it.runId }) + assertEquals("final", unhandled.single().state) + assertTrue(calls.none { it.method == "talk.session.submitToolResult" }) + } + + @Test + fun `early completion overflow fails pending calls instead of stranding them`() = + runTest { + val calls = mutableListOf() + val unhandled = mutableListOf() + val responses = List(3) { CompletableDeferred() } + var responseIndex = 0 + val coordinator = + coordinator( + calls = calls, + responses = { method -> + if (method == "talk.client.toolCall") responses[responseIndex++].await() else "{}" + }, + onUnhandledCompletion = unhandled::add, + maxCachedCompletions = 2, + ) + coordinator.beginSession(RealtimeAgentSession("relay-1", "session-1")) + repeat(3) { index -> + coordinator.handleToolCall("call-${index + 1}", "openclaw_agent_consult", null, forced = false) + } + runCurrent() + repeat(3) { index -> + coordinator.handleChatEvent( + sessionKey = "session-1", + runId = "run-${index + 1}", + state = "final", + message = Json.parseToJsonElement("""{"role":"assistant","content":"result-${index + 1}"}"""), + ) + } + runCurrent() + responses.take(2).forEachIndexed { index, response -> response.complete("""{"runId":"run-${index + 1}"}""") } + runCurrent() + + val submittedResults = + calls + .filter { it.method == "talk.session.submitToolResult" } + .associate { call -> + val params = Json.parseToJsonElement(call.params) as JsonObject + params.getValue("callId").jsonPrimitive.content to call.params + } + assertTrue(submittedResults.getValue("call-1").contains("correlation buffer overflow")) + assertTrue(submittedResults.getValue("call-2").contains("correlation buffer overflow")) + assertTrue(submittedResults.getValue("call-3").contains("too many concurrent")) + assertEquals(listOf("run-1", "run-2", "run-3"), unhandled.map { it.runId }) + assertEquals(2, calls.count { it.method == "talk.client.toolCall" }) + } + + @Test + fun `registered runs count against the concurrent call limit`() = + runTest { + val calls = mutableListOf() + var runIndex = 0 + val coordinator = + coordinator( + calls = calls, + responses = { method -> + if (method == "talk.client.toolCall") """{"runId":"run-${++runIndex}"}""" else "{}" + }, + maxCachedCompletions = 2, + ) + coordinator.beginSession(RealtimeAgentSession("relay-1", "session-1")) + + repeat(3) { index -> + coordinator.handleToolCall("call-${index + 1}", "openclaw_agent_consult", null, forced = false) + runCurrent() + } + + assertEquals(2, calls.count { it.method == "talk.client.toolCall" }) + val rejection = calls.single { it.method == "talk.session.submitToolResult" } + assertTrue(rejection.params.contains("\"callId\":\"call-3\"")) + assertTrue(rejection.params.contains("too many concurrent realtime Talk tool calls")) + } + + private fun kotlinx.coroutines.test.TestScope.coordinator( + calls: MutableList, + responses: suspend (String) -> String, + onWorking: (RealtimeAgentSession) -> Unit = {}, + onError: (String) -> Unit = {}, + onUnhandledCompletion: (RealtimeAgentUnhandledCompletion) -> Unit = {}, + maxCachedCompletions: Int = 128, + ) = RealtimeAgentCoordinator( + parentScope = backgroundScope, + requestGateway = { method, params, timeoutMs -> + calls += GatewayCall(method, params.orEmpty(), timeoutMs) + responses(method) + }, + onWorking = onWorking, + onError = { _, message -> onError(message) }, + onUnhandledCompletion = onUnhandledCompletion, + maxCachedCompletions = maxCachedCompletions, + ) + + private data class GatewayCall( + val method: String, + val params: String, + val timeoutMs: Long, + ) +} diff --git a/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt b/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt new file mode 100644 index 0000000..b19d0b0 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt @@ -0,0 +1,44 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkAudioPlayerTest { + @Test + fun resolvesPcmPlaybackFromOutputFormat() { + val mode = + TalkAudioPlayer.resolvePlaybackMode( + outputFormat = "pcm_24000", + mimeType = null, + fileExtension = null, + ) + + assertEquals(TalkPlaybackMode.Pcm(sampleRate = 24_000), mode) + } + + @Test + fun resolvesCompressedPlaybackFromMimeType() { + val mode = + TalkAudioPlayer.resolvePlaybackMode( + outputFormat = null, + mimeType = "audio/mpeg", + fileExtension = null, + ) + + assertEquals(TalkPlaybackMode.Compressed(fileExtension = ".mp3"), mode) + } + + @Test + fun preservesProvidedExtensionForCompressedPlayback() { + val mode = + TalkAudioPlayer.resolvePlaybackMode( + outputFormat = null, + mimeType = "audio/webm", + fileExtension = "webm", + ) + + assertTrue(mode is TalkPlaybackMode.Compressed) + assertEquals(".webm", (mode as TalkPlaybackMode.Compressed).fileExtension) + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt b/app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt new file mode 100644 index 0000000..6a1c47f --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/TalkDirectiveParserTest.kt @@ -0,0 +1,73 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkDirectiveParserTest { + @Test + fun parsesDirectiveAndStripsHeader() { + val input = + """ + {"voice":"voice-123","once":true} + Hello from talk mode. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("voice-123", result.directive?.voiceId) + assertEquals(true, result.directive?.once) + assertEquals("Hello from talk mode.", result.stripped.trim()) + } + + @Test + fun ignoresUnknownKeysButReportsThem() { + val input = + """ + {"voice":"abc","foo":1,"bar":"baz"} + Hi there. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("abc", result.directive?.voiceId) + assertTrue(result.unknownKeys.containsAll(listOf("bar", "foo"))) + } + + @Test + fun parsesAlternateKeys() { + val input = + """ + {"model_id":"eleven_v3","similarity_boost":0.4,"no_speaker_boost":true,"rate":200} + Speak. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("eleven_v3", result.directive?.modelId) + assertEquals(0.4, result.directive?.similarity) + assertEquals(false, result.directive?.speakerBoost) + assertEquals(200, result.directive?.rateWpm) + } + + @Test + fun parsesAliasKeysCaseInsensitively() { + val input = + """ + {"Voice":"voice-abc","NoSpeakerBoost":true,"Language_Code":"en"} + Speak clearly. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("voice-abc", result.directive?.voiceId) + assertEquals(false, result.directive?.speakerBoost) + assertEquals("en", result.directive?.language) + assertEquals(emptyList(), result.unknownKeys) + } + + @Test + fun returnsNullWhenNoDirectivePresent() { + val input = + """ + {} + Hello. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertNull(result.directive) + assertEquals(input, result.stripped) + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt new file mode 100644 index 0000000..7659eab --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -0,0 +1,137 @@ +package ai.openclaw.app.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkModeConfigParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun readsMainSessionKeyAndInterruptFlag() { + val config = + json + .parseToJsonElement( + """ + { + "talk": { + "interruptOnSpeech": true, + "speechLocale": "de_DE", + "silenceTimeoutMs": 1800 + }, + "session": { + "mainKey": "voice-main" + } + } + """.trimIndent(), + ).jsonObject + + val parsed = TalkModeGatewayConfigParser.parse(config) + + assertEquals("voice-main", parsed.mainSessionKey) + assertEquals("de-DE", parsed.speechLocale) + assertEquals(true, parsed.interruptOnSpeech) + assertEquals(1800L, parsed.silenceTimeoutMs) + } + + @Test + fun derivesRealtimeLanguageFromConfiguredLocale() { + assertEquals("de", realtimeTranscriptionLanguage("de-DE")) + assertEquals(null, realtimeTranscriptionLanguage("fil-PH")) + } + + @Test + fun gatesAndroidRealtimeRelayFromEffectiveModel() { + val browserOnly = + json + .parseToJsonElement( + """{"talk":{"realtime":{"model":"gpt-live-future"}}}""", + ).jsonObject + val relayCapable = + json + .parseToJsonElement( + """{"talk":{"realtime":{"model":"gpt-realtime-2.1"}}}""", + ).jsonObject + + assertFalse(TalkModeGatewayConfigParser.parse(browserOnly).realtimeRelayModelSupported) + assertTrue(TalkModeGatewayConfigParser.parse(relayCapable).realtimeRelayModelSupported) + } + + @Test + fun gatesAndroidRealtimeRelayFromProviderLevelModel() { + val providerLevelBrowserOnly = + json + .parseToJsonElement( + """{"talk":{"realtime":{"provider":"openai","providers":{"openai":{"model":"gpt-live-1-codex"}}}}}""", + ).jsonObject + val topLevelWins = + json + .parseToJsonElement( + """{"talk":{"realtime":{"provider":"openai","model":"gpt-realtime-2.1","providers":{"openai":{"model":"gpt-live-1-codex"}}}}}""", + ).jsonObject + + assertFalse(TalkModeGatewayConfigParser.parse(providerLevelBrowserOnly).realtimeRelayModelSupported) + assertTrue(TalkModeGatewayConfigParser.parse(topLevelWins).realtimeRelayModelSupported) + } + + @Test + fun resolvesRealtimeLanguageFromConfigThenWatchThenPhone() { + assertEquals( + "de", + resolveRealtimeTranscriptionLanguageHint( + configuredLocaleTag = "de-DE", + requestedLanguage = "en", + deviceLocaleTag = "fr-FR", + ), + ) + assertEquals( + "en", + resolveRealtimeTranscriptionLanguageHint( + configuredLocaleTag = null, + requestedLanguage = "en", + deviceLocaleTag = "fr-FR", + ), + ) + assertEquals( + "fr", + resolveRealtimeTranscriptionLanguageHint( + configuredLocaleTag = null, + requestedLanguage = null, + deviceLocaleTag = "fr-FR", + ), + ) + } + + @Test + fun defaultsSilenceTimeoutMsWhenMissing() { + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null), + ) + } + + @Test + fun defaultsSilenceTimeoutMsWhenInvalid() { + val talk = buildJsonObject { put("silenceTimeoutMs", 0) } + + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk), + ) + } + + @Test + fun defaultsSilenceTimeoutMsWhenString() { + val talk = buildJsonObject { put("silenceTimeoutMs", "1500") } + + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt new file mode 100644 index 0000000..ecd2a1c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -0,0 +1,1385 @@ +package ai.openclaw.app.voice + +import ai.openclaw.app.gateway.DeviceAuthEntry +import ai.openclaw.app.gateway.DeviceAuthTokenStore +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.gateway.testDeviceIdentityStore +import ai.openclaw.app.i18n.NativeText +import ai.openclaw.app.i18n.nativeText +import ai.openclaw.app.i18n.verbatimText +import android.Manifest +import android.content.ComponentName +import android.content.IntentFilter +import android.os.Bundle +import android.os.SystemClock +import android.speech.RecognitionListener +import android.speech.RecognitionService +import android.speech.SpeechRecognizer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TalkModeManagerTest { + @Test + fun phoneRealtimeRetriesWithoutLanguageWhenOlderGatewayRejectsCreateParams() = + runTest { + val requestedLanguages = mutableListOf() + + val payload = + requestPhoneRealtimeSessionWithLanguageFallback("de") { language -> + requestedLanguages += language + if (requestedLanguages.size == 1) { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "invalid talk.session.create params at root", + ), + ) + } + """{"relaySessionId":"relay-1"}""" + } + + assertEquals("""{"relaySessionId":"relay-1"}""", payload) + assertEquals(listOf("de", null), requestedLanguages) + } + + @Test + fun phoneRealtimeDoesNotRetryUnrelatedGatewayErrors() = + runTest { + var attempts = 0 + + val error = + runCatching { + requestPhoneRealtimeSessionWithLanguageFallback("de") { + attempts += 1 + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "invalid talk.session.appendAudio params", + ), + ) + } + }.exceptionOrNull() + + assertTrue(error is GatewayRequestRejected) + assertEquals(1, attempts) + } + + @Test + fun stopTtsCancelsTrackedPlaybackJob() { + val manager = createManager() + val playbackJob = Job() + + setPrivateField(manager, "ttsJob", playbackJob) + playbackGeneration(manager).set(7L) + + manager.stopTts() + + assertTrue(playbackJob.isCancelled) + assertEquals(8L, playbackGeneration(manager).get()) + } + + @Test + fun disablingPlaybackCancelsTrackedJobOnce() { + val manager = createManager() + val playbackJob = Job() + + setPrivateField(manager, "ttsJob", playbackJob) + playbackGeneration(manager).set(11L) + + manager.setPlaybackEnabled(false) + manager.setPlaybackEnabled(false) + + assertTrue(playbackJob.isCancelled) + assertEquals(12L, playbackGeneration(manager).get()) + } + + @Test + fun beginPushToTalkRejectsNewCaptureWhenNewCaptureIsDisallowed() = + runTest { + val manager = createManager() + + val error = + runCatching { manager.beginPushToTalk(allowNewCapture = false) } + .exceptionOrNull() + + assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", error?.message) + } + + @Test + fun beginPushToTalkReturnsActiveCaptureWhenNewCaptureIsDisallowed() = + runTest { + val manager = createManager() + setPrivateField(manager, "activePttCaptureId", "capture-1") + + val payload = manager.beginPushToTalk(allowNewCapture = false) + + assertEquals("capture-1", payload.captureId) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun beginPushToTalkRejectsInvalidatedCaptureBeforeStarting() = + runTest { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val packageManager = shadowOf(app.packageManager) + val speechService = ComponentName(app, "TestSpeechRecognitionService") + packageManager.addServiceIfNotPresent(speechService) + packageManager.addIntentFilterForService(speechService, IntentFilter(RecognitionService.SERVICE_INTERFACE)) + val manager = createManager() + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val error = + runCatching { + manager.beginPushToTalk( + allowNewCapture = true, + canStartCapture = { false }, + ) + }.exceptionOrNull() + + assertEquals("NODE_BACKGROUND_UNAVAILABLE: command requires foreground", error?.message) + assertNull(readPrivateField(manager, "activePttCaptureId")) + assertFalse(manager.isListening.value) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun stopAllCaptureClearsPttWhenContinuousModeIsDisabled() { + val manager = createManager() + val finishingJob = Job() + setPrivateField(manager, "activePttCaptureId", "capture-1") + setPrivateField(manager, "finishingPttCaptureId", "capture-finishing") + setPrivateField(manager, "finishingPttJob", finishingJob) + setMutableStateFlow(manager, "_isListening", true) + + manager.stopAllCapture() + + assertNull(readPrivateField(manager, "activePttCaptureId")) + assertEquals("capture-finishing", manager.finishingPushToTalkCaptureId) + assertTrue(finishingJob.isCancelled) + assertFalse(manager.isEnabled.value) + assertFalse(manager.isListening.value) + assertEquals("Off", manager.statusText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleCancellationDoesNotStopNewerPushToTalkCapture() = + runTest { + val manager = createManager() + val completion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-new") + setPrivateField(manager, "pttCompletion", completion) + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val payload = manager.cancelPushToTalk("capture-old") + + assertEquals("idle", payload.status) + assertEquals("capture-new", readPrivateField(manager, "activePttCaptureId")) + assertFalse(completion.isCompleted) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun oneShotRetryDoesNotReplaceActivePushToTalkCapture() = + runTest { + val manager = createManager() + val completion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-active") + setPrivateField(manager, "pttCompletion", completion) + + val start = manager.beginPushToTalkOnce() + val payload = manager.awaitPushToTalkOnce(start) + + assertEquals("busy", payload.status) + assertEquals("capture-active", payload.captureId) + assertEquals("capture-active", readPrivateField(manager, "activePttCaptureId")) + assertFalse(completion.isCompleted) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cancelledOneShotWaitCleansItsCapture() = + runTest { + val manager = createManager() + val completion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-1") + setPrivateField(manager, "pttCompletion", completion) + setMutableStateFlow(manager, "_isListening", true) + val start = TalkPttOnceStart.Started(captureId = "capture-1", completion = completion) + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val wait = launch { manager.awaitPushToTalkOnce(start) } + advanceUntilIdle() + wait.cancel() + runCurrent() + wait.join() + + assertNull(readPrivateField(manager, "activePttCaptureId")) + assertNull(readPrivateField(manager, "pttCompletion")) + assertFalse(manager.isListening.value) + assertTrue(completion.isCompleted) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun staleStopDoesNotSubmitNewerPushToTalkCapture() = + runTest { + val manager = createManager() + val completion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-new") + setPrivateField(manager, "pttCompletion", completion) + setPrivateField(manager, "lastTranscript", "new partial transcript") + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val payload = manager.endPushToTalk("capture-old") + + assertEquals("idle", payload.status) + assertEquals("capture-new", readPrivateField(manager, "activePttCaptureId")) + assertEquals("new partial transcript", readPrivateField(manager, "lastTranscript")) + assertFalse(completion.isCompleted) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun segmentDuringPushToTalkReleaseWaitsForEndOfSegmentedSession() { + val manager = createManager() + val releaseCompletion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-1") + setPrivateField(manager, "pttReleaseCompletion", releaseCompletion) + val listener = recognitionListener(manager, "capture-1") + val segment = + Bundle().apply { + putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, arrayListOf("first segment")) + } + + listener.onSegmentResults(segment) + + assertFalse(releaseCompletion.isCompleted) + assertEquals(listOf("first segment"), readPrivateField(manager, "pttFinalSegments")) + + listener.onEndOfSegmentedSession() + + assertTrue(releaseCompletion.isCompleted) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun releaseKeepsWaitingPastOldGraceForLateTerminalSegment() = + runTest { + val manager = createManager(isConnected = { false }) + val releaseCompletion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-1") + setPrivateField(manager, "pttReleaseCompletion", releaseCompletion) + setPrivateField(manager, "pttRecognitionRung", silenceSegmentedRung()) + @Suppress("UNCHECKED_CAST") + (readPrivateField(manager, "pttFinalSegments") as MutableList) += "early segment" + val listener = recognitionListener(manager, "capture-1") + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val ending = async { manager.endPushToTalk("capture-1") } + runCurrent() + + advanceTimeBy(1_200) + listener.onSegmentResults( + Bundle().apply { + putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, arrayListOf("late segment")) + }, + ) + assertFalse(ending.isCompleted) + + listener.onEndOfSegmentedSession() + advanceUntilIdle() + + assertEquals("early segment. late segment", ending.await().transcript) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cancelledEndPushToTalkClearsPendingReleaseBeforeNextBegin() = + runTest { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val packageManager = shadowOf(app.packageManager) + val speechService = ComponentName(app, "TestSpeechRecognitionService") + packageManager.addServiceIfNotPresent(speechService) + packageManager.addIntentFilterForService(speechService, IntentFilter(RecognitionService.SERVICE_INTERFACE)) + val manager = createManager() + setPrivateField(manager, "activePttCaptureId", "capture-a") + setPrivateField(manager, "pttReleaseCompletion", CompletableDeferred()) + setPrivateField(manager, "pttRecognitionRung", silenceSegmentedRung()) + @Suppress("UNCHECKED_CAST") + (readPrivateField(manager, "pttFinalSegments") as MutableList) += "capture a" + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val ending = async { manager.endPushToTalk("capture-a") } + runCurrent() + ending.cancel() + runCurrent() + ending.join() + + assertTrue(ending.isCancelled) + assertNull(readPrivateField(manager, "activePttCaptureId")) + assertNull(readPrivateField(manager, "pttReleaseCompletion")) + assertEquals(emptyList(), readPrivateField(manager, "pttFinalSegments")) + + val started = manager.beginPushToTalk(allowNewCapture = true) + + assertEquals(started.captureId, readPrivateField(manager, "activePttCaptureId")) + assertEquals(emptyList(), readPrivateField(manager, "pttFinalSegments")) + } finally { + manager.stopAllCapture() + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun replacementBeginDrainsPendingReleaseBeforeStartingNewCapture() = + runTest { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val packageManager = shadowOf(app.packageManager) + val speechService = ComponentName(app, "TestSpeechRecognitionService") + packageManager.addServiceIfNotPresent(speechService) + packageManager.addIntentFilterForService(speechService, IntentFilter(RecognitionService.SERVICE_INTERFACE)) + var connectionChecks = 0 + val manager = + createManager( + isConnected = { + connectionChecks += 1 + connectionChecks != 2 + }, + ) + val releaseCompletion = CompletableDeferred() + setPrivateField(manager, "activePttCaptureId", "capture-a") + setPrivateField(manager, "pttReleaseCompletion", releaseCompletion) + setPrivateField(manager, "pttRecognitionRung", silenceSegmentedRung()) + @Suppress("UNCHECKED_CAST") + (readPrivateField(manager, "pttFinalSegments") as MutableList) += "first segment" + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val ending = async { manager.endPushToTalk("capture-a") } + runCurrent() + val starting = async { manager.beginPushToTalk(allowNewCapture = true) } + runCurrent() + + releaseCompletion.complete(Unit) + advanceUntilIdle() + + val ended = ending.await() + val started = starting.await() + assertEquals("offline", ended.status) + assertEquals("first segment", ended.transcript) + assertEquals(started.captureId, readPrivateField(manager, "activePttCaptureId")) + assertEquals(emptyList(), readPrivateField(manager, "pttFinalSegments")) + } finally { + manager.stopAllCapture() + Dispatchers.resetMain() + } + } + + @Test + fun duplicateFinalForPendingTalkRunDoesNotStartAllResponseTts() { + val manager = createManager() + val final = CompletableDeferred() + + manager.ttsOnAllResponses = true + setPrivateField(manager, "pendingRunId", "run-talk") + setPrivateField(manager, "pendingFinal", final) + + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-talk", text = "spoken once")) + assertTrue(final.isCompleted) + assertEquals(0L, playbackGeneration(manager).get()) + + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-talk", text = "spoken once")) + + assertEquals(0L, playbackGeneration(manager).get()) + } + + @Test + fun nonPendingFinalStillUsesAllResponseTts() { + val manager = createManager() + + manager.ttsOnAllResponses = true + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-other", text = "speak this")) + + assertEquals(1L, playbackGeneration(manager).get()) + } + + @Test + fun nonPendingUserFinalDoesNotUseAllResponseTts() { + val manager = createManager() + + manager.ttsOnAllResponses = true + manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-user", text = "do not speak", role = "user")) + + assertEquals(0L, playbackGeneration(manager).get()) + } + + @Test + fun realtimeCloseErrorDisablesTalkButKeepsFailureStatus() { + var stoppedByRelay = false + val manager = createManager(onStoppedByRelay = { stoppedByRelay = true }) + + setPrivateField(manager, "realtimeSessionId", "relay-1") + setMutableStateFlow(manager, "_isEnabled", true) + + manager.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"close","reason":"error"}""", + ) + + assertFalse(manager.isEnabled.value) + assertTrue(stoppedByRelay) + assertEquals( + "Talk failed: Realtime provider closed unexpectedly.", + manager.statusText.value, + ) + } + + @Test + fun realtimeClosePreservesTypedFailureWithoutEnglishPrefix() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + setMutableStateFlow(manager, "_isEnabled", true) + setTalkFailure(manager, verbatimText("Échec de Talk : session refusée.")) + + manager.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"close","reason":"error"}""", + ) + + assertEquals("Échec de Talk : session refusée.", manager.statusText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun localizedOffStatusDoesNotBecomeRealtimeStartFailure() = + runTest { + val manager = createManager(scope = this) + val turn = + async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + manager.runE2eRealtimeTurn( + userText = "ignored", + assistantText = "ignored", + timeoutMs = 250L, + ) + }.exceptionOrNull() + } + + manager.stopAllCapture() + setMutableStateFlow(manager, "_statusText", verbatimText("Désactivé")) + assertEquals("Désactivé", manager.statusText.value) + advanceUntilIdle() + + assertTrue(turn.await() is TimeoutCancellationException) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun realtimePlaybackMarkAcknowledgesAfterQueuedAudioBarrier() = + runTest { + val acknowledgements = mutableListOf>() + val dispatcher = StandardTestDispatcher(testScheduler) + val manager = + createManager( + scope = this, + realtimePlaybackDispatcher = dispatcher, + realtimeMarkAcknowledger = { sessionId, markName -> + acknowledgements += sessionId to markName + }, + ) + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"mark","markName":"audio-1"}""", + ) + runCurrent() + + assertEquals(listOf("relay-1" to "audio-1"), acknowledgements) + } + + @Test + fun realtimeTranscriptsPopulateVoiceConversation() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello world", final = true)) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi there", final = true)) + + assertEquals( + listOf( + VoiceConversationEntry( + id = manager.conversation.value[0].id, + role = VoiceConversationRole.User, + text = "hello world", + ), + VoiceConversationEntry( + id = manager.conversation.value[1].id, + role = VoiceConversationRole.Assistant, + text = "hi there", + ), + ), + manager.conversation.value, + ) + } + + @Test + fun realtimeUserTranscriptsDriveSpeechActive() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + assertFalse(manager.speechActive.value) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello")) + assertTrue(manager.speechActive.value) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello world", final = true)) + assertFalse(manager.speechActive.value) + } + + @Test + fun finalUserTranscriptMarksAwaitingAgentUntilStatusMovesOn() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + assertFalse(manager.awaitingAgent.value) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello", final = true)) + assertTrue(manager.awaitingAgent.value) + // Any later status transition clears the typed flag; forgetting it at a + // new setStatus site fails safe instead of showing a stale Thinking wave. + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi there", final = true)) + manager.stopAllCapture() + assertFalse(manager.awaitingAgent.value) + } + + @Test + fun realtimeTranscriptDeltasAccumulateVoiceConversation() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "The")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = " answer")) + + val entry = manager.conversation.value.single() + assertEquals("The answer", entry.text) + assertTrue(entry.isStreaming) + } + + @Test + fun realtimeTranscriptFragmentsInsertWordSpacing() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Turn off")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "the lights")) + + val entry = manager.conversation.value.single() + assertEquals("Turn off the lights", entry.text) + assertTrue(entry.isStreaming) + } + + @Test + fun realtimeTranscriptFragmentsInsertSpacingAfterPunctuation() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "Ready.")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "What next?")) + + val entry = manager.conversation.value.single() + assertEquals("Ready. What next?", entry.text) + assertTrue(entry.isStreaming) + } + + @Test + fun realtimeFinalTranscriptCanCompleteDeltaText() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "The")) + manager.handleGatewayEvent( + "talk.event", + realtimeTranscriptPayload(role = "assistant", text = " answer", final = true), + ) + + val entry = manager.conversation.value.single() + assertEquals("The answer", entry.text) + assertFalse(entry.isStreaming) + } + + @Test + fun realtimeAssistantOutputSeparatesNextUserBubble() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "First request")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "Checking")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Second request")) + + val entries = manager.conversation.value + assertEquals(3, entries.size) + assertEquals(VoiceConversationRole.User, entries[0].role) + assertEquals("First request", entries[0].text) + assertFalse(entries[0].isStreaming) + assertEquals(VoiceConversationRole.Assistant, entries[1].role) + assertEquals("Checking", entries[1].text) + assertFalse(entries[1].isStreaming) + assertEquals(VoiceConversationRole.User, entries[2].role) + assertEquals("Second request", entries[2].text) + assertTrue(entries[2].isStreaming) + } + + @Test + fun realtimeUserTranscriptRewriteStaysInSameBubble() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Can you tack")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Can you check?", final = true)) + + val entry = manager.conversation.value.single() + assertEquals(VoiceConversationRole.User, entry.role) + assertEquals("Can you check?", entry.text) + assertFalse(entry.isStreaming) + } + + @Test + fun realtimeLateFinalUserTranscriptRewritesBubbleAfterAssistantStarts() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Can you tack")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "Checking")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Can you check?", final = true)) + + val entries = manager.conversation.value + assertEquals(2, entries.size) + assertEquals(VoiceConversationRole.User, entries[0].role) + assertEquals("Can you check?", entries[0].text) + assertFalse(entries[0].isStreaming) + assertEquals(VoiceConversationRole.Assistant, entries[1].role) + assertEquals("Checking", entries[1].text) + } + + @Test + fun realtimeFinalNextUserAfterAssistantStartsCreatesNewBubble() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "First request")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "Checking")) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Second request", final = true)) + + val entries = manager.conversation.value + assertEquals(3, entries.size) + assertEquals(VoiceConversationRole.User, entries[0].role) + assertEquals("First request", entries[0].text) + assertEquals(VoiceConversationRole.Assistant, entries[1].role) + assertEquals("Checking", entries[1].text) + assertEquals(VoiceConversationRole.User, entries[2].role) + assertEquals("Second request", entries[2].text) + assertFalse(entries[2].isStreaming) + } + + @Test + fun realtimeAlternatingTurnsStayInSeparateBubbles() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Hey, what time is it?", final = true)) + manager.handleGatewayEvent( + "talk.event", + realtimeTranscriptPayload( + role = "assistant", + text = "Let me look into that for you. It's currently 7:55 PM UTC.", + final = true, + ), + ) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "How's it going?", final = true)) + manager.handleGatewayEvent( + "talk.event", + realtimeTranscriptPayload( + role = "assistant", + text = "Great! Ready for the next task. What can I do for you?", + final = true, + ), + ) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "Turn on the basement lights", final = true)) + manager.handleGatewayEvent( + "talk.event", + realtimeTranscriptPayload( + role = "assistant", + text = "Got it, let me check on that.", + final = true, + ), + ) + + val entries = manager.conversation.value + assertEquals(6, entries.size) + assertEquals(VoiceConversationRole.User, entries[0].role) + assertEquals("Hey, what time is it?", entries[0].text) + assertEquals(VoiceConversationRole.Assistant, entries[1].role) + assertEquals("Let me look into that for you. It's currently 7:55 PM UTC.", entries[1].text) + assertEquals(VoiceConversationRole.User, entries[2].role) + assertEquals("How's it going?", entries[2].text) + assertEquals(VoiceConversationRole.Assistant, entries[3].role) + assertEquals("Great! Ready for the next task. What can I do for you?", entries[3].text) + assertEquals(VoiceConversationRole.User, entries[4].role) + assertEquals("Turn on the basement lights", entries[4].text) + assertEquals(VoiceConversationRole.Assistant, entries[5].role) + assertEquals("Got it, let me check on that.", entries[5].text) + assertTrue(entries.none { it.isStreaming }) + } + + @Test + fun e2eRealtimeTurnUsesRelayTranscriptPath() = + runTest { + val manager = createManager(scope = this) + + setPrivateField(manager, "realtimeSessionId", "relay-1") + setMutableStateFlow(manager, "_isEnabled", true) + manager.runE2eRealtimeTurn( + userText = "voice e2e user", + assistantText = "voice e2e assistant", + timeoutMs = 1_000L, + ) + + val entries = manager.conversation.value + assertEquals(2, entries.size) + assertEquals(VoiceConversationRole.User, entries[0].role) + assertEquals("voice e2e user", entries[0].text) + assertEquals(VoiceConversationRole.Assistant, entries[1].role) + assertEquals("voice e2e assistant", entries[1].text) + assertTrue(entries.none { it.isStreaming }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun realtimeStartWithoutGatewayTurnsTalkOff() = + runTest { + val stoppedByRelay = AtomicBoolean(false) + val manager = + createManager( + scope = this, + isConnected = { false }, + onStoppedByRelay = { stoppedByRelay.set(true) }, + ) + + setPrivateField(manager, "configLoaded", true) + manager.setEnabled(true) + advanceUntilIdle() + + assertFalse(manager.isEnabled.value) + assertFalse(manager.isListening.value) + assertEquals("Gateway not connected", manager.statusText.value) + assertTrue(stoppedByRelay.get()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun browserOnlyRealtimeConfigStartsNativeTalkInsteadOfRelay() = + runTest { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO) + val packageManager = shadowOf(app.packageManager) + val speechService = ComponentName(app, "TestSpeechRecognitionService") + packageManager.addServiceIfNotPresent(speechService) + packageManager.addIntentFilterForService(speechService, IntentFilter(RecognitionService.SERVICE_INTERFACE)) + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val manager = + createManager( + scope = this, + ) + try { + setPrivateField(manager, "configLoaded", true) + setPrivateField(manager, "realtimeRelayModelSupported", false) + manager.setEnabled(true) + advanceUntilIdle() + + assertTrue(manager.isEnabled.value) + assertTrue(manager.isListening.value) + assertNull(readPrivateField(manager, "realtimeSessionId")) + assertEquals("Listening", manager.statusText.value) + } finally { + manager.setEnabled(false) + Dispatchers.resetMain() + } + } + + @Test + fun textReadyDoesNotEnterSpeakingUntilAudioPlaybackStarts() = + runTest { + val talkSpeakClient = FakeTalkSpeechSynthesizer() + val talkAudioPlayer = FakeTalkAudioPlayer() + val manager = createManager(talkSpeakClient = talkSpeakClient, talkAudioPlayer = talkAudioPlayer) + + val job = launch { manager.speakAssistantReply("hello") } + talkSpeakClient.requested.await() + + assertEquals("Generating voice…", manager.statusText.value) + assertFalse(manager.isSpeaking.value) + + talkSpeakClient.result.complete( + TalkSpeakResult.Success( + TalkSpeakAudio( + bytes = byteArrayOf(1, 2, 3), + provider = "test", + outputFormat = "mp3_44100_128", + voiceCompatible = true, + mimeType = "audio/mpeg", + fileExtension = ".mp3", + ), + ), + ) + talkAudioPlayer.started.await() + + assertEquals("Speaking…", manager.statusText.value) + assertTrue(manager.isSpeaking.value) + + talkAudioPlayer.finished.complete(Unit) + job.join() + } + + @Test + fun realtimeAudioFramesStreamUntilPlaybackStarts() { + val manager = createManager() + + assertFalse(shouldAppendRealtimeCapturedFrame(manager, 0)) + assertTrue(shouldAppendRealtimeCapturedFrame(manager, 16)) + assertTrue(shouldAppendRealtimeCapturedFrame(manager, 4_800)) + + setPrivateField(manager, "realtimePlaybackEndsAtMs", SystemClock.elapsedRealtime() + 1_000) + + assertFalse(shouldAppendRealtimeCapturedFrame(manager, 4_800)) + + setPrivateField(manager, "realtimePlaybackEndsAtMs", SystemClock.elapsedRealtime() - 1) + + assertTrue(shouldAppendRealtimeCapturedFrame(manager, 4_800)) + } + + @Test + fun pushToTalkPauseWaitsForRealtimeCaptureJobs() = + runTest { + val manager = createManager() + val captureJob = Job() + val appendJob = Job() + setPrivateField(manager, "realtimeCaptureJob", captureJob) + setPrivateField(manager, "realtimeAppendJob", appendJob) + setMutableStateFlow(manager, "_isEnabled", true) + + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + + assertTrue(captureJob.isCancelled) + assertTrue(appendJob.isCancelled) + assertNull(readPrivateField(manager, "realtimeCaptureJob")) + assertNull(readPrivateField(manager, "realtimeAppendJob")) + assertTrue(readPrivateField(manager, "realtimeCapturePause") != null) + } + + @Test + fun unconfirmedOutputCancellationClosesRealtimeRelay() = + runTest { + var stoppedByRelay = false + val manager = + createManager( + scope = this, + onStoppedByRelay = { stoppedByRelay = true }, + ) + setPrivateField(manager, "realtimeSessionId", "relay-1") + setMutableStateFlow(manager, "_isEnabled", true) + + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + + assertNull(readPrivateField(manager, "realtimeSessionId")) + val pause = readPrivateField(manager, "realtimeCapturePause")!! + assertEquals("capture-1", readPrivateField(pause, "pttCaptureId")) + assertTrue(readPrivateField(pause, "restartRelay") as Boolean) + assertTrue(manager.isEnabled.value) + assertFalse(stoppedByRelay) + } + + @Test + fun stalePushToTalkCompletionCannotResumeNewerPause() = + runTest { + val manager = createManager() + setMutableStateFlow(manager, "_isEnabled", true) + manager.pauseRealtimeCaptureForPushToTalk("capture-new") + setPrivateField(manager, "activePttCaptureId", "capture-new") + + manager.resumeRealtimeCaptureAfterPushToTalk("capture-old") + + assertTrue(readPrivateField(manager, "realtimeCapturePause") != null) + assertEquals("capture-new", readPrivateField(manager, "activePttCaptureId")) + } + + @Test + fun pushToTalkPauseOutlivesRecognitionWhileRelayConnects() = + runTest { + val manager = createManager() + + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + setPrivateField(manager, "activePttCaptureId", null) + + val pause = readPrivateField(manager, "realtimeCapturePause") + assertTrue(pause != null) + assertNull(readPrivateField(pause!!, "sessionId")) + assertEquals("capture-1", readPrivateField(pause, "pttCaptureId")) + + manager.resumeRealtimeCaptureAfterPushToTalk("capture-1") + + assertNull(readPrivateField(manager, "realtimeCapturePause")) + } + + @Test + fun resumingRealtimeCaptureRestoresListeningState() = + runTest { + val manager = + createManager( + scope = this, + realtimeCaptureDispatcher = StandardTestDispatcher(testScheduler), + ) + setMutableStateFlow(manager, "_isEnabled", true) + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + val pause = readPrivateField(manager, "realtimeCapturePause")!! + setPrivateField(pause, "sessionId", "relay-1") + setPrivateField(manager, "realtimeSessionId", "relay-1") + setMutableStateFlow(manager, "_isListening", false) + setMutableStateFlow(manager, "_statusText", nativeText("Thinking…")) + + manager.resumeRealtimeCaptureAfterPushToTalk("capture-1") + + assertTrue(manager.isListening.value) + assertEquals("Listening", manager.statusText.value) + assertTrue(readPrivateField(manager, "realtimeOutputSuppressed") as Boolean) + + manager.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"transcript","role":"user","text":"stale","final":true}""", + ) + + assertTrue(readPrivateField(manager, "realtimeOutputSuppressed") as Boolean) + + manager.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"inputAudio","byteLength":4800}""", + ) + + assertFalse(readPrivateField(manager, "realtimeOutputSuppressed") as Boolean) + manager.stopAllCapture() + } + + @Test + fun replacementRelayPublishedDuringPushToTalkResumesCapture() = + runTest { + val manager = + createManager( + scope = this, + realtimeCaptureDispatcher = StandardTestDispatcher(testScheduler), + ) + setMutableStateFlow(manager, "_isEnabled", true) + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + val pause = readPrivateField(manager, "realtimeCapturePause")!! + setPrivateField(pause, "sessionId", "relay-replacement") + setPrivateField(pause, "restartRelay", true) + setPrivateField(manager, "realtimeSessionId", "relay-replacement") + + manager.resumeRealtimeCaptureAfterPushToTalk("capture-1") + + assertNull(readPrivateField(manager, "realtimeCapturePause")) + assertTrue(manager.isListening.value) + assertTrue((readPrivateField(manager, "realtimeCaptureJob") as Job).isActive) + assertTrue((readPrivateField(manager, "realtimeAppendJob") as Job).isActive) + manager.stopAllCapture() + } + + @Test + fun stoppedTalkModeDoesNotRestartRelayAfterPushToTalk() = + runTest { + val manager = createManager(scope = this) + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + val pause = readPrivateField(manager, "realtimeCapturePause")!! + setPrivateField(pause, "restartRelay", true) + setPrivateField(manager, "stopRequested", true) + setMutableStateFlow(manager, "_statusText", nativeText("Off")) + + manager.resumeRealtimeCaptureAfterPushToTalk("capture-1") + + assertNull(readPrivateField(manager, "realtimeCapturePause")) + assertNull(readPrivateField(manager, "realtimeSessionId")) + assertFalse(manager.isEnabled.value) + assertEquals("Off", manager.statusText.value) + } + + @Test + fun pausedPushToTalkTurnSuppressesSpeechInterruptListener() = + runTest { + val manager = createManager(scope = this) + assertTrue(manager.shouldAllowSpeechInterrupt()) + + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + + assertFalse(manager.shouldAllowSpeechInterrupt()) + manager.resumeRealtimeCaptureAfterPushToTalk("capture-1") + assertTrue(manager.shouldAllowSpeechInterrupt()) + } + + @Test + fun finishingPushToTalkTurnRejectsReplacementCapture() = + runTest { + val manager = createManager(scope = this) + setPrivateField(manager, "finishingPttCaptureId", "capture-1") + + val error = + runCatching { manager.beginPushToTalk(allowNewCapture = true) } + .exceptionOrNull() + val oneShot = manager.beginPushToTalkOnce() + + assertEquals("PTT_BUSY: previous push-to-talk turn is still finishing", error?.message) + assertTrue(oneShot is TalkPttOnceStart.Busy) + assertEquals("capture-1", (oneShot as TalkPttOnceStart.Busy).payload.captureId) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cancelledQueuedFinalizerResumesOnlyItsRealtimeCaptureOnMain() = + runTest { + val finalizerDispatcher = StandardTestDispatcher() + val manager = + createManager( + scope = CoroutineScope(SupervisorJob() + finalizerDispatcher), + ) + Dispatchers.setMain(Dispatchers.Unconfined) + try { + setMutableStateFlow(manager, "_isEnabled", true) + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + setPrivateField(manager, "activePttCaptureId", "capture-1") + @Suppress("UNCHECKED_CAST") + (readPrivateField(manager, "pttFinalSegments") as MutableList) += "finish this capture" + + val payload = manager.endPushToTalk("capture-1") + val finalizer = readPrivateField(manager, "finishingPttJob") as Job + + assertEquals("queued", payload.status) + assertEquals("capture-1", manager.finishingPushToTalkCaptureId) + assertTrue(readPrivateField(manager, "realtimeCapturePause") != null) + + finalizer.cancel() + finalizerDispatcher.scheduler.runCurrent() + + assertTrue(finalizer.isCancelled) + assertNull(manager.finishingPushToTalkCaptureId) + assertNull(readPrivateField(manager, "realtimeCapturePause")) + assertNull(readPrivateField(manager, "activePttCaptureId")) + } finally { + manager.stopAllCapture() + Dispatchers.resetMain() + } + } + + @Test + fun relayClosePreservesFinishingPushToTalkOwnership() = + runTest { + val manager = createManager(scope = this) + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + setPrivateField(manager, "realtimeSessionId", "relay-1") + setPrivateField(manager, "finishingPttCaptureId", "capture-1") + + manager.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"close","reason":"completed"}""", + ) + + assertNull(readPrivateField(manager, "realtimeCapturePause")) + assertEquals("capture-1", manager.finishingPushToTalkCaptureId) + } + + @Test + fun disconnectedRelayDoesNotResumeAfterPushToTalk() = + runTest { + var stoppedByRelay = false + val manager = + createManager( + scope = this, + isConnected = { false }, + onStoppedByRelay = { stoppedByRelay = true }, + ) + setMutableStateFlow(manager, "_isEnabled", true) + manager.pauseRealtimeCaptureForPushToTalk("capture-1") + val pause = readPrivateField(manager, "realtimeCapturePause")!! + setPrivateField(pause, "sessionId", "relay-1") + setPrivateField(manager, "realtimeSessionId", "relay-1") + setMutableStateFlow(manager, "_isListening", false) + setMutableStateFlow(manager, "_statusText", nativeText("Gateway not connected")) + + manager.resumeRealtimeCaptureAfterPushToTalk("capture-1") + + assertFalse(manager.isListening.value) + assertFalse(manager.isEnabled.value) + assertTrue(stoppedByRelay) + assertEquals("Gateway not connected", manager.statusText.value) + assertNull(readPrivateField(manager, "realtimeSessionId")) + assertNull(readPrivateField(manager, "realtimeCaptureJob")) + assertNull(readPrivateField(manager, "realtimeAppendJob")) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun chatFinalWaitUsesGatewayEventTimeout() = + runTest { + val manager = createManager(scope = this) + + setPrivateField(manager, "pendingRunId", "run-missing-final") + setPrivateField(manager, "pendingFinal", CompletableDeferred()) + + assertFalse(manager.waitForChatFinal("run-missing-final")) + assertEquals(45_000, currentTime) + } + + private fun createManager( + talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(), + talkAudioPlayer: TalkAudioPlaying? = null, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + isConnected: () -> Boolean = { true }, + onStoppedByRelay: () -> Unit = {}, + realtimeCaptureDispatcher: CoroutineDispatcher = Dispatchers.IO, + realtimePlaybackDispatcher: CoroutineDispatcher = Dispatchers.IO, + realtimeMarkAcknowledger: (suspend (String, String) -> Unit)? = null, + ): TalkModeManager { + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = testDeviceIdentityStore(app), + deviceAuthStore = InMemoryDeviceAuthStore(), + onConnected = {}, + onDisconnected = {}, + onEvent = { _, _ -> }, + ) + return TalkModeManager( + context = app, + scope = scope, + session = session, + isConnected = isConnected, + onStoppedByRelay = onStoppedByRelay, + talkSpeakClient = talkSpeakClient, + talkAudioPlayer = talkAudioPlayer ?: TalkAudioPlayer(app), + realtimeCaptureDispatcher = realtimeCaptureDispatcher, + realtimePlaybackDispatcher = realtimePlaybackDispatcher, + realtimeMarkAcknowledger = realtimeMarkAcknowledger, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun playbackGeneration(manager: TalkModeManager) = readPrivateField(manager, "playbackGeneration") as AtomicLong + + private fun setPrivateField( + target: Any, + name: String, + value: Any?, + ) { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + field.set(target, value) + } + + private fun readPrivateField( + target: Any, + name: String, + ): Any? { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + return field.get(target) + } + + private fun setTalkFailure( + manager: TalkModeManager, + text: NativeText, + ) { + val method = manager.javaClass.getDeclaredMethod("setTalkFailure", NativeText::class.java) + method.isAccessible = true + method.invoke(manager, text) + } + + @Suppress("UNCHECKED_CAST") + private fun setMutableStateFlow( + target: Any, + name: String, + value: T, + ) { + (readPrivateField(target, name) as MutableStateFlow).value = value + } + + private fun shouldAppendRealtimeCapturedFrame( + manager: TalkModeManager, + length: Int, + ): Boolean { + val method = + manager.javaClass.getDeclaredMethod( + "shouldAppendRealtimeCapturedFrame", + Int::class.javaPrimitiveType, + ) + method.isAccessible = true + return method.invoke(manager, length) as Boolean + } + + private fun recognitionListener( + manager: TalkModeManager, + captureId: String, + ): RecognitionListener { + val method = manager.javaClass.getDeclaredMethod("recognitionListener", String::class.java) + method.isAccessible = true + return method.invoke(manager, captureId) as RecognitionListener + } + + private fun silenceSegmentedRung(): Any { + val clazz = Class.forName("ai.openclaw.app.voice.PushToTalkRecognitionRung\$SilenceSegmented") + return requireNotNull(clazz.getField("INSTANCE").get(null)) + } + + private fun chatFinalPayload( + runId: String, + text: String, + role: String = "assistant", + ): String = + """ + { + "runId": "$runId", + "sessionKey": "main", + "state": "final", + "message": { + "role": "$role", + "content": [ + { "type": "text", "text": "$text" } + ] + } + } + """.trimIndent() + + private fun realtimeTranscriptPayload( + role: String, + text: String, + final: Boolean = false, + ): String = + """ + { + "relaySessionId": "relay-1", + "type": "transcript", + "role": "$role", + "text": "$text", + "final": $final + } + """.trimIndent() +} + +private class FakeTalkSpeechSynthesizer : TalkSpeechSynthesizing { + val requested = CompletableDeferred() + val result = CompletableDeferred() + + override suspend fun synthesize( + text: String, + directive: TalkDirective?, + ): TalkSpeakResult { + requested.complete(Unit) + return result.await() + } +} + +private class FakeTalkAudioPlayer : TalkAudioPlaying { + val started = CompletableDeferred() + val finished = CompletableDeferred() + var stopped = false + + override suspend fun play(audio: TalkSpeakAudio) { + started.complete(Unit) + finished.await() + } + + override fun stop() { + stopped = true + } +} + +private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { + override fun loadEntry( + gatewayId: String, + deviceId: String, + role: String, + ): DeviceAuthEntry? = null + + override fun saveToken( + gatewayId: String, + deviceId: String, + role: String, + token: String, + scopes: List, + ) = Unit + + override fun clearToken( + gatewayId: String, + deviceId: String, + role: String, + ) = Unit +} diff --git a/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt b/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt new file mode 100644 index 0000000..f09f07c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt @@ -0,0 +1,149 @@ +package ai.openclaw.app.voice + +import ai.openclaw.app.gateway.GatewayErrorDetails +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class TalkSpeakClientTest { + @Test + fun buildsRequestFromDirective() { + val request = + TalkSpeakRequest.from( + text = "Hello from talk mode.", + directive = + TalkDirective( + voiceId = "voice-123", + modelId = "model-abc", + speed = 1.1, + rateWpm = 190, + stability = 0.5, + similarity = 0.7, + style = 0.2, + speakerBoost = true, + seed = 42, + normalize = "auto", + language = "en", + outputFormat = "pcm_24000", + latencyTier = 3, + once = true, + ), + ) + + assertEquals("Hello from talk mode.", request.text) + assertEquals("voice-123", request.voiceId) + assertEquals("model-abc", request.modelId) + assertEquals(1.1, request.speed) + assertEquals(190, request.rateWpm) + assertEquals(0.5, request.stability) + assertEquals(0.7, request.similarity) + assertEquals(0.2, request.style) + assertEquals(true, request.speakerBoost) + assertEquals(42L, request.seed) + assertEquals("auto", request.normalize) + assertEquals("en", request.language) + assertEquals("pcm_24000", request.outputFormat) + assertEquals(3, request.latencyTier) + } + + @Test + fun fallsBackOnlyForUnavailableReasons() = + runTest { + val client = + TalkSpeakClient( + requestDetailed = { _, _, _ -> + GatewaySession.RpcResult( + ok = false, + payloadJson = null, + error = + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "talk unavailable", + details = + GatewayErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = "talk_unconfigured", + ), + ), + ) + }, + ) + + val result = client.synthesize(text = "Hello", directive = null) + assertTrue(result is TalkSpeakResult.FallbackToLocal) + } + + @Test + fun doesNotFallBackForSynthesisFailure() = + runTest { + val client = + TalkSpeakClient( + requestDetailed = { _, _, _ -> + GatewaySession.RpcResult( + ok = false, + payloadJson = null, + error = + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "provider failed", + details = + GatewayErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = "synthesis_failed", + ), + ), + ) + }, + ) + + val result = client.synthesize(text = "Hello", directive = null) + assertTrue(result is TalkSpeakResult.Failure) + } + + @Test + fun fallsBackWhenGatewayOmitsReason() = + runTest { + val client = + TalkSpeakClient( + requestDetailed = { _, _, _ -> + GatewaySession.RpcResult( + ok = false, + payloadJson = null, + error = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: talk.speak", + details = null, + ), + ) + }, + ) + + val result = client.synthesize(text = "Hello", directive = null) + assertTrue(result is TalkSpeakResult.FallbackToLocal) + } + + @Test + fun propagatesRequestCancellation() = + runTest { + val client = + TalkSpeakClient( + requestDetailed = { _, _, _ -> throw CancellationException("talk stopped") }, + ) + + try { + client.synthesize(text = "Hello", directive = null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("talk stopped", err.message) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/VoiceWakeManagerTest.kt b/app/src/test/java/ai/openclaw/app/voice/VoiceWakeManagerTest.kt new file mode 100644 index 0000000..75d5118 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/VoiceWakeManagerTest.kt @@ -0,0 +1,279 @@ +package ai.openclaw.app.voice + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class VoiceWakeManagerTest { + @Test + fun retiredRecognitionSessionDropsLateCallbacks() { + val events = mutableListOf() + val session = VoiceWakeRecognitionSession(events::add) + + session.emit(VoiceWakeRecognitionEvent.Ready) + session.retire() + session.emit(VoiceWakeRecognitionEvent.Transcript("openclaw stale command", isFinal = true)) + + assertEquals(listOf(VoiceWakeRecognitionEvent.Ready), events) + } + + @Test + fun enabledForegroundRecognizerDispatchesCommandAndRestarts() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + val commands = mutableListOf() + val manager = + manager(recognizer = recognizer) { match -> + commands += match + true + } + + manager.setForeground(true) + manager.setEnabled(true) + recognizer.emit(VoiceWakeRecognitionEvent.Ready) + + assertTrue(manager.isListening.value) + assertEquals("Listening", manager.statusText.value) + + recognizer.emit(VoiceWakeRecognitionEvent.Transcript("OpenClaw, show", isFinal = false)) + runCurrent() + assertEquals(emptyList(), commands) + + recognizer.emit(VoiceWakeRecognitionEvent.Transcript("OpenClaw, show status", isFinal = true)) + runCurrent() + + assertEquals(listOf(VoiceWakeMatch("OpenClaw", "show status")), commands) + assertEquals("show status", manager.lastTriggeredCommand.value) + assertEquals(1, recognizer.stopCount) + + advanceUntilIdle() + assertEquals(2, recognizer.startCount) + } + + @Test + fun suppressionStopsAndResumesRecognizer() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + manager.setSuppressed(VoiceWakeSuppressionReason.VoiceCapture, true) + + assertEquals("Paused", manager.statusText.value) + assertEquals(1, recognizer.stopCount) + + manager.setSuppressed(VoiceWakeSuppressionReason.VoiceCapture, false) + assertEquals(2, recognizer.startCount) + } + + @Test + fun suppressionDoesNotHoldManagerLockWhileStoppingRecognizer() = + runTest { + val recognizer = FakeVoiceWakeRecognizer(callbackDuringStop = true) + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + manager.setSuppressed(VoiceWakeSuppressionReason.VoiceCapture, true) + + assertTrue(recognizer.stopCallbackCompleted) + assertEquals("Paused", manager.statusText.value) + } + + @Test + fun oneAudioOwnerCannotReleaseAnotherOwnersSuppression() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + manager.setSuppressed(VoiceWakeSuppressionReason.Camera, true) + manager.setSuppressed(VoiceWakeSuppressionReason.VoiceCapture, true) + manager.setSuppressed(VoiceWakeSuppressionReason.Camera, false) + + assertEquals("Paused", manager.statusText.value) + assertEquals(1, recognizer.startCount) + + manager.setSuppressed(VoiceWakeSuppressionReason.VoiceCapture, false) + assertEquals(2, recognizer.startCount) + } + + @Test + fun staleSuppressionRevisionCannotReleaseNewAudioOwner() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + manager.setSuppressed(VoiceWakeSuppressionReason.Camera, true, revision = 2) + manager.setSuppressed(VoiceWakeSuppressionReason.Camera, false, revision = 1) + + assertEquals("Paused", manager.statusText.value) + assertEquals(1, recognizer.startCount) + } + + @Test + fun suppressionCancelsPendingCommandDispatch() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + var commandStarted = false + var commandCancelled = false + val manager = + manager(recognizer = recognizer) { + commandStarted = true + try { + awaitCancellation() + } finally { + commandCancelled = true + } + } + + manager.setForeground(true) + manager.setEnabled(true) + recognizer.emit(VoiceWakeRecognitionEvent.Transcript("openclaw show status", isFinal = true)) + runCurrent() + assertTrue(commandStarted) + + manager.setSuppressed(VoiceWakeSuppressionReason.VoiceNote, true) + runCurrent() + + assertTrue(commandCancelled) + assertEquals("Paused", manager.statusText.value) + } + + @Test + fun permissionRefreshStartsAfterGrant() = + runTest { + var permissionGranted = false + val recognizer = FakeVoiceWakeRecognizer() + val manager = manager(recognizer = recognizer, hasPermission = { permissionGranted }) + + manager.setForeground(true) + manager.setEnabled(true) + assertEquals("Microphone permission required", manager.statusText.value) + assertEquals(0, recognizer.startCount) + + permissionGranted = true + manager.refreshPermission() + assertEquals(1, recognizer.startCount) + } + + @Test + fun unavailableRecognizerNeverStarts() = + runTest { + val recognizer = FakeVoiceWakeRecognizer(isAvailable = false) + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + + assertFalse(manager.isListening.value) + assertEquals("On-device speech recognition unavailable", manager.statusText.value) + assertEquals(0, recognizer.startCount) + } + + @Test + fun permanentLanguageErrorDoesNotRetry() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + recognizer.emit(VoiceWakeRecognitionEvent.Error(android.speech.SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE)) + advanceUntilIdle() + + assertEquals("On-device language model unavailable", manager.statusText.value) + assertEquals(1, recognizer.startCount) + } + + @Test + fun quotaErrorUsesLongBackoff() = + runTest { + val recognizer = FakeVoiceWakeRecognizer() + val manager = manager(recognizer = recognizer) + + manager.setForeground(true) + manager.setEnabled(true) + recognizer.emit(VoiceWakeRecognitionEvent.Error(android.speech.SpeechRecognizer.ERROR_TOO_MANY_REQUESTS)) + + advanceTimeBy(14_999) + runCurrent() + assertEquals(1, recognizer.startCount) + + advanceTimeBy(1) + runCurrent() + assertEquals(2, recognizer.startCount) + } + + private fun kotlinx.coroutines.test.TestScope.manager( + recognizer: FakeVoiceWakeRecognizer, + hasPermission: () -> Boolean = { true }, + onCommand: suspend (VoiceWakeMatch) -> Boolean = { true }, + ): VoiceWakeManager = + VoiceWakeManager( + context = RuntimeEnvironment.getApplication(), + scope = this, + recognizer = recognizer, + initialTriggerWords = listOf("openclaw"), + onCommand = onCommand, + restartDelayMs = 1, + hasRecordAudioPermission = hasPermission, + ) + + private class FakeVoiceWakeRecognizer( + override val isAvailable: Boolean = true, + private val callbackDuringStop: Boolean = false, + ) : VoiceWakeRecognizer { + var startCount = 0 + var stopCount = 0 + var destroyCount = 0 + var stopCallbackCompleted = false + private var onEvent: ((VoiceWakeRecognitionEvent) -> Unit)? = null + + override fun start( + operationId: Long, + onEvent: (VoiceWakeRecognitionEvent) -> Unit, + ) { + startCount += 1 + this.onEvent = onEvent + } + + override fun stop(operationId: Long) { + stopCount += 1 + if (callbackDuringStop) { + val completed = CountDownLatch(1) + Thread { + onEvent?.invoke(VoiceWakeRecognitionEvent.Error(android.speech.SpeechRecognizer.ERROR_CLIENT)) + completed.countDown() + }.start() + stopCallbackCompleted = completed.await(1, TimeUnit.SECONDS) + } + } + + override fun destroy(operationId: Long) { + destroyCount += 1 + } + + fun emit(event: VoiceWakeRecognitionEvent) { + onEvent?.invoke(event) + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/voice/VoiceWakePreferencesTest.kt b/app/src/test/java/ai/openclaw/app/voice/VoiceWakePreferencesTest.kt new file mode 100644 index 0000000..fd75a8c --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/voice/VoiceWakePreferencesTest.kt @@ -0,0 +1,60 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceWakePreferencesTest { + @Test + fun sanitizeTrimsDropsEmptyAndUsesDefaults() { + assertEquals(listOf("hello", "computer"), VoiceWakePreferences.sanitizeTriggerWords(listOf(" hello ", "", "computer"))) + assertEquals(VoiceWakePreferences.defaultTriggerWords, VoiceWakePreferences.sanitizeTriggerWords(emptyList())) + } + + @Test + fun sanitizePreservesPhrasePunctuationAndNewlines() { + assertEquals( + listOf("hey, openclaw", "line\nbreak"), + VoiceWakePreferences.sanitizeTriggerWords(listOf(" hey, openclaw ", "line\nbreak")), + ) + } + + @Test + fun matcherRequiresWordBoundariesAndCommand() { + assertNull(VoiceWakePhraseMatcher.match("reopenclaw show status", listOf("openclaw"))) + assertNull(VoiceWakePhraseMatcher.match("openclaw", listOf("openclaw"))) + assertNull(VoiceWakePhraseMatcher.match("tell openclaw show status", listOf("openclaw"))) + assertEquals( + VoiceWakeMatch(trigger = "OpenClaw", command = "show status"), + VoiceWakePhraseMatcher.match("Hey OpenClaw, show status", listOf("openclaw")), + ) + } + + @Test + fun matcherUsesEarliestTrigger() { + assertEquals( + VoiceWakeMatch(trigger = "computer", command = "ask claude for status"), + VoiceWakePhraseMatcher.match("computer ask claude for status", listOf("claude", "computer")), + ) + } + + @Test + fun matcherSupportsScriptsWithoutWhitespaceWordBoundaries() { + assertEquals( + VoiceWakeMatch(trigger = "小龙虾", command = "天气怎么样"), + VoiceWakePhraseMatcher.match("小龙虾天气怎么样", listOf("小龙虾")), + ) + assertEquals( + VoiceWakeMatch(trigger = "โอเพนคลอ", command = "สภาพอากาศ"), + VoiceWakePhraseMatcher.match("โอเพนคลอสภาพอากาศ", listOf("โอเพนคลอ")), + ) + } + + @Test + fun matcherNormalizesSpokenPunctuationAndWhitespace() { + assertEquals( + VoiceWakeMatch(trigger = "Hey OpenClaw", command = "show status"), + VoiceWakePhraseMatcher.match("Hey OpenClaw show status", listOf("hey,\nopenclaw")), + ) + } +} diff --git a/app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt b/app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt new file mode 100644 index 0000000..ee02be7 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt @@ -0,0 +1,960 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearConnectionFailure +import ai.openclaw.wear.shared.WearDecodeResult +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearRpcMethod +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WearProxyBridgeTest { + private fun withActorScope(block: suspend CoroutineScope.(CoroutineScope) -> Unit) = + runBlocking { + withTimeout(ACTOR_TEST_TIMEOUT_MILLIS) { + val actorJob = Job(coroutineContext[Job]) + val actorScope = CoroutineScope(actorJob + Dispatchers.Default) + try { + block(actorScope) + } finally { + actorJob.cancelAndJoin() + } + } + } + + private companion object { + const val ACTOR_TEST_TIMEOUT_MILLIS = 30_000L + } + + @Test + fun validRequestRegistersPeerAndReturnsCorrelatedResponse() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { _, request -> + WearMessage.Response( + requestId = request.requestId, + ok = true, + result = buildJsonObject { put("connected", true) }, + ) + }, + ) + + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + + assertEquals(1, bridge.peerCountForTests()) + assertEquals(1, sent.size) + assertEquals("watch-1", sent.single().nodeId) + assertEquals(WearProtocol.RESPONSE_PATH, sent.single().path) + val response = (WearProtocolCodec.decode(sent.single().data) as WearDecodeResult.Success).message as WearMessage.Response + assertEquals("req-1", response.requestId) + assertTrue(!response.eventStreamId.isNullOrBlank()) + assertEquals(0L, response.eventSequence) + } + + @Test + fun malformedMessageDoesNotRegisterPeer() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { _, _ -> error("must not run") }, + ) + + bridge.handleMessage("watch-1", "not-json".encodeToByteArray()) + + assertEquals(0, bridge.peerCountForTests()) + assertTrue(sent.isEmpty()) + } + + @Test + fun responseSendCancellationRetriesWithoutTerminatingActor() = + runTest { + var cancelFirstResponse = true + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = + WearMessageSender { nodeId, path, data -> + if (cancelFirstResponse) { + cancelFirstResponse = false + throw CancellationException("request canceled") + } + sent += SentWearMessage(nodeId, path, data) + }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + assertTrue(bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1")))) + assertEquals(1, bridge.peerCountForTests()) + + assertTrue(bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-2")))) + + assertEquals(listOf(WearProtocol.RESPONSE_PATH, WearProtocol.RESPONSE_PATH), sent.map { it.path }) + } + + @Test + fun eventSendCancellationDoesNotTerminateRequestActor() = + withActorScope { actorScope -> + var cancelFirstEvent = true + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = actorScope, + sender = + WearMessageSender { nodeId, path, data -> + if (path == WearProtocol.EVENT_PATH && cancelFirstEvent) { + cancelFirstEvent = false + throw CancellationException("event canceled") + } + sent += SentWearMessage(nodeId, path, data) + }, + peerResolver = WearPeerResolver { setOf("watch-1") }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + bridge.publishConnection(connected = true, status = "Connected") + bridge.awaitIdleForTests() + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + + assertEquals(listOf(WearProtocol.EVENT_PATH, WearProtocol.RESPONSE_PATH), sent.map { it.path }) + } + + @Test + fun discoveryTaskCancellationDoesNotTerminateEventActor() = + withActorScope { actorScope -> + var cancelFirstDiscovery = true + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = actorScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + peerResolver = + WearPeerResolver { + if (cancelFirstDiscovery) { + cancelFirstDiscovery = false + throw CancellationException("discovery canceled") + } + setOf("watch-1") + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + bridge.publishConnection(connected = true, status = "first") + bridge.publishConnection(connected = true, status = "second") + bridge.awaitIdleForTests() + + assertEquals(listOf(WearProtocol.EVENT_PATH, WearProtocol.EVENT_PATH), sent.map { it.path }) + } + + @Test + fun resyncInvalidatesTheWatchSnapshot() = + withActorScope { actorScope -> + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = actorScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + peerResolver = WearPeerResolver { setOf("watch-1") }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + bridge.publishResync() + bridge.awaitIdleForTests() + + val event = (WearProtocolCodec.decode(sent.single().data) as WearDecodeResult.Success).message as WearMessage.Event + assertEquals(WearProtocol.EVENT_PATH, sent.single().path) + assertEquals(WearEventType.Resync, event.event) + assertEquals(1L, event.sequence) + assertEquals(null, event.payload) + } + + @Test + fun historyResponseWatermarkExcludesEventsQueuedBehindIt() = + runTest { + val sent = mutableListOf() + val requestStarted = CompletableDeferred() + val finishRequest = CompletableDeferred() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { _, request -> + requestStarted.complete(Unit) + finishRequest.await() + WearMessage.Response(requestId = request.requestId, ok = true) + }, + ) + + val requestJob = async { bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) } + runCurrent() + requestStarted.await() + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "new") + }, + ) + runCurrent() + assertTrue(sent.isEmpty()) + + finishRequest.complete(Unit) + requestJob.await() + runCurrent() + + assertEquals(listOf(WearProtocol.RESPONSE_PATH, WearProtocol.EVENT_PATH), sent.map { it.path }) + val response = (WearProtocolCodec.decode(sent[0].data) as WearDecodeResult.Success).message as WearMessage.Response + val event = (WearProtocolCodec.decode(sent[1].data) as WearDecodeResult.Success).message as WearMessage.Event + assertEquals(response.eventStreamId, event.streamId) + assertEquals(0L, response.eventSequence) + assertEquals(1L, event.sequence) + } + + @Test + fun chatStreamProjectionCarriesCanonicalTextAndFallbackCompleteness() { + val projector = WearChatStreamProjector() + val first = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("runId", "run-1") + put("state", "delta") + put("deltaText", "Hel") + put( + "message", + buildJsonObject { + put("role", "assistant") + put("content", "Hel") + }, + ) + }, + ), + ) + val continued = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("runId", "run-1") + put("state", "delta") + put("deltaText", "lo") + }, + ), + ) + val unknownPrefix = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("runId", "run-2") + put("state", "delta") + put("deltaText", "tail") + }, + ), + ) + + assertEquals("Hel", first.getValue("streamText").jsonPrimitive.content) + assertEquals("true", first.getValue("streamTextComplete").jsonPrimitive.content) + assertEquals("Hello", continued.getValue("streamText").jsonPrimitive.content) + assertEquals("true", continued.getValue("streamTextComplete").jsonPrimitive.content) + assertEquals("tail", unknownPrefix.getValue("streamText").jsonPrimitive.content) + assertEquals("false", unknownPrefix.getValue("streamTextComplete").jsonPrimitive.content) + } + + @Test + fun foreignFinalPreservesTheActiveWatchStream() { + assertForeignTerminalPreservesActiveStream("final") + } + + @Test + fun foreignAbortPreservesTheActiveWatchStream() { + assertForeignTerminalPreservesActiveStream("aborted") + } + + @Test + fun foreignErrorPreservesTheActiveWatchStream() { + assertForeignTerminalPreservesActiveStream("error") + } + + @Test + fun foreignFinalPreservesAnAnonymousWatchStream() { + assertForeignTerminalPreservesAnonymousStream("final") + } + + @Test + fun foreignAbortPreservesAnAnonymousWatchStream() { + assertForeignTerminalPreservesAnonymousStream("aborted") + } + + @Test + fun foreignErrorPreservesAnAnonymousWatchStream() { + assertForeignTerminalPreservesAnonymousStream("error") + } + + @Test + fun identifiedTerminalClearsItsOwnStreamWithoutErasingAnotherRun() { + val projector = WearChatStreamProjector() + projectStreamEvent(projector, state = "delta", runId = "older-run", text = "Old", message = "Old") + projectStreamEvent(projector, state = "delta", runId = "active-run", text = "Hel", message = "Hel") + + projectStreamEvent(projector, state = "final", runId = "older-run") + + val active = projectStreamEvent(projector, state = "delta", runId = "active-run", text = "lo") + val retired = projectStreamEvent(projector, state = "delta", runId = "older-run", text = "new") + assertEquals("Hello", active.getValue("streamText").jsonPrimitive.content) + assertEquals("true", active.getValue("streamTextComplete").jsonPrimitive.content) + assertEquals("new", retired.getValue("streamText").jsonPrimitive.content) + assertEquals("false", retired.getValue("streamTextComplete").jsonPrimitive.content) + } + + @Test + fun unidentifiedTerminalClearsEveryStreamInItsSession() { + val projector = WearChatStreamProjector() + projectStreamEvent(projector, state = "delta", runId = "older-run", text = "old", message = "old") + projectStreamEvent(projector, state = "delta", runId = "active-run", text = "stale", message = "stale") + + projectStreamEvent(projector, state = "final") + + val next = projectStreamEvent(projector, state = "delta", runId = "active-run", text = "fresh") + assertEquals("fresh", next.getValue("streamText").jsonPrimitive.content) + assertEquals("false", next.getValue("streamTextComplete").jsonPrimitive.content) + } + + @Test + fun runIdLessDeltasUseSessionSnapshotAndKeepExactAppendSemantics() { + val projector = WearChatStreamProjector() + + val first = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "a") + }, + ), + ) + val second = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "a") + }, + ), + ) + val identified = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("runId", "run-now-known") + put("state", "delta") + put("deltaText", "a") + }, + ), + ) + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("runId", "run-now-known") + put("state", "final") + }, + ), + ) + val afterFinal = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "a") + }, + ), + ) + + assertEquals("a", first.getValue("streamText").jsonPrimitive.content) + assertEquals("aa", second.getValue("streamText").jsonPrimitive.content) + assertEquals("aaa", identified.getValue("streamText").jsonPrimitive.content) + assertEquals("a", afterFinal.getValue("streamText").jsonPrimitive.content) + } + + @Test + fun runIdLessDeltaContinuesTheActiveIdentifiedStream() { + val projector = WearChatStreamProjector() + + fun delta( + text: String, + runId: String? = null, + ): JsonObject = + buildJsonObject { + put("sessionKey", "main") + runId?.let { put("runId", it) } + put("state", "delta") + put("deltaText", text) + } + + checkNotNull(projector.project(delta("H", runId = "run-1"))) + val anonymous = checkNotNull(projector.project(delta("i"))) + val identified = checkNotNull(projector.project(delta("!", runId = "run-1"))) + + assertEquals("Hi", anonymous.getValue("streamText").jsonPrimitive.content) + assertEquals("Hi!", identified.getValue("streamText").jsonPrimitive.content) + } + + @Test + fun connectionResetClearsRunIdLessStreamState() { + val projector = WearChatStreamProjector() + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "stale") + }, + ), + ) + + projector.reset() + val next = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "fresh") + }, + ), + ) + + assertEquals("fresh", next.getValue("streamText").jsonPrimitive.content) + } + + @Test + fun coldBridgeDiscoversReachableWatchBeforeBackgroundEvent() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + peerResolver = WearPeerResolver { setOf("watch-1") }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "final") + }, + ) + runCurrent() + + assertEquals("watch-1", sent.single().nodeId) + assertEquals(WearProtocol.EVENT_PATH, sent.single().path) + } + + @Test + fun noWatchDiscoveryIsRateLimitedAcrossStreamEvents() = + runTest { + var resolutions = 0 + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { _, _, _ -> error("must not send") }, + peerResolver = + WearPeerResolver { + resolutions += 1 + emptySet() + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + repeat(20) { index -> + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "$index") + }, + ) + } + runCurrent() + + assertEquals(1, resolutions) + } + + @Test + fun terminalEventBypassesCachedEmptyPeerDiscovery() = + runTest { + val sent = mutableListOf() + var resolutions = 0 + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + peerResolver = + WearPeerResolver { + resolutions += 1 + if (resolutions == 1) emptySet() else setOf("watch-1") + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "working") + }, + ) + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "final") + }, + ) + runCurrent() + + assertEquals(2, resolutions) + assertEquals("watch-1", sent.single().nodeId) + val event = (WearProtocolCodec.decode(sent.single().data) as WearDecodeResult.Success).message as WearMessage.Event + assertEquals(2L, event.sequence) + } + + @Test + fun terminalEventDiscoversAnotherWatchWhileRememberedPeerIsHealthy() = + runTest { + val sent = mutableListOf() + var resolutions = 0 + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + peerResolver = + WearPeerResolver { + resolutions += 1 + setOf("watch-1", "watch-2") + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + sent.clear() + + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "final") + }, + ) + bridge.awaitIdleForTests() + + assertEquals(1, resolutions) + assertEquals(listOf("watch-1", "watch-2"), sent.map { it.nodeId }) + assertTrue(sent.all { it.path == WearProtocol.EVENT_PATH }) + } + + @Test + fun staleRememberedPeerTriggersDiscoveryAndCurrentEventRetry() = + runTest { + val attempts = mutableListOf() + var resolutions = 0 + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = + WearMessageSender { nodeId, path, data -> + attempts += SentWearMessage(nodeId, path, data) + if (nodeId == "watch-stale" && path == WearProtocol.EVENT_PATH) { + error("stale node") + } + }, + peerResolver = + WearPeerResolver { + resolutions += 1 + setOf("watch-current") + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + bridge.handleMessage("watch-stale", WearProtocolCodec.encode(request("req-1"))) + attempts.clear() + + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "final") + }, + ) + bridge.awaitIdleForTests() + + // Terminal delivery refreshes before sending; the stale failure then forces a second + // discovery so a watch that appeared during that send still receives the terminal state. + assertEquals(2, resolutions) + assertEquals(listOf("watch-stale", "watch-current"), attempts.map { it.nodeId }) + assertTrue(attempts.all { it.path == WearProtocol.EVENT_PATH }) + } + + @Test + fun partialPeerFailureRediscoversAndRetriesOnlyTheFailedWatch() = + runTest { + val attempts = mutableListOf() + var failSecondWatch = true + var resolutions = 0 + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = + WearMessageSender { nodeId, path, _ -> + if (path != WearProtocol.EVENT_PATH) return@WearMessageSender + attempts += nodeId + if (nodeId == "watch-2" && failSecondWatch) { + failSecondWatch = false + error("transient") + } + }, + peerResolver = + WearPeerResolver { + resolutions += 1 + setOf("watch-1", "watch-2") + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + bridge.handleMessage("watch-2", WearProtocolCodec.encode(request("req-2"))) + attempts.clear() + + bridge.publishConnection(connected = true, status = "Connected") + runCurrent() + + assertEquals(1, resolutions) + assertEquals(listOf("watch-1", "watch-2", "watch-2"), attempts) + } + + @Test + fun queueOverflowRetainsTerminalEventAndEmitsResync() = + withActorScope { actorScope -> + val sent = mutableListOf() + val requestStarted = CompletableDeferred() + val finishRequest = CompletableDeferred() + val bridge = + WearProxyBridge( + scope = actorScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> + requestStarted.complete(Unit) + finishRequest.await() + WearMessage.Response(requestId = request.requestId, ok = true) + }, + ) + val requestJob = async { bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) } + requestStarted.await() + + repeat(40) { index -> + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "delta") + put("deltaText", "$index") + }, + ) + } + bridge.publishChat( + buildJsonObject { + put("sessionKey", "main") + put("state", "final") + put( + "message", + buildJsonObject { + put("role", "assistant") + put("content", "done") + }, + ) + }, + ) + finishRequest.complete(Unit) + requestJob.await() + bridge.awaitIdleForTests() + + val events = + sent + .filter { it.path == WearProtocol.EVENT_PATH } + .map { (WearProtocolCodec.decode(it.data) as WearDecodeResult.Success).message as WearMessage.Event } + assertTrue(events.any { it.event == WearEventType.Resync }) + assertTrue( + events.any { event -> + event.event == WearEventType.Chat && + event.payload + ?.jsonObject + ?.get("state") + ?.jsonPrimitive + ?.content == "final" + }, + ) + assertEquals(events.map { it.sequence }.sorted(), events.map { it.sequence }) + assertEquals(WearEventType.Resync, events.last().event) + } + + @Test + fun eventQueuePreservesSequenceAndBoundsPeers() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + repeat(9) { index -> bridge.handleMessage("watch-$index", WearProtocolCodec.encode(request("req-$index"))) } + sent.clear() + + bridge.publishConnection(connected = true, status = "Connected") + bridge.publishChat( + buildJsonObject { + put("runId", "run-1") + put("sessionKey", "main") + put("seq", 1) + put("state", "delta") + put("deltaText", "hello") + put("privateField", "drop me") + }, + ) + runCurrent() + + assertEquals(8, bridge.peerCountForTests()) + val events = + sent + .filter { it.path == WearProtocol.EVENT_PATH } + .map { (WearProtocolCodec.decode(it.data) as WearDecodeResult.Success).message as WearMessage.Event } + assertEquals(16, events.size) + assertEquals(setOf(1L, 2L), events.map { it.sequence }.toSet()) + assertEquals(setOf(WearEventType.Connection, WearEventType.Chat), events.map { it.event }.toSet()) + val chat = events.first { it.event == WearEventType.Chat } + val payload = checkNotNull(chat.payload).jsonObject + assertEquals( + setOf("runId", "sessionKey", "seq", "state", "deltaText", "streamText", "streamTextComplete"), + payload.keys, + ) + assertEquals("hello", payload.getValue("deltaText").jsonPrimitive.content) + assertEquals("hello", payload.getValue("streamText").jsonPrimitive.content) + } + + @Test + fun connectionEventsCarrySemanticFailureReasons() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = + WearMessageSender { nodeId, path, data -> + sent += SentWearMessage(nodeId, path, data) + }, + peerResolver = WearPeerResolver { setOf("watch-1") }, + handleRequest = { _, request -> + WearMessage.Response(requestId = request.requestId, ok = true) + }, + ) + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + sent.clear() + + bridge.publishConnection( + connected = false, + status = "Update required", + failure = WearConnectionFailure.Incompatible, + ) + bridge.awaitIdleForTests() + + val event = + sent + .single { it.path == WearProtocol.EVENT_PATH } + .let { (WearProtocolCodec.decode(it.data) as WearDecodeResult.Success).message } + as WearMessage.Event + val payload = checkNotNull(event.payload).jsonObject + assertEquals(false, payload.getValue("connected").jsonPrimitive.boolean) + assertEquals("incompatible", payload.getValue("failure").jsonPrimitive.content) + } + + @Test + fun connectionFailurePreservesProtocolMismatchAndLegacyUpdateSignals() { + assertEquals( + WearConnectionFailure.Incompatible, + wearConnectionFailure(problemCode = "PROTOCOL_MISMATCH", status = "Connection failed"), + ) + assertEquals( + WearConnectionFailure.Incompatible, + wearConnectionFailure(problemCode = null, status = "Update required"), + ) + assertEquals( + WearConnectionFailure.GatewayOffline, + wearConnectionFailure(problemCode = null, status = "Offline"), + ) + } + + @Test + fun canceledGoogleTaskResumesAsSendFailure() = + runTest { + val failure = runCatching { Tasks.forCanceled().awaitWearTask() }.exceptionOrNull() + + assertTrue(failure is WearTaskCanceledException) + } + + @Test + fun callerCancellationWinsLaterGoogleTaskCompletion() = + runTest { + val source = TaskCompletionSource() + val awaiting = backgroundScope.async { source.task.awaitWearTask() } + runCurrent() + + awaiting.cancel() + runCurrent() + source.setResult(1) + runCurrent() + + assertTrue(awaiting.isCancelled) + } + + @Test + fun staleEventFailureDoesNotRemoveRefreshedPeer() = + withActorScope { actorScope -> + val eventStarted = CompletableDeferred() + val releaseEvent = CompletableDeferred() + val sent = mutableListOf() + var failFirstEvent = true + val bridge = + WearProxyBridge( + scope = actorScope, + sender = + WearMessageSender { nodeId, path, data -> + if (path == WearProtocol.EVENT_PATH && failFirstEvent) { + failFirstEvent = false + eventStarted.complete(Unit) + releaseEvent.await() + error("stale send failed") + } + sent += SentWearMessage(nodeId, path, data) + }, + monotonicMillis = { 1_000L }, + handleRequest = { _, request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + + bridge.publishConnection(connected = true, status = "Connected") + eventStarted.await() + val refreshed = + async(start = CoroutineStart.UNDISPATCHED) { + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-2"))) + } + releaseEvent.complete(Unit) + assertTrue(refreshed.await()) + bridge.awaitIdleForTests() + + bridge.publishConnection(connected = false, status = "Offline") + bridge.awaitIdleForTests() + + assertEquals(1, bridge.peerCountForTests()) + // The stale send is retried after discovery, then the later offline event also delivers. + assertEquals(2, sent.count { it.path == WearProtocol.EVENT_PATH }) + } + + private fun assertForeignTerminalPreservesActiveStream(state: String) { + val projector = WearChatStreamProjector() + projectStreamEvent(projector, state = "delta", runId = "active-run", text = "Hel", message = "Hel") + + projectStreamEvent(projector, state = state, runId = "older-run") + + val continued = projectStreamEvent(projector, state = "delta", runId = "active-run", text = "lo") + assertEquals("Hello", continued.getValue("streamText").jsonPrimitive.content) + assertEquals("true", continued.getValue("streamTextComplete").jsonPrimitive.content) + } + + private fun assertForeignTerminalPreservesAnonymousStream(state: String) { + val projector = WearChatStreamProjector() + projectStreamEvent(projector, state = "delta", text = "Hel", message = "Hel") + + projectStreamEvent(projector, state = state, runId = "older-run") + + val continued = projectStreamEvent(projector, state = "delta", text = "lo") + assertEquals("Hello", continued.getValue("streamText").jsonPrimitive.content) + assertEquals("true", continued.getValue("streamTextComplete").jsonPrimitive.content) + } + + private fun projectStreamEvent( + projector: WearChatStreamProjector, + state: String, + runId: String? = null, + text: String? = null, + message: String? = null, + ): JsonObject = + checkNotNull( + projector.project( + buildJsonObject { + put("sessionKey", "main") + runId?.let { put("runId", it) } + put("state", state) + text?.let { put("deltaText", it) } + message?.let { fullText -> + put( + "message", + buildJsonObject { + put("role", "assistant") + put("content", fullText) + }, + ) + } + }, + ), + ) + + private fun request(requestId: String): WearMessage.Request = WearMessage.Request(requestId = requestId, method = WearRpcMethod.ProxyStatus) +} + +private data class SentWearMessage( + val nodeId: String, + val path: String, + val data: ByteArray, +) diff --git a/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt b/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt new file mode 100644 index 0000000..acb2cec --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt @@ -0,0 +1,903 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import ai.openclaw.wear.shared.WearRpcMethod +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearProxyControllerTest { + private val json = Json + + @Test + fun statusDoesNotTouchGateway() = + runTest { + var gatewayCalls = 0 + val controller = + WearProxyController( + requestGateway = { _, _ -> + gatewayCalls += 1 + buildJsonObject {} + }, + isGatewayConnected = { false }, + gatewayStatusText = { "Offline" }, + ) + + val response = controller.handle(request(WearRpcMethod.ProxyStatus)) + + assertTrue(response.ok) + assertEquals(0, gatewayCalls) + val result = checkNotNull(response.result).jsonObject + assertEquals( + false, + result + .getValue("connected") + .jsonPrimitive + .content + .toBoolean(), + ) + assertEquals("Offline", result.getValue("status").jsonPrimitive.content) + assertEquals( + WearProxyCapability.entries.map(WearProxyCapability::wireValue), + result.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }, + ) + } + + @Test + fun agentsAndGatewayControlsStayOnThePhoneRuntimeBoundary() = + runTest { + var gatewayRequests = 0 + var connected = true + var selectedAgent = "main" + val controller = + WearProxyController( + requestGateway = { _, _ -> + gatewayRequests += 1 + buildJsonObject {} + }, + isGatewayConnected = { connected }, + gatewayStatusText = { if (connected) "Connected" else "Offline" }, + activeAgentId = { selectedAgent }, + activeSessionKey = { "agent:$selectedAgent:main" }, + selectedModelRef = { "openai/gpt-test" }, + agents = { + listOf( + WearProxyAgent(id = "main", name = "Main", emoji = "*"), + WearProxyAgent(id = "ops", name = "Ops", emoji = null), + ) + }, + selectGatewayAgent = { agentId -> + selectedAgent = agentId + true + }, + connectGateway = { connected = true }, + disconnectGateway = { connected = false }, + ) + + val status = controller.handle(request(WearRpcMethod.ProxyStatus)) + val agents = controller.handle(request(WearRpcMethod.AgentsList)) + val selected = + controller.handle( + request( + WearRpcMethod.AgentsSelect, + buildJsonObject { put("agentId", "ops") }, + ), + ) + val selectedStatus = controller.handle(request(WearRpcMethod.ProxyStatus)) + val disconnected = controller.handle(request(WearRpcMethod.GatewayDisconnect)) + val reconnected = controller.handle(request(WearRpcMethod.GatewayConnect)) + + val statusResult = checkNotNull(status.result).jsonObject + val agentsResult = checkNotNull(agents.result).jsonObject + val selectedResult = checkNotNull(selected.result).jsonObject + val selectedStatusResult = checkNotNull(selectedStatus.result).jsonObject + val disconnectedResult = checkNotNull(disconnected.result).jsonObject + val reconnectedResult = checkNotNull(reconnected.result).jsonObject + + assertEquals("main", statusResult.getValue("activeAgentId").jsonPrimitive.content) + assertEquals("agent:main:main", statusResult.getValue("activeSessionKey").jsonPrimitive.content) + assertEquals("openai/gpt-test", statusResult.getValue("selectedModelRef").jsonPrimitive.content) + assertEquals(2, agentsResult.getValue("agents").jsonArray.size) + assertEquals("ops", selectedResult.getValue("activeAgentId").jsonPrimitive.content) + assertEquals("ops", selectedStatusResult.getValue("activeAgentId").jsonPrimitive.content) + assertEquals("agent:ops:main", selectedStatusResult.getValue("activeSessionKey").jsonPrimitive.content) + assertEquals("openai/gpt-test", selectedStatusResult.getValue("selectedModelRef").jsonPrimitive.content) + assertFalse( + disconnectedResult + .getValue("connected") + .jsonPrimitive.content + .toBoolean(), + ) + assertTrue( + reconnectedResult + .getValue("connected") + .jsonPrimitive.content + .toBoolean(), + ) + assertEquals(0, gatewayRequests) + } + + @Test + fun boundedAgentListPreservesTheActiveAgent() = + runTest { + val activeAgentId = "agent-32" + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + activeAgentId = { activeAgentId }, + agents = { + listOf(WearProxyAgent(id = " ", name = "Invalid", emoji = null)) + + (0..32).map { index -> + WearProxyAgent(id = "agent-$index", name = "Agent $index", emoji = null) + } + }, + ) + + val response = controller.handle(request(WearRpcMethod.AgentsList)) + val agents = checkNotNull(response.result).jsonObject.getValue("agents").jsonArray + + assertEquals(32, agents.size) + assertTrue( + agents.any { agent -> + val value = agent.jsonObject + value.getValue("id").jsonPrimitive.content == activeAgentId && + value + .getValue("selected") + .jsonPrimitive + .content + .toBoolean() + }, + ) + } + + @Test + fun modelListAndSelectionStayBoundToTheRequestedSession() = + runTest { + var selection: Pair? = null + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + models = { + listOf( + WearProxyModel(ref = "openai/gpt-a", name = "GPT A"), + WearProxyModel(ref = "openai/gpt-b", name = "GPT B"), + ) + }, + selectSessionModel = { sessionKey, modelRef -> + selection = sessionKey to modelRef + true + }, + ) + + val listed = controller.handle(request(WearRpcMethod.ModelsList)) + val selected = + controller.handle( + request( + WearRpcMethod.ModelsSelect, + buildJsonObject { + put("sessionKey", "agent:main:thread-7") + put("modelRef", "openai/gpt-b") + }, + ), + ) + + assertEquals( + listOf("openai/gpt-a", "openai/gpt-b"), + checkNotNull(listed.result) + .jsonObject + .getValue("models") + .jsonArray + .map { + it.jsonObject + .getValue("ref") + .jsonPrimitive.content + }, + ) + assertTrue(selected.ok) + assertEquals("agent:main:thread-7" to "openai/gpt-b", selection) + assertEquals( + "openai/gpt-b", + checkNotNull(selected.result) + .jsonObject + .getValue("selectedModelRef") + .jsonPrimitive.content, + ) + } + + @Test + fun modelReferencesUseOneCanonicalNonLossyValue() = + runTest { + var selection: Pair? = null + val overlongRef = "m".repeat(201) + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + selectedModelRef = { " openai/gpt-a " }, + models = { + listOf( + WearProxyModel(ref = " openai/gpt-a ", name = "GPT A"), + WearProxyModel(ref = "openai/gpt-a", name = "Duplicate"), + WearProxyModel(ref = overlongRef, name = "Too long"), + ) + }, + selectSessionModel = { sessionKey, modelRef -> + selection = sessionKey to modelRef + true + }, + ) + + val status = controller.handle(request(WearRpcMethod.ProxyStatus)) + val listed = controller.handle(request(WearRpcMethod.ModelsList)) + val selected = + controller.handle( + request( + WearRpcMethod.ModelsSelect, + buildJsonObject { + put("sessionKey", "agent:main:thread-7") + put("modelRef", "openai/gpt-a") + }, + ), + ) + + assertEquals( + "openai/gpt-a", + checkNotNull(status.result) + .jsonObject + .getValue("selectedModelRef") + .jsonPrimitive + .content, + ) + assertEquals( + listOf("openai/gpt-a"), + checkNotNull(listed.result) + .jsonObject + .getValue("models") + .jsonArray + .map { + it.jsonObject + .getValue("ref") + .jsonPrimitive + .content + }, + ) + assertTrue(selected.ok) + assertEquals("agent:main:thread-7" to "openai/gpt-a", selection) + } + + @Test + fun modelListCapCentersAWindowOnTheWatchSelectedSessionsModel() = + runTest { + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + selectedModelRef = { "openai/gpt-0" }, + models = { + (0 until 60).map { index -> + WearProxyModel(ref = "openai/gpt-$index", name = "GPT $index") + } + }, + ) + + val listed = + controller.handle( + request( + WearRpcMethod.ModelsList, + buildJsonObject { put("selectedModelRef", "openai/gpt-59") }, + ), + ) + val refs = + checkNotNull(listed.result) + .jsonObject + .getValue("models") + .jsonArray + .map { model -> + model.jsonObject + .getValue("ref") + .jsonPrimitive + .content + } + + assertEquals(50, refs.size) + assertTrue("openai/gpt-59" in refs) + assertEquals("openai/gpt-10", refs.first()) + assertEquals("openai/gpt-59", refs.last()) + } + + @Test + fun modelListWindowKeepsAdjacentModelsReachableAcrossTheCap() = + runTest { + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + models = { + (0 until 120).map { index -> + WearProxyModel(ref = "openai/gpt-$index", name = "GPT $index") + } + }, + ) + + val listed = + controller.handle( + request( + WearRpcMethod.ModelsList, + buildJsonObject { put("selectedModelRef", "openai/gpt-49") }, + ), + ) + val refs = + checkNotNull(listed.result) + .jsonObject + .getValue("models") + .jsonArray + .map { model -> + model.jsonObject + .getValue("ref") + .jsonPrimitive.content + } + + val selectedIndex = refs.indexOf("openai/gpt-49") + assertEquals("openai/gpt-48", refs[selectedIndex - 1]) + assertEquals("openai/gpt-50", refs[selectedIndex + 1]) + } + + @Test + fun modelSelectionRejectsAStaleModelBeforePatchingTheSession() = + runTest { + var selections = 0 + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + models = { listOf(WearProxyModel(ref = "openai/gpt-a", name = "GPT A")) }, + selectSessionModel = { _, _ -> + selections += 1 + true + }, + ) + + val response = + controller.handle( + request( + WearRpcMethod.ModelsSelect, + buildJsonObject { + put("sessionKey", "agent:main:thread-7") + put("modelRef", "openai/removed") + }, + ), + ) + + assertFalse(response.ok) + assertEquals("not_found", response.error?.code) + assertEquals(0, selections) + } + + @Test + fun talkStartBindsTheWatchNodeAndSelectedSession() = + runTest { + var startArgs: List? = null + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + startRealtimeTalk = { nodeId, sessionKey, attemptId, language, attemptScopedAudio -> + startArgs = listOf(nodeId, sessionKey, attemptId, language, attemptScopedAudio.toString()) + WearRealtimeTalkSnapshot(attemptId = attemptId, active = true) + }, + ) + + val response = + controller.handle( + request( + WearRpcMethod.TalkStart, + buildJsonObject { + put("sessionKey", "agent:main:thread-7") + put("attemptId", "attempt-7") + put("language", "DE") + put("attemptScopedAudio", true) + }, + ), + sourceNodeId = "watch-a", + ) + + assertTrue(response.ok) + assertEquals(listOf("watch-a", "agent:main:thread-7", "attempt-7", "de", "true"), startArgs) + assertTrue( + checkNotNull(response.result) + .jsonObject + .getValue("active") + .jsonPrimitive + .content + .toBoolean(), + ) + } + + @Test + fun talkStartRejectsAMissingSessionBeforeStarting() = + runTest { + var starts = 0 + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + startRealtimeTalk = { _, _, _, _, _ -> + starts += 1 + WearRealtimeTalkSnapshot(active = true) + }, + ) + + val response = controller.handle(request(WearRpcMethod.TalkStart), sourceNodeId = "watch-a") + + assertFalse(response.ok) + assertEquals("invalid_request", response.error?.code) + assertEquals(0, starts) + } + + @Test + fun legacyTalkStartDefaultsToTheFixedAudioChannel() = + runTest { + var attemptScopedAudio: Boolean? = null + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + startRealtimeTalk = { _, _, attemptId, _, scoped -> + attemptScopedAudio = scoped + WearRealtimeTalkSnapshot(attemptId = attemptId, active = true) + }, + ) + + val response = + controller.handle( + request( + WearRpcMethod.TalkStart, + buildJsonObject { + put("sessionKey", "agent:main:thread-7") + put("attemptId", "attempt-7") + }, + ), + sourceNodeId = "watch-a", + ) + + assertTrue(response.ok) + assertEquals(false, attemptScopedAudio) + } + + @Test + fun talkStopBindsTheWatchNodeAndAttempt() = + runTest { + var stopArgs: List? = null + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + stopRealtimeTalk = { nodeId, attemptId -> + stopArgs = listOf(nodeId, attemptId) + WearRealtimeTalkSnapshot(attemptId = attemptId) + }, + ) + + val response = + controller.handle( + request(WearRpcMethod.TalkStop, buildJsonObject { put("attemptId", "attempt-7") }), + sourceNodeId = "watch-a", + ) + + assertTrue(response.ok) + assertEquals(listOf("watch-a", "attempt-7"), stopArgs) + } + + @Test + fun sessionsListBuildsFixedGatewayScopeAndProjectsRows() = + runTest { + var requestedMethod: String? = null + var requestedParams: JsonObject? = null + val controller = + WearProxyController( + requestGateway = { method, params -> + requestedMethod = method + requestedParams = params + json.parseToJsonElement( + """{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"modelProvider":"openai","model":"gpt-test","lastMessage":"hidden"}],"hasMore":true,"totalCount":9}""", + ) + }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + activeAgentId = { "main" }, + ) + + val response = + controller.handle( + request( + WearRpcMethod.SessionsList, + buildJsonObject { put("limit", 5) }, + ), + ) + + assertEquals("sessions.list", requestedMethod) + assertEquals( + json + .parseToJsonElement("""{"limit":5,"includeGlobal":false,"includeUnknown":false,"agentId":"main"}""") + .jsonObject, + requestedParams, + ) + val result = checkNotNull(response.result).jsonObject + val session = + result + .getValue("sessions") + .jsonArray + .single() + .jsonObject + assertEquals(setOf("key", "agentId", "displayName", "updatedAt", "modelRef"), session.keys) + assertEquals("openai/gpt-test", session.getValue("modelRef").jsonPrimitive.content) + assertEquals("main", result.getValue("activeAgentId").jsonPrimitive.content) + assertEquals( + true, + result + .getValue("hasMore") + .jsonPrimitive + .content + .toBoolean(), + ) + } + + @Test + fun sessionsListValidatesSelectedSessionOutsideBoundedPage() = + runTest { + val requestedMethods = mutableListOf() + val requestedParams = mutableListOf() + val controller = + WearProxyController( + requestGateway = { method, params -> + requestedMethods += method + requestedParams += params + if (method == "sessions.resolve") { + json.parseToJsonElement("""{"ok":true,"key":"agent:main:watch-selected"}""") + } else { + assertEquals("sessions.list", method) + json.parseToJsonElement( + """{"sessions":[{"key":"agent:main:recent","displayName":"Recent"}],"hasMore":true}""", + ) + } + }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + activeAgentId = { "main" }, + ) + + val response = + controller.handle( + request( + WearRpcMethod.SessionsList, + buildJsonObject { + put("limit", 5) + put("selectedSessionKey", "agent:main:watch-selected") + }, + ), + ) + + assertTrue(response.ok) + assertEquals( + listOf("agent:main:recent"), + checkNotNull(response.result) + .jsonObject + .getValue("sessions") + .jsonArray + .map { + it.jsonObject + .getValue("key") + .jsonPrimitive.content + }, + ) + assertEquals(listOf("sessions.list", "sessions.resolve"), requestedMethods) + assertEquals("agent:main:watch-selected", requestedParams[1].getValue("key").jsonPrimitive.content) + assertEquals("main", requestedParams[1].getValue("agentId").jsonPrimitive.content) + assertTrue( + checkNotNull(response.result) + .jsonObject + .getValue("selectedSessionValid") + .jsonPrimitive.content + .toBoolean(), + ) + } + + @Test + fun historyBoundsGatewayRequestAndDropsBinaryContent() = + runTest { + var requestedParams: JsonObject? = null + val controller = + controller { method, params -> + assertEquals("chat.history", method) + requestedParams = params + json.parseToJsonElement( + """{"sessionKey":"main","messages":[{"id":"m1","role":"assistant","content":[{"type":"text","text":"hello 😀"},{"type":"image","base64":"private"}],"timestamp":9}],"sessionInfo":{"model":"${"m".repeat(201)}"},"defaults":{"token":"hidden"},"offset":40,"nextOffset":60,"totalMessages":80,"hasMore":true}""", + ) + } + + val response = + controller.handle( + request( + WearRpcMethod.ChatHistory, + buildJsonObject { + put("sessionKey", "main") + put("limit", 20) + put("maxChars", 2_000) + put("offset", 40) + }, + ), + ) + + assertEquals( + json + .parseToJsonElement("""{"sessionKey":"main","limit":20,"maxChars":2000,"offset":40}""") + .jsonObject, + requestedParams, + ) + val result = checkNotNull(response.result).jsonObject + assertFalse("defaults" in result) + assertFalse("selectedModelRef" in result) + assertEquals( + 40, + result + .getValue("offset") + .jsonPrimitive + .content + .toInt(), + ) + assertEquals( + 60, + result + .getValue("nextOffset") + .jsonPrimitive + .content + .toInt(), + ) + assertEquals( + 80, + result + .getValue("totalMessages") + .jsonPrimitive + .content + .toInt(), + ) + assertTrue( + result + .getValue("hasMore") + .jsonPrimitive + .content + .toBoolean(), + ) + val content = + result + .getValue("messages") + .jsonArray + .single() + .jsonObject + .getValue("content") + .jsonArray + assertEquals(1, content.size) + assertEquals( + "hello 😀", + content + .single() + .jsonObject + .getValue("text") + .jsonPrimitive + .content, + ) + assertTrue(WearProtocolCodec.encode(response).isNotEmpty()) + } + + @Test + fun sendForwardsOnlyApprovedFields() = + runTest { + var requestedParams: JsonObject? = null + val controller = + controller { method, params -> + assertEquals("chat.send", method) + requestedParams = params + json.parseToJsonElement("""{"runId":"run-1","status":"started","internal":true}""") + } + + val response = + controller.handle( + request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", "main") + put("message", "reply") + put("idempotencyKey", "wear-1") + }, + ), + ) + + assertEquals( + json + .parseToJsonElement( + """{"sessionKey":"main","message":"reply","idempotencyKey":"wear-1","deliver":false}""", + ).jsonObject, + requestedParams, + ) + assertEquals(setOf("runId", "status"), checkNotNull(response.result).jsonObject.keys) + } + + @Test + fun rejectsUnknownOrOversizedWatchFieldsBeforeGateway() = + runTest { + var gatewayCalls = 0 + val controller = + controller { _, _ -> + gatewayCalls += 1 + buildJsonObject {} + } + val unknownField = + controller.handle( + request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", "main") + put("message", "reply") + put("idempotencyKey", "wear-1") + put("attachments", "not allowed") + }, + ), + ) + val oversized = + controller.handle( + request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", "main") + put("message", "x".repeat(4_001)) + put("idempotencyKey", "wear-2") + }, + ), + ) + + assertEquals(0, gatewayCalls) + assertEquals("invalid_request", unknownField.error?.code) + assertEquals("invalid_request", oversized.error?.code) + } + + @Test + fun preservesBoundedGatewayError() = + runTest { + val controller = + controller { _, _ -> throw WearProxyGatewayException("INVALID_REQUEST", "session unavailable") } + + val response = + controller.handle( + request( + WearRpcMethod.ChatAbort, + buildJsonObject { put("sessionKey", "main") }, + ), + ) + + assertFalse(response.ok) + assertEquals("INVALID_REQUEST", response.error?.code) + assertEquals("session unavailable", response.error?.message) + assertNotNull(WearProtocolCodec.encode(response)) + } + + @Test + fun chatEventPreservesReplacementSemantics() { + val payload = + checkNotNull( + projectWearChatEvent( + json.parseToJsonElement( + """{"runId":"run-1","state":"delta","deltaText":"replacement","replace":true,"privateField":"drop"}""", + ), + ), + ) + + assertEquals(setOf("runId", "state", "deltaText", "replace"), payload.keys) + assertTrue( + payload + .getValue("replace") + .jsonPrimitive + .content + .toBoolean(), + ) + } + + @Test + fun chatEventBoundsAggregateContentAndPreservesTerminalState() { + val payload = + checkNotNull( + projectWearChatEvent( + buildJsonObject { + put("runId", "run-1") + put("state", "final") + put( + "message", + buildJsonObject { + put("role", "assistant") + put( + "content", + buildJsonArray { + repeat(100) { + add( + buildJsonObject { + put("type", "text") + put("text", "😀".repeat(2_000)) + }, + ) + } + }, + ) + }, + ) + }, + ), + ) + + assertEquals("final", payload.getValue("state").jsonPrimitive.content) + val content = + payload + .getValue("message") + .jsonObject + .getValue("content") + .jsonArray + val projectedBytes = + content.sumOf { part -> + part.jsonObject + .getValue("text") + .jsonPrimitive.content + .toByteArray(Charsets.UTF_8) + .size + } + assertTrue(projectedBytes <= 1_024) + assertTrue(content.size < 100) + assertTrue( + WearProtocolCodec + .encode( + WearMessage.Event( + sequence = 1, + event = WearEventType.Chat, + payload = payload, + ), + ).isNotEmpty(), + ) + } + + private fun controller(requestGateway: suspend (String, JsonObject) -> JsonElement): WearProxyController = + WearProxyController( + requestGateway = requestGateway, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + ) + + private fun request( + method: WearRpcMethod, + params: JsonObject = buildJsonObject {}, + ): WearMessage.Request = WearMessage.Request(requestId = "req-1", method = method, params = params) +} diff --git a/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt b/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt new file mode 100644 index 0000000..1ce2d58 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt @@ -0,0 +1,96 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearProtocol +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.MessageClient +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WearProxyListenerManifestTest { + @Test + fun protocolRequestsResolveToBridgeService() { + assertTrue( + resolvesToBridgeService( + action = MessageClient.ACTION_MESSAGE_RECEIVED, + path = WearProtocol.REQUEST_PATH, + ), + ) + } + + @Test + fun realtimeAudioChannelsResolveToBridgeService() { + assertTrue( + resolvesToBridgeService( + action = ChannelClient.ACTION_CHANNEL_EVENT, + path = WearProtocol.realtimeAudioChannelPath("attempt-7"), + ), + ) + assertTrue( + resolvesToBridgeService( + action = ChannelClient.ACTION_CHANNEL_EVENT, + path = WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ), + ) + assertFalse( + resolvesToBridgeService( + action = ChannelClient.ACTION_CHANNEL_EVENT, + path = "${WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH}-invalid", + ), + ) + } + + @Test + fun responseAndLegacyRequestRoutesDoNotResolveToPhoneBridge() { + assertFalse( + resolvesToBridgeService( + action = MessageClient.ACTION_MESSAGE_RECEIVED, + path = WearProtocol.RESPONSE_PATH, + ), + ) + assertFalse( + resolvesToBridgeService( + action = MessageClient.ACTION_REQUEST_RECEIVED, + path = WearProtocol.REQUEST_PATH, + ), + ) + assertFalse( + resolvesToBridgeService( + action = MessageClient.ACTION_MESSAGE_RECEIVED, + path = "/openclaw/v1/conversation", + ), + ) + } + + private fun resolvesToBridgeService( + action: String, + path: String, + ): Boolean { + val intent = + Intent( + action, + Uri.parse("wear://watch-node$path"), + ) + val services = + RuntimeEnvironment + .getApplication() + .packageManager + .queryIntentServices( + intent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()), + ) + + return services.any { resolveInfo -> + resolveInfo.serviceInfo.name == WearProxyListenerService::class.java.name + } + } +} diff --git a/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt b/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt new file mode 100644 index 0000000..e5010b8 --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt @@ -0,0 +1,1073 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearRealtimeAudioFrameType +import ai.openclaw.wear.shared.WearRealtimeTalkStatus +import android.os.Parcel +import com.google.android.gms.wearable.ChannelClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class WearRealtimeChannelRegistryTest { + @Test + fun `replacement stops displaced owner once before its finalizer`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + val firstClaim = checkNotNull(registry.claim("watch-a", "attempt-a")) + val firstOwner = firstClaim.owner + assertTrue(firstClaim.newlyAcquired) + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + assertTrue(registry.isCurrent(firstOwner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + + val secondClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + withTimeout(2_000L) { + transport.awaitClosed(first) + while (synchronized(stoppedOwners) { stoppedOwners.size } != 1) { + kotlinx.coroutines.yield() + } + } + + val secondOwner = secondClaim.owner + assertTrue(secondClaim.newlyAcquired) + assertEquals(listOf(firstOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) + assertEquals(1, transport.closeCount(first)) + + registry.release(firstOwner) + val repeatedClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + assertFalse(repeatedClaim.newlyAcquired) + assertSame(secondOwner, repeatedClaim.owner) + + registry.release(secondOwner) + val staleClaim = async { registry.claim("watch-a", "attempt-c") } + delay(100L) + assertFalse(staleClaim.isCompleted) + staleClaim.cancel() + val retryClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + val retryOwner = retryClaim.owner + assertTrue(retryClaim.newlyAcquired) + assertEquals(secondOwner.channelGeneration, retryOwner.channelGeneration) + + registry.close(retryOwner) + assertEquals(listOf(firstOwner, retryOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) + } finally { + scope.cancel() + } + } + + @Test + fun `replacement claim waits for owner retirement but not delayed gateway close`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val closeStarted = CompletableDeferred() + val releaseClose = CompletableDeferred() + val closeFinished = CompletableDeferred() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = scope, + isConnected = { true }, + requestGateway = { method, _, _ -> + when (method) { + "talk.session.create" -> { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } + "talk.session.close" -> { + closeStarted.complete(Unit) + releaseClose.await() + closeFinished.complete(Unit) + """{"ok":true}""" + } + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + controller.stop(owner) + } + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + val firstOwner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + assertTrue(controller.start(firstOwner, "session-main", "de")) + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + val secondClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { closeStarted.await() } + + val secondOwner = withTimeout(1_000L) { secondClaim.await() }?.owner + assertNotNull(secondOwner) + assertFalse(releaseClose.isCompleted) + assertEquals(WearRealtimeTalkStatus.OFF, controller.snapshot.value.status) + + val replacementStart = + async { + controller.start(checkNotNull(secondOwner), "session-main", "de") + } + assertTrue(withTimeout(1_000L) { replacementStart.await() }) + assertFalse(releaseClose.isCompleted) + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"close"}""", + ) + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + + releaseClose.complete(Unit) + withTimeout(1_000L) { closeFinished.await() } + registry.close(checkNotNull(secondOwner)) + } finally { + releaseClose.complete(Unit) + scope.cancel() + } + } + + @Test + fun `reader retirement blocks replacement claim until owner stops`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { + stopStarted.complete(Unit) + releaseStop.await() + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + + transport.finishInput(first) + withTimeout(1_000L) { stopStarted.await() } + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + delay(100L) + assertFalse(replacementClaim.isCompleted) + + releaseStop.complete(Unit) + val replacementOwner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + registry.close(replacementOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `rapid replacements preserve transitive owner retirement`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val third = FakeChannel("watch-a", "channel-c", "attempt-c") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { + stopStarted.complete(Unit) + releaseStop.await() + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + registry.accept(third, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(third) + val newestClaim = async { registry.claim("watch-a", "attempt-c") } + withTimeout(1_000L) { stopStarted.await() } + delay(100L) + assertFalse(newestClaim.isCompleted) + + releaseStop.complete(Unit) + val newestOwner = checkNotNull(withTimeout(1_000L) { newestClaim.await() }).owner + registry.close(newestOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `delayed unclaimed channel cannot retire the active attempt`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val active = FakeChannel("watch-a", "channel-b", "attempt-b") + val delayed = FakeChannel("watch-a", "channel-a", "attempt-a") + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + val activeClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + + registry.accept(delayed, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(delayed) + delay(100L) + + assertTrue(registry.isCurrent(activeClaim.owner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + val repeated = checkNotNull(registry.claim("watch-a", "attempt-b")) + assertFalse(repeated.newlyAcquired) + assertSame(activeClaim.owner, repeated.owner) + registry.close(activeClaim.owner) + } finally { + scope.cancel() + } + } + + @Test + fun `same path reconnect inherits the active attempt owner`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reconnect = FakeChannel("watch-a", "channel-a-reconnect", "attempt-a") + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + + registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reconnect) + withTimeout(1_000L) { + transport.awaitClosed(active) + while (!transport.hasStartedReading(reconnect)) yield() + } + + val repeated = checkNotNull(registry.claim("watch-a", "attempt-a")) + assertFalse(repeated.newlyAcquired) + assertSame(owner, repeated.owner) + assertTrue(registry.isCurrent(owner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + registry.close(owner) + } finally { + scope.cancel() + } + } + + @Test + fun `same path reconnect waits for an in flight frame before retiring its channel`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reconnect = FakeChannel("watch-a", "channel-a-reconnect", "attempt-a") + val releaseWrite = transport.holdWrite(active) + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(active) + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + val send = + async(Dispatchers.IO) { + registry.send(owner, WearRealtimeAudioFrameType.OUTPUT_PCM, byteArrayOf(1, 2, 3, 4)) + } + transport.awaitWriteStarted(active) + + registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(reconnect) + delay(100L) + assertFalse(send.isCompleted) + assertEquals(0, transport.closeCount(active)) + + releaseWrite.countDown() + withTimeout(1_000L) { send.await() } + withTimeout(1_000L) { + transport.awaitClosed(active) + while (!transport.hasStartedReading(reconnect)) yield() + } + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + releaseWrite.countDown() + scope.cancel() + } + } + + @Test + fun `missing stale claim does not block a valid attempt on the same watch`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val current = FakeChannel("watch-a", "channel-b", "attempt-b") + + try { + registry.accept(current, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(current) + val missingClaim = async { registry.claim("watch-a", "attempt-a") } + delay(100L) + + val currentOwner = + withTimeout(1_000L) { + checkNotNull(registry.claim("watch-a", "attempt-b")).owner + } + assertFalse(missingClaim.isCompleted) + missingClaim.cancel() + registry.close(currentOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `replacement claim bounds a stalled owner stop callback`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val stopFinished = CompletableDeferred() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + stopFinished.complete(Unit) + } + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + val replacement = checkNotNull(withTimeout(2_500L) { replacementClaim.await() }).owner + assertFalse(stopFinished.isCompleted) + releaseStop.complete(Unit) + withTimeout(1_000L) { stopFinished.await() } + registry.close(replacement) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `retirement timeout keeps transport cleanup running`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + retireCallbackTimeoutMillis = 100L, + ) + val channel = FakeChannel("watch-a", "channel-a", "attempt-a") + val releaseClose = transport.holdClose(channel) + + try { + registry.accept(channel, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(channel) + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + + withTimeout(1_000L) { registry.close(owner) } + assertEquals(0, transport.closeCount(channel)) + scope.cancel() + releaseClose.complete(Unit) + withTimeout(1_000L) { transport.awaitClosed(channel) } + } finally { + releaseClose.complete(Unit) + scope.cancel() + } + } + + @Test + fun `old path reconnect cannot inherit an owner reserved for retirement`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + pendingConnectionTimeoutMillis = 100L, + ) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val stoppedOwners = mutableListOf() + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val replacement = FakeChannel("watch-a", "channel-b", "attempt-b") + val staleReconnect = FakeChannel("watch-a", "channel-a-reconnect", "attempt-a") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + stopStarted.complete(Unit) + releaseStop.await() + synchronized(stoppedOwners) { stoppedOwners += owner } + } + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + val activeOwner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + registry.accept(replacement, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(replacement) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(staleReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(staleReconnect) + releaseStop.complete(Unit) + + val replacementOwner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + assertEquals(listOf(activeOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) + assertTrue(registry.isCurrent(replacementOwner)) + withTimeout(1_000L) { transport.awaitClosed(staleReconnect) } + registry.close(replacementOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `promotion reserved before discovery timeout commits after bounded cleanup`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + connectionReadyTimeoutMillis = 200L, + pendingConnectionTimeoutMillis = 1_000L, + retireCallbackTimeoutMillis = 100L, + ) + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val replacement = FakeChannel("watch-a", "channel-b", "attempt-b") + val releaseReplacement = transport.holdOpen(replacement) + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") awaitCancellation() + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(replacement, appendAudio = { _, _ -> }, stopTalk = stopTalk) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + delay(150L) + releaseReplacement.complete(Unit) + transport.awaitOpened(replacement) + + val replacementOwner = + withTimeout(1_000L) { + checkNotNull(replacementClaim.await()).owner + } + assertTrue(registry.isCurrent(replacementOwner)) + registry.close(replacementOwner) + } finally { + releaseReplacement.complete(Unit) + scope.cancel() + } + } + + @Test + fun `claim keeps polling after an unclaimed channel expires`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + connectionReadyTimeoutMillis = 1_000L, + pendingConnectionTimeoutMillis = 100L, + ) + val expired = FakeChannel("watch-a", "channel-a-expired", "attempt-a") + val replacement = FakeChannel("watch-a", "channel-a-replacement", "attempt-a") + + try { + registry.accept(expired, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(expired) + withTimeout(1_000L) { transport.awaitClosed(expired) } + + val claim = async { registry.claim("watch-a", "attempt-a") } + delay(100L) + assertFalse(claim.isCompleted) + registry.accept(replacement, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(replacement) + + val owner = checkNotNull(withTimeout(1_000L) { claim.await() }).owner + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + scope.cancel() + } + } + + @Test + fun `older polling claim cannot replace a newer active attempt`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val newer = FakeChannel("watch-a", "channel-b", "attempt-b") + val delayedOlder = FakeChannel("watch-a", "channel-a", "attempt-a") + + try { + val olderClaim = async { registry.claim("watch-a", "attempt-a") } + delay(100L) + registry.accept(newer, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(newer) + val newerOwner = checkNotNull(registry.claim("watch-a", "attempt-b")).owner + + registry.accept(delayedOlder, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(delayedOlder) + + assertEquals(null, withTimeout(1_000L) { olderClaim.await() }) + assertTrue(registry.isCurrent(newerOwner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + registry.close(newerOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `newer waiting channel survives an older promotion`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val third = FakeChannel("watch-a", "channel-c", "attempt-c") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + } + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + val secondClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(third, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(third) + val thirdClaim = async { registry.claim("watch-a", "attempt-c") } + releaseStop.complete(Unit) + + assertNotNull(withTimeout(1_000L) { secondClaim.await() }) + val thirdOwner = checkNotNull(withTimeout(1_000L) { thirdClaim.await() }).owner + assertTrue(registry.isCurrent(thirdOwner)) + registry.close(thirdOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `same path reconnect replaces a reserved channel before promotion`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reserved = FakeChannel("watch-a", "channel-b-reserved", "attempt-b") + val supersededReconnect = FakeChannel("watch-a", "channel-b-superseded", "attempt-b") + val reconnect = FakeChannel("watch-a", "channel-b-reconnect", "attempt-b") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + } + } + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(reserved, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reserved) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(supersededReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(supersededReconnect) + registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reconnect) + // The older reconnect cannot close until the newest channel is published in the registry. + withTimeout(1_000L) { transport.awaitClosed(supersededReconnect) } + releaseStop.complete(Unit) + + val owner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + assertEquals(4L, owner.channelGeneration) + assertEquals(1, transport.closeCount(reserved)) + assertEquals(1, transport.closeCount(supersededReconnect)) + assertEquals(0, transport.closeCount(reconnect)) + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `latest reconnect wins while an older promotion candidate retires`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reserved = FakeChannel("watch-a", "channel-b-reserved", "attempt-b") + val firstReconnect = FakeChannel("watch-a", "channel-b-first-reconnect", "attempt-b") + val latestReconnect = FakeChannel("watch-a", "channel-b-latest-reconnect", "attempt-b") + val releaseReservedClose = transport.holdClose(reserved) + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + } + } + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(reserved, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reserved) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(firstReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(firstReconnect) + releaseStop.complete(Unit) + transport.awaitCloseStarted(reserved) + + registry.accept(latestReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(latestReconnect) + releaseReservedClose.complete(Unit) + + val owner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + withTimeout(1_000L) { + transport.awaitClosed(reserved) + transport.awaitClosed(firstReconnect) + while (!transport.hasStartedReading(latestReconnect)) yield() + } + assertEquals("attempt-b", owner.attemptId) + assertEquals(0, transport.closeCount(latestReconnect)) + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + releaseStop.complete(Unit) + releaseReservedClose.complete(Unit) + scope.cancel() + } + } + + @Test + fun `cancelled promotion retires its reserved channel before retry`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + connectionReadyTimeoutMillis = 500L, + pendingConnectionTimeoutMillis = 1_000L, + retireCallbackTimeoutMillis = 100L, + ) + val stopStarted = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val cancelled = FakeChannel("watch-a", "channel-b", "attempt-b") + val retry = FakeChannel("watch-a", "channel-b-retry", "attempt-b") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + awaitCancellation() + } + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(cancelled, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(cancelled) + + val cancelledClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + cancelledClaim.cancelAndJoin() + withTimeout(1_000L) { transport.awaitClosed(cancelled) } + + registry.accept(retry, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(retry) + val retryOwner = checkNotNull(withTimeout(1_000L) { registry.claim("watch-a", "attempt-b") }).owner + registry.close(retryOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `pending channel does not read pcm before promotion`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val channel = FakeChannel("watch-a", "channel-a", "attempt-a") + + try { + registry.accept(channel, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(channel) + delay(100L) + assertFalse(transport.hasStartedReading(channel)) + + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + withTimeout(1_000L) { + while (!transport.hasStartedReading(channel)) yield() + } + registry.close(owner) + } finally { + scope.cancel() + } + } + + @Test + fun `older channel setup cannot displace a newer published channel`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val releaseFirst = transport.holdOpen(first) + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = {}) + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(second) + val secondOwner = checkNotNull(registry.claim("watch-a", "attempt-b")).owner + + releaseFirst.complete(Unit) + transport.awaitOpened(first) + withTimeout(1_000L) { transport.awaitClosed(first) } + + val repeated = checkNotNull(registry.claim("watch-a", "attempt-b")) + assertFalse(repeated.newlyAcquired) + assertSame(secondOwner, repeated.owner) + registry.close(secondOwner) + } finally { + releaseFirst.complete(Unit) + scope.cancel() + } + } + + @Test + fun `legacy channel replacement binds the new attempt instead of inheriting the old owner`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val first = + FakeChannel( + nodeId = "watch-a", + label = "legacy-a", + attemptId = "ignored-a", + pathOverride = WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ) + val second = + FakeChannel( + nodeId = "watch-a", + label = "legacy-b", + attemptId = "ignored-b", + pathOverride = WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ) + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = { stoppedOwners += it }) + transport.awaitOpened(first) + val firstOwner = + checkNotNull( + registry.claim( + nodeId = "watch-a", + attemptId = "attempt-a", + attemptScopedAudio = false, + ), + ).owner + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = { stoppedOwners += it }) + transport.awaitOpened(second) + val secondOwner = + checkNotNull( + registry.claim( + nodeId = "watch-a", + attemptId = "attempt-b", + attemptScopedAudio = false, + ), + ).owner + + assertEquals("attempt-b", secondOwner.attemptId) + assertTrue(secondOwner.channelGeneration > firstOwner.channelGeneration) + assertEquals(listOf(firstOwner), stoppedOwners) + registry.close(secondOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `staged channel limits reject excess connections before opening streams`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + maxStagedConnectionsPerNode = 1, + maxStagedConnections = 2, + ) + val first = FakeChannel("watch-a", "first", "attempt-a") + val sameNodeExcess = FakeChannel("watch-a", "same-node-excess", "attempt-b") + val secondNode = FakeChannel("watch-b", "second-node", "attempt-c") + val globalExcess = FakeChannel("watch-c", "global-excess", "attempt-d") + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(first) + + registry.accept(sameNodeExcess, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitClosed(sameNodeExcess) + assertFalse(transport.wasOpened(sameNodeExcess)) + assertEquals(1, transport.closeCount(sameNodeExcess)) + + registry.accept(secondNode, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(secondNode) + + registry.accept(globalExcess, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitClosed(globalExcess) + assertFalse(transport.wasOpened(globalExcess)) + assertEquals(1, transport.closeCount(globalExcess)) + } finally { + scope.cancel() + } + } +} + +private class FakeChannelTransport : WearRealtimeChannelTransport { + private val opened = ConcurrentHashMap>() + private val openGates = ConcurrentHashMap>() + private val closeGates = ConcurrentHashMap>() + private val closeStarted = ConcurrentHashMap>() + private val closeCompleted = ConcurrentHashMap>() + private val closeCounts = ConcurrentHashMap() + private val writeGates = ConcurrentHashMap() + private val writeStarted = ConcurrentHashMap>() + private val openedResources = ConcurrentHashMap() + + override suspend fun open(channel: ChannelClient.Channel): WearRealtimeChannelResources { + openGates[channel]?.await() + val resources = + WearRealtimeChannelResources( + ClosingInputStream(), + GatedOutputStream( + gate = writeGates[channel], + started = writeStarted.computeIfAbsent(channel) { CompletableDeferred() }, + ), + ) + openedResources[channel] = resources + opened.computeIfAbsent(channel) { CompletableDeferred() }.complete(Unit) + return resources + } + + override suspend fun close( + channel: ChannelClient.Channel, + resources: WearRealtimeChannelResources?, + ) { + closeStarted.computeIfAbsent(channel) { CompletableDeferred() }.complete(Unit) + closeGates[channel]?.await() + resources?.input?.close() + resources?.output?.close() + closeCounts.compute(channel) { _, count -> (count ?: 0) + 1 } + closeCompleted.computeIfAbsent(channel) { CompletableDeferred() }.complete(Unit) + } + + suspend fun awaitOpened(channel: ChannelClient.Channel) { + opened.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + fun holdOpen(channel: ChannelClient.Channel): CompletableDeferred { + val gate = CompletableDeferred() + openGates[channel] = gate + return gate + } + + fun holdClose(channel: ChannelClient.Channel): CompletableDeferred { + val gate = CompletableDeferred() + closeGates[channel] = gate + return gate + } + + suspend fun awaitCloseStarted(channel: ChannelClient.Channel) { + closeStarted.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + suspend fun awaitClosed(channel: ChannelClient.Channel) { + closeCompleted.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + fun holdWrite(channel: ChannelClient.Channel): CountDownLatch = + CountDownLatch(1).also { gate -> + writeGates[channel] = gate + } + + suspend fun awaitWriteStarted(channel: ChannelClient.Channel) { + writeStarted.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + fun finishInput(channel: ChannelClient.Channel) { + openedResources[channel]?.input?.close() + } + + fun closeCount(channel: ChannelClient.Channel): Int = closeCounts[channel] ?: 0 + + fun wasOpened(channel: ChannelClient.Channel): Boolean = openedResources.containsKey(channel) + + fun hasStartedReading(channel: ChannelClient.Channel): Boolean = (openedResources[channel]?.input as? ClosingInputStream)?.hasStartedReading() == true +} + +private class ClosingInputStream : InputStream() { + private val started = CountDownLatch(1) + private val closed = CountDownLatch(1) + + override fun read(): Int { + started.countDown() + closed.await() + return -1 + } + + override fun close() { + closed.countDown() + } + + fun hasStartedReading(): Boolean = started.count == 0L +} + +private class GatedOutputStream( + private val gate: CountDownLatch?, + private val started: CompletableDeferred, +) : ByteArrayOutputStream() { + override fun write( + buffer: ByteArray, + offset: Int, + length: Int, + ) { + started.complete(Unit) + gate?.let { check(it.await(5, TimeUnit.SECONDS)) } + super.write(buffer, offset, length) + } +} + +private data class FakeChannel( + private val nodeId: String, + private val label: String, + private val attemptId: String, + private val pathOverride: String? = null, +) : ChannelClient.Channel { + override fun getNodeId(): String = nodeId + + override fun getPath(): String = pathOverride ?: WearProtocol.realtimeAudioChannelPath(attemptId) + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + dest: Parcel, + flags: Int, + ) { + dest.writeString(label) + } +} diff --git a/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt b/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt new file mode 100644 index 0000000..733c27f --- /dev/null +++ b/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt @@ -0,0 +1,1065 @@ +package ai.openclaw.app.wear + +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearRealtimeAudioFrameType +import ai.openclaw.wear.shared.WearRealtimeTalkStatus +import android.util.Base64 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class WearRealtimeTalkControllerTest { + @Test + fun `playback deadline counts only audio remaining after slow chunk delivery`() { + val chunkBytes = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ / 10 * 2 + + val first = advanceWearRealtimePlaybackDeadline(0L, 0L, chunkBytes) + val second = advanceWearRealtimePlaybackDeadline(first, 100L, chunkBytes) + val third = advanceWearRealtimePlaybackDeadline(second, 200L, chunkBytes) + + assertEquals(300L, third) + } + + @Test + fun `stop before a delayed start prevents relay resurrection`() = + runTest { + var gatewayCalls = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { _, _, _ -> + gatewayCalls += 1 + """{"relaySessionId":"relay-late"}""" + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + + assertTrue(controller.stop("watch-a", "attempt-a")) + assertFalse( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + assertEquals(0, gatewayCalls) + } + + @Test + fun `partial scoped stop rejects when no active owner can match`() = + runTest { + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { _, _, _ -> """{"relaySessionId":"relay-late"}""" }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + + assertFalse(controller.stop(nodeId = "watch-a")) + assertFalse(controller.stop(attemptId = "attempt-a")) + assertTrue(controller.start("watch-b", "session-b", "attempt-b", "de")) + assertTrue(controller.stop("watch-b", "attempt-b")) + } + + @Test + fun `abort during connecting keeps a missing late session off`() = + runTest { + val forcedChannelCloses = mutableListOf() + lateinit var controller: WearRealtimeTalkController + controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { _, _, _ -> """{"ok":true}""" }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + onSnapshot = { snapshot -> + if (snapshot.status == WearRealtimeTalkStatus.CONNECTING) controller.abort() + }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, + ) + + assertFalse( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + assertEquals(listOf("watch-a"), forcedChannelCloses) + assertEquals(WearRealtimeTalkStatus.OFF, controller.snapshot.value.status) + assertEquals("attempt-a", controller.snapshot.value.attemptId) + } + + @Test + fun `disconnect during session creation closes a late relay`() = + runTest { + var connected = true + val createStarted = CompletableDeferred() + val createResult = CompletableDeferred() + val gatewayMethods = mutableListOf() + val forcedChannelCloses = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { connected }, + requestGateway = { method, _, _ -> + gatewayMethods += method + if (method == "talk.session.create") { + createStarted.complete(Unit) + createResult.await() + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, + ) + + val startResult = + async { + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ) + } + createStarted.await() + connected = false + controller.abort() + createResult.complete("""{"relaySessionId":"relay-late"}""") + + assertFalse(startResult.await()) + assertEquals(listOf("talk.session.create", "talk.session.close"), gatewayMethods) + assertEquals(listOf("watch-a"), forcedChannelCloses) + assertEquals(WearRealtimeTalkStatus.OFF, controller.snapshot.value.status) + assertEquals("attempt-a", controller.snapshot.value.attemptId) + } + + @Test + fun `active session remains owned by the node that started it`() = + runTest { + val forcedChannelCloses = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + """{"relaySessionId":"relay-1"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, + ) + + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + assertFalse( + controller.start( + nodeId = "watch-b", + sessionKey = "session-a", + attemptId = "attempt-b", + language = "de", + ), + ) + assertFalse( + controller.start( + nodeId = "watch-a", + sessionKey = "session-b", + attemptId = "attempt-b", + language = "de", + ), + ) + + assertFalse(controller.stop("watch-b")) + assertFalse(controller.stop("watch-a", "attempt-b")) + assertTrue(controller.stop("watch-a", "attempt-a")) + assertTrue(forcedChannelCloses.isEmpty()) + } + + @Test + fun `late append error from a stopped session does not fail its replacement`() = + runTest { + var staleAppendError: ((String) -> Unit)? = null + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, onError -> + if (staleAppendError == null) staleAppendError = onError + }, + sendWatchFrame = { _, _, _ -> }, + ) + + assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) + controller.appendAudio("watch-a", ByteArray(2)) + runCurrent() + assertTrue(staleAppendError != null) + + assertTrue(controller.stop("watch-a", "attempt-a")) + assertTrue(controller.start("watch-a", "session-b", "attempt-b", "de")) + staleAppendError?.invoke("request timeout") + + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + assertTrue(controller.stop("watch-a", "attempt-b")) + } + + @Test + fun `late Watch output error from a stopped session does not fail its replacement`() = + runTest { + val outputStarted = CompletableDeferred() + val releaseOutput = CompletableDeferred() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> + outputStarted.complete(Unit) + withContext(NonCancellable) { + releaseOutput.await() + error("wear link down") + } + }, + ) + + assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"audio", + "audioBase64":"${Base64.encodeToString(ByteArray(16), Base64.NO_WRAP)}" + } + """.trimIndent(), + ) + outputStarted.await() + + assertTrue(controller.stop("watch-a", "attempt-a")) + assertTrue(controller.start("watch-a", "session-b", "attempt-b", "de")) + releaseOutput.complete(Unit) + runCurrent() + + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + assertTrue(controller.stop("watch-a", "attempt-b")) + } + + @Test + fun `stale close callback cannot abort replacement`() = + runTest { + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val staleOwner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val replacementOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + + assertTrue(controller.start(staleOwner, "session-a", "de")) + controller.abort() + assertTrue(controller.start(replacementOwner, "session-b", "de")) + + WearRealtimeTalkController::class.java + .getDeclaredMethod( + "abort", + WearRealtimeAttemptOwner::class.java, + String::class.java, + ).apply { isAccessible = true } + .invoke(controller, staleOwner, "relay-1") + + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + assertTrue(controller.stop(replacementOwner)) + } + + @Test + fun `stale relay events cannot mutate or dispatch into replacement`() = + runTest { + val gatewayMethods = mutableListOf() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + gatewayMethods += method + if (method == "talk.session.create") { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val staleOwner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val replacementOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + + assertTrue(controller.start(staleOwner, "session-a", "de")) + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"mark","markName":"stale-mark"}""", + ) + controller.abort() + assertTrue(controller.start(replacementOwner, "session-b", "de")) + + controller.invokePrivate( + "handleTranscriptEvent", + staleOwner, + "relay-1", + buildJsonObject { + put("role", JsonPrimitive("user")) + put("text", JsonPrimitive("stale transcript")) + put("final", JsonPrimitive(true)) + }, + ) + controller.invokePrivate( + "handleToolCallEvent", + staleOwner, + "relay-1", + buildJsonObject { + put("callId", JsonPrimitive("stale-call")) + put("name", JsonPrimitive("stale-tool")) + }, + ) + runCurrent() + + val snapshot = controller.snapshot.value + assertTrue(snapshot.conversation.isEmpty()) + assertEquals(WearRealtimeTalkStatus.LISTENING, snapshot.status) + assertEquals("attempt-b", snapshot.attemptId) + assertFalse("talk.session.acknowledgeMark" in gatewayMethods) + assertFalse("talk.client.toolCall" in gatewayMethods) + assertTrue(controller.stop(replacementOwner)) + } + + @Test + fun `replacement cancels delayed tool correlation when the session key is reused`() = + runTest { + val gatewayMethods = mutableListOf() + val oldToolStarted = CompletableDeferred() + val oldToolResponse = CompletableDeferred() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + gatewayMethods += method + when (method) { + "talk.session.create" -> { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } + "talk.client.toolCall" -> { + oldToolStarted.complete(Unit) + oldToolResponse.await() + } + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val staleOwner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val replacementOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + + assertTrue(controller.start(staleOwner, "session-main", "de")) + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"toolCall", + "callId":"old-call", + "name":"openclaw_agent_consult" + } + """.trimIndent(), + ) + runCurrent() + oldToolStarted.await() + + assertTrue(controller.stop(staleOwner)) + assertTrue(controller.start(replacementOwner, "session-main", "de")) + oldToolResponse.complete("""{"runId":"old-run"}""") + runCurrent() + controller.handleGatewayEvent( + "chat", + """ + { + "sessionKey":"session-main", + "runId":"old-run", + "state":"final", + "message":{"role":"assistant","content":"stale"} + } + """.trimIndent(), + ) + runCurrent() + + assertEquals(0, gatewayMethods.count { it == "talk.session.submitToolResult" }) + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + assertTrue(controller.stop(replacementOwner)) + } + + @Test + fun `retries without language when an older gateway rejects only that field`() = + runTest { + val createParams = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, params, _ -> + if (method != "talk.session.create") { + """{"ok":true}""" + } else { + createParams += params + if (createParams.size == 1) { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = + "invalid talk.session.create params: at root: unexpected property 'language'", + ), + ) + } + """{"relaySessionId":"relay-legacy"}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + + assertEquals(2, createParams.size) + assertTrue(createParams.first().orEmpty().contains("\"sessionKey\":\"session-a\"")) + assertTrue(createParams.first().orEmpty().contains(""""language":"de"""")) + assertFalse(createParams.last().orEmpty().contains(""""language"""")) + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + controller.stop("watch-a") + } + + @Test + fun `does not retry unrelated invalid requests`() = + runTest { + var createAttempts = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + createAttempts += 1 + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "invalid talk.session.appendAudio params", + ), + ) + } + """{"ok":true}""" + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + + assertFalse( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + assertEquals(1, createAttempts) + } + + @Test + fun `does not force final transcripts and relays a provider-selected tool call`() = + runTest { + val gatewayCalls = mutableListOf>() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, params, _ -> + gatewayCalls += method to params + when (method) { + "talk.session.create" -> """{"relaySessionId":"relay-1"}""" + "talk.client.toolCall" -> """{"runId":"run-1"}""" + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"transcript","role":"user","text":"Hello","final":true}""", + ) + runCurrent() + assertTrue(gatewayCalls.none { it.first == "talk.client.toolCall" }) + + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"toolCall", + "callId":"call-1", + "name":"openclaw_agent_consult", + "args":{"question":"Check the repository"} + } + """.trimIndent(), + ) + runCurrent() + + val toolCall = gatewayCalls.single { it.first == "talk.client.toolCall" }.second.orEmpty() + assertTrue(toolCall.contains("\"sessionKey\":\"session-a\"")) + assertTrue(toolCall.contains("\"relaySessionId\":\"relay-1\"")) + assertTrue(toolCall.contains("\"callId\":\"call-1\"")) + assertEquals(WearRealtimeTalkStatus.THINKING, controller.snapshot.value.status) + + controller.handleGatewayEvent( + "chat", + """ + { + "sessionKey":"other-session", + "runId":"run-1", + "state":"final", + "message":{"role":"assistant","content":[{"type":"text","text":"Wrong session"}]} + } + """.trimIndent(), + ) + runCurrent() + assertTrue(gatewayCalls.none { it.first == "talk.session.submitToolResult" }) + + controller.handleGatewayEvent( + "chat", + """ + { + "sessionKey":"session-a", + "runId":"run-1", + "state":"final", + "message":{"role":"assistant","content":[{"type":"text","text":"Repository checked"}]} + } + """.trimIndent(), + ) + runCurrent() + + val result = gatewayCalls.single { it.first == "talk.session.submitToolResult" }.second.orEmpty() + assertTrue(result.contains("\"sessionId\":\"relay-1\"")) + assertTrue(result.contains("\"callId\":\"call-1\"")) + assertTrue(result.contains("\"text\":\"Repository checked\"")) + assertTrue(controller.stop("watch-a")) + } + + @Test + fun `keeps an early chat completion until the tool call run id arrives`() = + runTest { + val toolCallResponse = CompletableDeferred() + val submittedResults = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, params, _ -> + when (method) { + "talk.session.create" -> """{"relaySessionId":"relay-1"}""" + "talk.client.toolCall" -> toolCallResponse.await() + "talk.session.submitToolResult" -> { + submittedResults += params.orEmpty() + """{"ok":true}""" + } + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = null, + ), + ) + + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"toolCall","callId":"call-1","name":"openclaw_agent_consult"}""", + ) + runCurrent() + controller.handleGatewayEvent( + "chat", + """ + { + "sessionKey":"session-a", + "runId":"run-early", + "state":"final", + "message":{"role":"assistant","content":"Early result"} + } + """.trimIndent(), + ) + toolCallResponse.complete("""{"runId":"run-early"}""") + runCurrent() + + assertEquals(1, submittedResults.size) + assertTrue(submittedResults.single().contains("\"text\":\"Early result\"")) + assertTrue(controller.stop("watch-a")) + } + + @Test + fun `relays realtime agent control without starting another consult`() = + runTest { + val gatewayCalls = mutableListOf>() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, params, _ -> + gatewayCalls += method to params + when (method) { + "talk.session.create" -> """{"relaySessionId":"relay-1"}""" + "talk.session.steer" -> """{"status":"steered","message":"Stopping"}""" + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = null, + ), + ) + + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"toolCall", + "callId":"control-1", + "name":"openclaw_agent_control", + "args":{"text":"stop","mode":"cancel"} + } + """.trimIndent(), + ) + runCurrent() + + assertTrue(gatewayCalls.none { it.first == "talk.client.toolCall" }) + val steer = gatewayCalls.single { it.first == "talk.session.steer" }.second.orEmpty() + assertTrue(steer.contains("\"sessionId\":\"relay-1\"")) + assertTrue(steer.contains("\"sessionKey\":\"session-a\"")) + assertTrue(steer.contains("\"mode\":\"cancel\"")) + val result = gatewayCalls.single { it.first == "talk.session.submitToolResult" }.second.orEmpty() + assertTrue(result.contains("\"callId\":\"control-1\"")) + assertTrue(result.contains("\"status\":\"steered\"")) + assertTrue(controller.stop("watch-a")) + } + + @Test + fun `chunks provider audio and sends clear in order`() = + runTest { + val output = mutableListOf>() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + """{"relaySessionId":"relay-1"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, type, payload -> output += type to payload }, + ) + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + val audio = + ByteArray(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES * 2 + 8) { index -> + (index % 127).toByte() + } + + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"audio", + "audioBase64":"${Base64.encodeToString(audio, Base64.NO_WRAP)}" + } + """.trimIndent(), + ) + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"clear"}""", + ) + runCurrent() + + assertEquals( + listOf( + WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES, + WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES, + 8, + ), + output + .filter { it.first == WearRealtimeAudioFrameType.OUTPUT_PCM } + .map { it.second.size }, + ) + val deliveredAudio = + output + .filter { it.first == WearRealtimeAudioFrameType.OUTPUT_PCM } + .flatMap { it.second.asIterable() } + .toByteArray() + assertArrayEquals(audio, deliveredAudio) + assertEquals(WearRealtimeAudioFrameType.CLEAR_OUTPUT, output.last().first) + assertTrue(controller.stop("watch-a")) + } + + @Test + fun `relays provider output larger than the frame queue capacity`() = + runTest { + val output = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + """{"relaySessionId":"relay-1"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, type, payload -> + if (type == WearRealtimeAudioFrameType.OUTPUT_PCM) output += payload + }, + ) + assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) + val audio = + ByteArray(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES * 65 + 8) { index -> + (index % 127).toByte() + } + + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"audio", + "audioBase64":"${Base64.encodeToString(audio, Base64.NO_WRAP)}" + } + """.trimIndent(), + ) + runCurrent() + + val deliveredAudio = output.flatMap { it.asIterable() }.toByteArray() + assertArrayEquals(audio, deliveredAudio) + assertEquals(WearRealtimeTalkStatus.SPEAKING, controller.snapshot.value.status) + assertTrue(controller.stop("watch-a")) + } + + @Test + fun `stops chunking provider output when the session is aborted`() = + runTest { + val output = mutableListOf() + lateinit var controller: WearRealtimeTalkController + controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") """{"relaySessionId":"relay-1"}""" else """{"ok":true}""" + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, type, payload -> + if (type == WearRealtimeAudioFrameType.OUTPUT_PCM) { + output += payload + if (output.size == 1) controller.abort() + } + }, + ) + assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) + val audio = ByteArray(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES * 3) + + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"audio", + "audioBase64":"${Base64.encodeToString(audio, Base64.NO_WRAP)}" + } + """.trimIndent(), + ) + runCurrent() + + assertEquals(1, output.size) + assertEquals(WearRealtimeTalkStatus.OFF, controller.snapshot.value.status) + } + + @Test + fun `fails when queued provider output exceeds the byte budget`() = + runTest { + val outputStarted = CompletableDeferred() + val releaseOutput = CompletableDeferred() + val forcedChannelCloses = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") """{"relaySessionId":"relay-1"}""" else """{"ok":true}""" + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, type, _ -> + if (type == WearRealtimeAudioFrameType.OUTPUT_PCM) { + outputStarted.complete(Unit) + releaseOutput.await() + } + }, + onForceCloseWatchChannel = { forcedChannelCloses += it.nodeId }, + ) + assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) + val audio = ByteArray(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES * 65) + val event = + """ + { + "relaySessionId":"relay-1", + "type":"audio", + "audioBase64":"${Base64.encodeToString(audio, Base64.NO_WRAP)}" + } + """.trimIndent() + + controller.handleGatewayEvent("talk.event", event) + outputStarted.await() + controller.handleGatewayEvent("talk.event", event) + + assertEquals(WearRealtimeTalkStatus.ERROR, controller.snapshot.value.status) + assertEquals(listOf("watch-a"), forcedChannelCloses) + releaseOutput.complete(Unit) + runCurrent() + } + + @Test + fun `fails instead of dropping Watch audio when the input queue is full`() = + runTest { + val forcedChannelCloses = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + """{"relaySessionId":"relay-1"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + onForceCloseWatchChannel = { forcedChannelCloses += it.nodeId }, + ) + assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) + + repeat(65) { index -> + controller.appendAudio("watch-a", byteArrayOf(index.toByte(), 0)) + } + + assertEquals(WearRealtimeTalkStatus.ERROR, controller.snapshot.value.status) + assertEquals(listOf("watch-a"), forcedChannelCloses) + runCurrent() + } + + @Test + fun `reports an error and closes the relay when watch audio delivery fails`() = + runTest { + val gatewayMethods = mutableListOf() + val forcedChannelCloses = mutableListOf() + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + gatewayMethods += method + if (method == "talk.session.create") { + """{"relaySessionId":"relay-1"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> error("wear link down") }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, + ) + assertTrue( + controller.start( + nodeId = "watch-a", + sessionKey = "session-a", + attemptId = "attempt-a", + language = "de", + ), + ) + + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"audio", + "audioBase64":"${Base64.encodeToString(ByteArray(16), Base64.NO_WRAP)}" + } + """.trimIndent(), + ) + runCurrent() + + assertEquals(WearRealtimeTalkStatus.ERROR, controller.snapshot.value.status) + assertEquals("Unable to send audio to Watch", controller.snapshot.value.statusText) + assertTrue("talk.session.close" in gatewayMethods) + assertEquals(listOf("watch-a"), forcedChannelCloses) + } +} + +private suspend fun WearRealtimeTalkController.start( + nodeId: String, + sessionKey: String, + attemptId: String, + language: String?, +): Boolean = + start( + owner = testWearRealtimeOwner(nodeId, attemptId), + sessionKey = sessionKey, + language = language, + ) + +private fun WearRealtimeTalkController.appendAudio( + nodeId: String, + payload: ByteArray, +) { + val attemptId = snapshot.value.attemptId ?: return + appendAudio(testWearRealtimeOwner(nodeId, attemptId), payload) +} + +private fun testWearRealtimeOwner( + nodeId: String, + attemptId: String, +): WearRealtimeAttemptOwner = + WearRealtimeAttemptOwner( + nodeId = nodeId, + attemptId = attemptId, + channelGeneration = attemptId.hashCode().toLong(), + ) + +private fun WearRealtimeTalkController.invokePrivate( + name: String, + vararg args: Any, +) { + javaClass.declaredMethods + .single { method -> + method.name == name && + method.parameterTypes.size == args.size && + method.parameterTypes.zip(args).all { (type, arg) -> type.isAssignableFrom(arg.javaClass) } + }.apply { isAccessible = true } + .invoke(this, *args) +} diff --git a/app/src/test/resources/chat/markdown_stream_fixture.md b/app/src/test/resources/chat/markdown_stream_fixture.md new file mode 100644 index 0000000..51640ae --- /dev/null +++ b/app/src/test/resources/chat/markdown_stream_fixture.md @@ -0,0 +1,49 @@ +# Streaming Markdown Shapes + +## Headings and emphasis + +This paragraph mixes **bold**, _italic_, `inline code`, and unicode text such as +café, naïve, Größe, 流式渲染, and emoji 🦀🌊 that force surrogate pairs through the +JSON event pipeline. + +### Lists + +- unordered item one +- unordered item two + - nested child with `code` + - nested child with a [link](https://docs.openclaw.ai) +- unordered item three + +1. ordered first +2. ordered second +3. ordered third + +### Fenced code + +```kotlin +fun greet(name: String): String { + val trimmed = name.trim() + return "Hello, $trimmed! & \"quotes\" survive" +} +``` + +```json +{ "runId": "run-1", "state": "delta", "text": "chunk — escaped" } +``` + +### Table + +| Column A | Column B | Column C | +| -------- | -------- | -------- | +| alpha | 1 | true | +| beta | 2 | false | +| gamma 🦀 | 3 | null | + +> Blockquote line one +> Blockquote line two + +--- + +### Long paragraph + +Streaming transports must not corrupt long unbroken prose, so this single paragraph keeps going for quite a while without any line breaks to make sure chunk boundaries land in the middle of words, punctuation, and multi-byte sequences like ünïcödé and 🦀, verifying that every accumulated snapshot remains a strict prefix of the final text and that the terminal snapshot is byte-identical to this fixture file exactly as it was committed, trailing newline included. diff --git a/app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt b/app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt new file mode 100644 index 0000000..f87121a --- /dev/null +++ b/app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt @@ -0,0 +1,91 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceE2eReceiverTest { + @Test + fun terminalAuthFailureStopsWaiting() { + val problem = + problem( + code = "AUTH_TOKEN_MISSING", + message = "unauthorized: gateway token missing", + pauseReconnect = true, + retryable = false, + ) + + assertEquals( + "unauthorized: gateway token missing", + voiceE2eTerminalGatewayFailure(problem), + ) + } + + @Test + fun pairingApprovalKeepsWaiting() { + val problem = + problem( + code = "PAIRING_REQUIRED", + message = "pairing approval required", + pauseReconnect = true, + retryable = true, + ) + + assertNull(voiceE2eTerminalGatewayFailure(problem)) + } + + @Test + fun retryableTransportFailureKeepsWaiting() { + val problem = + problem( + code = "UNAVAILABLE", + message = "gateway unavailable", + pauseReconnect = false, + retryable = true, + ) + + assertNull(voiceE2eTerminalGatewayFailure(problem)) + } + + @Test + fun timeoutIncludesLatestConnectionDetail() { + assertEquals( + "Gateway connection timed out after 12000 ms: pairing approval required", + voiceE2eGatewayTimeoutMessage( + timeoutMs = 12_000L, + statusText = "Reconnecting...", + problem = + problem( + code = "PAIRING_REQUIRED", + message = "pairing approval required", + pauseReconnect = true, + retryable = true, + ), + ), + ) + assertEquals( + "Gateway connection timed out after 12000 ms: Connecting...", + voiceE2eGatewayTimeoutMessage( + timeoutMs = 12_000L, + statusText = "Connecting...", + problem = null, + ), + ) + } + + private fun problem( + code: String, + message: String, + pauseReconnect: Boolean, + retryable: Boolean, + ): GatewayConnectionProblem = + GatewayConnectionProblem( + code = code, + message = message, + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = pauseReconnect, + retryable = retryable, + ) +} diff --git a/app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentControllerTest.kt b/app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentControllerTest.kt new file mode 100644 index 0000000..e7ad105 --- /dev/null +++ b/app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentControllerTest.kt @@ -0,0 +1,55 @@ +package ai.openclaw.app.accessibility + +import android.content.ComponentName +import android.content.pm.PackageManager +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AccessibilityComponentControllerTest { + @Test + fun componentState_mapsEnabledAndDisabled() { + assertEquals( + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + accessibilityComponentEnabledState(enabled = true), + ) + assertEquals( + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + accessibilityComponentEnabledState(enabled = false), + ) + } + + @Test + fun setEnabled_updatesServiceAndDevActivity() { + val context = RuntimeEnvironment.getApplication() + val packageManager = context.packageManager + val service = ComponentName(context, OpenClawAccessibilityService::class.java) + val activity = ComponentName(context, AccessibilityDevActivity::class.java) + val controller = AccessibilityComponentController(context) + + controller.setEnabled(true) + + assertEquals( + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + packageManager.getComponentEnabledSetting(service), + ) + assertEquals( + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + packageManager.getComponentEnabledSetting(activity), + ) + + controller.setEnabled(false) + + assertEquals( + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + packageManager.getComponentEnabledSetting(service), + ) + assertEquals( + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + packageManager.getComponentEnabledSetting(activity), + ) + } +} diff --git a/app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotterTest.kt b/app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotterTest.kt new file mode 100644 index 0000000..e06703e --- /dev/null +++ b/app/src/testThirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotterTest.kt @@ -0,0 +1,500 @@ +package ai.openclaw.app.accessibility + +import android.graphics.Rect +import android.text.InputType +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AccessibilitySnapshotterTest { + @Test + fun observableServiceInstanceTracksTheLiveInstance() { + val state = ObservableServiceInstance() + val first = Any() + val second = Any() + + assertEquals(AccessibilityServiceConnection(instance = null, generation = 0), state.connection.value) + assertFalse(state.isConnected.value) + + state.connect(first) + assertTrue(state.connection.value.instance === first) + assertEquals(1L, state.connection.value.generation) + assertTrue(state.isConnected.value) + + state.connect(second) + state.disconnect(first) + assertTrue(state.connection.value.instance === second) + assertEquals(2L, state.connection.value.generation) + assertTrue(state.isConnected.value) + + state.disconnect(second) + assertEquals(null, state.connection.value.instance) + assertEquals(2L, state.connection.value.generation) + assertFalse(state.isConnected.value) + } + + @Test + fun normalizerCapsSnapshotsAtMaximumNodeCountAndRecyclesDiscardedNodes() { + val children = List(MAX_NODES + 5) { index -> FakeNode(text = "node-$index") } + val root = FakeNode(boundsInScreen = Rect(), children = children) + + val result = AccessibilityTreeNormalizer.normalize(root) + + assertEquals(MAX_NODES, result.nodes.size) + assertEquals("n0", result.nodes.first().ref) + assertEquals("n${MAX_NODES - 1}", result.nodes.last().ref) + assertTrue(root.recycled) + assertTrue(children.takeLast(5).all(FakeNode::recycled)) + assertFalse(children.take(MAX_NODES).any(FakeNode::recycled)) + + result.retainedNodes.values.forEach(AccessibilityNodeAdapter::recycle) + assertTrue(children.all(FakeNode::recycled)) + } + + @Test + fun normalizerDoesNotWalkBelowMaximumDepth() { + var root = FakeNode(text = "deepest") + repeat(MAX_DEPTH + 2) { depth -> + root = FakeNode(text = "depth-$depth", children = listOf(root)) + } + + val result = AccessibilityTreeNormalizer.normalize(root) + + assertEquals(MAX_DEPTH + 1, result.nodes.size) + result.retainedNodes.values.forEach(AccessibilityNodeAdapter::recycle) + } + + @Test + fun passwordTextIsRedactedAndLongTextIsBounded() { + assertTrue(shouldRedactText(isPassword = true, isEditable = false, inputType = 0)) + assertTrue( + shouldRedactText( + isPassword = false, + isEditable = true, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + ), + ) + assertFalse( + shouldRedactText( + isPassword = false, + isEditable = true, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_NORMAL, + ), + ) + assertEquals("[redacted]", normalizeNodeText("hunter2", sensitive = true)) + + val truncated = normalizeNodeText("x".repeat(MAX_TEXT_PER_NODE + 10), sensitive = false) + assertEquals(MAX_TEXT_PER_NODE, truncated?.length) + assertTrue(truncated?.endsWith("…") == true) + } + + @Test + fun actionNamesUseStableVocabularyAndOrdering() { + val actions = + stableActionNames( + listOf( + AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, + AccessibilityNodeInfo.ACTION_SET_TEXT, + AccessibilityNodeInfo.ACTION_CLICK, + AccessibilityNodeInfo.ACTION_COPY, + AccessibilityNodeInfo.ACTION_FOCUS, + ), + ) + + assertEquals(listOf("activate", "set_text", "scroll_backward", "focus"), actions) + } + + @Test + fun wrongSnapshotOrMissingRefIsStaleAndReplacementReleasesOldNodes() { + val released = mutableListOf() + val store = SnapshotGenerationStore(released::add) + store.replace( + snapshotId = "snapshot-1", + packageName = "example.one", + uiEpoch = 11, + connectionGeneration = 21, + values = mapOf("n0" to "first"), + ) + + assertEquals(GenerationTarget.Stale, store.resolve("wrong-snapshot", "n0")) + assertEquals(GenerationTarget.Stale, store.resolve("snapshot-1", "missing")) + assertEquals(GenerationTarget.Found("first"), store.resolve("snapshot-1", "n0")) + + store.replace( + snapshotId = "snapshot-2", + packageName = "example.two", + uiEpoch = 12, + connectionGeneration = 22, + values = emptyMap(), + ) + assertEquals(listOf("first"), released) + assertEquals("example.two", store.packageName) + assertEquals(12L, store.uiEpoch) + assertEquals(22L, store.connectionGeneration) + } + + @Test + fun executorRejectsAnActionFromTheWrongSnapshotGeneration() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val executor = AccessibilityActionExecutor(connectionProvider = { testConnection(service) }) + val observed = executor.observe() + + val result = executor.act("${observed.id}-stale", MobileUiAction.Wait(0)) + + assertEquals(ActionOutcomeCode.TargetStale, result.code) + executor.close() + service.onDestroy() + } + + @Test + fun sensitiveNodeRedactsTextAndContentDescription() { + val sensitiveNode = + FakeNode( + text = "secret text", + contentDescription = "secret description", + viewId = "example:id/password", + password = true, + ) + + val result = AccessibilityTreeNormalizer.normalize(sensitiveNode) + + assertEquals("[redacted]", result.nodes.single().text) + assertEquals("[redacted]", result.nodes.single().contentDescription) + assertEquals("example:id/password", result.nodes.single().viewId) + result.retainedNodes.values.forEach(AccessibilityNodeAdapter::recycle) + } + + @Test + fun normalizerDoesNotFetchBeyondTraversalBudget() { + val children = List(MAX_VISITED_NODES * 2) { index -> FakeNode(text = "wide-$index") } + val root = FakeNode(boundsInScreen = Rect(), children = children) + + val result = AccessibilityTreeNormalizer.normalize(root) + + assertEquals(MAX_NODES, result.nodes.size) + assertEquals(MAX_VISITED_NODES - 1, root.childRequests) + result.retainedNodes.values.forEach(AccessibilityNodeAdapter::recycle) + assertTrue(root.recycled) + assertTrue(children.take(MAX_VISITED_NODES - 1).all(FakeNode::recycled)) + assertFalse(children.drop(MAX_VISITED_NODES - 1).any(FakeNode::recycled)) + } + + @Test + fun coordinateGestureFailsClosedWhenPackageIdentityIsMissingOrChanged() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val cases = + listOf( + null to "example.current", + "example.expected" to null, + "example.expected" to "example.current", + ) + + cases.forEachIndexed { index, (expectedPackage, currentPackage) -> + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service) }, + captureSnapshot = { fakeCapture("snapshot-$index", expectedPackage) }, + foregroundPackageProvider = { currentPackage }, + uiEpochProvider = { 20 }, + ) + val observed = executor.observe() + + val result = executor.act(observed.id, MobileUiAction.Tap(10, 20)) + + assertEquals(ActionOutcomeCode.PackageChanged, result.code) + executor.close() + } + service.onDestroy() + } + + @Test + fun coordinateGestureIsStaleAfterUiEpochAdvances() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service) }, + captureSnapshot = { fakeCapture("epoch-snapshot", "example.package") }, + foregroundPackageProvider = { "example.package" }, + ) + val observed = executor.observe() + val capturedEpoch = OpenClawAccessibilityService.uiEpoch + + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_VIEW_FOCUSED) + assertEquals(capturedEpoch, OpenClawAccessibilityService.uiEpoch) + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_WINDOWS_CHANGED) + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_VIEW_SCROLLED) + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) + sendAccessibilityEvent(service, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) + assertEquals(capturedEpoch + 6, OpenClawAccessibilityService.uiEpoch) + val result = executor.act(observed.id, MobileUiAction.Tap(10, 20)) + + assertEquals(ActionOutcomeCode.TargetStale, result.code) + assertEquals("UI changed since observe; re-observe before coordinate actions", result.message) + executor.close() + service.onDestroy() + } + + @Test + fun actionIsStaleAfterAccessibilityServiceReconnects() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + var connectionGeneration = 70L + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service, connectionGeneration) }, + captureSnapshot = { fakeCapture("connection-snapshot", "example.package") }, + foregroundPackageProvider = { "example.package" }, + uiEpochProvider = { 80 }, + ) + val observed = executor.observe() + connectionGeneration += 1 + + val result = executor.act(observed.id, MobileUiAction.Tap(10, 20)) + + assertEquals(ActionOutcomeCode.TargetStale, result.code) + assertEquals("Accessibility service reconnected; re-observe before acting", result.message) + executor.close() + service.onDestroy() + } + + @Test + @Suppress("DEPRECATION") + fun observeCannotPublishAGenerationAfterExecutorCloses() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val node = AccessibilityNodeInfo.obtain() + lateinit var executor: AccessibilityActionExecutor + executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service, generation = 100) }, + captureSnapshot = { + executor.close() + fakeCapture( + id = "closed-snapshot", + packageName = "example.package", + nodesByRef = mapOf("n0" to node), + ) + }, + foregroundPackageProvider = { "example.package" }, + uiEpochProvider = { 90 }, + ) + + val error = assertThrows(AccessibilityServiceDisabledException::class.java) { executor.observe() } + val result = executor.act("closed-snapshot", MobileUiAction.GlobalAction(GlobalActionName.Home)) + + assertEquals("Accessibility executor closed during observe", error.message) + assertEquals(ActionOutcomeCode.ServiceDisabled, result.code) + service.onDestroy() + } + + @Test + fun nodeActionFailsClosedWhenPackageIdentityIsMissingOrChanged() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val cases = + listOf( + null to "example.current", + "example.expected" to null, + "example.expected" to "example.current", + ) + + cases.forEachIndexed { index, (expectedPackage, currentPackage) -> + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service) }, + captureSnapshot = { fakeCapture("node-snapshot-$index", expectedPackage) }, + foregroundPackageProvider = { currentPackage }, + uiEpochProvider = { 40 }, + ) + val observed = executor.observe() + + val result = executor.act(observed.id, MobileUiAction.Activate("n0")) + + assertEquals(ActionOutcomeCode.PackageChanged, result.code) + executor.close() + } + service.onDestroy() + } + + @Test + @Suppress("DEPRECATION") + fun nodeActionUsesNodeFreshnessInsteadOfUiEpoch() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val node = AccessibilityNodeInfo.obtain() + var uiEpoch = 50L + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service) }, + captureSnapshot = { + fakeCapture( + id = "node-epoch-snapshot", + packageName = "example.package", + nodesByRef = mapOf("n0" to node), + ) + }, + foregroundPackageProvider = { "example.package" }, + uiEpochProvider = { uiEpoch }, + ) + val observed = executor.observe() + uiEpoch += 1 + + val result = executor.act(observed.id, MobileUiAction.Activate("n0")) + + assertTrue( + result.code == ActionOutcomeCode.TargetNotFound || + result.code == ActionOutcomeCode.ActionNotSupported, + ) + assertFalse(result.message?.contains("UI changed since observe") == true) + executor.close() + service.onDestroy() + } + + @Test + @Suppress("DEPRECATION") + fun setTextChecksSecureContentAfterTheFinalNodeRefresh() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val node = + AccessibilityNodeInfo.obtain().apply { + isEditable = true + isPassword = true + addAction(AccessibilityNodeInfo.ACTION_SET_TEXT) + } + val shadowNode = shadowOf(node).apply { setRefreshReturnValue(true) } + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service) }, + captureSnapshot = { + fakeCapture( + id = "secure-text-snapshot", + packageName = "example.package", + nodesByRef = mapOf("n0" to node), + ) + }, + foregroundPackageProvider = { "example.package" }, + uiEpochProvider = { 60 }, + ) + val observed = executor.observe() + + val result = executor.act(observed.id, MobileUiAction.SetText("n0", "must not be sent")) + + assertEquals(ActionOutcomeCode.SecureContent, result.code) + assertTrue(shadowNode.performedActions.isEmpty()) + executor.close() + service.onDestroy() + } + + @Test + fun devUiEnablesNodeActionsOnlyForMatchingKnownPackages() { + assertFalse(canRunNodeActions(snapshotPackageName = null, foregroundPackageName = "example")) + assertFalse(canRunNodeActions(snapshotPackageName = "example", foregroundPackageName = null)) + assertFalse(canRunNodeActions(snapshotPackageName = "example", foregroundPackageName = "other")) + assertTrue(canRunNodeActions(snapshotPackageName = "example", foregroundPackageName = "example")) + } + + @Test + fun globalActionDoesNotRequirePackageIdentity() = + runTest { + val service = Robolectric.buildService(OpenClawAccessibilityService::class.java).create().get() + val executor = + AccessibilityActionExecutor( + connectionProvider = { testConnection(service) }, + foregroundPackageProvider = { error("Global actions must not query the foreground package") }, + uiEpochProvider = { error("Global actions must not query the UI epoch") }, + ) + + val result = executor.act("no-snapshot", MobileUiAction.GlobalAction(GlobalActionName.Home)) + + assertTrue(result.code == ActionOutcomeCode.Completed || result.code == ActionOutcomeCode.ActionRejected) + executor.close() + service.onDestroy() + } +} + +private fun testConnection( + service: OpenClawAccessibilityService, + generation: Long = 0, +): AccessibilityServiceConnection = AccessibilityServiceConnection(instance = service, generation = generation) + +private fun fakeCapture( + id: String, + packageName: String?, + nodesByRef: Map = emptyMap(), +): AccessibilitySnapshotCapture = + AccessibilitySnapshotCapture( + snapshot = + MobileUiSnapshot( + id = id, + capturedAtMs = 0, + packageName = packageName, + windowTitle = null, + nodes = emptyList(), + ), + nodesByRef = nodesByRef, + ) + +@Suppress("DEPRECATION") +private fun sendAccessibilityEvent( + service: OpenClawAccessibilityService, + eventType: Int, +) { + val event = AccessibilityEvent.obtain(eventType) + try { + service.onAccessibilityEvent(event) + } finally { + event.recycle() + } +} + +private class FakeNode( + override val className: String? = "android.view.View", + override val text: String? = null, + override val contentDescription: String? = null, + override val viewId: String? = null, + override val boundsInScreen: Rect = Rect(0, 0, 10, 10), + override val clickable: Boolean = false, + override val editable: Boolean = false, + override val scrollable: Boolean = false, + override val enabled: Boolean = true, + override val focused: Boolean = false, + override val password: Boolean = false, + override val inputType: Int = 0, + override val actionIds: List = emptyList(), + private val children: List = emptyList(), +) : AccessibilityNodeAdapter { + var childRequests: Int = 0 + private set + var recycled: Boolean = false + private set + + override val childCount: Int + get() = children.size + + override fun childAt(index: Int): AccessibilityNodeAdapter { + childRequests += 1 + return children[index] + } + + override fun recycle() { + check(!recycled) { "Fake node recycled twice" } + recycled = true + } +} diff --git a/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt b/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt new file mode 100644 index 0000000..c68e8e2 --- /dev/null +++ b/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt @@ -0,0 +1,299 @@ +package ai.openclaw.app.node + +import android.content.Context +import android.provider.CallLog +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CallLogHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleCallLogSearch_requiresPermission() { + val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false)) + + val result = handler.handleCallLogSearch(null) + + assertFalse(result.ok) + assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleCallLogSearch_rejectsInvalidJson() { + val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true)) + + val result = handler.handleCallLogSearch("invalid json") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handleCallLogSearch_returnsCallLogs() { + val callLog = + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 60L, + type = 1, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = handler.handleCallLogSearch("""{"limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + assertEquals( + "+123456", + callLogs + .first() + .jsonObject + .getValue("number") + .jsonPrimitive.content, + ) + assertEquals( + "lixuankai", + callLogs + .first() + .jsonObject + .getValue("cachedName") + .jsonPrimitive.content, + ) + assertEquals( + 1709280000000L, + callLogs + .first() + .jsonObject + .getValue("date") + .jsonPrimitive.content + .toLong(), + ) + assertEquals( + 60L, + callLogs + .first() + .jsonObject + .getValue("duration") + .jsonPrimitive.content + .toLong(), + ) + assertEquals( + 1, + callLogs + .first() + .jsonObject + .getValue("type") + .jsonPrimitive.content + .toInt(), + ) + } + + @Test + fun handleCallLogSearch_withFilters() { + val callLog = + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 120L, + type = 2, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = + handler.handleCallLogSearch( + """{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}""", + ) + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + assertEquals( + "lixuankai", + callLogs + .first() + .jsonObject + .getValue("cachedName") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCallLogSearch_withPagination() { + val callLogs = + listOf( + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 60L, + type = 1, + ), + CallLogRecord( + number = "+654321", + cachedName = "lixuankai2", + date = 1709280001000L, + duration = 120L, + type = 2, + ), + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = callLogs), + ) + + val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogsResult = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogsResult.size) + assertEquals( + "lixuankai2", + callLogsResult + .first() + .jsonObject + .getValue("cachedName") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCallLogSearch_withDefaultParams() { + val callLog = + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 60L, + type = 1, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = handler.handleCallLogSearch(null) + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + assertEquals( + "+123456", + callLogs + .first() + .jsonObject + .getValue("number") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCallLogSearch_withNullFields() { + val callLog = + CallLogRecord( + number = null, + cachedName = null, + date = 1709280000000L, + duration = 60L, + type = 1, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = handler.handleCallLogSearch("""{"limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + // Verify null values are properly serialized + val callLogObj = callLogs.first().jsonObject + assertTrue(callLogObj.containsKey("number")) + assertTrue(callLogObj.containsKey("cachedName")) + } + + @Test + fun handleCallLogSearch_clampsLimitAndOffsetBeforeSearch() { + val source = FakeCallLogDataSource(canRead = true) + val handler = CallLogHandler.forTesting(appContext(), source) + + val result = handler.handleCallLogSearch("""{"limit":999,"offset":-5}""") + + assertTrue(result.ok) + assertEquals(200, source.lastRequest?.limit) + assertEquals(0, source.lastRequest?.offset) + } + + @Test + fun callLogLikeFiltersEscapeWildcards() { + assertEquals("${CallLog.Calls.CACHED_NAME} LIKE ? ESCAPE '\\'", buildCallLogCachedNameLikeSelection()) + assertEquals("${CallLog.Calls.NUMBER} LIKE ? ESCAPE '\\'", buildCallLogNumberLikeSelection()) + assertEquals("%a\\%b\\_c\\\\d%", buildCallLogLikeArg("a%b_c\\d")) + } + + @Test + fun handleCallLogSearch_mapsSearchFailuresToUnavailable() { + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource( + canRead = true, + failure = IllegalStateException("provider down"), + ), + ) + + val result = handler.handleCallLogSearch(null) + + assertFalse(result.ok) + assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code) + assertEquals("CALL_LOG_UNAVAILABLE: provider down", result.error?.message) + } +} + +private class FakeCallLogDataSource( + private val canRead: Boolean, + private val searchResults: List = emptyList(), + private val failure: Throwable? = null, +) : CallLogDataSource { + var lastRequest: CallLogSearchRequest? = null + + override fun hasReadPermission(context: Context): Boolean = canRead + + override fun search( + context: Context, + request: CallLogSearchRequest, + ): List { + lastRequest = request + failure?.let { throw it } + val startIndex = request.offset.coerceAtLeast(0) + val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size) + return if (startIndex < searchResults.size) { + searchResults.subList(startIndex, endIndex) + } else { + emptyList() + } + } +} diff --git a/app/src/testThirdParty/java/ai/openclaw/app/node/MobileUiHandlerTest.kt b/app/src/testThirdParty/java/ai/openclaw/app/node/MobileUiHandlerTest.kt new file mode 100644 index 0000000..e3f06fc --- /dev/null +++ b/app/src/testThirdParty/java/ai/openclaw/app/node/MobileUiHandlerTest.kt @@ -0,0 +1,105 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.accessibility.GlobalActionName +import ai.openclaw.app.accessibility.MobileUiAction +import ai.openclaw.app.accessibility.MobileUiNode +import ai.openclaw.app.accessibility.MobileUiSnapshot +import ai.openclaw.app.accessibility.ScrollDirection +import android.graphics.Rect +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MobileUiHandlerTest { + @Test + fun snapshotJsonUsesStableTransportShape() { + val snapshot = + MobileUiSnapshot( + id = "snapshot-1", + capturedAtMs = 1234, + packageName = "example.app", + windowTitle = "Example", + nodes = + listOf( + MobileUiNode( + ref = "n0", + parentRef = null, + role = "button", + text = "Continue", + contentDescription = "Continue button", + viewId = "example.app:id/continue", + boundsInScreen = Rect(1, 2, 30, 40), + clickable = true, + editable = false, + scrollable = false, + enabled = true, + focused = false, + actions = listOf("activate"), + ), + ), + ) + + val payload = Json.parseToJsonElement(mobileUiSnapshotJson(snapshot)) as JsonObject + val node = (payload["nodes"] as JsonArray).single() as JsonObject + + assertEquals("snapshot-1", payload["snapshotId"]?.jsonPrimitive?.content) + assertEquals("example.app", payload["package"]?.jsonPrimitive?.content) + assertEquals(listOf("1", "2", "30", "40"), (node["bounds"] as JsonArray).map { it.jsonPrimitive.content }) + assertEquals("true", ((node["flags"] as JsonObject)["clickable"])?.jsonPrimitive?.content) + assertEquals("activate", (node["actions"] as JsonArray).single().jsonPrimitive.content) + } + + @Test + fun actParserMapsEverySupportedAction() { + assertEquals(MobileUiAction.Activate("n1"), parse(action("activate", "\"ref\":\"n1\""))) + assertEquals( + MobileUiAction.SetText("n2", "hello"), + parse(action("set_text", "\"ref\":\"n2\",\"text\":\"hello\"")), + ) + assertEquals( + MobileUiAction.Scroll("n3", ScrollDirection.Backward), + parse(action("scroll", "\"ref\":\"n3\",\"direction\":\"backward\"")), + ) + assertEquals(MobileUiAction.Tap(10, 20), parse(action("tap", "\"x\":10,\"y\":20"))) + assertEquals( + MobileUiAction.Swipe(1, 2, 3, 4, 500), + parse(action("swipe", "\"x1\":1,\"y1\":2,\"x2\":3,\"y2\":4,\"durationMs\":500")), + ) + assertEquals( + MobileUiAction.GlobalAction(GlobalActionName.Notifications), + parse(action("global_action", "\"name\":\"notifications\"")), + ) + assertEquals(MobileUiAction.Wait(250), parse(action("wait", "\"ms\":250"))) + } + + @Test + fun actParserRejectsMalformedRequests() { + assertNull(parseMobileUiActRequest(null)) + assertNull(parseMobileUiActRequest("{}")) + assertNull(parseMobileUiActRequest(action("scroll", "\"ref\":\"n1\",\"direction\":\"sideways\""))) + } + + @Test + fun observeMapsDisconnectedServiceToStructuredError() = + runTest { + val result = MobileUiHandler().handleObserve(null) + + assertEquals(false, result.ok) + assertEquals("SERVICE_DISABLED", result.error?.code) + } + + private fun parse(raw: String): MobileUiAction? = parseMobileUiActRequest(raw)?.action + + private fun action( + type: String, + fields: String, + ): String = """{"snapshotId":"snapshot-1","action":{"type":"$type",$fields}}""" +} diff --git a/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt b/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt new file mode 100644 index 0000000..efdd356 --- /dev/null +++ b/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt @@ -0,0 +1,1089 @@ +package ai.openclaw.app.node + +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SmsManagerTest { + private val json = SmsManager.JsonConfig + + private fun smsMessage( + id: Long, + date: Long, + status: Int = 0, + body: String? = "msg-$id", + transportType: String? = null, + ): SmsManager.SmsMessage = + SmsManager.SmsMessage( + id = id, + threadId = 1L, + address = "+15551234567", + person = null, + date = date, + dateSent = date, + read = true, + type = 1, + body = body, + status = status, + transportType = transportType, + ) + + @Test + fun parseParamsRejectsEmptyPayload() { + val result = SmsManager.parseParams("", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: paramsJSON required", error.error) + } + + @Test + fun parseParamsRejectsInvalidJson() { + val result = SmsManager.parseParams("not-json", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsNonObjectJson() { + val result = SmsManager.parseParams("[]", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsMissingTo() { + val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) + assertEquals("Hi", error.message) + } + + @Test + fun parseParamsRejectsMissingMessage() { + val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'message' text required", error.error) + assertEquals("+1234", error.to) + } + + @Test + fun parseParamsTrimsToField() { + val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) + assertTrue(result is SmsManager.ParseResult.Ok) + val ok = result as SmsManager.ParseResult.Ok + assertEquals("+1555", ok.params.to) + assertEquals("Hello", ok.params.message) + } + + @Test + fun parseQueryParamsDefaultsWhenPayloadEmpty() { + val result = SmsManager.parseQueryParams(null, json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(25, ok.params.limit) + assertEquals(0, ok.params.offset) + assertEquals(null, ok.params.startTime) + assertEquals(null, ok.params.endTime) + } + + @Test + fun parseQueryParamsRejectsInvalidJson() { + val result = SmsManager.parseQueryParams("not-json", json) + assertTrue(result is SmsManager.QueryParseResult.Error) + val error = result as SmsManager.QueryParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseQueryParamsRejectsInvertedTimeRange() { + val result = SmsManager.parseQueryParams("{\"startTime\":200,\"endTime\":100}", json) + assertTrue(result is SmsManager.QueryParseResult.Error) + val error = result as SmsManager.QueryParseResult.Error + assertEquals("INVALID_REQUEST: startTime must be less than or equal to endTime", error.error) + } + + @Test + fun parseQueryParamsClampsLimitAndOffset() { + val result = SmsManager.parseQueryParams("{\"limit\":999,\"offset\":-5}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(200, ok.params.limit) + assertEquals(0, ok.params.offset) + } + + @Test + fun parseQueryParamsParsesAllSupportedFields() { + val result = + SmsManager.parseQueryParams( + """ + { + "startTime": 100, + "endTime": 200, + "contactName": " Leah ", + "phoneNumber": " +1555 ", + "keyword": " ping ", + "type": 1, + "isRead": true, + "limit": 10, + "offset": 2 + } + """.trimIndent(), + json, + ) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(100L, ok.params.startTime) + assertEquals(200L, ok.params.endTime) + assertEquals("Leah", ok.params.contactName) + assertEquals("+1555", ok.params.phoneNumber) + assertEquals("ping", ok.params.keyword) + assertEquals(1, ok.params.type) + assertEquals(true, ok.params.isRead) + assertEquals(10, ok.params.limit) + assertEquals(2, ok.params.offset) + } + + @Test + fun buildPayloadJsonEscapesFields() { + val payload = + SmsManager.buildPayloadJson( + json = json, + ok = false, + to = "+1\"23", + error = "SMS_SEND_FAILED: \"nope\"", + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) + assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) + assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) + } + + @Test + fun buildQueryPayloadJsonIncludesCountAndMessages() { + val payload = + SmsManager.buildQueryPayloadJson( + json = json, + ok = true, + messages = + listOf( + SmsManager.SmsMessage( + id = 1L, + threadId = 2L, + address = "+1555", + person = null, + date = 123L, + dateSent = 124L, + read = true, + type = 1, + body = "hello", + status = 0, + ), + ), + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("true", parsed["ok"]?.jsonPrimitive?.content) + assertEquals(1, parsed["count"]?.jsonPrimitive?.content?.toInt()) + val messages = parsed["messages"]?.jsonArray + assertEquals(1, messages?.size) + assertEquals( + "hello", + messages + ?.get(0) + ?.jsonObject + ?.get("body") + ?.jsonPrimitive + ?.content, + ) + } + + @Test + fun buildQueryPayloadJsonIncludesErrorOnFailure() { + val payload = + SmsManager.buildQueryPayloadJson( + json = json, + ok = false, + messages = emptyList(), + error = "SMS_QUERY_FAILED: nope", + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) + assertEquals(0, parsed["count"]?.jsonPrimitive?.content?.toInt()) + assertEquals("SMS_QUERY_FAILED: nope", parsed["error"]?.jsonPrimitive?.content) + } + + @Test + fun buildQueryPayloadJsonIncludesMmsMetadataWhenProvided() { + val payload = + SmsManager.buildQueryPayloadJson( + json = json, + ok = true, + messages = listOf(smsMessage(id = 1L, date = 1000L)), + queryMetadata = + SmsManager.QueryMetadata( + mmsRequested = true, + mmsEligible = true, + mmsAttempted = true, + mmsIncluded = false, + ), + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("true", parsed["mmsRequested"]?.jsonPrimitive?.content) + assertEquals("true", parsed["mmsEligible"]?.jsonPrimitive?.content) + assertEquals("true", parsed["mmsAttempted"]?.jsonPrimitive?.content) + assertEquals("false", parsed["mmsIncluded"]?.jsonPrimitive?.content) + } + + @Test + fun buildSendPlanUsesMultipartWhenMultipleParts() { + val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") } + assertTrue(plan.useMultipart) + assertEquals(listOf("a", "b"), plan.parts) + } + + @Test + fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { + val plan = SmsManager.buildSendPlan("hello") { emptyList() } + assertFalse(plan.useMultipart) + assertEquals(listOf("hello"), plan.parts) + } + + @Test + fun parseQueryParamsAcceptsEmptyPayload() { + val result = SmsManager.parseQueryParams(null, json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(25, ok.params.limit) + assertEquals(0, ok.params.offset) + } + + @Test + fun parseQueryParamsRejectsNonObjectJson() { + val result = SmsManager.parseQueryParams("[]", json) + assertTrue(result is SmsManager.QueryParseResult.Error) + val error = result as SmsManager.QueryParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseQueryParamsParsesLimitAndOffset() { + val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(10, ok.params.limit) + assertEquals(5, ok.params.offset) + } + + @Test + fun parseQueryParamsClampsLimitRange() { + val result = SmsManager.parseQueryParams("{\"limit\":300}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(200, ok.params.limit) + } + + @Test + fun parseQueryParamsParsesPhoneNumber() { + val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals("+1234567890", ok.params.phoneNumber) + } + + @Test + fun parseQueryParamsParsesContactName() { + val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals("lixuankai", ok.params.contactName) + } + + @Test + fun parseQueryParamsParsesKeyword() { + val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals("test", ok.params.keyword) + } + + @Test + fun parseQueryParamsParsesTimeRange() { + val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(1000L, ok.params.startTime) + assertEquals(2000L, ok.params.endTime) + } + + @Test + fun parseQueryParamsParsesType() { + val result = SmsManager.parseQueryParams("{\"type\":1}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(1, ok.params.type) + } + + @Test + fun parseQueryParamsParsesReadStatus() { + val result = SmsManager.parseQueryParams("{\"isRead\":true}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertEquals(true, ok.params.isRead) + } + + @Test + fun parseQueryParamsIncludeMmsDefaultsFalse() { + val result = SmsManager.parseQueryParams("{}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertFalse(ok.params.includeMms) + } + + @Test + fun parseQueryParamsParsesIncludeMmsTrue() { + val result = SmsManager.parseQueryParams("{\"includeMms\":true}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertTrue(ok.params.includeMms) + } + + @Test + fun parseQueryParamsParsesConversationReviewTrue() { + val result = SmsManager.parseQueryParams("{\"conversationReview\":true}", json) + assertTrue(result is SmsManager.QueryParseResult.Ok) + val ok = result as SmsManager.QueryParseResult.Ok + assertTrue(ok.params.conversationReview) + } + + @Test + fun toByPhoneLookupNumberStripsFormattingToDigits() { + assertEquals("12107588120", SmsManager.toByPhoneLookupNumber("+1 (210) 758-8120")) + } + + @Test + fun normalizePhoneNumberOrNullReturnsNullForFormattingOnlyInput() { + assertNull(SmsManager.normalizePhoneNumberOrNull("() - ")) + } + + @Test + fun normalizePhoneNumberOrNullReturnsNullForPlusOnlyInput() { + assertNull(SmsManager.normalizePhoneNumberOrNull(" + ")) + } + + @Test + fun normalizePhoneNumberOrNullKeepsUsableNormalizedNumber() { + assertEquals("+15551234567", SmsManager.normalizePhoneNumberOrNull(" +1 (555) 123-4567 ")) + } + + @Test + fun sanitizeContactPhoneNumberOrNullDropsFormattingOnlyInput() { + assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" () - ")) + } + + @Test + fun sanitizeContactPhoneNumberOrNullDropsPlusOnlyInput() { + assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" + ")) + } + + @Test + fun sanitizeContactPhoneNumberOrNullKeepsUsableNormalizedNumber() { + assertEquals("+15551234567", SmsManager.sanitizeContactPhoneNumberOrNull(" +1 (555) 123-4567 ")) + } + + @Test + fun sanitizeContactPhoneNumberOrNullDropsPercentWildcardInput() { + assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1%2")) + } + + @Test + fun sanitizeContactPhoneNumberOrNullDropsUnderscoreWildcardInput() { + assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1_2")) + } + + @Test + fun shouldPromptForContactNameSearchPermissionTrueForContactNameOnlyWithoutContactsAccess() { + assertTrue( + SmsManager.shouldPromptForContactNameSearchPermission( + contactName = "Alice", + phoneNumber = null, + hasReadContactsPermission = false, + ), + ) + } + + @Test + fun shouldPromptForContactNameSearchPermissionFalseWhenExplicitPhoneFallbackExists() { + assertFalse( + SmsManager.shouldPromptForContactNameSearchPermission( + contactName = "Alice", + phoneNumber = "+15551234567", + hasReadContactsPermission = false, + ), + ) + } + + @Test + fun shouldPromptForContactNameSearchPermissionFalseWhenContactsAlreadyGranted() { + assertFalse( + SmsManager.shouldPromptForContactNameSearchPermission( + contactName = "Alice", + phoneNumber = null, + hasReadContactsPermission = true, + ), + ) + } + + @Test + fun escapeSqlLikeLiteralEscapesPercentUnderscoreAndBackslash() { + assertEquals("\\%a\\_b\\\\c", SmsManager.escapeSqlLikeLiteral("%a_b\\c")) + } + + @Test + fun escapeSqlLikeLiteralLeavesOrdinaryTextUnchanged() { + assertEquals("Leah", SmsManager.escapeSqlLikeLiteral("Leah")) + } + + @Test + fun buildContactNameLikeSelectionUsesSingleBackslashEscapeLiteral() { + assertEquals( + "display_name LIKE ? ESCAPE '\\'", + SmsManager.buildContactNameLikeSelection(), + ) + } + + @Test + fun buildContactNameLikeArgEscapesWildcardsAndBackslash() { + assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildContactNameLikeArg("%a_b\\c")) + } + + @Test + fun buildKeywordLikeSelectionUsesSingleBackslashEscapeLiteral() { + assertEquals( + "body LIKE ? ESCAPE '\\'", + SmsManager.buildKeywordLikeSelection(), + ) + } + + @Test + fun buildKeywordLikeArgEscapesWildcardsAndBackslash() { + assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildKeywordLikeArg("%a_b\\c")) + } + + @Test + fun buildMixedByPhoneProjectionMatchesExpectedStatusAwareShape() { + assertArrayEquals( + arrayOf( + "_id", + "thread_id", + "transport_type", + "address", + "date", + "date_sent", + "read", + "type", + "body", + "status", + ), + SmsManager.buildMixedByPhoneProjection(), + ) + } + + @Test + fun compareByPhoneCandidateOrderUsesDateThenIdDescending() { + val newer = smsMessage(id = 1L, date = 2000L) + val older = smsMessage(id = 2L, date = 1000L) + val sameDateHigherId = smsMessage(id = 9L, date = 1500L) + val sameDateLowerId = smsMessage(id = 3L, date = 1500L) + + assertTrue(SmsManager.compareByPhoneCandidateOrder(newer, older) < 0) + assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateHigherId, sameDateLowerId) < 0) + assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateLowerId, sameDateHigherId) > 0) + } + + @Test + fun upsertTopDateCandidatesKeepsDescendingOrderAndBounds() { + val candidates = mutableListOf>() + val max = 2 + + SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1700L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 1500L), max) + + assertEquals(listOf(2L, 1L), candidates.map { it.second.id }) + assertEquals(listOf(2000L, 1700L), candidates.map { it.second.date }) + } + + @Test + fun upsertTopDateCandidatesSupportsDefaultMixedPathBoundedWindow() { + val params = SmsManager.QueryParams(limit = 3, offset = 2, includeMms = true, phoneNumber = "+15551234567") + val candidates = mutableListOf>() + val max = params.offset + params.limit + + SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1000L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 3000L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:4", smsMessage(id = 4L, date = 4000L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:5", smsMessage(id = 5L, date = 5000L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:6", smsMessage(id = 6L, date = 6000L), max) + + assertEquals(5, candidates.size) + assertEquals(listOf(6L, 5L, 4L, 3L, 2L), candidates.map { it.second.id }) + assertEquals(listOf(4000L, 3000L, 2000L), SmsManager.pageByPhoneCandidates(candidates.map { it.second }, params).map { it.date }) + } + + @Test + fun upsertTopDateCandidatesDedupesBySourceAwareIdentityAndKeepsBestOrdering() { + val candidates = mutableListOf>() + val max = 5 + + SmsManager.upsertTopDateCandidates(candidates, "sms:1987", smsMessage(id = 1987L, date = 1773950752506L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:1985", smsMessage(id = 1985L, date = 1773872989602L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:1981", smsMessage(id = 1981L, date = 1773790733566L), max) + SmsManager.upsertTopDateCandidates(candidates, "sms:1976", smsMessage(id = 1976L, date = 1773784153770L), max) + + // same source-aware identity should replace, not duplicate + SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max) + // different source-aware identity with same raw id must be preserved + SmsManager.upsertTopDateCandidates(candidates, "mms:1986", smsMessage(id = 1986L, date = 1773899354038L), max) + + assertEquals(5, candidates.size) + assertEquals(2, candidates.count { it.second.id == 1986L }) + assertEquals(listOf("sms:1987", "sms:1986", "mms:1986", "sms:1985", "sms:1981"), candidates.map { it.first }) + } + + @Test + fun materializeByPhoneCandidateDedupesBySourceAwareIdentity() { + val candidates = linkedMapOf() + + SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 1000L)) + SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 2000L)) + SmsManager.materializeByPhoneCandidate(candidates, "mms:1", smsMessage(id = 1L, date = 1500L)) + + assertEquals(2, candidates.size) + assertEquals(2000L, candidates["sms:1"]?.date) + assertEquals(1500L, candidates["mms:1"]?.date) + } + + @Test + fun collectMixedByPhoneCandidateUsesBoundedCollectorWhenReviewModeDisabled() { + val topCandidates = mutableListOf>() + val materializedCandidates = linkedMapOf() + + SmsManager.collectMixedByPhoneCandidate( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + identityKey = "sms:1", + message = smsMessage(id = 1L, date = 1000L), + maxCandidates = 1, + reviewMode = false, + ) + SmsManager.collectMixedByPhoneCandidate( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + identityKey = "mms:2", + message = smsMessage(id = 2L, date = 2000L, transportType = "mms"), + maxCandidates = 1, + reviewMode = false, + ) + + assertEquals(listOf(2L), topCandidates.map { it.second.id }) + assertTrue(materializedCandidates.isEmpty()) + } + + @Test + fun collectMixedByPhoneCandidateMaterializesFullSetWhenReviewModeEnabled() { + val topCandidates = mutableListOf>() + val materializedCandidates = linkedMapOf() + + SmsManager.collectMixedByPhoneCandidate( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + identityKey = "sms:1", + message = smsMessage(id = 1L, date = 1000L), + maxCandidates = 1, + reviewMode = true, + ) + SmsManager.collectMixedByPhoneCandidate( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + identityKey = "mms:2", + message = smsMessage(id = 2L, date = 2000L, transportType = "mms"), + maxCandidates = 1, + reviewMode = true, + ) + + assertTrue(topCandidates.isEmpty()) + assertEquals(listOf(1L, 2L), materializedCandidates.values.map { it.id }) + } + + @Test + fun pageMixedByPhoneCandidatesLetsReviewModeSurfaceOlderRowsBeyondBoundedDefaultWindow() { + val params = + SmsManager.QueryParams( + limit = 2, + offset = 2, + includeMms = true, + phoneNumber = "+15551234567", + conversationReview = true, + ) + val topCandidates = + listOf( + "sms:9" to smsMessage(id = 9L, date = 9000L), + "sms:8" to smsMessage(id = 8L, date = 8000L), + "sms:7" to smsMessage(id = 7L, date = 7000L), + ) + val materializedCandidates = + linkedMapOf( + "sms:9" to smsMessage(id = 9L, date = 9000L), + "sms:8" to smsMessage(id = 8L, date = 8000L), + "sms:7" to smsMessage(id = 7L, date = 7000L), + "mms:6" to smsMessage(id = 6L, date = 6000L, transportType = "mms"), + ) + + val defaultPage = + SmsManager.pageMixedByPhoneCandidates( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + params = params.copy(conversationReview = false), + reviewMode = false, + ) + val reviewPage = + SmsManager.pageMixedByPhoneCandidates( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + params = params, + reviewMode = true, + ) + + assertEquals(listOf(7L), defaultPage.map { it.id }) + assertEquals(listOf(7L, 6L), reviewPage.map { it.id }) + assertEquals(4, materializedCandidates.size) + } + + @Test + fun pageByPhoneCandidatesHonorsDeepOffsetAfterStableSort() { + val params = SmsManager.QueryParams(limit = 5, offset = 5, includeMms = true) + val candidates = + listOf( + smsMessage(id = 1399L, date = 1741112335720L), + smsMessage(id = 1976L, date = 1773784153770L), + smsMessage(id = 1981L, date = 1773790733566L), + smsMessage(id = 1985L, date = 1773872989602L), + smsMessage(id = 1986L, date = 1773899354039L), + smsMessage(id = 1987L, date = 1773950752506L), + ) + + assertEquals(listOf(1399L), SmsManager.pageByPhoneCandidates(candidates, params).map { it.id }) + assertTrue(SmsManager.pageByPhoneCandidates(candidates, params.copy(offset = 10)).isEmpty()) + } + + @Test + fun upsertTopDateCandidatesNoOpWhenMaxIsZero() { + val candidates = mutableListOf>() + SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 2000L), 0) + assertTrue(candidates.isEmpty()) + } + + @Test + fun buildMixedRowIdentityUsesTransportTypeAndRowId() { + assertEquals("sms:7", SmsManager.buildMixedRowIdentity(7L, "sms")) + assertEquals("mms:7", SmsManager.buildMixedRowIdentity(7L, "mms")) + assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, null)) + assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, "")) + } + + @Test + fun normalizeProviderDateMillisConvertsSecondsToMillis() { + assertEquals(1773944910000L, SmsManager.normalizeProviderDateMillis(1773944910L)) + } + + @Test + fun normalizeProviderDateMillisKeepsMillisUnchanged() { + assertEquals(1773944910123L, SmsManager.normalizeProviderDateMillis(1773944910123L)) + } + + @Test + fun normalizeProviderDateMillisKeepsHistoricMillisUnchanged() { + assertEquals(946684800000L, SmsManager.normalizeProviderDateMillis(946684800000L)) + } + + @Test + fun resolveMixedByPhoneRowStatusPreservesRealSmsStatus() { + assertEquals(64, SmsManager.resolveMixedByPhoneRowStatus("sms", 64)) + assertEquals(32, SmsManager.resolveMixedByPhoneRowStatus(null, 32)) + } + + @Test + fun resolveMixedByPhoneRowStatusKeepsMmsOnSentinelValue() { + assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("mms", 64)) + assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("MMS", null)) + } + + @Test + fun resolveMixedByPhoneRowStatusFallsBackToZeroWhenSmsStatusMissing() { + assertEquals(0, SmsManager.resolveMixedByPhoneRowStatus("sms", null)) + } + + @Test + fun resolveMixedByPhoneRowAddressPreservesProviderAddressWhenPresent() { + assertEquals( + "+12107588120", + SmsManager.resolveMixedByPhoneRowAddress("+12107588120", "12107588120"), + ) + } + + @Test + fun resolveMixedByPhoneRowAddressFallsBackToLookupNumberWhenProviderAddressMissing() { + assertEquals( + "12107588120", + SmsManager.resolveMixedByPhoneRowAddress(null, "12107588120"), + ) + } + + @Test + fun resolveMixedByPhoneRowAddressCanPreserveLookupNumberWhenProviderAlreadyReturnsIt() { + assertEquals( + "12107588120", + SmsManager.resolveMixedByPhoneRowAddress("12107588120", "12107588120"), + ) + } + + @Test + fun resolveMixedByPhoneRowAddressPreservesNonMatchingProviderAddress() { + assertEquals( + "+13105550123", + SmsManager.resolveMixedByPhoneRowAddress("+13105550123", "12107588120"), + ) + } + + @Test + fun resolveMixedByPhoneRowAddressPrefersResolvedMmsParticipantAddress() { + assertEquals( + "+13105550123", + SmsManager.resolveMixedByPhoneRowAddress("insert-address-token", "12107588120", "+13105550123"), + ) + } + + @Test + fun selectPreferredMmsAddressPrefersType137AddressThatDoesNotMatchLookup() { + assertEquals( + "+13105550123", + SmsManager.selectPreferredMmsAddress( + listOf( + "+12107588120" to 151, + "+13105550123" to 137, + "+12107588120" to 130, + ), + "12107588120", + ), + ) + } + + @Test + fun selectPreferredMmsAddressFallsBackToFirstNormalizedAddressWhenOnlyLookupMatchesExist() { + assertEquals( + "+12107588120", + SmsManager.selectPreferredMmsAddress( + listOf( + "insert-address-token" to 137, + "+12107588120" to 151, + ), + "12107588120", + ), + ) + } + + @Test + fun isExplicitPhoneInputInvalidTrueWhenCallerSuppliesOnlyFormatting() { + val normalized = SmsManager.normalizePhoneNumberOrNull(" + ") + assertTrue(SmsManager.isExplicitPhoneInputInvalid(" + ", normalized)) + } + + @Test + fun hasSqlLikeWildcardDetectsPercentAndUnderscore() { + assertTrue(SmsManager.hasSqlLikeWildcard("+1555%1234")) + assertTrue(SmsManager.hasSqlLikeWildcard("+1555_1234")) + assertFalse(SmsManager.hasSqlLikeWildcard("+15551234")) + } + + @Test + fun isExplicitPhoneInputInvalidRejectsLikeWildcardPhoneFilter() { + assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555%1234", "+1555%1234")) + assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555_1234", "+1555_1234")) + } + + @Test + fun isExplicitPhoneInputInvalidFalseWhenPhoneWasOmitted() { + assertFalse(SmsManager.isExplicitPhoneInputInvalid(null, null)) + assertFalse(SmsManager.isExplicitPhoneInputInvalid(" ", null)) + } + + @Test + fun mapMmsMsgBoxToSearchTypeCoversSearchRelevantMmsBoxes() { + assertEquals(1, SmsManager.mapMmsMsgBoxToSearchType(1)) + assertEquals(2, SmsManager.mapMmsMsgBoxToSearchType(2)) + assertEquals(3, SmsManager.mapMmsMsgBoxToSearchType(3)) + assertEquals(4, SmsManager.mapMmsMsgBoxToSearchType(4)) + assertEquals(5, SmsManager.mapMmsMsgBoxToSearchType(5)) + assertEquals(6, SmsManager.mapMmsMsgBoxToSearchType(6)) + } + + @Test + fun mapMmsMsgBoxToSearchTypeLeavesUnsupportedBoxesUnmapped() { + assertNull(SmsManager.mapMmsMsgBoxToSearchType(0)) + assertNull(SmsManager.mapMmsMsgBoxToSearchType(99)) + assertNull(SmsManager.mapMmsMsgBoxToSearchType(null)) + } + + @Test + fun shouldUseConversationReviewByPhoneModeOnlyForMixedByPhoneReviewPulls() { + val active = + SmsManager.QueryParams( + limit = 5, + offset = 0, + isRead = null, + contactName = null, + phoneNumber = "+12107588120", + keyword = null, + startTime = null, + endTime = null, + includeMms = true, + conversationReview = true, + ) + val disabledByMode = active.copy(conversationReview = false) + val disabledByMms = active.copy(includeMms = false) + val disabledByPhone = active.copy(phoneNumber = null) + + assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(active)) + assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMode)) + assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMms)) + assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByPhone)) + } + + @Test + fun effectiveSearchParamsRaisesConversationReviewLimitFloor() { + val params = + SmsManager.QueryParams( + limit = 5, + offset = 0, + isRead = null, + contactName = null, + phoneNumber = "+12107588120", + keyword = null, + startTime = null, + endTime = null, + includeMms = true, + conversationReview = true, + ) + + assertEquals(25, SmsManager.effectiveSearchParams(params).limit) + assertEquals(40, SmsManager.effectiveSearchParams(params.copy(limit = 40)).limit) + assertEquals(5, SmsManager.effectiveSearchParams(params.copy(conversationReview = false)).limit) + + val singleResolvedContact = params.copy(phoneNumber = null, contactName = "Leah") + assertEquals(25, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit) + assertEquals(5, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567", "15557654321")).limit) + assertEquals( + SmsManager.effectiveSearchParams(params).limit, + SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit, + ) + } + + @Test + fun resolveSearchParamsCarriesSingleResolvedContactIntoReviewMode() { + val params = + SmsManager.QueryParams( + limit = 5, + offset = 0, + isRead = null, + contactName = "Leah", + phoneNumber = null, + keyword = null, + startTime = null, + endTime = null, + includeMms = true, + conversationReview = true, + ) + + val beforeResolution = SmsManager.resolveSearchParams(params, normalizedPhoneNumber = null) + val singleResolved = + SmsManager.resolveSearchParams( + params, + normalizedPhoneNumber = null, + resolvedPhoneNumbers = listOf("15551234567"), + ) + val multiResolved = + SmsManager.resolveSearchParams( + params, + normalizedPhoneNumber = null, + resolvedPhoneNumbers = listOf("15551234567", "15557654321"), + ) + val explicit = + SmsManager.resolveSearchParams( + params.copy(contactName = null, phoneNumber = "+12107588120"), + normalizedPhoneNumber = "12107588120", + ) + val nonReview = + SmsManager.resolveSearchParams( + params.copy(conversationReview = false), + normalizedPhoneNumber = null, + resolvedPhoneNumbers = listOf("15551234567"), + ) + + assertEquals(5, beforeResolution.limit) + assertEquals(25, singleResolved.limit) + assertEquals("15551234567", singleResolved.phoneNumber) + assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(singleResolved)) + assertEquals(5, multiResolved.limit) + assertNull(multiResolved.phoneNumber) + assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(multiResolved)) + assertEquals(25, explicit.limit) + assertEquals("12107588120", explicit.phoneNumber) + assertEquals(5, nonReview.limit) + assertEquals("15551234567", nonReview.phoneNumber) + assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(nonReview)) + } + + @Test + fun canonicalizeMixedPathPhoneFiltersDedupesEquivalentExplicitAndContactNumbers() { + assertEquals( + listOf("15551234567"), + SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567")), + ) + } + + @Test + fun canonicalizeMixedPathPhoneFiltersDropsBlankByPhoneValues() { + assertEquals( + listOf("15551234567"), + SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "+", " ")), + ) + } + + @Test + fun buildQueryMetadataUsesCanonicalizedSingleMixedFilterAsEligible() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567") + val canonical = SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567")) + + val metadata = SmsManager.buildQueryMetadata(params, canonical, emptyList()) + + assertTrue(metadata.mmsEligible) + assertTrue(metadata.mmsAttempted) + } + + @Test + fun requestedMixedByPhoneCandidateWindowAddsOffsetAndLimitSafely() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300) + assertEquals(500L, SmsManager.requestedMixedByPhoneCandidateWindow(params)) + } + + @Test + fun exceedsMixedByPhoneCandidateWindowFalseAtSupportedBoundary() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300) + assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567"))) + } + + @Test + fun exceedsMixedByPhoneCandidateWindowTrueWhenSingleNumberMixedWindowTooLarge() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 301) + assertTrue(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567"))) + } + + @Test + fun exceedsMixedByPhoneCandidateWindowFalseForSmsOnlyQueries() { + val params = SmsManager.QueryParams(includeMms = false, phoneNumber = "+15551234567", limit = 200, offset = 50000) + assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567"))) + } + + @Test + fun exceedsMixedByPhoneCandidateWindowFalseWhenMultiplePhoneNumbersDisableMixedByPhonePath() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = null, limit = 200, offset = 50000) + assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567", "+15557654321"))) + } + + @Test + fun mixedByPhoneWindowErrorMentionsSupportedWindow() { + assertEquals( + "INVALID_REQUEST: includeMms offset+limit exceeds supported window (500)", + SmsManager.mixedByPhoneWindowError(), + ) + } + + @Test + fun buildQueryMetadataMarksIneligibleWhenIncludeMmsNotRequested() { + val params = SmsManager.QueryParams(includeMms = false) + + val metadata = SmsManager.buildQueryMetadata(params, emptyList(), emptyList()) + + assertFalse(metadata.mmsRequested) + assertFalse(metadata.mmsEligible) + assertFalse(metadata.mmsAttempted) + assertFalse(metadata.mmsIncluded) + } + + @Test + fun buildQueryMetadataMarksEligibleAttemptedButNotIncludedForSingleNumberFallback() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567") + val messages = listOf(smsMessage(id = 1L, date = 1000L)) + + val metadata = SmsManager.buildQueryMetadata(params, listOf("+15551234567"), messages) + + assertTrue(metadata.mmsRequested) + assertTrue(metadata.mmsEligible) + assertTrue(metadata.mmsAttempted) + assertFalse(metadata.mmsIncluded) + } + + @Test + fun isMmsTransportRowTrueOnlyForMmsTransport() { + assertTrue(SmsManager.isMmsTransportRow(smsMessage(id = 1L, date = 1000L, transportType = "mms"))) + assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 2L, date = 1000L, transportType = "sms"))) + assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 3L, date = 1000L, transportType = null))) + } + + @Test + fun shouldHydrateMmsByPhoneRowTrueOnlyForMmsTransportWithBlankBodyOrZeroType() { + assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", null, 1)) + assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "", 1)) + assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 0)) + assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("sms", null, 0)) + assertFalse(SmsManager.shouldHydrateMmsByPhoneRow(null, null, 0)) + assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 1)) + } + + @Test + fun buildQueryMetadataDoesNotTreatSmsStatusSentinelAsMmsInclusion() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567") + val smsLikeMessage = smsMessage(id = 7L, date = 1000L, status = -1, transportType = "sms") + + val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(smsLikeMessage)) + + assertTrue(metadata.mmsRequested) + assertTrue(metadata.mmsEligible) + assertTrue(metadata.mmsAttempted) + assertFalse(metadata.mmsIncluded) + } + + @Test + fun buildQueryMetadataMarksIncludedWhenMixedQueryYieldsMmsTransportRow() { + val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567") + val mmsTransportMessage = smsMessage(id = 7L, date = 1000L, status = 0, body = null, transportType = "mms") + + val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(mmsTransportMessage)) + + assertTrue(metadata.mmsRequested) + assertTrue(metadata.mmsEligible) + assertTrue(metadata.mmsAttempted) + assertTrue(metadata.mmsIncluded) + } +} diff --git a/app/src/thirdParty/AndroidManifest.xml b/app/src/thirdParty/AndroidManifest.xml new file mode 100644 index 0000000..08a8eb6 --- /dev/null +++ b/app/src/thirdParty/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt b/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt new file mode 100644 index 0000000..6b7458d --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt @@ -0,0 +1,9 @@ +package ai.openclaw.app + +object SensitiveFeatureConfig { + const val smsEnabled: Boolean = true + const val callLogEnabled: Boolean = true + const val photosEnabled: Boolean = true + const val backgroundLocationEnabled: Boolean = true + const val accessibilityControlEnabled: Boolean = true +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityActionExecutor.kt b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityActionExecutor.kt new file mode 100644 index 0000000..bbf2607 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityActionExecutor.kt @@ -0,0 +1,456 @@ +package ai.openclaw.app.accessibility + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.graphics.Path +import android.os.Bundle +import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume + +private const val GESTURE_RESULT_TIMEOUT_MS = 10_000L + +sealed interface MobileUiAction { + data class Activate( + val ref: String, + ) : MobileUiAction + + data class SetText( + val ref: String, + val text: String, + ) : MobileUiAction + + data class Scroll( + val ref: String, + val direction: ScrollDirection, + ) : MobileUiAction + + data class Tap( + val x: Int, + val y: Int, + ) : MobileUiAction + + data class Swipe( + val x1: Int, + val y1: Int, + val x2: Int, + val y2: Int, + val durationMs: Long, + ) : MobileUiAction + + data class GlobalAction( + val name: GlobalActionName, + ) : MobileUiAction + + data class Wait( + val ms: Long, + ) : MobileUiAction +} + +enum class ScrollDirection { + Forward, + Backward, +} + +enum class GlobalActionName { + Back, + Home, + Recents, + Notifications, +} + +enum class ActionOutcomeCode( + val value: String, +) { + Completed("completed"), + AcceptedButUnverified("accepted_but_unverified"), + TargetStale("target_stale"), + TargetNotFound("target_not_found"), + ActionNotSupported("action_not_supported"), + ActionRejected("action_rejected"), + GestureCancelled("gesture_cancelled"), + PackageChanged("package_changed"), + ServiceDisabled("service_disabled"), + SecureContent("secure_content"), + TimedOutOutcomeUnknown("timed_out_outcome_unknown"), +} + +data class ActionResult( + val code: ActionOutcomeCode, + val message: String? = null, +) + +internal class AccessibilityServiceDisabledException( + message: String = "Accessibility service is disabled", +) : IllegalStateException(message) + +class AccessibilityActionExecutor internal constructor( + private val connectionProvider: () -> AccessibilityServiceConnection = { + OpenClawAccessibilityService.connection.value + }, + private val captureSnapshot: (OpenClawAccessibilityService) -> AccessibilitySnapshotCapture = + AccessibilitySnapshotter()::capture, + private val foregroundPackageProvider: (OpenClawAccessibilityService) -> String? = + OpenClawAccessibilityService::foregroundPackageName, + private val uiEpochProvider: () -> Long = { OpenClawAccessibilityService.uiEpoch }, +) : AutoCloseable { + private val generationLock = Any() + private var closed = false + private val generation = + SnapshotGenerationStore { node -> + @Suppress("DEPRECATION") + node.recycle() + } + + fun observe(): MobileUiSnapshot { + synchronized(generationLock) { + if (closed) throw AccessibilityServiceDisabledException("Accessibility executor is closed") + } + val capturedConnection = connectionProvider() + val service = capturedConnection.instance + if (service == null) { + synchronized(generationLock) { generation.clear() } + throw AccessibilityServiceDisabledException() + } + val capturedUiEpoch = uiEpochProvider() + val capturedConnectionGeneration = capturedConnection.generation + val capture = captureSnapshot(service) + synchronized(generationLock) { + val currentConnection = connectionProvider() + val connectionChanged = + currentConnection.instance !== service || currentConnection.generation != capturedConnectionGeneration + if (closed || connectionChanged) { + generation.clear() + recycleCapture(capture) + val message = if (closed) "Accessibility executor closed during observe" else "Accessibility service changed during observe" + throw AccessibilityServiceDisabledException(message) + } + generation.replace( + snapshotId = capture.snapshot.id, + packageName = capture.snapshot.packageName, + uiEpoch = capturedUiEpoch, + connectionGeneration = capturedConnectionGeneration, + values = capture.nodesByRef, + ) + } + return capture.snapshot + } + + suspend fun act( + snapshotId: String, + action: MobileUiAction, + ): ActionResult { + val currentConnection = connectionProvider() + val service = currentConnection.instance + if (service == null) { + synchronized(generationLock) { generation.clear() } + return ActionResult(ActionOutcomeCode.ServiceDisabled, "Accessibility service is not connected") + } + synchronized(generationLock) { + if (closed) { + return ActionResult(ActionOutcomeCode.ServiceDisabled, "Accessibility executor is closed") + } + } + if (action is MobileUiAction.GlobalAction) { + return performGlobalAction(service, action.name) + } + synchronized(generationLock) { + if (closed) { + return ActionResult(ActionOutcomeCode.ServiceDisabled, "Accessibility executor is closed") + } + if (!generation.matches(snapshotId)) { + return ActionResult(ActionOutcomeCode.TargetStale, "Observe again before acting") + } + if (currentConnection.generation != generation.connectionGeneration) { + return ActionResult(ActionOutcomeCode.TargetStale, "Accessibility service reconnected; re-observe before acting") + } + + when (action) { + is MobileUiAction.Tap, + is MobileUiAction.Swipe, + -> coordinateGesturePreflight(service)?.let { return it } + // Node actions use per-node refresh() for freshness; UI epoch gates only blind coordinates. + // Do not add an epoch check here: unrelated changes/app switches would break valid act flows. + is MobileUiAction.Activate, + is MobileUiAction.SetText, + is MobileUiAction.Scroll, + -> nodeActionPackagePreflight(service)?.let { return it } + is MobileUiAction.GlobalAction, + is MobileUiAction.Wait, + -> Unit + } + } + + return when (action) { + is MobileUiAction.Activate -> + synchronized(generationLock) { + performNodeAction( + snapshotId = snapshotId, + ref = action.ref, + actionId = AccessibilityNodeInfo.ACTION_CLICK, + actionName = "activate", + ) + } + is MobileUiAction.SetText -> synchronized(generationLock) { setText(snapshotId, action) } + is MobileUiAction.Scroll -> synchronized(generationLock) { scroll(snapshotId, action) } + is MobileUiAction.Tap -> { + val gesture = + runCatching { tapGesture(action.x, action.y) } + .getOrElse { return ActionResult(ActionOutcomeCode.ActionRejected, "Invalid tap gesture") } + dispatchGesture(service, gesture) + } + is MobileUiAction.Swipe -> { + if (action.durationMs <= 0) { + ActionResult(ActionOutcomeCode.ActionRejected, "Swipe duration must be positive") + } else { + val gesture = + runCatching { swipeGesture(action) } + .getOrElse { return ActionResult(ActionOutcomeCode.ActionRejected, "Invalid swipe gesture") } + dispatchGesture(service, gesture) + } + } + is MobileUiAction.GlobalAction -> performGlobalAction(service, action.name) + is MobileUiAction.Wait -> { + if (action.ms < 0) { + ActionResult(ActionOutcomeCode.ActionRejected, "Wait duration cannot be negative") + } else { + delay(action.ms) + ActionResult(ActionOutcomeCode.Completed) + } + } + } + } + + override fun close() { + synchronized(generationLock) { + if (closed) return + closed = true + generation.clear() + } + } + + @Suppress("DEPRECATION") + private fun recycleCapture(capture: AccessibilitySnapshotCapture) { + capture.nodesByRef.values.forEach(AccessibilityNodeInfo::recycle) + } + + private fun coordinateGesturePreflight(service: OpenClawAccessibilityService): ActionResult? { + val expectedPackage = generation.packageName + val currentPackage = foregroundPackageProvider(service) + if (expectedPackage == null || currentPackage == null || expectedPackage != currentPackage) { + return ActionResult( + ActionOutcomeCode.PackageChanged, + "Active package cannot be verified against the snapshot; re-observe before coordinate actions", + ) + } + if (uiEpochProvider() > generation.uiEpoch) { + return ActionResult( + ActionOutcomeCode.TargetStale, + "UI changed since observe; re-observe before coordinate actions", + ) + } + return null + } + + private fun nodeActionPackagePreflight(service: OpenClawAccessibilityService): ActionResult? { + val expectedPackage = generation.packageName + val currentPackage = foregroundPackageProvider(service) + if (expectedPackage == null || currentPackage == null || expectedPackage != currentPackage) { + return ActionResult( + ActionOutcomeCode.PackageChanged, + "Active package cannot be verified against the snapshot; re-observe before node actions", + ) + } + return null + } + + private fun performNodeAction( + snapshotId: String, + ref: String, + actionId: Int, + actionName: String, + arguments: Bundle? = null, + validateRefreshedNode: ((AccessibilityNodeInfo) -> ActionResult?)? = null, + ): ActionResult { + val node = + when (val target = generation.resolve(snapshotId, ref)) { + is GenerationTarget.Found -> target.value + GenerationTarget.Stale -> + return ActionResult(ActionOutcomeCode.TargetStale, "Node $ref is not in the current snapshot") + } + if (!runCatching { node.refresh() }.getOrDefault(false)) { + return ActionResult(ActionOutcomeCode.TargetNotFound, "Node $ref is no longer available") + } + validateRefreshedNode?.invoke(node)?.let { return it } + if (node.actionList.none { it.id == actionId }) { + return ActionResult(ActionOutcomeCode.ActionNotSupported, "Node $ref does not advertise $actionName") + } + val accepted = runCatching { node.performAction(actionId, arguments) }.getOrDefault(false) + return if (accepted) { + ActionResult(ActionOutcomeCode.AcceptedButUnverified) + } else { + ActionResult(ActionOutcomeCode.ActionRejected, "Android rejected $actionName for node $ref") + } + } + + private fun setText( + snapshotId: String, + action: MobileUiAction.SetText, + ): ActionResult { + val arguments = + Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, action.text) + } + return performNodeAction( + snapshotId = snapshotId, + ref = action.ref, + actionId = AccessibilityNodeInfo.ACTION_SET_TEXT, + actionName = "set_text", + arguments = arguments, + ) { node -> + if (shouldRedactText(node.isPassword, node.isEditable, node.inputType)) { + ActionResult(ActionOutcomeCode.SecureContent, "Text entry into password fields is refused") + } else { + null + } + } + } + + private fun scroll( + snapshotId: String, + action: MobileUiAction.Scroll, + ): ActionResult { + val (actionId, actionName) = + when (action.direction) { + ScrollDirection.Forward -> AccessibilityNodeInfo.ACTION_SCROLL_FORWARD to "scroll_forward" + ScrollDirection.Backward -> AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD to "scroll_backward" + } + return performNodeAction(snapshotId, action.ref, actionId, actionName) + } + + private suspend fun dispatchGesture( + service: OpenClawAccessibilityService, + gesture: GestureDescription, + ): ActionResult = + withTimeoutOrNull(GESTURE_RESULT_TIMEOUT_MS) { + suspendCancellableCoroutine { continuation -> + val callback = + object : AccessibilityService.GestureResultCallback() { + override fun onCompleted(gestureDescription: GestureDescription?) { + if (continuation.isActive) continuation.resume(ActionResult(ActionOutcomeCode.Completed)) + } + + override fun onCancelled(gestureDescription: GestureDescription?) { + if (continuation.isActive) { + continuation.resume(ActionResult(ActionOutcomeCode.GestureCancelled)) + } + } + } + val accepted = runCatching { service.dispatchGesture(gesture, callback, null) }.getOrDefault(false) + if (!accepted && continuation.isActive) { + continuation.resume(ActionResult(ActionOutcomeCode.ActionRejected, "Android rejected the gesture")) + } + } + } ?: ActionResult( + ActionOutcomeCode.TimedOutOutcomeUnknown, + "Gesture callback did not arrive within $GESTURE_RESULT_TIMEOUT_MS ms", + ) + + private fun performGlobalAction( + service: OpenClawAccessibilityService, + name: GlobalActionName, + ): ActionResult { + val actionId = + when (name) { + GlobalActionName.Back -> AccessibilityService.GLOBAL_ACTION_BACK + GlobalActionName.Home -> AccessibilityService.GLOBAL_ACTION_HOME + GlobalActionName.Recents -> AccessibilityService.GLOBAL_ACTION_RECENTS + GlobalActionName.Notifications -> AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS + } + return if (runCatching { service.performGlobalAction(actionId) }.getOrDefault(false)) { + ActionResult(ActionOutcomeCode.Completed) + } else { + ActionResult(ActionOutcomeCode.ActionRejected, "Android rejected global action ${name.name.lowercase()}") + } + } +} + +internal class SnapshotGenerationStore( + private val release: (T) -> Unit, +) { + var packageName: String? = null + private set + var uiEpoch: Long = 0 + private set + var connectionGeneration: Long = 0 + private set + private var snapshotId: String? = null + private var values: Map = emptyMap() + + fun replace( + snapshotId: String, + packageName: String?, + uiEpoch: Long, + connectionGeneration: Long, + values: Map, + ) { + clear() + this.snapshotId = snapshotId + this.packageName = packageName + this.uiEpoch = uiEpoch + this.connectionGeneration = connectionGeneration + this.values = values + } + + fun matches(snapshotId: String): Boolean = this.snapshotId == snapshotId + + fun resolve( + snapshotId: String, + ref: String, + ): GenerationTarget { + if (!matches(snapshotId)) return GenerationTarget.Stale + return values[ref]?.let { value -> GenerationTarget.Found(value) } ?: GenerationTarget.Stale + } + + fun clear() { + values.values.forEach(release) + values = emptyMap() + snapshotId = null + packageName = null + uiEpoch = 0 + connectionGeneration = 0 + } +} + +internal sealed interface GenerationTarget { + data class Found( + val value: T, + ) : GenerationTarget + + data object Stale : GenerationTarget +} + +private fun tapGesture( + x: Int, + y: Int, +): GestureDescription { + val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) } + return GestureDescription + .Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, 1)) + .build() +} + +private fun swipeGesture(action: MobileUiAction.Swipe): GestureDescription { + val path = + Path().apply { + moveTo(action.x1.toFloat(), action.y1.toFloat()) + lineTo(action.x2.toFloat(), action.y2.toFloat()) + } + return GestureDescription + .Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, action.durationMs)) + .build() +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentController.kt b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentController.kt new file mode 100644 index 0000000..d9b2b65 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityComponentController.kt @@ -0,0 +1,34 @@ +package ai.openclaw.app.accessibility + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager + +internal class AccessibilityComponentController( + context: Context, +) { + private val appContext = context.applicationContext + private val components = + listOf( + ComponentName(appContext, OpenClawAccessibilityService::class.java), + ComponentName(appContext, AccessibilityDevActivity::class.java), + ) + + fun setEnabled(enabled: Boolean) { + val state = accessibilityComponentEnabledState(enabled) + components.forEach { component -> + appContext.packageManager.setComponentEnabledSetting( + component, + state, + PackageManager.DONT_KILL_APP, + ) + } + } +} + +internal fun accessibilityComponentEnabledState(enabled: Boolean): Int = + if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } diff --git a/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt new file mode 100644 index 0000000..a74fbf4 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt @@ -0,0 +1,500 @@ +package ai.openclaw.app.accessibility + +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.ui.OpenClawTheme +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val DELAYED_OBSERVE_SECONDS = 3 + +class AccessibilityDevActivity : AppCompatActivity() { + private val executor = AccessibilityActionExecutor() + private var delayedObserveJob: Job? = null + private var foregroundPackageName by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + OpenClawTheme { + AccessibilityDevScreen( + executor = executor, + captureSnapshot = ::captureSnapshotOffMain, + foregroundPackageName = foregroundPackageName, + refreshForegroundPackage = ::refreshForegroundPackage, + startDelayedObserve = ::startDelayedObserve, + cancelDelayedObserve = ::cancelDelayedObserve, + openAccessibilitySettings = { + startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + }, + ) + } + } + } + + override fun onDestroy() { + cancelDelayedObserve() + executor.close() + super.onDestroy() + } + + override fun onResume() { + super.onResume() + refreshForegroundPackage() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) refreshForegroundPackage() + } + + private fun startDelayedObserve( + onCountdown: (Int) -> Unit, + onResult: (Result) -> Unit, + ) { + cancelDelayedObserve() + delayedObserveJob = + lifecycleScope.launch { + for (remaining in DELAYED_OBSERVE_SECONDS downTo 1) { + onCountdown(remaining) + delay(1_000) + } + onResult(runCatching { captureSnapshotOffMain() }) + } + } + + private fun cancelDelayedObserve() { + delayedObserveJob?.cancel() + delayedObserveJob = null + } + + private fun refreshForegroundPackage() { + foregroundPackageName = OpenClawAccessibilityService.instance?.foregroundPackageName() + } + + private suspend fun captureSnapshotOffMain(): MobileUiSnapshot = + withContext(Dispatchers.Default) { + // Accessibility tree traversal can involve thousands of blocking IPC calls; keep it off Main. + executor.observe() + } +} + +@Composable +private fun AccessibilityDevScreen( + executor: AccessibilityActionExecutor, + captureSnapshot: suspend () -> MobileUiSnapshot, + foregroundPackageName: String?, + refreshForegroundPackage: () -> Unit, + startDelayedObserve: ( + onCountdown: (Int) -> Unit, + onResult: (Result) -> Unit, + ) -> Unit, + cancelDelayedObserve: () -> Unit, + openAccessibilitySettings: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val serviceConnection by OpenClawAccessibilityService.connection.collectAsState() + val connected = serviceConnection.instance != null + var snapshot by remember { mutableStateOf(null) } + var selectedRef by remember { mutableStateOf(null) } + var textInput by remember { mutableStateOf("") } + var lastResult by remember { mutableStateOf(null) } + var progressMessage by remember { mutableStateOf(null) } + var delayedObserveRunning by remember { mutableStateOf(false) } + var immediateObserveRunning by remember { mutableStateOf(false) } + + fun applyObservedSnapshot(observed: MobileUiSnapshot) { + snapshot = observed + selectedRef = null + textInput = "" + lastResult = + ActionResult( + ActionOutcomeCode.Completed, + nativeString("Observed nodes: \$count", observed.nodes.size), + ) + refreshForegroundPackage() + } + + fun applyObserveFailure(error: Throwable) { + snapshot = null + selectedRef = null + lastResult = ActionResult(ActionOutcomeCode.ServiceDisabled, error.message) + } + + fun observe() { + if (immediateObserveRunning || delayedObserveRunning) return + cancelDelayedObserve() + delayedObserveRunning = false + immediateObserveRunning = true + progressMessage = nativeString("Observing…") + coroutineScope.launch { + val result = runCatching { captureSnapshot() } + immediateObserveRunning = false + progressMessage = null + result.fold( + onSuccess = ::applyObservedSnapshot, + onFailure = ::applyObserveFailure, + ) + } + } + + fun observeDelayed() { + if (immediateObserveRunning || delayedObserveRunning) return + delayedObserveRunning = true + startDelayedObserve( + { remaining -> + progressMessage = + nativeString( + "Observing in \${remaining}s — switch to the target app", + remaining, + ) + }, + { result -> + delayedObserveRunning = false + progressMessage = null + result.fold( + onSuccess = ::applyObservedSnapshot, + onFailure = ::applyObserveFailure, + ) + }, + ) + } + + fun cancelDelayedObservation() { + cancelDelayedObserve() + delayedObserveRunning = false + progressMessage = null + } + + fun startImmediateObservation() { + if (delayedObserveRunning) { + cancelDelayedObservation() + } + observe() + } + + fun act(action: MobileUiAction) { + val activeSnapshot = snapshot ?: return + coroutineScope.launch { + lastResult = executor.act(activeSnapshot.id, action) + } + } + + fun actGlobal(name: GlobalActionName) { + coroutineScope.launch { + lastResult = executor.act(snapshot?.id.orEmpty(), MobileUiAction.GlobalAction(name)) + } + } + + val selectedNode = snapshot?.nodes?.firstOrNull { it.ref == selectedRef } + val observeRunning = immediateObserveRunning || delayedObserveRunning + val targetPackageMatches = canRunNodeActions(snapshot?.packageName, foregroundPackageName) + val nodeActionsEnabled = targetPackageMatches && !observeRunning + val snapshotLabel = snapshot?.id ?: nativeString("none") + val snapshotPackage = snapshot?.packageName ?: nativeString("none") + val foregroundPackage = foregroundPackageName ?: nativeString("unknown") + Surface( + modifier = + Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(nativeString("Accessibility executor"), style = MaterialTheme.typography.headlineSmall) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = + if (connected) { + nativeString("Service connected") + } else { + nativeString("Service disabled") + }, + color = if (connected) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + if (!connected) { + OutlinedButton(onClick = openAccessibilitySettings) { + Text(nativeString("Open settings")) + } + } + } + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = ::startImmediateObservation, enabled = connected && !observeRunning) { + Text(nativeString("Observe")) + } + OutlinedButton(onClick = ::observeDelayed, enabled = connected && !observeRunning) { + Text(nativeString("Observe in 3s")) + } + GlobalActionButton(nativeString("Back"), connected && !observeRunning) { + actGlobal(GlobalActionName.Back) + } + GlobalActionButton(nativeString("Home"), connected && !observeRunning) { + actGlobal(GlobalActionName.Home) + } + GlobalActionButton(nativeString("Recents"), connected && !observeRunning) { + actGlobal(GlobalActionName.Recents) + } + } + + Text( + text = nativeString("Snapshot: \$snapshotLabel", snapshotLabel), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + Text( + text = + nativeString( + "Packages: snapshot=\$snapshotPackage foreground=\$foregroundPackage", + snapshotPackage, + foregroundPackage, + ), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + Text( + text = + progressMessage ?: lastResult?.let { result -> + // Action messages are protocol diagnostics; keep them verbatim so UI evidence + // matches the mobile.ui result returned to the agent. + listOfNotNull(result.code.value, result.message).joinToString(" — ") + } ?: nativeString("No action result"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + + selectedNode?.let { node -> + SelectedNodeControls( + node = node, + textInput = textInput, + onTextInputChange = { textInput = it }, + actionsEnabled = nodeActionsEnabled, + showTargetMismatchNote = !targetPackageMatches, + act = ::act, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(snapshot?.nodes.orEmpty(), key = MobileUiNode::ref) { node -> + NodeRow( + node = node, + selected = node.ref == selectedRef, + onSelect = { + refreshForegroundPackage() + selectedRef = node.ref + }, + ) + } + } + } + } +} + +@Composable +private fun GlobalActionButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + OutlinedButton(onClick = onClick, enabled = enabled) { + Text(label) + } +} + +@Composable +private fun SelectedNodeControls( + node: MobileUiNode, + textInput: String, + onTextInputChange: (String) -> Unit, + actionsEnabled: Boolean, + showTargetMismatchNote: Boolean, + act: (MobileUiAction) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + nativeString("Selected \$reference", node.ref), + style = MaterialTheme.typography.titleSmall, + ) + if (showTargetMismatchNote) { + Text( + nativeString( + "Node actions run only when the target app is foreground (validated via the remote path). Global actions and same-app actions work here.", + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if ("activate" in node.actions) { + Button(onClick = { act(MobileUiAction.Activate(node.ref)) }, enabled = actionsEnabled) { + Text(nativeString("Activate")) + } + } + if ("scroll_forward" in node.actions) { + OutlinedButton( + onClick = { act(MobileUiAction.Scroll(node.ref, ScrollDirection.Forward)) }, + enabled = actionsEnabled, + ) { + Text(nativeString("Scroll forward")) + } + } + if ("scroll_backward" in node.actions) { + OutlinedButton( + onClick = { act(MobileUiAction.Scroll(node.ref, ScrollDirection.Backward)) }, + enabled = actionsEnabled, + ) { + Text(nativeString("Scroll back")) + } + } + } + if (node.editable && "set_text" in node.actions) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = textInput, + onValueChange = onTextInputChange, + label = { Text(nativeString("Text")) }, + singleLine = true, + enabled = actionsEnabled, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { act(MobileUiAction.SetText(node.ref, textInput)) }, + enabled = actionsEnabled, + ) { + Text(nativeString("Set text")) + } + } + } + } + } +} + +internal fun canRunNodeActions( + snapshotPackageName: String?, + foregroundPackageName: String?, +): Boolean = snapshotPackageName != null && snapshotPackageName == foregroundPackageName + +@Composable +private fun NodeRow( + node: MobileUiNode, + selected: Boolean, + onSelect: () -> Unit, +) { + val selectionMarker = if (selected) "▶ " else "" + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onSelect), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = + selectionMarker + + nativeString( + "\$reference \$role", + node.ref, + node.role, + ), + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + ) + node.text?.let { + Text( + nativeString("text: \$value", it), + style = MaterialTheme.typography.bodySmall, + ) + } + node.contentDescription?.let { + Text( + nativeString("description: \$value", it), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = + nativeString( + "bounds: \$value", + node.boundsInScreen.flattenToString(), + ), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + Text( + text = + nativeString( + "actions: \$value", + node.actions.joinToString().ifEmpty { nativeString("none") }, + ), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotter.kt b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotter.kt new file mode 100644 index 0000000..1b194b1 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilitySnapshotter.kt @@ -0,0 +1,280 @@ +package ai.openclaw.app.accessibility + +import android.graphics.Rect +import android.text.InputType +import android.view.accessibility.AccessibilityNodeInfo +import java.util.UUID + +internal const val MAX_NODES = 400 +internal const val MAX_DEPTH = 40 +internal const val MAX_TEXT_PER_NODE = 200 + +// Bounds AccessibilityNodeInfo acquisition so pathological trees cannot stall the UI thread or spike memory. +internal const val MAX_VISITED_NODES = 4_000 + +internal data class AccessibilitySnapshotCapture( + val snapshot: MobileUiSnapshot, + val nodesByRef: Map, +) + +internal class AccessibilitySnapshotter { + fun capture(service: OpenClawAccessibilityService): AccessibilitySnapshotCapture { + val root = service.rootInActiveWindow + if (root == null) { + return AccessibilitySnapshotCapture( + snapshot = + MobileUiSnapshot( + id = UUID.randomUUID().toString(), + capturedAtMs = System.currentTimeMillis(), + packageName = null, + windowTitle = null, + nodes = emptyList(), + ), + nodesByRef = emptyMap(), + ) + } + + val packageName = root.packageName?.toString() + val windowTitle = root.readWindowTitle() + val normalized = AccessibilityTreeNormalizer.normalize(AndroidAccessibilityNode(root)) + return AccessibilitySnapshotCapture( + snapshot = + MobileUiSnapshot( + id = UUID.randomUUID().toString(), + capturedAtMs = System.currentTimeMillis(), + packageName = packageName, + windowTitle = windowTitle, + nodes = normalized.nodes, + ), + nodesByRef = + normalized.retainedNodes.mapValues { (_, node) -> + (node as AndroidAccessibilityNode).platformNode + }, + ) + } +} + +internal interface AccessibilityNodeAdapter { + val className: String? + val text: String? + val contentDescription: String? + val viewId: String? + val boundsInScreen: Rect + val clickable: Boolean + val editable: Boolean + val scrollable: Boolean + val enabled: Boolean + val focused: Boolean + val password: Boolean + val inputType: Int + val actionIds: List + val childCount: Int + + fun childAt(index: Int): AccessibilityNodeAdapter? + + fun recycle() +} + +private class AndroidAccessibilityNode( + val platformNode: AccessibilityNodeInfo, +) : AccessibilityNodeAdapter { + override val className: String? + get() = platformNode.className?.toString() + override val text: String? + get() = platformNode.text?.toString() + override val contentDescription: String? + get() = platformNode.contentDescription?.toString() + override val viewId: String? + get() = platformNode.viewIdResourceName + override val boundsInScreen: Rect + get() = Rect().also(platformNode::getBoundsInScreen) + override val clickable: Boolean + get() = platformNode.isClickable + override val editable: Boolean + get() = platformNode.isEditable + override val scrollable: Boolean + get() = platformNode.isScrollable + override val enabled: Boolean + get() = platformNode.isEnabled + override val focused: Boolean + get() = platformNode.isFocused + override val password: Boolean + get() = platformNode.isPassword + override val inputType: Int + get() = platformNode.inputType + override val actionIds: List + get() = platformNode.actionList.map(AccessibilityNodeInfo.AccessibilityAction::getId) + override val childCount: Int + get() = platformNode.childCount + + override fun childAt(index: Int): AccessibilityNodeAdapter? = platformNode.getChild(index)?.let(::AndroidAccessibilityNode) + + @Suppress("DEPRECATION") + override fun recycle() = platformNode.recycle() +} + +internal data class NormalizedAccessibilityTree( + val nodes: List, + val retainedNodes: Map, +) + +internal object AccessibilityTreeNormalizer { + private data class PendingNode( + val node: AccessibilityNodeAdapter, + val depth: Int, + val parentRef: String?, + ) + + fun normalize(root: AccessibilityNodeAdapter): NormalizedAccessibilityTree { + val pending = ArrayDeque() + val nodes = mutableListOf() + val retained = linkedMapOf() + pending.addLast(PendingNode(root, depth = 0, parentRef = null)) + var discoveredNodeCount = 1 + + var current: AccessibilityNodeAdapter? = null + try { + while (pending.isNotEmpty()) { + val item = pending.removeLast() + current = item.node + if (item.depth > MAX_DEPTH) { + current.recycle() + current = null + continue + } + + val bounds = current.boundsInScreen + val rawText = current.text + val rawDescription = current.contentDescription + val include = + (bounds.width() > 0 && bounds.height() > 0) || + !rawText.isNullOrEmpty() || + !rawDescription.isNullOrEmpty() || + current.clickable + val ref = if (include) "n${nodes.size}" else item.parentRef + val sensitive = shouldRedactText(current.password, current.editable, current.inputType) + + if (include) { + nodes += + MobileUiNode( + ref = checkNotNull(ref), + parentRef = item.parentRef, + role = stableRole(current.className, current.clickable, current.editable), + text = normalizeNodeText(rawText, sensitive), + contentDescription = normalizeNodeText(rawDescription, sensitive), + viewId = truncateNodeText(current.viewId), + boundsInScreen = Rect(bounds), + clickable = current.clickable, + editable = current.editable, + scrollable = current.scrollable, + enabled = current.enabled, + focused = current.focused, + actions = stableActionNames(current.actionIds), + ) + } + + val reachedNodeLimit = nodes.size >= MAX_NODES + if (!reachedNodeLimit && item.depth < MAX_DEPTH) { + val remainingTraversalBudget = MAX_VISITED_NODES - discoveredNodeCount + val childSlotsToInspect = minOf(current.childCount, remainingTraversalBudget) + for (index in childSlotsToInspect - 1 downTo 0) { + discoveredNodeCount += 1 + current.childAt(index)?.let { child -> + pending.addLast(PendingNode(child, depth = item.depth + 1, parentRef = ref)) + } + } + } + + if (include) { + retained[checkNotNull(ref)] = current + } else { + current.recycle() + } + current = null + if (reachedNodeLimit) break + } + } catch (error: Throwable) { + current?.recycle() + retained.values.forEach(AccessibilityNodeAdapter::recycle) + pending.forEach { it.node.recycle() } + throw error + } + + pending.forEach { it.node.recycle() } + return NormalizedAccessibilityTree(nodes = nodes, retainedNodes = retained) + } +} + +internal fun shouldRedactText( + isPassword: Boolean, + isEditable: Boolean, + inputType: Int, +): Boolean { + if (isPassword) return true + if (!isEditable) return false + + val inputClass = inputType and InputType.TYPE_MASK_CLASS + val variation = inputType and InputType.TYPE_MASK_VARIATION + return when (inputClass) { + InputType.TYPE_CLASS_TEXT -> + variation == InputType.TYPE_TEXT_VARIATION_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD + InputType.TYPE_CLASS_NUMBER -> variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD + else -> false + } +} + +internal fun normalizeNodeText( + text: String?, + sensitive: Boolean, +): String? = if (sensitive) "[redacted]" else truncateNodeText(text) + +internal fun truncateNodeText(text: String?): String? { + if (text == null || text.length <= MAX_TEXT_PER_NODE) return text + return text.take(MAX_TEXT_PER_NODE - 1) + "…" +} + +internal fun stableActionNames(actionIds: Collection): List { + val ids = actionIds.toSet() + return buildList { + if (AccessibilityNodeInfo.ACTION_CLICK in ids) add("activate") + if (AccessibilityNodeInfo.ACTION_LONG_CLICK in ids) add("long_press") + if (AccessibilityNodeInfo.ACTION_SET_TEXT in ids) add("set_text") + if (AccessibilityNodeInfo.ACTION_SCROLL_FORWARD in ids) add("scroll_forward") + if (AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD in ids) add("scroll_backward") + if (AccessibilityNodeInfo.ACTION_FOCUS in ids) add("focus") + } +} + +internal fun stableRole( + className: String?, + clickable: Boolean, + editable: Boolean, +): String { + val simpleName = className?.substringAfterLast('.') + return when { + editable -> "text_field" + simpleName == "Button" || simpleName == "ImageButton" -> "button" + simpleName == "CheckBox" -> "checkbox" + simpleName == "Switch" || simpleName == "SwitchCompat" -> "switch" + simpleName == "RadioButton" -> "radio" + simpleName == "ImageView" -> "image" + simpleName == "TextView" -> if (clickable) "button" else "text" + simpleName == "ListView" || simpleName == "RecyclerView" -> "list" + simpleName == "ScrollView" || simpleName == "HorizontalScrollView" -> "scroll_view" + simpleName == "WebView" -> "web_view" + clickable -> "button" + else -> "node" + } +} + +@Suppress("DEPRECATION") +private fun AccessibilityNodeInfo.readWindowTitle(): String? { + val nodeWindow = window ?: return null + return try { + nodeWindow.title?.toString() + } finally { + nodeWindow.recycle() + } +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/accessibility/MobileUiSnapshot.kt b/app/src/thirdParty/java/ai/openclaw/app/accessibility/MobileUiSnapshot.kt new file mode 100644 index 0000000..91409b2 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/accessibility/MobileUiSnapshot.kt @@ -0,0 +1,27 @@ +package ai.openclaw.app.accessibility + +import android.graphics.Rect + +data class MobileUiSnapshot( + val id: String, + val capturedAtMs: Long, + val packageName: String?, + val windowTitle: String?, + val nodes: List, +) + +data class MobileUiNode( + val ref: String, + val parentRef: String?, + val role: String, + val text: String?, + val contentDescription: String?, + val viewId: String?, + val boundsInScreen: Rect, + val clickable: Boolean, + val editable: Boolean, + val scrollable: Boolean, + val enabled: Boolean, + val focused: Boolean, + val actions: List, +) diff --git a/app/src/thirdParty/java/ai/openclaw/app/accessibility/OpenClawAccessibilityService.kt b/app/src/thirdParty/java/ai/openclaw/app/accessibility/OpenClawAccessibilityService.kt new file mode 100644 index 0000000..db63840 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/accessibility/OpenClawAccessibilityService.kt @@ -0,0 +1,103 @@ +package ai.openclaw.app.accessibility + +import android.accessibilityservice.AccessibilityService +import android.content.Intent +import android.view.accessibility.AccessibilityEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicLong + +class OpenClawAccessibilityService : AccessibilityService() { + override fun onServiceConnected() { + super.onServiceConnected() + connectionState.connect(this) + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // Coordinate staleness epoch: advance on any event that can move or change on-screen content. + // Pure focus/hover/announcement events are intentionally excluded. + when (event?.eventType) { + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, + AccessibilityEvent.TYPE_WINDOWS_CHANGED, + AccessibilityEvent.TYPE_VIEW_SCROLLED, + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, + AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED, + -> advanceUiEpoch() + else -> Unit + } + } + + override fun onInterrupt() = Unit + + override fun onUnbind(intent: Intent?): Boolean { + connectionState.disconnect(this) + return super.onUnbind(intent) + } + + override fun onDestroy() { + connectionState.disconnect(this) + super.onDestroy() + } + + @Suppress("DEPRECATION") + internal fun foregroundPackageName(): String? { + val root = rootInActiveWindow ?: return null + return try { + root.packageName?.toString() + } finally { + root.recycle() + } + } + + companion object { + private val connectionState = ObservableServiceInstance() + private val uiEpochCounter = AtomicLong(0) + + val instance: OpenClawAccessibilityService? + get() = connectionState.connection.value.instance + + internal val connection: StateFlow> = + connectionState.connection + + val isConnected: StateFlow = connectionState.isConnected + + val uiEpoch: Long + get() = uiEpochCounter.get() + + val connectionGeneration: Long + get() = connectionState.connection.value.generation + + internal fun advanceUiEpoch(): Long = uiEpochCounter.incrementAndGet() + } +} + +internal data class AccessibilityServiceConnection( + val instance: T?, + val generation: Long, +) + +internal class ObservableServiceInstance { + private val mutableConnection = MutableStateFlow(AccessibilityServiceConnection(instance = null, generation = 0)) + private val mutableIsConnected = MutableStateFlow(false) + + val connection: StateFlow> = mutableConnection.asStateFlow() + val isConnected: StateFlow = mutableIsConnected.asStateFlow() + + fun connect(instance: T) { + val current = mutableConnection.value + mutableConnection.value = AccessibilityServiceConnection(instance, generation = current.generation + 1) + mutableIsConnected.value = true + } + + fun disconnect(instance: T) { + val current = mutableConnection.value + // Only the current instance may clear connectivity: during a service-replacement + // race the old instance's teardown must not publish false after the replacement + // already connected, or NodeRuntime would withdraw the mobile UI capability. + if (current.instance !== instance) return + mutableConnection.value = current.copy(instance = null) + mutableIsConnected.value = false + } +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt b/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt new file mode 100644 index 0000000..04ea0a8 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt @@ -0,0 +1,276 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import android.Manifest +import android.content.Context +import android.provider.CallLog +import androidx.core.content.ContextCompat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private const val DEFAULT_CALL_LOG_LIMIT = 25 + +internal data class CallLogRecord( + val number: String?, + val cachedName: String?, + val date: Long, + val duration: Long, + val type: Int, +) + +internal data class CallLogSearchRequest( + val limit: Int, // Number of records to return + val offset: Int, // Offset value + val cachedName: String?, // Search by contact name + val number: String?, // Search by phone number + val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd) + val dateStart: Long?, // Query start time (timestamp) + val dateEnd: Long?, // Query end time (timestamp) + val duration: Long?, // Search by duration (seconds) + val type: Int?, // Search by call log type +) + +internal interface CallLogDataSource { + fun hasReadPermission(context: Context): Boolean + + fun search( + context: Context, + request: CallLogSearchRequest, + ): List +} + +private object SystemCallLogDataSource : CallLogDataSource { + override fun hasReadPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CALL_LOG, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + override fun search( + context: Context, + request: CallLogSearchRequest, + ): List { + val resolver = context.contentResolver + val projection = + arrayOf( + CallLog.Calls.NUMBER, + CallLog.Calls.CACHED_NAME, + CallLog.Calls.DATE, + CallLog.Calls.DURATION, + CallLog.Calls.TYPE, + ) + + // Build selection and selectionArgs for filtering + val selections = mutableListOf() + val selectionArgs = mutableListOf() + + request.cachedName?.let { + selections.add(buildCallLogCachedNameLikeSelection()) + selectionArgs.add(buildCallLogLikeArg(it)) + } + + request.number?.let { + selections.add(buildCallLogNumberLikeSelection()) + selectionArgs.add(buildCallLogLikeArg(it)) + } + + // Support time range query + if (request.dateStart != null && request.dateEnd != null) { + selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?") + selectionArgs.add(request.dateStart.toString()) + selectionArgs.add(request.dateEnd.toString()) + } else if (request.dateStart != null) { + selections.add("${CallLog.Calls.DATE} >= ?") + selectionArgs.add(request.dateStart.toString()) + } else if (request.dateEnd != null) { + selections.add("${CallLog.Calls.DATE} <= ?") + selectionArgs.add(request.dateEnd.toString()) + } else if (request.date != null) { + // Compatible with the old date parameter (exact match) + selections.add("${CallLog.Calls.DATE} = ?") + selectionArgs.add(request.date.toString()) + } + + request.duration?.let { + selections.add("${CallLog.Calls.DURATION} = ?") + selectionArgs.add(it.toString()) + } + + request.type?.let { + selections.add("${CallLog.Calls.TYPE} = ?") + selectionArgs.add(it.toString()) + } + + val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null + val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null + + val sortOrder = "${CallLog.Calls.DATE} DESC" + + resolver + .query( + CallLog.Calls.CONTENT_URI, + projection, + selection, + selectionArgsArray, + sortOrder, + ).use { cursor -> + if (cursor == null) return emptyList() + + val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER) + val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME) + val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE) + val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION) + val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE) + + // Skip offset rows + if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) { + // Successfully moved to offset position + } + + val out = mutableListOf() + var count = 0 + while (cursor.moveToNext() && count < request.limit) { + out += + CallLogRecord( + number = cursor.getString(numberIndex), + cachedName = cursor.getString(cachedNameIndex), + date = cursor.getLong(dateIndex), + duration = cursor.getLong(durationIndex), + type = cursor.getInt(typeIndex), + ) + count++ + } + return out + } + } +} + +internal fun escapeCallLogSqlLikeLiteral(value: String): String = + buildString(value.length) { + for (ch in value) { + when (ch) { + '\\', '%', '_' -> { + append('\\') + append(ch) + } + else -> append(ch) + } + } + } + +internal fun buildCallLogCachedNameLikeSelection(): String = "${CallLog.Calls.CACHED_NAME} LIKE ? ESCAPE '\\'" + +internal fun buildCallLogNumberLikeSelection(): String = "${CallLog.Calls.NUMBER} LIKE ? ESCAPE '\\'" + +internal fun buildCallLogLikeArg(value: String): String = "%${escapeCallLogSqlLikeLiteral(value)}%" + +class CallLogHandler private constructor( + private val appContext: Context, + private val dataSource: CallLogDataSource, +) { + constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource) + + fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult { + if (!dataSource.hasReadPermission(appContext)) { + return GatewaySession.InvokeResult.error( + code = "CALL_LOG_PERMISSION_REQUIRED", + message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission", + ) + } + + val request = + parseSearchRequest(paramsJson) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: expected JSON object", + ) + + return try { + val callLogs = dataSource.search(appContext, request) + GatewaySession.InvokeResult.ok( + buildJsonObject { + put( + "callLogs", + buildJsonArray { + callLogs.forEach { add(callLogJson(it)) } + }, + ) + }.toString(), + ) + } catch (err: Throwable) { + GatewaySession.InvokeResult.error( + code = "CALL_LOG_UNAVAILABLE", + message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}", + ) + } + } + + private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? { + if (paramsJson.isNullOrBlank()) { + return CallLogSearchRequest( + limit = DEFAULT_CALL_LOG_LIMIT, + offset = 0, + cachedName = null, + number = null, + date = null, + dateStart = null, + dateEnd = null, + duration = null, + type = null, + ) + } + + val params = + try { + Json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return null + + val limit = + ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT) + .coerceIn(1, 200) + val offset = + ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0) + .coerceAtLeast(0) + val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull() + val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull() + val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull() + val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull() + val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull() + + return CallLogSearchRequest( + limit = limit, + offset = offset, + cachedName = cachedName, + number = number, + date = date, + dateStart = dateStart, + dateEnd = dateEnd, + duration = duration, + type = type, + ) + } + + private fun callLogJson(callLog: CallLogRecord): JsonObject = + buildJsonObject { + put("number", JsonPrimitive(callLog.number)) + put("cachedName", JsonPrimitive(callLog.cachedName)) + put("date", JsonPrimitive(callLog.date)) + put("duration", JsonPrimitive(callLog.duration)) + put("type", JsonPrimitive(callLog.type)) + } + + companion object { + internal fun forTesting( + appContext: Context, + dataSource: CallLogDataSource, + ): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource) + } +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/node/MobileUiHandler.kt b/app/src/thirdParty/java/ai/openclaw/app/node/MobileUiHandler.kt new file mode 100644 index 0000000..6d5db8f --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/node/MobileUiHandler.kt @@ -0,0 +1,200 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.accessibility.AccessibilityActionExecutor +import ai.openclaw.app.accessibility.AccessibilityServiceDisabledException +import ai.openclaw.app.accessibility.ActionResult +import ai.openclaw.app.accessibility.GlobalActionName +import ai.openclaw.app.accessibility.MobileUiAction +import ai.openclaw.app.accessibility.MobileUiSnapshot +import ai.openclaw.app.accessibility.OpenClawAccessibilityService +import ai.openclaw.app.accessibility.ScrollDirection +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put + +internal data class MobileUiActRequest( + val snapshotId: String, + val action: MobileUiAction, +) + +/** + * Flavor-owned bridge from node.invoke to the Android accessibility executor. + * + * Observe returns `{snapshotId,capturedAtMs,package,windowTitle,nodes}`. Each node contains + * `{ref,parentRef,role,text,contentDescription,viewId,bounds:[l,t,r,b],flags,actions}`. + * Act accepts `{snapshotId,action:{type,...}}` and returns `{code,message}`. + */ +class MobileUiHandler { + private val executor = AccessibilityActionExecutor() + private val invokeMutex = Mutex() + + val isConnected: StateFlow = OpenClawAccessibilityService.isConnected + + suspend fun handleObserve( + @Suppress("UNUSED_PARAMETER") paramsJson: String?, + ): GatewaySession.InvokeResult = + invokeMutex.withLock { + try { + GatewaySession.InvokeResult.ok(mobileUiSnapshotJson(executor.observe())) + } catch (error: AccessibilityServiceDisabledException) { + GatewaySession.InvokeResult.error( + code = "SERVICE_DISABLED", + message = "SERVICE_DISABLED: ${error.message ?: "accessibility service is disabled"}", + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + GatewaySession.InvokeResult.error( + code = "MOBILE_UI_OBSERVE_FAILED", + message = "MOBILE_UI_OBSERVE_FAILED: ${error.message ?: "snapshot failed"}", + ) + } + } + + suspend fun handleAct(paramsJson: String?): GatewaySession.InvokeResult = + invokeMutex.withLock { + val request = + parseMobileUiActRequest(paramsJson) + ?: return@withLock GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: expected {snapshotId,action:{type,...}}", + ) + try { + GatewaySession.InvokeResult.ok(actionResultJson(executor.act(request.snapshotId, request.action))) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + GatewaySession.InvokeResult.error( + code = "MOBILE_UI_ACT_FAILED", + message = "MOBILE_UI_ACT_FAILED: ${error.message ?: "action failed"}", + ) + } + } +} + +internal fun mobileUiSnapshotJson(snapshot: MobileUiSnapshot): String = + buildJsonObject { + put("snapshotId", snapshot.id) + put("capturedAtMs", snapshot.capturedAtMs) + put("package", JsonPrimitive(snapshot.packageName)) + put("windowTitle", JsonPrimitive(snapshot.windowTitle)) + put( + "nodes", + buildJsonArray { + snapshot.nodes.forEach { node -> + add( + buildJsonObject { + put("ref", node.ref) + put("parentRef", JsonPrimitive(node.parentRef)) + put("role", node.role) + put("text", JsonPrimitive(node.text)) + put("contentDescription", JsonPrimitive(node.contentDescription)) + put("viewId", JsonPrimitive(node.viewId)) + put( + "bounds", + buildJsonArray { + add(JsonPrimitive(node.boundsInScreen.left)) + add(JsonPrimitive(node.boundsInScreen.top)) + add(JsonPrimitive(node.boundsInScreen.right)) + add(JsonPrimitive(node.boundsInScreen.bottom)) + }, + ) + put( + "flags", + buildJsonObject { + put("clickable", node.clickable) + put("editable", node.editable) + put("scrollable", node.scrollable) + put("enabled", node.enabled) + put("focused", node.focused) + }, + ) + put( + "actions", + buildJsonArray { + node.actions.forEach { action -> add(JsonPrimitive(action)) } + }, + ) + }, + ) + } + }, + ) + }.toString() + +internal fun parseMobileUiActRequest(paramsJson: String?): MobileUiActRequest? { + val params = parseJsonParamsObject(paramsJson) ?: return null + val snapshotId = params.requiredString("snapshotId") ?: return null + val actionParams = params["action"] as? JsonObject ?: return null + val type = actionParams.requiredString("type") ?: return null + val action = + when (type) { + "activate" -> MobileUiAction.Activate(actionParams.requiredString("ref") ?: return null) + "set_text" -> + MobileUiAction.SetText( + ref = actionParams.requiredString("ref") ?: return null, + text = actionParams.string("text") ?: return null, + ) + "scroll" -> + MobileUiAction.Scroll( + ref = actionParams.requiredString("ref") ?: return null, + direction = + when (actionParams.requiredString("direction")) { + "forward" -> ScrollDirection.Forward + "backward" -> ScrollDirection.Backward + else -> return null + }, + ) + "tap" -> + MobileUiAction.Tap( + x = actionParams.int("x") ?: return null, + y = actionParams.int("y") ?: return null, + ) + "swipe" -> + MobileUiAction.Swipe( + x1 = actionParams.int("x1") ?: return null, + y1 = actionParams.int("y1") ?: return null, + x2 = actionParams.int("x2") ?: return null, + y2 = actionParams.int("y2") ?: return null, + durationMs = actionParams.long("durationMs") ?: return null, + ) + "global_action" -> + MobileUiAction.GlobalAction( + when (actionParams.requiredString("name")) { + "back" -> GlobalActionName.Back + "home" -> GlobalActionName.Home + "recents" -> GlobalActionName.Recents + "notifications" -> GlobalActionName.Notifications + else -> return null + }, + ) + "wait" -> MobileUiAction.Wait(actionParams.long("ms") ?: return null) + else -> return null + } + return MobileUiActRequest(snapshotId = snapshotId, action = action) +} + +private fun actionResultJson(result: ActionResult): String = + buildJsonObject { + put("code", result.code.value) + put("message", JsonPrimitive(result.message)) + }.toString() + +private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive) + ?.takeIf { it.isString } + ?.contentOrNull + +private fun JsonObject.requiredString(key: String): String? = string(key)?.takeIf(String::isNotBlank) + +private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() + +private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.contentOrNull?.toLongOrNull() diff --git a/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt b/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt new file mode 100644 index 0000000..c5c82aa --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt @@ -0,0 +1,39 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession + +class SmsHandler( + private val sms: SmsManager, +) { + suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult { + val res = sms.send(paramsJson) + if (res.ok) { + return GatewaySession.InvokeResult.ok(res.payloadJson) + } + return errorResult(res.error, defaultCode = "SMS_SEND_FAILED") + } + + suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult { + val res = sms.search(paramsJson) + if (res.ok) { + return GatewaySession.InvokeResult.ok(res.payloadJson) + } + return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED") + } + + private fun errorResult( + error: String?, + defaultCode: String, + ): GatewaySession.InvokeResult { + val rawMessage = error ?: defaultCode + val idx = rawMessage.indexOf(':') + val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode + val message = + if (idx > 0 && code == rawMessage.substring(0, idx).trim()) { + rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage } + } else { + rawMessage + } + return GatewaySession.InvokeResult.error(code = code, message = message) + } +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt b/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt new file mode 100644 index 0000000..0de7657 --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt @@ -0,0 +1,1154 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.PermissionRequester +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.ContactsContract +import android.provider.Telephony +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import android.telephony.SmsManager as AndroidSmsManager + +/** + * Sends SMS messages via the Android SMS API. + * Requires SEND_SMS permission to be granted. + * + * Also provides SMS query functionality with READ_SMS permission. + */ +class SmsManager( + private val context: Context, +) { + private val json = JsonConfig + + @Volatile private var permissionRequester: PermissionRequester? = null + + data class SendResult( + val ok: Boolean, + val to: String, + val message: String?, + val error: String? = null, + val payloadJson: String, + ) + + /** + * Represents a single SMS message. + */ + @Serializable + data class SmsMessage( + val id: Long, + val threadId: Long, + val address: String?, + val person: String?, + val date: Long, + val dateSent: Long, + val read: Boolean, + val type: Int, + val body: String?, + val status: Int, + val transportType: String? = null, + ) + + data class SearchResult( + val ok: Boolean, + val messages: List, + val error: String? = null, + val payloadJson: String, + ) + + internal data class QueryMetadata( + val mmsRequested: Boolean, + val mmsEligible: Boolean, + val mmsAttempted: Boolean, + val mmsIncluded: Boolean, + ) + + internal data class ParsedParams( + val to: String, + val message: String, + ) + + internal sealed interface ParseResult { + data class Ok( + val params: ParsedParams, + ) : ParseResult + + data class Error( + val error: String, + val to: String = "", + val message: String? = null, + ) : ParseResult + } + + internal data class QueryParams( + val startTime: Long? = null, + val endTime: Long? = null, + val contactName: String? = null, + val phoneNumber: String? = null, + val keyword: String? = null, + val type: Int? = null, + val isRead: Boolean? = null, + val includeMms: Boolean = false, + val conversationReview: Boolean = false, + val limit: Int = DEFAULT_SMS_LIMIT, + val offset: Int = 0, + ) + + internal sealed interface QueryParseResult { + data class Ok( + val params: QueryParams, + ) : QueryParseResult + + data class Error( + val error: String, + ) : QueryParseResult + } + + internal data class SendPlan( + val parts: List, + val useMultipart: Boolean, + ) + + companion object { + private const val DEFAULT_SMS_LIMIT = 25 + internal const val MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW = 500 + private const val MMS_SMS_BY_PHONE_BASE = "content://mms-sms/messages/byphone" + private const val MMS_CONTENT_BASE = "content://mms" + private const val MMS_PART_URI = "content://mms/part" + private val PHONE_FORMATTING_REGEX = Regex("""[\s\-()]""") + internal val JsonConfig = Json { ignoreUnknownKeys = true } + + internal fun parseParams( + paramsJson: String?, + json: Json = JsonConfig, + ): ParseResult { + val params = paramsJson?.trim().orEmpty() + if (params.isEmpty()) { + return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") + } + + val obj = + try { + json.parseToJsonElement(params).jsonObject + } catch (_: Throwable) { + null + } + + if (obj == null) { + return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") + } + + val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() + val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() + + if (to.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'to' phone number required", + message = message, + ) + } + + if (message.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'message' text required", + to = to, + ) + } + + return ParseResult.Ok(ParsedParams(to = to, message = message)) + } + + internal fun parseQueryParams( + paramsJson: String?, + json: Json = JsonConfig, + ): QueryParseResult { + val params = paramsJson?.trim().orEmpty() + if (params.isEmpty()) { + return QueryParseResult.Ok(QueryParams()) + } + + val obj = + try { + json.parseToJsonElement(params).jsonObject + } catch (_: Throwable) { + return QueryParseResult.Error("INVALID_REQUEST: expected JSON object") + } + + val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull() + val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull() + val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim() + val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim() + val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim() + val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull() + val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() + val includeMms = (obj["includeMms"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false + val conversationReview = (obj["conversationReview"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false + val limit = + ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT) + .coerceIn(1, 200) + val offset = + ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0) + .coerceAtLeast(0) + + if (startTime != null && endTime != null && startTime > endTime) { + return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime") + } + + return QueryParseResult.Ok( + QueryParams( + startTime = startTime, + endTime = endTime, + contactName = contactName, + phoneNumber = phoneNumber, + keyword = keyword, + type = type, + isRead = isRead, + includeMms = includeMms, + conversationReview = conversationReview, + limit = limit, + offset = offset, + ), + ) + } + + private fun normalizePhoneNumber(phone: String): String = phone.replace(PHONE_FORMATTING_REGEX, "") + + internal fun normalizePhoneNumberOrNull(phone: String?): String? { + val normalized = phone?.let(::normalizePhoneNumber)?.trim().orEmpty() + if (normalized.isEmpty()) { + return null + } + val digits = toByPhoneLookupNumber(normalized) + return normalized.takeIf { digits.isNotEmpty() } + } + + internal fun sanitizeContactPhoneNumberOrNull(phone: String?): String? { + val normalized = normalizePhoneNumberOrNull(phone) ?: return null + return normalized.takeUnless(::hasSqlLikeWildcard) + } + + internal fun shouldPromptForContactNameSearchPermission( + contactName: String?, + phoneNumber: String?, + hasReadContactsPermission: Boolean, + ): Boolean = !contactName.isNullOrEmpty() && phoneNumber.isNullOrEmpty() && !hasReadContactsPermission + + internal fun mapMmsMsgBoxToSearchType(msgBox: Int?): Int? = + when (msgBox) { + 1 -> 1 // inbox + 2 -> 2 // sent + 3 -> 3 // draft + 4 -> 4 // outbox + 5 -> 5 // failed + 6 -> 6 // queued + else -> null + } + + internal fun escapeSqlLikeLiteral(value: String): String = + buildString(value.length) { + for (ch in value) { + when (ch) { + '\\', '%', '_' -> { + append('\\') + append(ch) + } + else -> append(ch) + } + } + } + + internal fun buildContactNameLikeSelection(): String = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? ESCAPE '\\'" + + internal fun buildContactNameLikeArg(contactName: String): String = "%${escapeSqlLikeLiteral(contactName)}%" + + internal fun buildKeywordLikeSelection(): String = "${Telephony.Sms.BODY} LIKE ? ESCAPE '\\'" + + internal fun buildKeywordLikeArg(keyword: String): String = "%${escapeSqlLikeLiteral(keyword)}%" + + internal fun buildMixedByPhoneProjection(): Array = + arrayOf( + "_id", + "thread_id", + "transport_type", + "address", + "date", + "date_sent", + "read", + "type", + "body", + "status", + ) + + internal fun hasSqlLikeWildcard(value: String): Boolean = value.contains('%') || value.contains('_') + + internal fun isExplicitPhoneInputInvalid( + rawPhone: String?, + normalizedPhone: String?, + ): Boolean { + if (rawPhone.isNullOrBlank()) { + return false + } + if (normalizedPhone == null) { + return true + } + return hasSqlLikeWildcard(normalizedPhone) + } + + internal fun resolveMixedByPhoneRowStatus( + transportType: String?, + smsStatus: Int?, + ): Int = if (transportType.equals("mms", ignoreCase = true)) -1 else (smsStatus ?: 0) + + internal fun resolveMixedByPhoneRowAddress( + providerAddress: String?, + phoneNumber: String, + mmsAddress: String? = null, + ): String? { + val resolvedMmsAddress = normalizePhoneNumberOrNull(mmsAddress) + if (resolvedMmsAddress != null) { + return resolvedMmsAddress + } + + val resolvedProviderAddress = normalizePhoneNumberOrNull(providerAddress) + return resolvedProviderAddress ?: phoneNumber + } + + internal fun selectPreferredMmsAddress( + addressRows: List>, + lookupNumber: String, + ): String? { + val lookupDigits = toByPhoneLookupNumber(lookupNumber) + val normalizedRows = + addressRows.mapNotNull { (address, type) -> + val normalized = normalizePhoneNumberOrNull(address) ?: return@mapNotNull null + val digits = toByPhoneLookupNumber(normalized) + if (digits.isBlank()) return@mapNotNull null + Triple(normalized, digits, type) + } + + fun firstPreferred(vararg types: Int): String? = + normalizedRows + .firstOrNull { row -> + (types.isEmpty() || types.contains(row.third ?: -1)) && row.second != lookupDigits + }?.first + + return firstPreferred(137) + ?: firstPreferred(151, 130, 129) + ?: firstPreferred() + ?: normalizedRows.firstOrNull()?.first + } + + internal fun shouldUseConversationReviewByPhoneMode( + params: QueryParams, + resolvedPhoneNumbers: List = emptyList(), + ): Boolean { + val hasExplicitPhoneNumber = !params.phoneNumber.isNullOrEmpty() + val hasSingleResolvedPhoneNumber = resolvedPhoneNumbers.size == 1 + return params.conversationReview && params.includeMms && (hasExplicitPhoneNumber || hasSingleResolvedPhoneNumber) + } + + internal fun effectiveSearchParams( + params: QueryParams, + resolvedPhoneNumbers: List = emptyList(), + ): QueryParams { + if (!shouldUseConversationReviewByPhoneMode(params, resolvedPhoneNumbers)) return params + val reviewLimit = maxOf(params.limit, 25) + return params.copy(limit = reviewLimit) + } + + internal fun resolveSearchParams( + params: QueryParams, + normalizedPhoneNumber: String?, + resolvedPhoneNumbers: List = emptyList(), + ): QueryParams { + val effectivePhoneNumber = normalizedPhoneNumber ?: resolvedPhoneNumbers.singleOrNull() + val normalizedParams = params.copy(phoneNumber = effectivePhoneNumber) + return effectiveSearchParams(normalizedParams, resolvedPhoneNumbers) + } + + internal fun toByPhoneLookupNumber(phone: String): String = phone.filter { it.isDigit() } + + internal fun normalizeProviderDateMillis(rawDate: Long): Long = if (rawDate in 1..99_999_999_999L) rawDate * 1000L else rawDate + + internal fun canonicalizeMixedPathPhoneFilters(phoneNumbers: List): List = + phoneNumbers + .map(::toByPhoneLookupNumber) + .filter { it.isNotBlank() } + .distinct() + + internal fun requestedMixedByPhoneCandidateWindow(params: QueryParams): Long = params.offset.toLong() + params.limit.toLong() + + internal fun exceedsMixedByPhoneCandidateWindow( + params: QueryParams, + allPhoneNumbers: List, + ): Boolean = + params.includeMms && + allPhoneNumbers.size == 1 && + requestedMixedByPhoneCandidateWindow(params) > MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW + + internal fun mixedByPhoneWindowError(): String = "INVALID_REQUEST: includeMms offset+limit exceeds supported window ($MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW)" + + internal fun isMmsTransportRow(message: SmsMessage): Boolean = message.transportType.equals("mms", ignoreCase = true) + + internal fun shouldHydrateMmsByPhoneRow( + transportType: String?, + body: String?, + type: Int, + ): Boolean = transportType.equals("mms", ignoreCase = true) && (body.isNullOrBlank() || type == 0) + + internal fun buildQueryMetadata( + params: QueryParams, + allPhoneNumbers: List, + messages: List, + ): QueryMetadata { + val mmsRequested = params.includeMms + val mmsEligible = mmsRequested && allPhoneNumbers.size == 1 + val mmsAttempted = mmsEligible + val mmsIncluded = mmsAttempted && messages.any(::isMmsTransportRow) + return QueryMetadata( + mmsRequested = mmsRequested, + mmsEligible = mmsEligible, + mmsAttempted = mmsAttempted, + mmsIncluded = mmsIncluded, + ) + } + + internal fun compareByPhoneCandidateOrder( + left: SmsMessage, + right: SmsMessage, + ): Int = + when { + left.date != right.date -> right.date.compareTo(left.date) + left.id != right.id -> right.id.compareTo(left.id) + else -> 0 + } + + internal fun buildMixedRowIdentity( + rowId: Long, + transportType: String?, + ): String = "${transportType?.ifBlank { "unknown" } ?: "unknown"}:$rowId" + + internal fun upsertTopDateCandidates( + candidates: MutableList>, + identityKey: String, + message: SmsMessage, + maxCandidates: Int, + ) { + if (maxCandidates <= 0) { + return + } + + candidates.removeAll { existing -> existing.first == identityKey } + candidates.add(identityKey to message) + candidates.sortWith { left, right -> compareByPhoneCandidateOrder(left.second, right.second) } + + while (candidates.size > maxCandidates) { + candidates.removeAt(candidates.lastIndex) + } + } + + internal fun materializeByPhoneCandidate( + candidates: MutableMap, + identityKey: String, + message: SmsMessage, + ) { + candidates[identityKey] = message + } + + internal fun collectMixedByPhoneCandidate( + topCandidates: MutableList>, + materializedCandidates: MutableMap, + identityKey: String, + message: SmsMessage, + maxCandidates: Int, + reviewMode: Boolean, + ) { + if (reviewMode) { + materializeByPhoneCandidate(materializedCandidates, identityKey, message) + } else { + upsertTopDateCandidates(topCandidates, identityKey, message, maxCandidates) + } + } + + internal fun pageMixedByPhoneCandidates( + topCandidates: Collection>, + materializedCandidates: Map, + params: QueryParams, + reviewMode: Boolean, + ): List = + if (reviewMode) { + pageByPhoneCandidates(materializedCandidates.values, params) + } else { + pageByPhoneCandidates(topCandidates.map { it.second }, params) + } + + internal fun pageByPhoneCandidates( + candidates: Collection, + params: QueryParams, + ): List = + candidates + .sortedWith(::compareByPhoneCandidateOrder) + .drop(params.offset) + .take(params.limit) + + internal fun buildSendPlan( + message: String, + divider: (String) -> List, + ): SendPlan { + val parts = divider(message).ifEmpty { listOf(message) } + return SendPlan(parts = parts, useMultipart = parts.size > 1) + } + + internal fun buildPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + to: String, + error: String?, + ): String { + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "to" to JsonPrimitive(to), + ) + if (!ok) { + payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } + + internal fun buildQueryPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + messages: List, + error: String? = null, + queryMetadata: QueryMetadata? = null, + ): String { + val messagesArray = json.encodeToString(messages) + val messagesElement = json.parseToJsonElement(messagesArray) + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "count" to JsonPrimitive(messages.size), + "messages" to messagesElement, + ) + queryMetadata?.let { + payload["mmsRequested"] = JsonPrimitive(it.mmsRequested) + payload["mmsEligible"] = JsonPrimitive(it.mmsEligible) + payload["mmsAttempted"] = JsonPrimitive(it.mmsAttempted) + payload["mmsIncluded"] = JsonPrimitive(it.mmsIncluded) + } + if (!ok && error != null) { + payload["error"] = JsonPrimitive(error) + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } + } + + fun hasSmsPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.SEND_SMS, + ) == PackageManager.PERMISSION_GRANTED + + fun hasReadSmsPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_SMS, + ) == PackageManager.PERMISSION_GRANTED + + fun hasReadContactsPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) == PackageManager.PERMISSION_GRANTED + + fun canSendSms(): Boolean = hasSmsPermission() && hasTelephonyFeature() + + fun canSearchSms(): Boolean = hasReadSmsPermission() && hasTelephonyFeature() + + fun canReadSms(): Boolean = canSearchSms() + + fun hasTelephonyFeature(): Boolean = context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + + /** + * Send an SMS message. + * + * @param paramsJson JSON with "to" (phone number) and "message" (text) fields + * @return SendResult indicating success or failure + */ + suspend fun send(paramsJson: String?): SendResult { + if (!hasTelephonyFeature()) { + return errorResult( + error = "SMS_UNAVAILABLE: telephony not available", + ) + } + + if (!ensureSmsPermission()) { + return errorResult( + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", + ) + } + + val parseResult = parseParams(paramsJson, json) + if (parseResult is ParseResult.Error) { + return errorResult( + error = parseResult.error, + to = parseResult.to, + message = parseResult.message, + ) + } + val params = (parseResult as ParseResult.Ok).params + + return try { + val smsManager = + context.getSystemService(AndroidSmsManager::class.java) + ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") + + val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } + if (plan.useMultipart) { + smsManager.sendMultipartTextMessage( + params.to, + null, + ArrayList(plan.parts), + null, + null, + ) + } else { + smsManager.sendTextMessage( + params.to, + null, + params.message, + null, + null, + ) + } + + okResult(to = params.to, message = params.message) + } catch (e: SecurityException) { + errorResult( + error = "SMS_PERMISSION_REQUIRED: ${e.message}", + to = params.to, + message = params.message, + ) + } catch (e: Throwable) { + errorResult( + error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", + to = params.to, + message = params.message, + ) + } + } + + /** + * Search SMS messages with the specified parameters. + */ + suspend fun search(paramsJson: String?): SearchResult = + withContext(Dispatchers.IO) { + if (!hasTelephonyFeature()) { + return@withContext queryError("SMS_UNAVAILABLE: telephony not available") + } + + if (!ensureReadSmsPermission()) { + return@withContext queryError("SMS_PERMISSION_REQUIRED: grant READ_SMS permission") + } + + val parseResult = parseQueryParams(paramsJson, json) + if (parseResult is QueryParseResult.Error) { + return@withContext queryError(parseResult.error) + } + val parsedParams = (parseResult as QueryParseResult.Ok).params + val normalizedPhoneNumber = normalizePhoneNumberOrNull(parsedParams.phoneNumber) + if (isExplicitPhoneInputInvalid(parsedParams.phoneNumber, normalizedPhoneNumber)) { + val error = + if (!parsedParams.phoneNumber.isNullOrBlank() && + normalizedPhoneNumber != null && + hasSqlLikeWildcard(normalizedPhoneNumber) + ) { + "INVALID_REQUEST: phoneNumber must not contain SQL LIKE wildcard characters" + } else { + "INVALID_REQUEST: phoneNumber must contain at least one digit" + } + return@withContext queryError(error) + } + val normalizedParams = resolveSearchParams(parsedParams, normalizedPhoneNumber) + + return@withContext try { + val contactsPermissionGranted = hasReadContactsPermission() + val shouldPromptForContactsPermission = + shouldPromptForContactNameSearchPermission( + contactName = normalizedParams.contactName, + phoneNumber = normalizedParams.phoneNumber, + hasReadContactsPermission = contactsPermissionGranted, + ) + val phoneNumbers = + if (!normalizedParams.contactName.isNullOrEmpty()) { + if (contactsPermissionGranted || (shouldPromptForContactsPermission && ensureReadContactsPermission())) { + getPhoneNumbersFromContactName(normalizedParams.contactName) + } else if (shouldPromptForContactsPermission) { + return@withContext queryError("CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission") + } else { + emptyList() + } + } else { + emptyList() + } + val params = resolveSearchParams(parsedParams, normalizedPhoneNumber, phoneNumbers) + + val mixedPathPhoneFilters = + if (!params.phoneNumber.isNullOrEmpty()) { + canonicalizeMixedPathPhoneFilters(phoneNumbers + params.phoneNumber) + } else { + canonicalizeMixedPathPhoneFilters(phoneNumbers) + } + + if (exceedsMixedByPhoneCandidateWindow(params, mixedPathPhoneFilters)) { + val error = mixedByPhoneWindowError() + return@withContext queryError(error) + } + + if (!params.contactName.isNullOrEmpty() && phoneNumbers.isEmpty() && params.phoneNumber.isNullOrEmpty()) { + val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, emptyList()) + return@withContext queryOk(emptyList(), queryMetadata) + } + + val messages = querySmsMessages(params, phoneNumbers) + val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, messages) + queryOk(messages, queryMetadata) + } catch (e: SecurityException) { + queryError("SMS_PERMISSION_REQUIRED: ${e.message}") + } catch (e: Throwable) { + queryError("SMS_QUERY_FAILED: ${e.message ?: "unknown error"}") + } + } + + private suspend fun ensureSmsPermission(): Boolean { + if (hasSmsPermission()) return true + val requester = permissionRequester ?: return false + val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) + return results[Manifest.permission.SEND_SMS] == true + } + + private suspend fun ensureReadSmsPermission(): Boolean { + if (hasReadSmsPermission()) return true + val requester = permissionRequester ?: return false + val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS)) + return results[Manifest.permission.READ_SMS] == true + } + + private suspend fun ensureReadContactsPermission(): Boolean { + if (hasReadContactsPermission()) return true + val requester = permissionRequester ?: return false + val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS)) + return results[Manifest.permission.READ_CONTACTS] == true + } + + private fun okResult( + to: String, + message: String, + ): SendResult = + SendResult( + ok = true, + to = to, + message = message, + error = null, + payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), + ) + + private fun errorResult( + error: String, + to: String = "", + message: String? = null, + ): SendResult = + SendResult( + ok = false, + to = to, + message = message, + error = error, + payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), + ) + + private fun queryOk( + messages: List, + queryMetadata: QueryMetadata? = null, + ): SearchResult = + SearchResult( + ok = true, + messages = messages, + error = null, + payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages, queryMetadata = queryMetadata), + ) + + private fun queryError(error: String): SearchResult = + SearchResult( + ok = false, + messages = emptyList(), + error = error, + payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = error), + ) + + private fun getPhoneNumbersFromContactName(contactName: String): List { + val phoneNumbers = mutableListOf() + val selection = buildContactNameLikeSelection() + val selectionArgs = arrayOf(buildContactNameLikeArg(contactName)) + + val cursor = + context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER), + selection, + selectionArgs, + null, + ) + + cursor?.use { + val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + while (it.moveToNext()) { + val number = it.getString(numberIndex) + sanitizeContactPhoneNumberOrNull(number)?.let(phoneNumbers::add) + } + } + + return phoneNumbers + } + + private fun querySmsMessages( + params: QueryParams, + phoneNumbers: List, + ): List { + val messages = mutableListOf() + + val selections = mutableListOf() + val selectionArgs = mutableListOf() + + if (params.startTime != null) { + selections.add("${Telephony.Sms.DATE} >= ?") + selectionArgs.add(params.startTime.toString()) + } + if (params.endTime != null) { + selections.add("${Telephony.Sms.DATE} <= ?") + selectionArgs.add(params.endTime.toString()) + } + + val allPhoneNumbers = + if (!params.phoneNumber.isNullOrEmpty()) { + (phoneNumbers + normalizePhoneNumber(params.phoneNumber)).distinct() + } else { + phoneNumbers.distinct() + } + val mixedPathPhoneFilters = canonicalizeMixedPathPhoneFilters(allPhoneNumbers) + + // Unified SMS+MMS query path is opt-in to keep sms.search semantics + // stable by default. Use includeMms=true for by-phone provider behavior. + if (params.includeMms && mixedPathPhoneFilters.size == 1) { + return querySmsMmsMessagesByPhone(mixedPathPhoneFilters.first(), params) + } + + if (allPhoneNumbers.isNotEmpty()) { + val addressSelection = + allPhoneNumbers.joinToString(" OR ") { + "${Telephony.Sms.ADDRESS} LIKE ?" + } + selections.add("($addressSelection)") + allPhoneNumbers.forEach { + selectionArgs.add("%$it%") + } + } + + if (!params.keyword.isNullOrEmpty()) { + selections.add(buildKeywordLikeSelection()) + selectionArgs.add(buildKeywordLikeArg(params.keyword)) + } + + if (params.type != null) { + selections.add("${Telephony.Sms.TYPE} = ?") + selectionArgs.add(params.type.toString()) + } + + if (params.isRead != null) { + selections.add("${Telephony.Sms.READ} = ?") + selectionArgs.add(if (params.isRead) "1" else "0") + } + + val selection = + if (selections.isNotEmpty()) { + selections.joinToString(" AND ") + } else { + null + } + + val selectionArgsArray = + if (selectionArgs.isNotEmpty()) { + selectionArgs.toTypedArray() + } else { + null + } + + // Android SMS providers still honor LIMIT/OFFSET through sortOrder on this path. + // Keep the bounded interpolation here because parseQueryParams already clamps both values. + val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}" + val cursor = + context.contentResolver.query( + Telephony.Sms.CONTENT_URI, + arrayOf( + Telephony.Sms._ID, + Telephony.Sms.THREAD_ID, + Telephony.Sms.ADDRESS, + Telephony.Sms.PERSON, + Telephony.Sms.DATE, + Telephony.Sms.DATE_SENT, + Telephony.Sms.READ, + Telephony.Sms.TYPE, + Telephony.Sms.BODY, + Telephony.Sms.STATUS, + ), + selection, + selectionArgsArray, + sortOrder, + ) + + cursor?.use { + val idIndex = it.getColumnIndex(Telephony.Sms._ID) + val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID) + val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS) + val personIndex = it.getColumnIndex(Telephony.Sms.PERSON) + val dateIndex = it.getColumnIndex(Telephony.Sms.DATE) + val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT) + val readIndex = it.getColumnIndex(Telephony.Sms.READ) + val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE) + val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY) + val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS) + + var count = 0 + while (it.moveToNext() && count < params.limit) { + val message = + SmsMessage( + id = it.getLong(idIndex), + threadId = it.getLong(threadIdIndex), + address = it.getString(addressIndex), + person = it.getString(personIndex), + date = it.getLong(dateIndex), + dateSent = it.getLong(dateSentIndex), + read = it.getInt(readIndex) == 1, + type = it.getInt(typeIndex), + body = it.getString(bodyIndex), + status = it.getInt(statusIndex), + ) + messages.add(message) + count++ + } + } + + return messages + } + + private fun querySmsMmsMessagesByPhone( + phoneNumber: String, + params: QueryParams, + ): List { + val lookupNumber = toByPhoneLookupNumber(phoneNumber) + if (lookupNumber.isBlank()) { + return emptyList() + } + + val uri = "$MMS_SMS_BY_PHONE_BASE/${Uri.encode(lookupNumber)}".toUri() + val projection = buildMixedByPhoneProjection() + + val maxCandidates = params.offset + params.limit + if (maxCandidates <= 0) { + return emptyList() + } + + val reviewMode = shouldUseConversationReviewByPhoneMode(params) + val topCandidates = mutableListOf>() + val materializedCandidates = linkedMapOf() + val cursor = context.contentResolver.query(uri, projection, null, null, "date DESC") + cursor?.use { + val idIndex = it.getColumnIndex("_id") + val threadIdIndex = it.getColumnIndex("thread_id") + val transportTypeIndex = it.getColumnIndex("transport_type") + val addressIndex = it.getColumnIndex("address") + val dateIndex = it.getColumnIndex("date") + val dateSentIndex = it.getColumnIndex("date_sent") + val readIndex = it.getColumnIndex("read") + val typeIndex = it.getColumnIndex("type") + val bodyIndex = it.getColumnIndex("body") + val statusIndex = it.getColumnIndex("status") + + while (it.moveToNext()) { + val id = if (idIndex >= 0 && !it.isNull(idIndex)) it.getLong(idIndex) else continue + val rawDate = if (dateIndex >= 0 && !it.isNull(dateIndex)) it.getLong(dateIndex) else 0L + val dateMs = normalizeProviderDateMillis(rawDate) + + if (params.startTime != null && dateMs < params.startTime) continue + if (params.endTime != null && dateMs > params.endTime) continue + + val threadId = if (threadIdIndex >= 0 && !it.isNull(threadIdIndex)) it.getLong(threadIdIndex) else 0L + val transportType = + if (transportTypeIndex >= 0 && + !it.isNull(transportTypeIndex) + ) { + it.getString(transportTypeIndex) + } else { + null + } + val providerAddress = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null + val mmsAddress = if (transportType.equals("mms", ignoreCase = true)) getMmsAddress(id, phoneNumber) else null + val address = resolveMixedByPhoneRowAddress(providerAddress, phoneNumber, mmsAddress) + var read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else true + var type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else 0 + var body = if (bodyIndex >= 0 && !it.isNull(bodyIndex)) it.getString(bodyIndex) else null + val smsStatus = if (statusIndex >= 0 && !it.isNull(statusIndex)) it.getInt(statusIndex) else null + + // Only MMS transport rows are allowed to hydrate from MMS storage. + if (shouldHydrateMmsByPhoneRow(transportType, body, type)) { + body = body?.takeIf { msg -> msg.isNotBlank() } ?: getMmsTextBody(id) + val mmsMeta = getMmsMeta(id) + if (type == 0) { + type = mmsMeta.first ?: type + } + if (readIndex < 0 || it.isNull(readIndex)) { + read = mmsMeta.second ?: read + } + } + + val dateSentRaw = if (dateSentIndex >= 0 && !it.isNull(dateSentIndex)) it.getLong(dateSentIndex) else 0L + val dateSentMs = normalizeProviderDateMillis(dateSentRaw) + + if (!params.keyword.isNullOrEmpty()) { + val keyword = params.keyword + if (body.isNullOrEmpty() || !body.contains(keyword, ignoreCase = true)) { + continue + } + } + if (params.type != null && type != params.type) continue + if (params.isRead != null && read != params.isRead) continue + + val message = + SmsMessage( + id = id, + threadId = threadId, + address = address, + person = null, + date = dateMs, + dateSent = dateSentMs, + read = read, + type = type, + body = body, + status = resolveMixedByPhoneRowStatus(transportType, smsStatus), + transportType = transportType, + ) + val identityKey = buildMixedRowIdentity(id, transportType) + collectMixedByPhoneCandidate( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + identityKey = identityKey, + message = message, + maxCandidates = maxCandidates, + reviewMode = reviewMode, + ) + } + } + + return pageMixedByPhoneCandidates( + topCandidates = topCandidates, + materializedCandidates = materializedCandidates, + params = params, + reviewMode = reviewMode, + ) + } + + private fun getMmsTextBody(messageId: Long): String? { + val cursor = + context.contentResolver.query( + MMS_PART_URI.toUri(), + arrayOf("text", "ct"), + "mid=?", + arrayOf(messageId.toString()), + null, + ) + + cursor?.use { + val textIndex = it.getColumnIndex("text") + val ctIndex = it.getColumnIndex("ct") + while (it.moveToNext()) { + val contentType = if (ctIndex >= 0 && !it.isNull(ctIndex)) it.getString(ctIndex) else null + if (contentType != null && contentType != "text/plain") continue + val text = if (textIndex >= 0 && !it.isNull(textIndex)) it.getString(textIndex) else null + if (!text.isNullOrBlank()) return text + } + } + + return null + } + + private fun getMmsMeta(messageId: Long): Pair { + val cursor = + context.contentResolver.query( + "$MMS_CONTENT_BASE/$messageId".toUri(), + arrayOf("msg_box", "read"), + null, + null, + null, + ) + + cursor?.use { + if (it.moveToFirst()) { + val msgBoxIndex = it.getColumnIndex("msg_box") + val readIndex = it.getColumnIndex("read") + val msgBox = if (msgBoxIndex >= 0 && !it.isNull(msgBoxIndex)) it.getInt(msgBoxIndex) else null + val mappedType = mapMmsMsgBoxToSearchType(msgBox) + val read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else null + return mappedType to read + } + } + + return null to null + } + + private fun getMmsAddress( + messageId: Long, + phoneNumber: String, + ): String? { + val lookupNumber = toByPhoneLookupNumber(phoneNumber) + if (lookupNumber.isBlank()) { + return null + } + + val cursor = + context.contentResolver.query( + "$MMS_CONTENT_BASE/$messageId/addr".toUri(), + arrayOf("address", "type"), + null, + null, + null, + ) + + cursor?.use { + val addressIndex = it.getColumnIndex("address") + val typeIndex = it.getColumnIndex("type") + val addressRows = mutableListOf>() + while (it.moveToNext()) { + val address = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null + val type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else null + addressRows.add(address to type) + } + return selectPreferredMmsAddress(addressRows, lookupNumber) + } + + return null + } +} diff --git a/app/src/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt b/app/src/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt new file mode 100644 index 0000000..f1164db --- /dev/null +++ b/app/src/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt @@ -0,0 +1,104 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.accessibility.AccessibilityComponentController +import ai.openclaw.app.i18n.nativeString +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +@Composable +internal fun FlavorPhoneCapabilitiesSettings(viewModel: MainViewModel) { + val context = LocalContext.current + val enabled by viewModel.accessibilityControlEnabled.collectAsState() + var showDisclosure by rememberSaveable { mutableStateOf(false) } + + fun setControlEnabled(checked: Boolean) { + if (checked) { + showDisclosure = true + return + } + viewModel.setAccessibilityControlEnabled(false) + AccessibilityComponentController(context).setEnabled(false) + } + + SettingsTogglePanel( + rows = + listOf( + SettingsToggleRow( + title = nativeString("Control other apps"), + subtitle = + if (enabled) { + nativeString("Shown in Android Accessibility settings.") + } else { + nativeString("Other apps stay untouched.") + }, + icon = Icons.AutoMirrored.Filled.ScreenShare, + checked = enabled, + onCheckedChange = ::setControlEnabled, + ), + ), + ) + + if (showDisclosure) { + AccessibilityControlDisclosureDialog( + onDismiss = { showDisclosure = false }, + onAgree = { + showDisclosure = false + viewModel.setAccessibilityControlEnabled(true) + AccessibilityComponentController(context).setEnabled(true) + openAccessibilitySettings(context) + }, + ) + } +} + +@Composable +private fun AccessibilityControlDisclosureDialog( + onDismiss: () -> Unit, + onAgree: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(nativeString("Allow control of other apps?")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + nativeString( + "Enabling lets OpenClaw observe and control other apps' screens when armed. Android accessibility access is required.", + ), + ) + } + }, + confirmButton = { + TextButton(onClick = onAgree) { + Text(nativeString("Enable and Open Settings")) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(nativeString("Not Now")) + } + }, + ) +} + +private fun openAccessibilitySettings(context: Context) { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) +} diff --git a/app/src/thirdParty/res/values-ar/accessibility_strings.xml b/app/src/thirdParty/res/values-ar/accessibility_strings.xml new file mode 100644 index 0000000..65861d0 --- /dev/null +++ b/app/src/thirdParty/res/values-ar/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "التحكم في واجهة OpenClaw" + "مراقبة واجهة المطوّر والتحكم بها" + "يتيح لبنية OpenClaw من جهة خارجية فحص التطبيق النشط والتحكم به أثناء التطوير المحلي." + "مطوّر إمكانية الوصول في OpenClaw" + diff --git a/app/src/thirdParty/res/values-de/accessibility_strings.xml b/app/src/thirdParty/res/values-de/accessibility_strings.xml new file mode 100644 index 0000000..4393ace --- /dev/null +++ b/app/src/thirdParty/res/values-de/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw-UI-Steuerung" + "Entwickler-UI-Beobachtung und -Steuerung" + "Ermöglicht dem Drittanbieter-Build von OpenClaw, die aktive App während der lokalen Entwicklung zu untersuchen und zu steuern." + "OpenClaw Accessibility Developer" + diff --git a/app/src/thirdParty/res/values-es/accessibility_strings.xml b/app/src/thirdParty/res/values-es/accessibility_strings.xml new file mode 100644 index 0000000..539a683 --- /dev/null +++ b/app/src/thirdParty/res/values-es/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Control de UI de OpenClaw" + "Observación y control de UI para desarrolladores" + "Permite que la versión de OpenClaw de terceros inspeccione y controle la app activa durante el desarrollo local." + "Desarrollador de accesibilidad de OpenClaw" + diff --git a/app/src/thirdParty/res/values-fa/accessibility_strings.xml b/app/src/thirdParty/res/values-fa/accessibility_strings.xml new file mode 100644 index 0000000..cb1b820 --- /dev/null +++ b/app/src/thirdParty/res/values-fa/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "کنترل رابط کاربری OpenClaw" + "مشاهده و کنترل رابط کاربری توسعه‌دهنده" + "به بیلد شخص ثالث OpenClaw اجازه می‌دهد در طول توسعه محلی، برنامه فعال را بررسی و کنترل کند." + "توسعه‌دهنده دسترسی‌پذیری OpenClaw" + diff --git a/app/src/thirdParty/res/values-fr/accessibility_strings.xml b/app/src/thirdParty/res/values-fr/accessibility_strings.xml new file mode 100644 index 0000000..acf1306 --- /dev/null +++ b/app/src/thirdParty/res/values-fr/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Contrôle de l\'interface OpenClaw" + "Observation et contrôle de l\'interface développeur" + "Permet à la version tierce d\'OpenClaw d\'inspecter et de contrôler l\'application active pendant le développement local." + "Développeur Accessibilité OpenClaw" + diff --git a/app/src/thirdParty/res/values-hi/accessibility_strings.xml b/app/src/thirdParty/res/values-hi/accessibility_strings.xml new file mode 100644 index 0000000..4525caa --- /dev/null +++ b/app/src/thirdParty/res/values-hi/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI नियंत्रण" + "डेवलपर UI अवलोकन और नियंत्रण" + "तृतीय-पक्ष OpenClaw बिल्ड को स्थानीय विकास के दौरान सक्रिय ऐप का निरीक्षण और नियंत्रण करने की अनुमति देता है।" + "OpenClaw Accessibility Developer" + diff --git a/app/src/thirdParty/res/values-in/accessibility_strings.xml b/app/src/thirdParty/res/values-in/accessibility_strings.xml new file mode 100644 index 0000000..0ba2cd8 --- /dev/null +++ b/app/src/thirdParty/res/values-in/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Kontrol UI OpenClaw" + "Observasi dan kontrol UI developer" + "Memungkinkan build OpenClaw pihak ketiga memeriksa dan mengontrol aplikasi aktif selama pengembangan lokal." + "OpenClaw Accessibility Developer" + diff --git a/app/src/thirdParty/res/values-it/accessibility_strings.xml b/app/src/thirdParty/res/values-it/accessibility_strings.xml new file mode 100644 index 0000000..6d008bd --- /dev/null +++ b/app/src/thirdParty/res/values-it/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Controllo UI di OpenClaw" + "Osservazione e controllo UI per sviluppatori" + "Consente alla build di terze parti di OpenClaw di ispezionare e controllare l\'app attiva durante lo sviluppo locale." + "Sviluppatore Accessibilità OpenClaw" + diff --git a/app/src/thirdParty/res/values-ja/accessibility_strings.xml b/app/src/thirdParty/res/values-ja/accessibility_strings.xml new file mode 100644 index 0000000..e4166cc --- /dev/null +++ b/app/src/thirdParty/res/values-ja/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI 操作" + "開発者向け UI の観察と操作" + "ローカル開発中に、サードパーティの OpenClaw ビルドがアクティブなアプリを検査および操作できるようにします。" + "OpenClaw アクセシビリティ開発者" + diff --git a/app/src/thirdParty/res/values-ko/accessibility_strings.xml b/app/src/thirdParty/res/values-ko/accessibility_strings.xml new file mode 100644 index 0000000..b627219 --- /dev/null +++ b/app/src/thirdParty/res/values-ko/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI 제어" + "개발자 UI 관찰 및 제어" + "타사 OpenClaw 빌드가 로컬 개발 중 활성 앱을 검사하고 제어할 수 있도록 합니다." + "OpenClaw 접근성 개발자" + diff --git a/app/src/thirdParty/res/values-nl/accessibility_strings.xml b/app/src/thirdParty/res/values-nl/accessibility_strings.xml new file mode 100644 index 0000000..208d961 --- /dev/null +++ b/app/src/thirdParty/res/values-nl/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI-besturing" + "Observatie en besturing van ontwikkelaars-UI" + "Laat de externe OpenClaw-build de actieve app inspecteren en besturen tijdens lokale ontwikkeling." + "OpenClaw Accessibility Developer" + diff --git a/app/src/thirdParty/res/values-pl/accessibility_strings.xml b/app/src/thirdParty/res/values-pl/accessibility_strings.xml new file mode 100644 index 0000000..af56f16 --- /dev/null +++ b/app/src/thirdParty/res/values-pl/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Sterowanie interfejsem OpenClaw" + "Obserwacja i sterowanie interfejsem dla programistów" + "Umożliwia zewnętrznej wersji OpenClaw inspekcję i sterowanie aktywną aplikacją podczas lokalnego programowania." + "Deweloper dostępności OpenClaw" + diff --git a/app/src/thirdParty/res/values-pt-rBR/accessibility_strings.xml b/app/src/thirdParty/res/values-pt-rBR/accessibility_strings.xml new file mode 100644 index 0000000..79a8023 --- /dev/null +++ b/app/src/thirdParty/res/values-pt-rBR/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Controle da UI do OpenClaw" + "Observação e controle da UI de desenvolvedor" + "Permite que a versão OpenClaw de terceiros inspecione e controle o app ativo durante o desenvolvimento local." + "Desenvolvedor de Acessibilidade OpenClaw" + diff --git a/app/src/thirdParty/res/values-ru/accessibility_strings.xml b/app/src/thirdParty/res/values-ru/accessibility_strings.xml new file mode 100644 index 0000000..2a184c3 --- /dev/null +++ b/app/src/thirdParty/res/values-ru/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Управление интерфейсом OpenClaw" + "Наблюдение и управление интерфейсом для разработчиков" + "Позволяет стороннической сборке OpenClaw инспектировать и управлять активным приложением во время локальной разработки." + "Разработчик OpenClaw Accessibility" + diff --git a/app/src/thirdParty/res/values-sv/accessibility_strings.xml b/app/src/thirdParty/res/values-sv/accessibility_strings.xml new file mode 100644 index 0000000..02af1bf --- /dev/null +++ b/app/src/thirdParty/res/values-sv/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI-styrning" + "UI-observation och -styrning för utvecklare" + "Tillåter tredjepartsversionen av OpenClaw att inspektera och styra den aktiva appen under lokal utveckling." + "OpenClaw Accessibility Developer" + diff --git a/app/src/thirdParty/res/values-th/accessibility_strings.xml b/app/src/thirdParty/res/values-th/accessibility_strings.xml new file mode 100644 index 0000000..a9fbd98 --- /dev/null +++ b/app/src/thirdParty/res/values-th/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "การควบคุม UI ของ OpenClaw" + "การสังเกตและควบคุม UI สำหรับนักพัฒนา" + "อนุญาตให้บิลด์ OpenClaw ของบุคคลที่สามตรวจสอบและควบคุมแอปที่ใช้งานอยู่ระหว่างการพัฒนาบนเครื่อง" + "นักพัฒนา OpenClaw Accessibility" + diff --git a/app/src/thirdParty/res/values-tr/accessibility_strings.xml b/app/src/thirdParty/res/values-tr/accessibility_strings.xml new file mode 100644 index 0000000..f565b3c --- /dev/null +++ b/app/src/thirdParty/res/values-tr/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI kontrolü" + "Geliştirici UI gözlemleme ve kontrolü" + "Üçüncü taraf OpenClaw derlemesinin yerel geliştirme sırasında etkin uygulamayı incelemesine ve kontrol etmesine olanak tanır." + "OpenClaw Erişilebilirlik Geliştiricisi" + diff --git a/app/src/thirdParty/res/values-uk/accessibility_strings.xml b/app/src/thirdParty/res/values-uk/accessibility_strings.xml new file mode 100644 index 0000000..238763f --- /dev/null +++ b/app/src/thirdParty/res/values-uk/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Керування інтерфейсом OpenClaw" + "Спостереження та керування інтерфейсом для розробників" + "Дозволяє сторонній збірці OpenClaw перевіряти активний застосунок і керувати ним під час локальної розробки." + "Розробник спеціальних можливостей OpenClaw" + diff --git a/app/src/thirdParty/res/values-vi/accessibility_strings.xml b/app/src/thirdParty/res/values-vi/accessibility_strings.xml new file mode 100644 index 0000000..1747512 --- /dev/null +++ b/app/src/thirdParty/res/values-vi/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "Điều khiển giao diện OpenClaw" + "Quan sát và điều khiển giao diện dành cho nhà phát triển" + "Cho phép bản dựng OpenClaw của bên thứ ba kiểm tra và điều khiển ứng dụng đang hoạt động trong quá trình phát triển cục bộ." + "Nhà phát triển Accessibility OpenClaw" + diff --git a/app/src/thirdParty/res/values-zh-rCN/accessibility_strings.xml b/app/src/thirdParty/res/values-zh-rCN/accessibility_strings.xml new file mode 100644 index 0000000..17ff8c9 --- /dev/null +++ b/app/src/thirdParty/res/values-zh-rCN/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI 控制" + "开发者 UI 观察与控制" + "允许第三方 OpenClaw 构建在本地开发期间检查和控制当前应用。" + "OpenClaw 无障碍开发者" + diff --git a/app/src/thirdParty/res/values-zh-rTW/accessibility_strings.xml b/app/src/thirdParty/res/values-zh-rTW/accessibility_strings.xml new file mode 100644 index 0000000..0b7bd2a --- /dev/null +++ b/app/src/thirdParty/res/values-zh-rTW/accessibility_strings.xml @@ -0,0 +1,6 @@ + + "OpenClaw UI 控制" + "開發者 UI 觀察與控制" + "允許第三方 OpenClaw 版本在本機開發期間檢視並控制作用中的應用程式。" + "OpenClaw 無障礙開發人員" + diff --git a/app/src/thirdParty/res/values/accessibility_strings.xml b/app/src/thirdParty/res/values/accessibility_strings.xml new file mode 100644 index 0000000..f8997e4 --- /dev/null +++ b/app/src/thirdParty/res/values/accessibility_strings.xml @@ -0,0 +1,8 @@ + + + + OpenClaw UI control + Developer UI observation and control + Allows the third-party OpenClaw build to inspect and control the active app during local development. + OpenClaw Accessibility Developer + diff --git a/app/src/thirdParty/res/xml/accessibility_service_config.xml b/app/src/thirdParty/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..88186d4 --- /dev/null +++ b/app/src/thirdParty/res/xml/accessibility_service_config.xml @@ -0,0 +1,11 @@ + + diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..7324584 --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.ktlint) +} + +android { + namespace = "ai.openclaw.app.benchmark" + // Match the target app while targetSdk remains an independent behavior opt-in. + compileSdk = 37 + + defaultConfig { + minSdk = 31 + targetSdk = 36 + missingDimensionStrategy("store", "play") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR" + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +ktlint { + android.set(true) + ignoreFailures.set(false) + filter { + exclude("**/build/**") + } +} + +dependencies { + implementation(libs.androidx.benchmark.macro.junit4) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.uiautomator) +} diff --git a/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt b/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt new file mode 100644 index 0000000..2eb5a7f --- /dev/null +++ b/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt @@ -0,0 +1,80 @@ +package ai.openclaw.app.benchmark + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CronJobNavigationTest { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.executeShellCommand("am force-stop $packageName") + device.executeShellCommand( + "am start -W -n $packageName/.MainActivity " + + "--ez openclaw.screenshotMode true --es openclaw.screenshotScene settings", + ) + assertNotNull(device.wait(Until.findObject(By.text("Settings")), waitTimeoutMs)) + } + + @Test + fun opensCronJobFixtureDetail() { + findTextAfterScrolling("Automations").click() + + val cronJobLabel = findTextAfterScrolling("Android release digest") + val cronJobRow = + checkNotNull( + generateSequence(cronJobLabel) { it.parent } + .firstOrNull { it.isClickable }, + ) { "Cron fixture row must expose a click action" } + assertTrue("Cron fixture row must expose a click action", cronJobRow.isClickable) + assertFalse(device.hasObject(By.text("Run Now"))) + cronJobRow.click() + + assertNotNull(findTextAfterScrolling("Run Now")) + assertNotNull(findTextAfterScrolling("Recent Runs")) + assertNotNull(findTextAfterScrolling("Release checklist ready", exact = false)) + assertNotNull(findTextAfterScrolling("OK")) + assertNotNull(findTextAfterScrolling("Play publish blocked", exact = false)) + assertNotNull(findTextAfterScrolling("Issue")) + } + + private fun findTextAfterScrolling( + text: String, + exact: Boolean = true, + ): UiObject2 { + val selector = if (exact) By.text(text) else By.textContains(text) + repeat(maxScrolls + 1) { attempt -> + device.wait(Until.findObject(selector), shortWaitMs)?.let { return it } + if (attempt < maxScrolls) { + device.swipe( + device.displayWidth / 2, + (device.displayHeight * 0.8f).toInt(), + device.displayWidth / 2, + (device.displayHeight * 0.25f).toInt(), + 24, + ) + device.waitForIdle() + } + } + error("Could not find UI text: $text") + } + + private companion object { + const val packageName = "ai.openclaw.app" + const val waitTimeoutMs = 10_000L + const val shortWaitMs = 1_000L + const val maxScrolls = 6 + } +} diff --git a/benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt b/benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt new file mode 100644 index 0000000..f3e5678 --- /dev/null +++ b/benchmark/src/main/java/ai/openclaw/app/benchmark/StartupMacrobenchmark.kt @@ -0,0 +1,76 @@ +package ai.openclaw.app.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StartupMacrobenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + private val packageName = "ai.openclaw.app" + + @Test + fun coldStartup() { + runBenchmarkOrSkip { + benchmarkRule.measureRepeated( + packageName = packageName, + metrics = listOf(StartupTimingMetric()), + startupMode = StartupMode.COLD, + compilationMode = CompilationMode.None(), + iterations = 10, + ) { + pressHome() + startActivityAndWait() + } + } + } + + @Test + fun startupAndScrollFrameTiming() { + runBenchmarkOrSkip { + benchmarkRule.measureRepeated( + packageName = packageName, + metrics = listOf(FrameTimingMetric()), + startupMode = StartupMode.WARM, + compilationMode = CompilationMode.None(), + iterations = 10, + ) { + startActivityAndWait() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val x = device.displayWidth / 2 + val yStart = (device.displayHeight * 0.8f).toInt() + val yEnd = (device.displayHeight * 0.25f).toInt() + repeat(4) { + device.swipe(x, yStart, x, yEnd, 24) + device.waitForIdle() + } + } + } + } + + private fun runBenchmarkOrSkip(run: () -> Unit) { + try { + run() + } catch (err: IllegalStateException) { + val message = err.message.orEmpty() + val knownDeviceIssue = + message.contains("Unable to confirm activity launch completion") || + message.contains("no renderthread slices", ignoreCase = true) + if (knownDeviceIssue) { + assumeTrue("Skipping benchmark on this device: $message", false) + } + throw err + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4753ca1 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/fastlane/.env.example b/fastlane/.env.example new file mode 100644 index 0000000..cf78b3d --- /dev/null +++ b/fastlane/.env.example @@ -0,0 +1,20 @@ +# Google Play API key (pick one approach) +# +# Recommended local path: +# GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json +# +# Or raw JSON content for CI: +# GOOGLE_PLAY_JSON_KEY_DATA={"type":"service_account",...} + +# Optional app targeting +# GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app + +# Release target +# GOOGLE_PLAY_TRACK=internal +# GOOGLE_PLAY_RELEASE_STATUS=completed +# GOOGLE_PLAY_VALIDATE_ONLY=1 + +# Metadata toggles +# SUPPLY_UPLOAD_METADATA=1 +# SUPPLY_UPLOAD_IMAGES=1 +# SUPPLY_UPLOAD_SCREENSHOTS=1 diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..af4b866 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,3 @@ +package_name(ENV["GOOGLE_PLAY_PACKAGE_NAME"] || "ai.openclaw.app") + +json_key_file(ENV["GOOGLE_PLAY_JSON_KEY"]) if ENV["GOOGLE_PLAY_JSON_KEY"] diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..9abd6f7 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,614 @@ +require "fileutils" +require "json" +require "open3" +require "shellwords" +require "supply" + +default_platform(:android) + +ANDROID_FASTLANE_ROOT = File.expand_path(__dir__, Dir.pwd) +DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app" +DEFAULT_PLAY_TRACK = "internal" +DEFAULT_PLAY_RELEASE_STATUS = "completed" +ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [ + "OPENCLAW_ANDROID_STORE_FILE", + "OPENCLAW_ANDROID_STORE_PASSWORD", + "OPENCLAW_ANDROID_KEY_ALIAS", + "OPENCLAW_ANDROID_KEY_PASSWORD" +].freeze + +def load_env_file(path) + return unless File.exist?(path) + + File.foreach(path) do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + key, value = stripped.split("=", 2) + next if key.nil? || key.empty? || value.nil? + + ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty? + end +end + +def env_present?(value) + !value.nil? && !value.strip.empty? +end + +def android_root + File.expand_path("..", ANDROID_FASTLANE_ROOT) +end + +def repo_root + File.expand_path("../..", android_root) +end + +def android_release_signing_script + File.join(repo_root, "scripts", "android-release-signing.mjs") +end + +def android_release_signing_materialized_properties_path + File.join(android_root, "build", "release-signing", "gradle.properties") +end + +def shell_join(args) + args.shelljoin +end + +def play_package_name + raw = ENV["GOOGLE_PLAY_PACKAGE_NAME"].to_s.strip + raw.empty? ? DEFAULT_PLAY_PACKAGE_NAME : raw +end + +def play_track + raw = ENV["GOOGLE_PLAY_TRACK"].to_s.strip + raw.empty? ? DEFAULT_PLAY_TRACK : raw +end + +def wear_play_track + "wear:#{play_track}" +end + +def play_release_status + raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip + raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw +end + +def play_validate_only? + ENV["GOOGLE_PLAY_VALIDATE_ONLY"] == "1" +end + +def play_metadata_upload_requested? + ENV["SUPPLY_UPLOAD_METADATA"] == "1" +end + +def play_screenshot_upload_requested? + ENV["SUPPLY_UPLOAD_SCREENSHOTS"] == "1" +end + +def play_image_upload_requested? + ENV["SUPPLY_UPLOAD_IMAGES"] == "1" +end + +def play_auth_options + json_key = ENV["GOOGLE_PLAY_JSON_KEY"].to_s.strip + json_key = ENV["SUPPLY_JSON_KEY"].to_s.strip if json_key.empty? + json_key = ENV["GOOGLE_PLAY_JSON_KEY_PATH"].to_s.strip if json_key.empty? + return { json_key: json_key } unless json_key.empty? + + json_key_data = ENV["GOOGLE_PLAY_JSON_KEY_DATA"].to_s.strip + json_key_data = ENV["SUPPLY_JSON_KEY_DATA"].to_s.strip if json_key_data.empty? + return { json_key_data: json_key_data } unless json_key_data.empty? + + UI.user_error!("Missing Google Play API credentials. Set GOOGLE_PLAY_JSON_KEY or GOOGLE_PLAY_JSON_KEY_DATA.") +end + +def validate_play_auth! + client = nil + begin + client = Supply::Client.make_from_config(params: play_auth_options) + client.begin_edit(package_name: play_package_name) + rescue => e + UI.user_error!("Google Play API credentials are invalid for #{play_package_name}: #{e.message}") + ensure + if client&.current_edit + begin + client.abort_current_edit + rescue => e + UI.user_error!("Google Play API credentials opened a validation edit but could not close it: #{e.message}") + end + end + end +end + +def read_android_version_metadata + stdout, stderr, status = Open3.capture3( + "node", + "--import", + "tsx", + File.join(repo_root, "scripts", "android-version.ts"), + "--json", + "--root", + repo_root + ) + unless status.success? + detail = stderr.to_s.strip + detail = stdout.to_s.strip if detail.empty? + UI.user_error!("Failed to read Android version metadata: #{detail}") + end + + parsed = JSON.parse(stdout) + version = parsed.fetch("canonicalVersion").to_s + version_code = parsed.fetch("versionCode").to_i + UI.user_error!("Android version helper returned incomplete metadata.") if version.empty? || version_code <= 0 + + { version: version, version_code: version_code } +rescue JSON::ParserError => e + UI.user_error!("Invalid JSON from Android version helper: #{e.message}") +end + +def sync_android_versioning! + sh(shell_join(["node", "--import", "tsx", File.join(repo_root, "scripts", "android-sync-versioning.ts"), "--check", "--root", repo_root])) +end + +def android_release_notes_path + File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "release_notes.txt") +end + +def validate_android_release_notes! + release_notes_path = android_release_notes_path + UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path) + UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path)) +end + +def android_changelog_path(version_code) + File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt") +end + +def wear_version_code(phone_version_code) + build_number = phone_version_code % 100 + UI.user_error!("Android phone versionCode build number must be 01 through 49.") unless (1..49).cover?(build_number) + phone_version_code + 50 +end + +def sync_android_changelog!(version_code) + validate_android_release_notes! + + changelog_path = android_changelog_path(version_code) + FileUtils.mkdir_p(File.dirname(changelog_path)) + File.write(changelog_path, File.read(android_release_notes_path)) + wear_changelog_path = android_changelog_path(wear_version_code(version_code)) + File.write(wear_changelog_path, File.read(android_release_notes_path)) + [changelog_path, wear_changelog_path] +end + +def play_metadata_languages + Dir.children(play_metadata_path) + .select { |language| File.directory?(File.join(play_metadata_path, language)) } + .reject { |language| language.start_with?(".") } + .sort +end + +def play_release_notes(version_code) + play_metadata_languages.filter_map do |language| + changelog_path = File.join(play_metadata_path, language, "changelogs", "#{version_code}.txt") + fallback_path = File.join(play_metadata_path, language, "changelogs", "default.txt") + source_path = File.exist?(changelog_path) ? changelog_path : fallback_path + next unless File.exist?(source_path) + + AndroidPublisher::LocalizedText.new(language: language, text: File.read(source_path, encoding: "UTF-8")) + end +end + +def update_play_track!(client, track_name:, version_code:, version_name:) + release = AndroidPublisher::TrackRelease.new( + name: version_name, + status: play_release_status, + version_codes: [version_code], + release_notes: play_release_notes(version_code) + ) + track = client.tracks(track_name).first || AndroidPublisher::Track.new(track: track_name) + # Google preserves older releases when the edit replaces this list with the newest release. + track.releases = [release] + # Supply::Client owns the active edit; its public API takes (track_name, track_object). + client.update_track(track_name, track) +end + +def upload_play_listing_assets!(client, upload_metadata:, upload_images:, upload_screenshots:) + play_metadata_languages.each do |language| + language_path = File.join(play_metadata_path, language) + if upload_metadata + metadata_fields = Supply::AVAILABLE_METADATA_FIELDS.select { |field| File.exist?(File.join(language_path, "#{field}.txt")) } + unless metadata_fields.empty? + # This returns Supply::Listing, whose save writes through the same active edit. + listing = client.listing_for_language(language) + metadata_fields.each do |field| + listing.public_send("#{field}=", File.read(File.join(language_path, "#{field}.txt"), encoding: "UTF-8")) + end + listing.save + end + end + + if upload_images + Supply::IMAGES_TYPES.each do |image_type| + path = Dir.glob(File.join(language_path, "images", "#{image_type}.{png,jpg,jpeg}"), File::FNM_CASEFOLD).sort.last + client.upload_image(image_path: path, image_type: image_type, language: language) if path + end + end + + next unless upload_screenshots + + Supply::SCREENSHOT_TYPES.each do |screenshot_type| + paths = Dir.glob(File.join(language_path, "images", screenshot_type, "*.{png,jpg,jpeg}"), File::FNM_CASEFOLD).sort + next if paths.empty? + + client.clear_screenshots(image_type: screenshot_type, language: language) + paths.each { |path| client.upload_image(image_path: path, image_type: screenshot_type, language: language) } + end + end +end + +def fastlane_boolean_env(name, default:) + value = ENV[name] + return default if value.nil? + + normalized = value.downcase + return true if ["1", "yes", "true", "on"].include?(normalized) + return false if ["0", "no", "false", "off"].include?(normalized) + + UI.user_error!("#{name} must be true/false, yes/no, on/off, or 1/0.") +end + +def upload_play_builds_atomically!(phone_artifact_path:, wear_artifact_path:, version_metadata:, upload_metadata:, upload_images:, upload_screenshots:) + previous_supply_config = Supply.config + client = nil + begin + Supply.config = { + ack_bundle_installation_warning: fastlane_boolean_env("ACK_BUNDLE_INSTALLATION_WARNING", default: false), + changes_not_sent_for_review: fastlane_boolean_env("SUPPLY_CHANGES_NOT_SENT_FOR_REVIEW", default: false), + rescue_changes_not_sent_for_review: fastlane_boolean_env("SUPPLY_RESCUE_CHANGES_NOT_SENT_FOR_REVIEW", default: true) + } + client = Supply::Client.make_from_config(params: play_auth_options.merge(timeout: (ENV["SUPPLY_TIMEOUT"] || "300").to_i)) + client.begin_edit(package_name: play_package_name) + + phone_version_code = client.upload_bundle(phone_artifact_path) + wear_version_code_value = client.upload_bundle(wear_artifact_path) + expected_phone_version_code = version_metadata.fetch(:version_code) + expected_wear_version_code = wear_version_code(expected_phone_version_code) + UI.user_error!("Uploaded phone AAB versionCode #{phone_version_code}, expected #{expected_phone_version_code}.") unless phone_version_code.to_i == expected_phone_version_code + UI.user_error!("Uploaded Wear AAB versionCode #{wear_version_code_value}, expected #{expected_wear_version_code}.") unless wear_version_code_value.to_i == expected_wear_version_code + + update_play_track!( + client, + track_name: play_track, + version_code: phone_version_code, + version_name: version_metadata.fetch(:version) + ) + update_play_track!( + client, + track_name: wear_play_track, + version_code: wear_version_code_value, + version_name: version_metadata.fetch(:version) + ) + upload_play_listing_assets!( + client, + upload_metadata: upload_metadata, + upload_images: upload_images, + upload_screenshots: upload_screenshots + ) + + if play_validate_only? + client.validate_current_edit! + UI.success("Successfully validated the atomic phone and Wear upload.") + else + client.commit_current_edit! + UI.success("Successfully committed the atomic phone and Wear upload.") + end + ensure + if client&.current_edit + begin + client.abort_current_edit + rescue => error + UI.important("Could not abort Google Play edit after failure: #{error.message}") + end + end + Supply.config = previous_supply_config + end +end + +def play_metadata_path + File.join(ANDROID_FASTLANE_ROOT, "metadata", "android") +end + +def play_screenshot_paths_for_type(screenshot_type) + Dir[File.join(play_metadata_path, "**", "images", screenshot_type, "*.{png,jpg,jpeg}")] +end + +def validate_android_screenshots! + return unless play_screenshot_upload_requested? + + required_types = %w(phoneScreenshots wearScreenshots) + missing_types = required_types.select { |screenshot_type| play_screenshot_paths_for_type(screenshot_type).empty? } + unless missing_types.empty? + UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no screenshots were found for: #{missing_types.join(', ')}.") + end +end + +def release_artifact_path(version) + File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab") +end + +def wear_release_artifact_path(version) + File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-wear-release.aab") +end + +def play_release_artifact_paths(version) + [release_artifact_path(version), wear_release_artifact_path(version)] +end + +def build_release_artifacts! + sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")])) +end + +def capture_android_screenshots! + sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")])) +end + +def mobile_release_ref_script + File.join(repo_root, "scripts", "mobile-release-ref.ts") +end + +def release_git_sha + stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root) + UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success? + stdout.strip +end + +def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil) + args = [ + "node", + "--import", + "tsx", + mobile_release_ref_script, + command, + "--platform", + platform, + "--version", + version, + "--root", + repo_root, + ] + args.push("--build", build.to_s) if build + args.push("--version-code", version_code.to_s) if version_code + args.push("--sha", sha.to_s) if sha + sh(shell_join(args)) +end + +def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil) + mobile_release_ref_command( + "preflight", + platform: platform, + version: version, + build: build, + version_code: version_code, + sha: sha + ) +end + +def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil) + mobile_release_ref_command( + "record", + platform: platform, + version: version, + build: build, + version_code: version_code, + sha: sha + ) +end + +def read_android_release_signing_properties!(path) + UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path) + + properties = {} + File.foreach(path) do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + key, value = stripped.split("=", 2) + next if key.nil? || key.empty? || value.nil? + + properties[key] = value.strip + end + + missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) } + UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty? + + properties +end + +def export_android_release_signing_properties!(path) + read_android_release_signing_properties!(path).each do |key, value| + ENV["ORG_GRADLE_PROJECT_#{key}"] = value + end +end + +def sync_android_release_signing! + sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"])) + export_android_release_signing_properties!(android_release_signing_materialized_properties_path) +end + +def prepare_android_release_signing! + if env_present?(ENV["MATCH_PASSWORD"]) + sync_android_release_signing! + elsif File.exist?(android_release_signing_materialized_properties_path) + export_android_release_signing_properties!(android_release_signing_materialized_properties_path) + end +end + +def validate_android_release_signing! + Dir.chdir(android_root) do + sh(shell_join(["./gradlew", ":app:bundlePlayRelease", ":wear:bundleRelease", "--dry-run"])) + end +end + +def print_android_release_plan!(version_metadata) + UI.message("Android Play release plan:") + UI.message(" package: #{play_package_name}") + UI.message(" track: #{play_track}") + UI.message(" Wear track: #{wear_play_track}") + UI.message(" release_status: #{play_release_status}") + UI.message(" validate_only: #{play_validate_only?}") + UI.message(" versionName: #{version_metadata.fetch(:version)}") + UI.message(" phone versionCode: #{version_metadata.fetch(:version_code)}") + UI.message(" Wear versionCode: #{wear_version_code(version_metadata.fetch(:version_code))}") +end + +def validate_android_release_preflight!(version_metadata) + validate_play_auth! + prepare_android_release_signing! + validate_android_release_signing! + validate_android_release_notes! + print_android_release_plan!(version_metadata) +end + +def upload_play_store_metadata!(version_metadata) + validate_android_screenshots! + sync_android_changelog!(version_metadata.fetch(:version_code)) + + upload_to_play_store( + **play_auth_options, + package_name: play_package_name, + track: play_track, + version_code: version_metadata.fetch(:version_code), + metadata_path: play_metadata_path, + skip_upload_apk: true, + skip_upload_aab: true, + skip_upload_metadata: !play_metadata_upload_requested?, + skip_upload_changelogs: false, + skip_upload_images: !play_image_upload_requested?, + skip_upload_screenshots: !play_screenshot_upload_requested?, + validate_only: play_validate_only? + ) +end + +def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false) + release_sha = release_git_sha + ensure_mobile_release_ref_available!( + platform: "android", + version: version_metadata.fetch(:version), + version_code: version_metadata.fetch(:version_code), + sha: release_sha + ) + ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots + validate_android_screenshots! + sync_android_changelog!(version_metadata.fetch(:version_code)) + artifact_paths = play_release_artifact_paths(version_metadata.fetch(:version)) + missing_artifacts = artifact_paths.reject { |path| File.exist?(path) } + unless missing_artifacts.empty? + UI.user_error!("Missing Play release artifacts at #{missing_artifacts.join(', ')}. Run pnpm android:release:archive first.") + end + + phone_artifact_path, wear_artifact_path = artifact_paths + upload_play_builds_atomically!( + phone_artifact_path: phone_artifact_path, + wear_artifact_path: wear_artifact_path, + version_metadata: version_metadata, + upload_metadata: upload_metadata, + upload_images: upload_images, + upload_screenshots: upload_screenshots + ) + + unless play_validate_only? + record_mobile_release_ref!( + platform: "android", + version: version_metadata.fetch(:version), + version_code: version_metadata.fetch(:version_code), + sha: release_sha + ) + end +end + +load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env")) + +platform :android do + desc "Validate Google Play API credentials" + lane :auth_check do + validate_play_auth! + UI.success("Google Play API credentials are valid.") + end + + desc "Print the Android release signing plan" + lane :signing_plan do + sh(shell_join(["node", android_release_signing_script, "--mode", "plan"])) + end + + desc "Pull encrypted Android release signing assets and validate Gradle release signing" + lane :signing_check do + sync_android_release_signing! + validate_android_release_signing! + UI.success("Android release signing assets are available locally.") + end + + desc "Pull encrypted Android release signing assets from the shared signing repo" + lane :signing_sync_pull do + sync_android_release_signing! + UI.success("Pulled Android release signing assets.") + end + + desc "Create or refresh encrypted Android release signing assets in the shared signing repo" + lane :signing_sync_push do + sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"])) + UI.success("Pushed Android release signing assets.") + end + + desc "Validate Android Play release auth, signing, versioning, and release notes" + lane :release_preflight do + sync_android_versioning! + version_metadata = read_android_version_metadata + validate_android_release_preflight!(version_metadata) + UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).") + end + + desc "Upload Google Play metadata, changelog, and optional screenshots" + lane :metadata do + sync_android_versioning! + version_metadata = read_android_version_metadata + ENV["SUPPLY_UPLOAD_METADATA"] = "1" unless ENV.key?("SUPPLY_UPLOAD_METADATA") + upload_play_store_metadata!(version_metadata) + UI.success("Uploaded Android Play metadata for #{version_metadata[:version]} (#{version_metadata[:version_code]}).") + end + + desc "Build signed Android release artifacts locally without uploading" + lane :play_store_archive do + sync_android_versioning! + prepare_android_release_signing! + build_release_artifacts! + end + + desc "Generate deterministic Android screenshots for Google Play metadata" + lane :screenshots do + capture_android_screenshots! + end + + desc "Upload the signed Play AAB to Google Play" + lane :play_store do + sync_android_versioning! + version_metadata = read_android_version_metadata + upload_play_store_build!(version_metadata) + UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}") + end + + desc "Upload Android metadata, archive release artifacts, then upload the Play AAB" + lane :release_upload do + sync_android_versioning! + version_metadata = read_android_version_metadata + validate_android_release_preflight!(version_metadata) + screenshots + ENV["SUPPLY_UPLOAD_METADATA"] = "1" + ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" + build_release_artifacts! + upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true) + UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}") + UI.important("Production promotion remains manual in Google Play Console.") + end +end diff --git a/fastlane/SETUP.md b/fastlane/SETUP.md new file mode 100644 index 0000000..e5ed400 --- /dev/null +++ b/fastlane/SETUP.md @@ -0,0 +1,130 @@ +# fastlane setup (OpenClaw Android) + +Install: + +```bash +brew install fastlane +``` + +Create a Google Play service account JSON key with Google Play Developer API access, then grant that service account access to the OpenClaw app in Play Console. + +Recommended local auth: + +```bash +GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json +``` + +Optional app targeting: + +```bash +GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app +``` + +Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation: + +```bash +pnpm android:release:signing:plan +MATCH_PASSWORD= pnpm android:release:signing:sync:pull +MATCH_PASSWORD= pnpm android:release:signing:check +``` + +The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process. + +For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`: + +```bash +MATCH_PASSWORD= \ +OPENCLAW_ANDROID_UPLOAD_KEYSTORE= \ +OPENCLAW_ANDROID_SIGNING_PROPERTIES= \ +pnpm android:release:signing:sync:push +``` + +The source signing properties file must contain: + +```properties +OPENCLAW_ANDROID_STORE_PASSWORD= +OPENCLAW_ANDROID_KEY_ALIAS= +OPENCLAW_ANDROID_KEY_PASSWORD= +``` + +Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled. + +Validate auth: + +```bash +cd apps/android +fastlane android auth_check +``` + +Archive locally without upload: + +```bash +pnpm android:release:archive +``` + +This command is for local archive validation only. It is not a fallback upload +path after `pnpm android:release:upload` fails. + +Generate deterministic phone and Wear OS Google Play screenshots: + +```bash +pnpm android:screenshots +``` + +The script creates and boots retained Pixel 2 and Wear OS Large Round AVDs when +needed. Install `system-images;android-36;google_apis;` and +`system-images;android-34;android-wear;` first. Use +`--form-factor phone|wear` with `--avd ` or `--device ` to +capture one form factor from an explicitly selected emulator. + +Upload metadata, release notes, and the Play AAB to the configured Google Play track: + +```bash +pnpm android:release:upload +``` + +Direct Fastlane entry point: + +```bash +cd apps/android +fastlane android release_upload +``` + +Use the direct Fastlane entry point only for maintainer debugging when explicitly +requested. Agent-driven releases must use `pnpm android:release:upload` and stop +if it fails. + +Release rules: + +- `apps/android/version.json` is the pinned Android release version source. +- `apps/android/Config/Version.properties` is generated from that source and read by Gradle. +- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source. +- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`. +- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo. +- `apkCertificateSha256` in that manifest pins the upload certificate accepted for standalone release APKs; rotate it only with the encrypted keystore. +- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds. +- Supported pinned Android versions use CalVer: `YYYY.M.D`. +- Phone `versionCode` uses `YYYYMMDDNN`, where `NN` is `01` through `49`; the matching Wear APK adds `50` and uses `51` through `99`. +- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version. +- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train. +- `pnpm android:version:sync` updates generated version artifacts. +- `pnpm android:version:check` validates checked-in Android version artifacts. +- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded. +- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`. +- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`. +- `pnpm android:screenshots` builds and installs the phone and Wear OS debug + apps, launches deterministic screenshot scenes, and writes Play-ready JPEGs + to the matching `phoneScreenshots` and `wearScreenshots` metadata folders. +- `pnpm android:release:archive` builds the signed phone Play AAB, Wear AAB, and third-party APK into `apps/android/build/release-artifacts/`. +- `pnpm android:release:upload` commits the phone AAB, Wear AAB, metadata, and screenshots in one Google Play edit across the configured phone and `wear:` form-factor tracks. The default tracks are `internal` and `wear:internal`. +- Stable GitHub Release APK publication is separate from Google Play: `OpenClaw Release Publish` dispatches `.github/workflows/android-release.yml`, whose protected `android-release` environment provides `MATCH_PASSWORD`; the repository GitHub App reads the encrypted signing repo. +- Production promotion remains manual in Google Play Console. +- If `pnpm android:release:upload` fails, agent-driven releases must stop and report the failing step. Do not fall back to `pnpm android:release:archive`, `pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts plus Google Play upload commands, or mobile release ref recording. + +Screenshots: + +- Android screenshot capture writes Play screenshots under + `apps/android/fastlane/metadata/android//images/phoneScreenshots/` + and `apps/android/fastlane/metadata/android//images/wearScreenshots/`. +- Set `SUPPLY_UPLOAD_SCREENSHOTS=1` to include those screenshots in `fastlane android metadata`. +- Do not commit generated screenshot captures unless they become intentional store metadata assets. diff --git a/fastlane/metadata/android/en-US/changelogs/2026060201.txt b/fastlane/metadata/android/en-US/changelogs/2026060201.txt new file mode 100644 index 0000000..9d9d084 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026060201.txt @@ -0,0 +1,3 @@ +OpenClaw is now available on Android. + +Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows. diff --git a/fastlane/metadata/android/en-US/changelogs/2026060901.txt b/fastlane/metadata/android/en-US/changelogs/2026060901.txt new file mode 100644 index 0000000..4181288 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026060901.txt @@ -0,0 +1,3 @@ +Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox. + +Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability. diff --git a/fastlane/metadata/android/en-US/changelogs/2026070302.txt b/fastlane/metadata/android/en-US/changelogs/2026070302.txt new file mode 100644 index 0000000..d8583e5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026070302.txt @@ -0,0 +1,7 @@ +Adds a Wear OS companion for sessions, transcripts, text and voice replies, realtime Talk, Gateway controls, notifications, settings, and a launch Tile. + +Adds foreground, on-device Voice Wake with editable Gateway-synced wake words, plus copy and save-as-PNG actions for rendered chat widgets. + +Fixes composer media leaking across chats and malformed agent or profile initials when display names begin with emoji. + +Thanks @sibbl, @IWhatsskill, and @Leon-SK668. diff --git a/fastlane/metadata/android/en-US/changelogs/2026070352.txt b/fastlane/metadata/android/en-US/changelogs/2026070352.txt new file mode 100644 index 0000000..d8583e5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026070352.txt @@ -0,0 +1,7 @@ +Adds a Wear OS companion for sessions, transcripts, text and voice replies, realtime Talk, Gateway controls, notifications, settings, and a launch Tile. + +Adds foreground, on-device Voice Wake with editable Gateway-synced wake words, plus copy and save-as-PNG actions for rendered chat widgets. + +Fixes composer media leaking across chats and malformed agent or profile initials when display names begin with emoji. + +Thanks @sibbl, @IWhatsskill, and @Leon-SK668. diff --git a/fastlane/metadata/android/en-US/changelogs/2026070401.txt b/fastlane/metadata/android/en-US/changelogs/2026070401.txt new file mode 100644 index 0000000..272569a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026070401.txt @@ -0,0 +1,7 @@ +Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk. + +Improves the working claw, collapsible details, Skill Workshop flows, and generated images. + +Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability. + +Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo. diff --git a/fastlane/metadata/android/en-US/changelogs/2026070451.txt b/fastlane/metadata/android/en-US/changelogs/2026070451.txt new file mode 100644 index 0000000..272569a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026070451.txt @@ -0,0 +1,7 @@ +Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk. + +Improves the working claw, collapsible details, Skill Workshop flows, and generated images. + +Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability. + +Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..4e4f9a6 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,18 @@ +OpenClaw is a personal AI assistant you run on your own devices. + +Pair this Android app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, and device-aware automation. + +What you can do: +- Pair with your private OpenClaw Gateway by QR code or setup code +- Chat with your assistant from Android +- Use realtime Talk mode and push-to-talk +- Review Gateway action approvals from your phone +- Enable device capabilities such as camera, screen, location, and notifications when you choose +- Receive push wakes and node status updates for connected workflows + +OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by Android permissions and can be enabled only for the capabilities you want to use. + +Getting started: +1) Set up your OpenClaw Gateway +2) Open the Android app and pair with your gateway +3) Start using chat, Talk mode, approvals, and automations from your phone diff --git a/fastlane/metadata/android/en-US/release_notes.txt b/fastlane/metadata/android/en-US/release_notes.txt new file mode 100644 index 0000000..272569a --- /dev/null +++ b/fastlane/metadata/android/en-US/release_notes.txt @@ -0,0 +1,7 @@ +Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk. + +Improves the working claw, collapsible details, Skill Workshop flows, and generated images. + +Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability. + +Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo. diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..dd704a6 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Personal AI on your Android devices diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..9a5b139 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +OpenClaw diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8309916 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED +# Keep toolchain and plugin deprecations from returning as successful CI noise. +org.gradle.warning.mode=fail +android.useAndroidX=true +android.nonTransitiveRClass=true +android.enableR8.fullMode=true +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false +android.newDsl=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..25bc2f9 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,108 @@ +[versions] +agp = "9.3.1" +androidx-activity = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-benchmark = "1.4.1" +androidx-camera = "1.6.1" +androidx-compose-bom = "2026.06.01" +androidx-core = "1.19.0" +androidx-exifinterface = "1.4.2" +androidx-lifecycle = "2.11.0" +androidx-security = "1.1.0" +androidx-test-ext = "1.3.0" +androidx-test-runner = "1.7.0" +androidx-uiautomator = "2.4.0" +androidx-webkit = "1.16.0" +androidx-wear-compose = "1.6.2" +androidx-wear-input = "1.2.0" +androidx-wear-protolayout = "1.4.1" +androidx-wear-tiles = "1.6.1" +bcprov = "1.85" +commonmark = "0.29.0" +coil = "3.5.0" +coroutines = "1.11.0" +dnsjava = "3.6.5" +junit = "4.13.2" +junit-vintage = "6.1.2" +kotest = "6.2.3" +ksp = "2.3.10" +ktlint-gradle = "14.2.0" +kotlin = "2.4.10" +material = "1.14.0" +media3 = "1.10.1" +okhttp = "5.4.0" +play-services-wearable = "20.0.1" +barcode-scanning = "17.3.0" +robolectric = "4.16.1" +room = "2.8.4" +serialization-json = "1.11.0" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" } +androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "androidx-wear-compose" } +androidx-wear-input = { module = "androidx.wear:wear-input", version.ref = "androidx-wear-input" } +androidx-wear-protolayout = { module = "androidx.wear.protolayout:protolayout", version.ref = "androidx-wear-protolayout" } +androidx-wear-protolayout-material = { module = "androidx.wear.protolayout:protolayout-material3", version.ref = "androidx-wear-protolayout" } +androidx-wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "androidx-wear-tiles" } +bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" } +barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcode-scanning" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } +commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } +dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" } +material = { module = "com.google.android.material:material", version.ref = "material" } +media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ead03bc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,11 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +# Gradle 9.5+ exposes deprecated project-dependency notation inside AGP 9.2.1. +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/scripts/build-release-artifacts.ts b/scripts/build-release-artifacts.ts new file mode 100644 index 0000000..3e8b672 --- /dev/null +++ b/scripts/build-release-artifacts.ts @@ -0,0 +1,447 @@ +#!/usr/bin/env bun +/** + * Android release helper that builds signed release artifacts from the pinned + * version metadata, verifies signatures, and writes SHA-256 checksum files. + */ + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + accessSync, + constants, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { basename, delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts"; + +type ReleaseArtifact = { + flavorName: "play" | "wear" | "third-party"; + kind: "aab" | "apk"; + gradleTask: string; + sourcePath: string; +}; + +type CliOptions = { + artifact: "all" | ReleaseArtifact["flavorName"]; + dryRun: boolean; + verifyApk?: string; +}; + +export type AndroidBuildMetadata = { + commit: string; + timestamp: string; +}; + +type ResolveAndroidBuildMetadataOptions = { + env?: NodeJS.ProcessEnv; + now?: () => Date; + readGitCommit?: () => string; +}; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const androidDir = join(scriptDir, ".."); +const rootDir = join(androidDir, "..", ".."); +const releaseOutputDir = join(androidDir, "build", "release-artifacts"); +const releaseSigningManifestPath = join(androidDir, "Config", "ReleaseSigning.json"); +const fullGitCommitPattern = /^[a-f0-9]{40}$/u; +const isoUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; + +function normalizeFullGitCommit(raw: string): string { + const commit = raw.trim().toLowerCase(); + if (!fullGitCommitPattern.test(commit)) { + throw new Error("Android build metadata requires a full 40-character hexadecimal Git commit"); + } + return commit; +} + +function normalizeIsoUtcTimestamp(raw: string): string { + const timestamp = raw.trim(); + if (!isoUtcTimestampPattern.test(timestamp)) { + throw new Error("OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp"); + } + + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) { + throw new Error("OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp"); + } + const normalized = parsed.toISOString(); + if (normalized.slice(0, 19) !== timestamp.slice(0, 19)) { + throw new Error("OPENCLAW_BUILD_TIMESTAMP must be a valid ISO-8601 UTC timestamp"); + } + return normalized; +} + +function readRepositoryCommit(): string { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + throw new Error("Unable to resolve the Android release Git commit"); + } +} + +export function resolveAndroidBuildMetadata( + options: ResolveAndroidBuildMetadataOptions = {}, +): AndroidBuildMetadata { + const env = options.env ?? process.env; + const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); + let repositoryCommit: string | undefined; + if (!explicitCommit) { + try { + repositoryCommit = (options.readGitCommit ?? readRepositoryCommit)().trim() || undefined; + } catch { + // GitHub's ambient SHA is safe only when there is no readable checkout. + } + } + const commitSource = explicitCommit || repositoryCommit || env.GITHUB_SHA?.trim(); + if (!commitSource) { + throw new Error("Unable to resolve the Android release Git commit"); + } + const commit = normalizeFullGitCommit(commitSource); + + const configuredTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim(); + const timestamp = configuredTimestamp + ? normalizeIsoUtcTimestamp(configuredTimestamp) + : (options.now ?? (() => new Date()))().toISOString(); + + return { commit, timestamp }; +} + +export function androidBuildMetadataGradleArgs(metadata: AndroidBuildMetadata): string[] { + return [ + `-PopenclawBuildCommit=${metadata.commit}`, + `-PopenclawBuildTimestamp=${metadata.timestamp}`, + ]; +} + +export function verifyAndroidReleaseSource( + expectedCommit: string, + options: { + rootDir?: string; + runGit?: (args: string[], cwd: string) => string; + } = {}, +): void { + const cwd = options.rootDir ?? rootDir; + const runGit = + options.runGit ?? + ((args: string[], gitCwd: string) => + execFileSync("git", args, { + cwd: gitCwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + })); + let head: string; + let status: string; + try { + head = normalizeFullGitCommit(runGit(["rev-parse", "HEAD"], cwd)); + status = runGit(["status", "--porcelain", "--untracked-files=all"], cwd).trim(); + } catch { + throw new Error("Android release builds require a readable Git checkout"); + } + if (head !== expectedCommit) { + throw new Error(`Android release commit mismatch: metadata ${expectedCommit}, checkout ${head}`); + } + if (status) { + throw new Error("Android release builds require a clean Git checkout"); + } +} + +function parseArgs(argv: string[]): CliOptions { + let artifact: CliOptions["artifact"] = "all"; + let dryRun = false; + let verifyApk: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--artifact": { + const value = argv[index + 1]; + if (value !== "all" && value !== "play" && value !== "wear" && value !== "third-party") { + throw new Error("--artifact must be one of: all, play, wear, third-party"); + } + artifact = value; + index += 1; + break; + } + case "--dry-run": { + dryRun = true; + break; + } + case "--verify-apk": { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error("Missing value for --verify-apk"); + } + verifyApk = value; + index += 1; + break; + } + case "-h": + case "--help": { + console.log( + [ + "Usage: bun apps/android/scripts/build-release-artifacts.ts [--artifact all|play|wear|third-party] [--dry-run] [--verify-apk PATH]", + "", + "Builds the signed phone, Wear, and third-party Android artifacts.", + ].join("\n"), + ); + process.exit(0); + } + default: { + throw new Error(`Unknown argument: ${arg}`); + } + } + } + + if (verifyApk && (artifact !== "all" || dryRun)) { + throw new Error("--verify-apk cannot be combined with --artifact or --dry-run"); + } + + return { artifact, dryRun, verifyApk }; +} + +function pinnedApkCertificateSha256(): string { + const manifest = JSON.parse(readFileSync(releaseSigningManifestPath, "utf8")) as { + apkCertificateSha256?: unknown; + }; + const fingerprint = manifest.apkCertificateSha256; + if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(fingerprint)) { + throw new Error("ReleaseSigning.json must pin apkCertificateSha256 as 64 lowercase hex digits"); + } + return fingerprint; +} + +function releaseArtifacts(versionName: string): ReleaseArtifact[] { + return [ + { + flavorName: "wear", + kind: "aab", + gradleTask: ":wear:bundleRelease", + sourcePath: join(androidDir, "wear", "build", "outputs", "bundle", "release", "wear-release.aab"), + }, + { + flavorName: "play", + kind: "aab", + gradleTask: ":app:bundlePlayRelease", + sourcePath: join( + androidDir, + "app", + "build", + "outputs", + "bundle", + "playRelease", + "app-play-release.aab", + ), + }, + { + flavorName: "third-party", + kind: "apk", + gradleTask: ":app:assembleThirdPartyRelease", + sourcePath: join( + androidDir, + "app", + "build", + "outputs", + "apk", + "thirdParty", + "release", + `openclaw-${versionName}-thirdParty-release.apk`, + ), + }, + ]; +} + +function sha256Hex(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function writeSha256File(path: string): string { + const hash = sha256Hex(path); + const checksumPath = `${path}.sha256`; + writeFileSync(checksumPath, `${hash} ${basename(path)}\n`); + return hash; +} + +function verifyAabSignature(path: string, expectedCertificateSha256: string): void { + execFileSync("jarsigner", ["-verify", path], { stdio: "ignore" }); + const output = execFileSync("keytool", ["-printcert", "-jarfile", path], { + encoding: "utf8", + env: { ...process.env, LC_ALL: "C", LANG: "C" }, + stdio: ["ignore", "pipe", "inherit"], + }); + const fingerprints = Array.from(output.matchAll(/^\s*SHA256:\s*([a-fA-F0-9:]+)\s*$/gmu)).map( + (match) => match[1]?.replaceAll(":", "").toLowerCase(), + ); + if (fingerprints.length !== 1 || !/^[a-f0-9]{64}$/u.test(fingerprints[0] ?? "")) { + throw new Error(`Expected exactly one SHA-256 signing certificate for ${path}`); + } + if (fingerprints[0] !== expectedCertificateSha256) { + throw new Error( + `AAB signing certificate mismatch for ${path}: expected ${expectedCertificateSha256}, got ${fingerprints[0]}`, + ); + } +} + +function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null { + if (!sdkRoot) { + return null; + } + + const buildToolsDir = join(sdkRoot, "build-tools"); + if (!existsSync(buildToolsDir)) { + return null; + } + + const candidates = readdirSync(buildToolsDir) + .toSorted((left, right) => right.localeCompare(left)) + .map((version) => join(buildToolsDir, version, "apksigner")) + .filter((candidate) => existsSync(candidate)); + + return candidates[0] ?? null; +} + +function resolveApkSigner(): string { + const sdkApkSigner = + resolveApkSignerFromSdk(process.env.ANDROID_HOME) ?? + resolveApkSignerFromSdk(process.env.ANDROID_SDK_ROOT); + if (sdkApkSigner) { + return sdkApkSigner; + } + + for (const pathDir of (process.env.PATH ?? "").split(delimiter)) { + const candidate = join(pathDir, "apksigner"); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + continue; + } + } + + throw new Error("Missing apksigner. Install Android SDK build-tools or put apksigner on PATH."); +} + +function verifyApkSignature(path: string, expectedCertificateSha256: string): void { + const apkSigner = resolveApkSigner(); + let output: string; + try { + output = execFileSync(apkSigner, ["verify", "--print-certs", path], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + } catch { + throw new Error(`apksigner verification failed for ${path}`); + } + + const fingerprints: string[] = []; + for (const match of output.matchAll( + /^Signer #[0-9]+ certificate SHA-256 digest: ([a-fA-F0-9:]+)$/gmu, + )) { + const fingerprint = match[1]; + if (!fingerprint) { + throw new Error(`Malformed SHA-256 signing certificate output for ${path}`); + } + fingerprints.push(fingerprint.replaceAll(":", "").toLowerCase()); + } + if (fingerprints.length !== 1 || !/^[a-f0-9]{64}$/u.test(fingerprints[0] ?? "")) { + throw new Error(`Expected exactly one SHA-256 signing certificate for ${path}`); + } + if (fingerprints[0] !== expectedCertificateSha256) { + throw new Error( + `APK signing certificate mismatch for ${path}: expected ${expectedCertificateSha256}, got ${fingerprints[0]}`, + ); + } +} + +function copyArtifact(sourcePath: string, destinationPath: string): void { + if (!existsSync(sourcePath)) { + throw new Error(`Signed release artifact missing at ${sourcePath}`); + } + + copyFileSync(sourcePath, destinationPath); +} + +function verifyArtifactSignature( + artifact: ReleaseArtifact, + outputPath: string, + expectedCertificateSha256: string, +): void { + if (artifact.kind === "aab") { + verifyAabSignature(outputPath, expectedCertificateSha256); + } else { + verifyApkSignature(outputPath, expectedCertificateSha256); + } +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const expectedCertificateSha256 = pinnedApkCertificateSha256(); + if (options.verifyApk) { + verifyApkSignature(options.verifyApk, expectedCertificateSha256); + console.log(`Verified pinned APK signing certificate: ${options.verifyApk}`); + return; + } + + syncAndroidVersioning({ mode: "check", rootDir }); + const version = resolveAndroidVersion(rootDir); + const buildMetadata = resolveAndroidBuildMetadata(); + const artifacts = releaseArtifacts(version.canonicalVersion).filter( + (artifact) => options.artifact === "all" || artifact.flavorName === options.artifact, + ); + + console.log(`Android versionName: ${version.canonicalVersion}`); + console.log(`Android versionCode: ${version.versionCode}`); + console.log(`Android build commit: ${buildMetadata.commit}`); + console.log(`Android build timestamp: ${buildMetadata.timestamp}`); + for (const artifact of artifacts) { + console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`); + console.log(`Gradle task: ${artifact.gradleTask}`); + } + + if (options.dryRun) { + console.log("Dry run complete. No Gradle tasks were executed."); + return; + } + + verifyAndroidReleaseSource(buildMetadata.commit); + mkdirSync(releaseOutputDir, { recursive: true }); + execFileSync( + "./gradlew", + [ + ...androidBuildMetadataGradleArgs(buildMetadata), + ...artifacts.map((artifact) => artifact.gradleTask), + ], + { + cwd: androidDir, + stdio: "inherit", + }, + ); + + for (const artifact of artifacts) { + const outputPath = join( + releaseOutputDir, + `openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`, + ); + + copyArtifact(artifact.sourcePath, outputPath); + verifyArtifactSignature(artifact, outputPath, expectedCertificateSha256); + const hash = writeSha256File(outputPath); + + console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`); + console.log(`SHA-256 (${artifact.flavorName}): ${hash}`); + } +} + +const isMain = process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false; +if (isMain) { + main(); +} diff --git a/scripts/perf-online-benchmark.sh b/scripts/perf-online-benchmark.sh new file mode 100755 index 0000000..94f0857 --- /dev/null +++ b/scripts/perf-online-benchmark.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +RESULTS_DIR="$ANDROID_DIR/benchmark/results" + +PACKAGE="ai.openclaw.app" +ACTIVITY=".MainActivity" +DEVICE_SERIAL="" +INSTALL_APP="1" +LAUNCH_RUNS="4" +SCREEN_LOOPS="6" +CHAT_LOOPS="8" +POLL_ATTEMPTS="40" +POLL_INTERVAL_SECONDS="0.3" +SCREEN_MODE="transition" +CHAT_MODE="session-switch" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-online-benchmark.sh [options] + +Measures the fully-online Android app path on a connected device/emulator. +Assumes the app can reach a live gateway and will show "Connected" in the UI. + +Options: + --device adb device serial + --package package name (default: ai.openclaw.app) + --activity launch activity (default: .MainActivity) + --skip-install skip :app:installPlayDebug + --launch-runs launch-to-connected runs (default: 4) + --screen-loops screen benchmark loops (default: 6) + --chat-loops chat benchmark loops (default: 8) + --screen-mode transition | scroll (default: transition) + --chat-mode session-switch | scroll (default: session-switch) + -h, --help show help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --device) + DEVICE_SERIAL="${2:-}" + shift 2 + ;; + --package) + PACKAGE="${2:-}" + shift 2 + ;; + --activity) + ACTIVITY="${2:-}" + shift 2 + ;; + --skip-install) + INSTALL_APP="0" + shift + ;; + --launch-runs) + LAUNCH_RUNS="${2:-}" + shift 2 + ;; + --screen-loops) + SCREEN_LOOPS="${2:-}" + shift 2 + ;; + --chat-loops) + CHAT_LOOPS="${2:-}" + shift 2 + ;; + --screen-mode) + SCREEN_MODE="${2:-}" + shift 2 + ;; + --chat-mode) + CHAT_MODE="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "$1 required but missing." >&2 + exit 1 + fi +} + +require_cmd adb +require_cmd awk +require_cmd rg +require_cmd node + +adb_cmd() { + if [[ -n "$DEVICE_SERIAL" ]]; then + adb -s "$DEVICE_SERIAL" "$@" + else + adb "$@" + fi +} + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then + echo "Multiple adb devices found. Pass --device ." >&2 + adb devices -l >&2 + exit 1 +fi + +if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then + echo "Unsupported --screen-mode: $SCREEN_MODE" >&2 + exit 2 +fi + +if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then + echo "Unsupported --chat-mode: $CHAT_MODE" >&2 + exit 2 +fi + +mkdir -p "$RESULTS_DIR" + +timestamp="$(date +%Y%m%d-%H%M%S)" +run_dir="$RESULTS_DIR/online-$timestamp" +mkdir -p "$run_dir" + +cleanup() { + rm -f "$run_dir"/ui-*.xml +} +trap cleanup EXIT + +if [[ "$INSTALL_APP" == "1" ]]; then + ( + cd "$ANDROID_DIR" + ./gradlew :app:installPlayDebug --console=plain >"$run_dir/install.log" 2>&1 + ) +fi + +read -r display_width display_height <<<"$( + adb_cmd shell wm size \ + | awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }' +)" + +if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then + echo "Failed to read device display size." >&2 + exit 1 +fi + +pct_of() { + local total="$1" + local pct="$2" + awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }' +} + +tab_chat_x="$(pct_of "$display_width" "0.31")" +tab_screen_x="$(pct_of "$display_width" "0.69")" +tab_y="$(pct_of "$display_height" "0.93")" +chat_session_y="$(pct_of "$display_height" "0.13")" +chat_session_left_x="$(pct_of "$display_width" "0.16")" +chat_session_right_x="$(pct_of "$display_width" "0.85")" +center_x="$(pct_of "$display_width" "0.50")" +screen_swipe_top_y="$(pct_of "$display_height" "0.27")" +screen_swipe_mid_y="$(pct_of "$display_height" "0.38")" +screen_swipe_low_y="$(pct_of "$display_height" "0.75")" +screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")" +chat_swipe_top_y="$(pct_of "$display_height" "0.29")" +chat_swipe_mid_y="$(pct_of "$display_height" "0.38")" +chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")" + +dump_ui() { + local name="$1" + local file="$run_dir/ui-$name.xml" + adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1 + adb_cmd shell cat "/sdcard/$name.xml" >"$file" + printf '%s\n' "$file" +} + +ui_has() { + local pattern="$1" + local name="$2" + local file + file="$(dump_ui "$name")" + rg -q "$pattern" "$file" +} + +wait_for_pattern() { + local pattern="$1" + local prefix="$2" + for attempt in $(seq 1 "$POLL_ATTEMPTS"); do + if ui_has "$pattern" "$prefix-$attempt"; then + return 0 + fi + sleep "$POLL_INTERVAL_SECONDS" + done + return 1 +} + +ensure_connected() { + if ! wait_for_pattern 'text="Connected"' "connected"; then + echo "App never reached visible Connected state." >&2 + exit 1 + fi +} + +ensure_screen_online() { + adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null + sleep 2 + if ! ui_has 'android\.webkit\.WebView' "screen"; then + echo "Screen benchmark expected a live WebView." >&2 + exit 1 + fi +} + +ensure_chat_online() { + adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null + sleep 2 + if ! ui_has 'Type a message' "chat"; then + echo "Chat benchmark expected the live chat composer." >&2 + exit 1 + fi +} + +capture_mem() { + local file="$1" + adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file" +} + +start_cpu_sampler() { + local file="$1" + local samples="$2" + : >"$file" + ( + for _ in $(seq 1 "$samples"); do + adb_cmd shell top -b -n 1 \ + | awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file" + sleep 0.5 + done + ) & + CPU_SAMPLER_PID="$!" +} + +summarize_cpu() { + local file="$1" + local prefix="$2" + local avg max median count + avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")" + max="$(sort -n "$file" | tail -n 1)" + median="$( + sort -n "$file" \ + | awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }' + )" + count="$(wc -l <"$file" | tr -d ' ')" + printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt" + printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt" + printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt" + printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt" +} + +summarize_mem() { + local file="$1" + local prefix="$2" + awk -v prefix="$prefix" ' + /TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 } + /Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 } + /WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF } + ' "$file" >>"$run_dir/summary.txt" +} + +summarize_gfx() { + local file="$1" + local prefix="$2" + awk -v prefix="$prefix" ' + /Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 } + /Janky frames:/ && $4 ~ /\(/ { + pct=$4 + gsub(/[()%]/, "", pct) + printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct + } + /50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 } + /90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 } + /95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 } + /99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 } + ' "$file" >>"$run_dir/summary.txt" +} + +measure_launch() { + : >"$run_dir/launch-runs.txt" + for run in $(seq 1 "$LAUNCH_RUNS"); do + adb_cmd shell am force-stop "$PACKAGE" >/dev/null + sleep 1 + start_ms="$(node -e 'console.log(Date.now())')" + am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")" + total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')" + connected_ms="timeout" + for _ in $(seq 1 "$POLL_ATTEMPTS"); do + if ui_has 'text="Connected"' "launch-run-$run"; then + now_ms="$(node -e 'console.log(Date.now())')" + connected_ms="$((now_ms - start_ms))" + break + fi + sleep "$POLL_INTERVAL_SECONDS" + done + printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \ + | tee -a "$run_dir/launch-runs.txt" + done + + awk -F'[ =]' ' + /total_time_ms=[0-9]+/ { + value=$4 + sum+=value + count+=1 + if (min==0 || valuemax) max=value + } + END { + if (count==0) exit + printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max + } + ' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt" + + awk -F'[ =]' ' + /connected_ms=[0-9]+/ { + value=$6 + sum+=value + count+=1 + if (min==0 || valuemax) max=value + } + END { + if (count==0) exit + printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max + } + ' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt" +} + +run_screen_benchmark() { + ensure_screen_online + capture_mem "$run_dir/screen-mem-before.txt" + adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null + start_cpu_sampler "$run_dir/screen-cpu.txt" 18 + + if [[ "$SCREEN_MODE" == "transition" ]]; then + for _ in $(seq 1 "$SCREEN_LOOPS"); do + adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null + sleep 1.0 + adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null + sleep 0.8 + done + else + adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null + sleep 1.5 + for _ in $(seq 1 "$SCREEN_LOOPS"); do + adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null + sleep 0.35 + adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null + sleep 0.35 + done + fi + + wait "$CPU_SAMPLER_PID" + adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt" + capture_mem "$run_dir/screen-mem-after.txt" + summarize_gfx "$run_dir/screen-gfx.txt" "screen" + summarize_cpu "$run_dir/screen-cpu.txt" "screen" + summarize_mem "$run_dir/screen-mem-before.txt" "screen.before" + summarize_mem "$run_dir/screen-mem-after.txt" "screen.after" +} + +run_chat_benchmark() { + ensure_chat_online + capture_mem "$run_dir/chat-mem-before.txt" + adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null + start_cpu_sampler "$run_dir/chat-cpu.txt" 18 + + if [[ "$CHAT_MODE" == "session-switch" ]]; then + for _ in $(seq 1 "$CHAT_LOOPS"); do + adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null + sleep 0.8 + adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null + sleep 0.8 + done + else + for _ in $(seq 1 "$CHAT_LOOPS"); do + adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null + sleep 0.35 + adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null + sleep 0.35 + done + fi + + wait "$CPU_SAMPLER_PID" + adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt" + capture_mem "$run_dir/chat-mem-after.txt" + summarize_gfx "$run_dir/chat-gfx.txt" "chat" + summarize_cpu "$run_dir/chat-cpu.txt" "chat" + summarize_mem "$run_dir/chat-mem-before.txt" "chat.before" + summarize_mem "$run_dir/chat-mem-after.txt" "chat.after" +} + +printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt" +printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt" +printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt" +printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt" +printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt" +printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt" +printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt" + +ensure_connected +measure_launch +ensure_connected +run_screen_benchmark +ensure_connected +run_chat_benchmark + +printf 'results_dir=%s\n' "$run_dir" +cat "$run_dir/summary.txt" diff --git a/scripts/perf-startup-benchmark.sh b/scripts/perf-startup-benchmark.sh new file mode 100755 index 0000000..b85ec22 --- /dev/null +++ b/scripts/perf-startup-benchmark.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +RESULTS_DIR="$ANDROID_DIR/benchmark/results" +CLASS_FILTER="ai.openclaw.app.benchmark.StartupMacrobenchmark#coldStartup" +BASELINE_JSON="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-startup-benchmark.sh [--baseline ] + +Runs cold-start macrobenchmark only, then prints a compact summary. +Also saves a timestamped snapshot JSON under benchmark/results/. +If --baseline is omitted, compares against latest previous snapshot when available. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --baseline) + BASELINE_JSON="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v jq >/dev/null 2>&1; then + echo "jq required but missing." >&2 + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb required but missing." >&2 + exit 1 +fi + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +mkdir -p "$RESULTS_DIR" + +run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)" +trap 'rm -f "$run_log"' EXIT + +cd "$ANDROID_DIR" + +./gradlew :benchmark:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \ + --console=plain \ + >"$run_log" 2>&1 + +latest_json="$( + find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \ + -name '*benchmarkData.json' -type f \ + | while IFS= read -r file; do + printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file" + done \ + | sort -nr \ + | head -n1 \ + | cut -f2- +)" + +if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then + echo "benchmarkData.json not found after run." >&2 + tail -n 120 "$run_log" >&2 + exit 1 +fi + +timestamp="$(date +%Y%m%d-%H%M%S)" +snapshot_json="$RESULTS_DIR/startup-$timestamp.json" +cp "$latest_json" "$snapshot_json" + +median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")" +min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")" +max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")" +cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")" +device="$(jq -r '.context.build.model' "$snapshot_json")" +sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")" +runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")" + +printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \ + "$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk" +echo "snapshot_json=$snapshot_json" + +if [[ -z "$BASELINE_JSON" ]]; then + BASELINE_JSON="$( + find "$RESULTS_DIR" -name 'startup-*.json' -type f \ + | while IFS= read -r file; do + if [[ "$file" == "$snapshot_json" ]]; then + continue + fi + printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file" + done \ + | sort -nr \ + | head -n1 \ + | cut -f2- + )" +fi + +if [[ -n "$BASELINE_JSON" ]]; then + if [[ ! -f "$BASELINE_JSON" ]]; then + echo "Baseline file missing: $BASELINE_JSON" >&2 + exit 1 + fi + base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")" + delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')" + delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')" + echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%" +fi diff --git a/scripts/perf-startup-hotspots.sh b/scripts/perf-startup-hotspots.sh new file mode 100755 index 0000000..ae06f0e --- /dev/null +++ b/scripts/perf-startup-hotspots.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +PACKAGE="ai.openclaw.app" +ACTIVITY=".MainActivity" +DURATION_SECONDS="10" +OUTPUT_PERF_DATA="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-startup-hotspots.sh [--package ] [--activity ] [--duration ] [--out ] + +Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries. +Default package/activity target OpenClaw Android startup. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --package) + PACKAGE="${2:-}" + shift 2 + ;; + --activity) + ACTIVITY="${2:-}" + shift 2 + ;; + --duration) + DURATION_SECONDS="${2:-}" + shift 2 + ;; + --out) + OUTPUT_PERF_DATA="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v uv >/dev/null 2>&1; then + echo "uv required but missing." >&2 + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb required but missing." >&2 + exit 1 +fi + +if [[ -z "$OUTPUT_PERF_DATA" ]]; then + OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data" +fi + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +simpleperf_dir="" +if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then + simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf" +elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then + simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf" +else + latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)" + if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then + simpleperf_dir="$latest_simpleperf" + fi +fi + +if [[ -z "$simpleperf_dir" ]]; then + echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2 + exit 1 +fi + +app_profiler="$simpleperf_dir/app_profiler.py" +report_py="$simpleperf_dir/report.py" +ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)" + +tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)" +trap 'rm -rf "$tmp_dir"' EXIT + +capture_log="$tmp_dir/capture.log" +dso_csv="$tmp_dir/dso.csv" +symbols_csv="$tmp_dir/symbols.csv" +children_txt="$tmp_dir/children.txt" + +cd "$ANDROID_DIR" +./gradlew :app:installPlayDebug --console=plain >"$tmp_dir/install.log" 2>&1 + +if ! uv run --no-project python3 "$app_profiler" \ + -p "$PACKAGE" \ + -a "$ACTIVITY" \ + -o "$OUTPUT_PERF_DATA" \ + --ndk_path "$ndk_path" \ + -r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \ + >"$capture_log" 2>&1; then + echo "simpleperf capture failed. tail(capture_log):" >&2 + tail -n 120 "$capture_log" >&2 + exit 1 +fi + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --sort dso \ + --csv \ + --csv-separator "|" \ + --include-process-name "$PACKAGE" \ + >"$dso_csv" 2>"$tmp_dir/report-dso.err" + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --sort dso,symbol \ + --csv \ + --csv-separator "|" \ + --include-process-name "$PACKAGE" \ + >"$symbols_csv" 2>"$tmp_dir/report-symbols.err" + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --children \ + --sort dso,symbol \ + -n \ + --percent-limit 0.2 \ + --include-process-name "$PACKAGE" \ + >"$children_txt" 2>"$tmp_dir/report-children.err" + +clean_csv() { + awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1" +} + +echo "perf_data=$OUTPUT_PERF_DATA" +echo +echo "top_dso_self:" +clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}' +echo +echo "top_symbols_self:" +clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}' +echo +echo "app_path_clues_children:" +rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true diff --git a/scripts/voice-e2e.sh b/scripts/voice-e2e.sh new file mode 100755 index 0000000..b09ea6c --- /dev/null +++ b/scripts/voice-e2e.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +ANDROID_DIR="$ROOT_DIR/apps/android" +PACKAGE_NAME="ai.openclaw.app" +RECEIVER="$PACKAGE_NAME/.VoiceE2eReceiver" +RUN_ACTION="ai.openclaw.app.debug.RUN_VOICE_E2E" +OPEN_ACTION="ai.openclaw.app.debug.OPEN_VOICE_E2E" +PORT=18789 +HOST="127.0.0.1" +MODE="both" +TRANSCRIPT="Reply exactly: Android voice e2e normal path ok." +REALTIME_ASSISTANT="Android realtime voice e2e relay path ok." +TIMEOUT_MS=60000 +INSTALL=1 +CONNECT=1 +CLEANUP=0 +START_GATEWAY=0 + +usage() { + cat <<'USAGE' +Usage: apps/android/scripts/voice-e2e.sh [options] + +Options: + --mode connect|normal|realtime|both + Gateway probe or voice path to test. Default: both. + --transcript TEXT Synthetic user transcript for the voice turn. + --realtime-assistant TEXT Synthetic realtime assistant relay text. + --host HOST Gateway host visible from Android. Default: 127.0.0.1. + --port PORT Gateway port. Default: 18789. + --timeout-ms MS Per-mode timeout. Default: 60000. + --skip-install Reuse the installed debug app. + --no-connect Do not rewrite manual gateway settings. + --start-gateway Start a temporary local gateway with bws_get_secret. + --cleanup Stop voice capture after screenshots. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + MODE="$2" + shift 2 + ;; + --transcript) + TRANSCRIPT="$2" + shift 2 + ;; + --realtime-assistant) + REALTIME_ASSISTANT="$2" + shift 2 + ;; + --host) + HOST="$2" + shift 2 + ;; + --port) + PORT="$2" + shift 2 + ;; + --timeout-ms) + TIMEOUT_MS="$2" + shift 2 + ;; + --skip-install) + INSTALL=0 + shift + ;; + --no-connect) + CONNECT=0 + shift + ;; + --start-gateway) + START_GATEWAY=1 + shift + ;; + --cleanup) + CLEANUP=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home}" +export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" +export PATH="/opt/homebrew/opt/openjdk@17/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + +ARTIFACT_DIR="/tmp/openclaw-android-voice-e2e-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$ARTIFACT_DIR" + +cleanup_gateway() { + if [[ -n "${GATEWAY_PID:-}" ]]; then + kill "$GATEWAY_PID" >/dev/null 2>&1 || true + fi +} +trap cleanup_gateway EXIT + +if ! adb devices -l | awk 'NR > 1 && $2 == "device" { found = 1 } END { exit(found ? 0 : 1) }'; then + echo "no authorized Android device found" >&2 + adb devices -l >&2 + exit 1 +fi + +adb reverse "tcp:$PORT" "tcp:$PORT" >/dev/null + +if [[ "$START_GATEWAY" -eq 1 ]]; then + if command -v bws_get_secret >/dev/null 2>&1; then + OPENCLAW_OPENAI_API_KEY="$(bws_get_secret OPENCLAW_OPENAI_API_KEY)" + else + OPENCLAW_OPENAI_API_KEY="$(zsh -ic 'bws_get_secret OPENCLAW_OPENAI_API_KEY')" + fi + ( + cd "$ROOT_DIR" + OPENAI_API_KEY="$OPENCLAW_OPENAI_API_KEY" \ + pnpm openclaw gateway run \ + --port "$PORT" \ + --auth none \ + --bind loopback \ + --force \ + --allow-unconfigured \ + --ws-log compact + ) >"$ARTIFACT_DIR/gateway.log" 2>&1 & + GATEWAY_PID=$! + sleep 4 + if ! kill -0 "$GATEWAY_PID" >/dev/null 2>&1; then + cat "$ARTIFACT_DIR/gateway.log" >&2 + exit 1 + fi + unset OPENCLAW_OPENAI_API_KEY +fi + +if [[ "$INSTALL" -eq 1 ]]; then + (cd "$ANDROID_DIR" && ./gradlew :app:installPlayDebug) +fi + +adb shell pm grant "$PACKAGE_NAME" android.permission.RECORD_AUDIO >/dev/null 2>&1 || true +adb shell am force-stop "$PACKAGE_NAME" >/dev/null +adb shell am start -a "$OPEN_ACTION" -n "$PACKAGE_NAME/.MainActivity" >/dev/null +adb logcat -c + +run_mode() { + local test_mode="$1" + local result_name="$ARTIFACT_DIR/result-$test_mode.json" + local screenshot_name="$ARTIFACT_DIR/screen-$test_mode.png" + local transcript_base64 + local realtime_assistant_base64 + transcript_base64="$(printf '%s' "$TRANSCRIPT" | base64 | tr -d '\n')" + realtime_assistant_base64="$(printf '%s' "$REALTIME_ASSISTANT" | base64 | tr -d '\n')" + + adb shell run-as "$PACKAGE_NAME" rm -f cache/voice_e2e_result.json >/dev/null 2>&1 || true + local no_connect_flag=true + if [[ "$CONNECT" -eq 1 ]]; then + no_connect_flag=false + fi + + adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \ + -a "$RUN_ACTION" \ + -n "$RECEIVER" \ + --es mode "$test_mode" \ + --ez noConnect "$no_connect_flag" \ + --es host "$HOST" \ + --ei port "$PORT" \ + --ez tls false \ + --el timeoutMs "$TIMEOUT_MS" \ + --el connectTimeoutMs "$TIMEOUT_MS" \ + --es transcriptBase64 "$transcript_base64" \ + --es realtimeAssistantBase64 "$realtime_assistant_base64" >/dev/null + + local deadline=$((SECONDS + TIMEOUT_MS / 1000 + 20)) + local result="" + while [[ "$SECONDS" -lt "$deadline" ]]; do + result="$(adb shell run-as "$PACKAGE_NAME" cat cache/voice_e2e_result.json 2>/dev/null | tr -d '\r' || true)" + if [[ -n "$result" ]]; then + break + fi + sleep 1 + done + + if [[ -z "$result" ]]; then + echo "voice e2e $test_mode timed out waiting for result" >&2 + exit 1 + fi + printf '%s\n' "$result" >"$result_name" + adb exec-out screencap -p >"$screenshot_name" + if ! grep -q '"ok":true' "$result_name"; then + echo "voice e2e $test_mode failed: $result" >&2 + exit 1 + fi +} + +case "$MODE" in + both) + run_mode normal + run_mode realtime + ;; + normal|dictation) + run_mode normal + ;; + realtime|talk) + run_mode realtime + ;; + connect) + run_mode connect + ;; + *) + echo "unknown mode: $MODE" >&2 + exit 2 + ;; +esac + +adb logcat -d -v time | + rg -i 'OpenClaw|TalkMode|MicCapture|AudioRecord|SpeechRecognizer|realtime|talk.session|appendAudio|transcript|Talk failed|Transcription failed|Speech network|VoiceE2E' | + tail -250 >"$ARTIFACT_DIR/logcat.txt" || true + +if [[ "$CLEANUP" -eq 1 ]]; then + adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null +fi + +echo "$ARTIFACT_DIR" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..79aeab2 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "OpenClawNodeAndroid" +include(":app") +include(":benchmark") +include(":wear") +include(":wear-shared") diff --git a/style.md b/style.md new file mode 100644 index 0000000..0c824a5 --- /dev/null +++ b/style.md @@ -0,0 +1,113 @@ +# OpenClaw Android UI Style Guide + +Scope: all native Android UI in `apps/android` (Jetpack Compose). +Goal: one coherent visual system across onboarding, settings, and future screens. + +## 1. Design Direction + +- Clean, quiet surfaces. +- Strong readability first. +- One clear primary action per screen state. +- Progressive disclosure for advanced controls. +- Deterministic flows: validate early, fail clearly. + +## 2. Style Baseline + +The onboarding flow defines the current visual baseline. +New screens should match that language unless there is a strong product reason not to. + +Baseline traits: + +- Light neutral background with subtle depth. +- Clear blue accent for active/primary states. +- Strong border hierarchy for structure. +- Medium/semibold typography (no thin text). +- Divider-and-spacing layout over heavy card nesting. + +## 3. Core Tokens + +Use these as shared design tokens for new Compose UI. + +- Background gradient: `#FFFFFF`, `#F7F8FA`, `#EFF1F5` +- Surface: `#F6F7FA` +- Border: `#E5E7EC` +- Border strong: `#D6DAE2` +- Text primary: `#17181C` +- Text secondary: `#4D5563` +- Text tertiary: `#8A92A2` +- Accent primary: `#1D5DD8` +- Accent soft: `#ECF3FF` +- Success: `#2F8C5A` +- Warning: `#C8841A` + +Rule: do not introduce random per-screen colors when an existing token fits. + +## 4. Typography + +Primary type family: Manrope (`400/500/600/700`). + +Recommended scale: + +- Display: `34sp / 40sp`, bold +- Section title: `24sp / 30sp`, semibold +- Headline/action: `16sp / 22sp`, semibold +- Body: `15sp / 22sp`, medium +- Callout/helper: `14sp / 20sp`, medium +- Caption 1: `12sp / 16sp`, medium +- Caption 2: `11sp / 14sp`, medium + +Use monospace only for commands, setup codes, endpoint-like values. +Hard rule: avoid ultra-thin weights on light backgrounds. + +## 5. Layout And Spacing + +- Respect safe drawing insets. +- Keep content hierarchy mostly via spacing + dividers. +- Prefer vertical rhythm from `8/10/12/14/20dp`. +- Use pinned bottom actions for multi-step or high-importance flows. +- Avoid unnecessary container nesting. + +## 6. Buttons And Actions + +- Primary action: filled accent button, visually dominant. +- Secondary action: lower emphasis (outlined/text/surface button). +- Icon-only buttons must remain legible and >=44dp target. +- Back buttons in action rows use rounded-square shape, not circular by default. + +## 7. Inputs And Forms + +- Always show explicit label or clear context title. +- Keep helper copy short and actionable. +- Validate before advancing steps. +- Prefer immediate inline errors over hidden failure states. +- Keep optional advanced fields explicit (`Manual`, `Advanced`, etc.). + +## 8. Progress And Multi-Step Flows + +- Use clear step count (`Step X of N`). +- Use labeled progress rail/indicator when steps are discrete. +- Keep navigation predictable: back/next behavior should never surprise. + +## 9. Accessibility + +- Minimum practical touch target: `44dp`. +- Do not rely on color alone for status. +- Preserve high contrast for all text tiers. +- Add meaningful `contentDescription` for icon-only controls. + +## 10. Architecture Rules + +- Durable UI state in `MainViewModel`. +- Composables: state in, callbacks out. +- No business/network logic in composables. +- Keep side effects explicit (`LaunchedEffect`, activity result APIs). + +## 11. Source Of Truth + +- `app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt` +- `app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt` +- `app/src/main/java/ai/openclaw/app/ui/RootScreen.kt` +- `app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt` +- `app/src/main/java/ai/openclaw/app/MainViewModel.kt` + +If style and implementation diverge, update both in the same change. diff --git a/version.json b/version.json new file mode 100644 index 0000000..f5633ae --- /dev/null +++ b/version.json @@ -0,0 +1,4 @@ +{ + "version": "2026.7.4", + "versionCode": 2026070401 +} diff --git a/wear-shared/build.gradle.kts b/wear-shared/build.gradle.kts new file mode 100644 index 0000000..435caed --- /dev/null +++ b/wear-shared/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "ai.openclaw.wear.shared" + compileSdk = 37 + + defaultConfig { + minSdk = 31 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + lint { + warningsAsErrors = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +ktlint { + android.set(true) + ignoreFailures.set(false) + filter { + exclude("**/build/**") + } +} + +dependencies { + api(libs.kotlinx.serialization.json) + + testImplementation(libs.junit) +} diff --git a/wear-shared/src/main/AndroidManifest.xml b/wear-shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/wear-shared/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt b/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt new file mode 100644 index 0000000..3a3bd71 --- /dev/null +++ b/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt @@ -0,0 +1,363 @@ +package ai.openclaw.wear.shared + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import java.nio.charset.CharacterCodingException +import java.security.MessageDigest + +object WearProtocol { + const val VERSION = 1 + const val REQUEST_PATH = "/openclaw/wear/v1/request" + const val RESPONSE_PATH = "/openclaw/wear/v1/response" + const val EVENT_PATH = "/openclaw/wear/v1/event" + const val LEGACY_REALTIME_AUDIO_CHANNEL_PATH = "/openclaw/wear/v1/realtime/audio" + const val REALTIME_AUDIO_CHANNEL_PATH_PREFIX = "/openclaw/wear/v1/realtime/audio/" + const val PHONE_CAPABILITY = "openclaw_phone_proxy_v1" + const val WATCH_CAPABILITY = "openclaw_wear_companion_v1" + + // MessageClient has a 100 KiB ceiling. Keep headroom for transport metadata and + // force transcript pagination instead of depending on an edge-sized message. + const val MAX_MESSAGE_BYTES = 64 * 1024 + const val MAX_REALTIME_AUDIO_FRAME_BYTES = 8 * 1024 + const val REALTIME_AUDIO_SAMPLE_RATE_HZ = 24_000 + const val REALTIME_AUDIO_FRAME_MILLIS = 20 + const val RPC_REQUEST_TIMEOUT_MILLIS = 10_000L + + // The Watch opens the audio channel before sending talk.start. Keep the + // pending phone-side channel through that RPC deadline plus setup margin. + const val REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS = RPC_REQUEST_TIMEOUT_MILLIS + 5_000L + + // Bound recursive JSON parsing at the untrusted Data Layer boundary. + const val MAX_JSON_DEPTH = 32 + + fun realtimeAudioChannelPath(attemptId: String): String { + require(attemptId.isNotBlank()) + val digest = MessageDigest.getInstance("SHA-256").digest(attemptId.encodeToByteArray()) + return buildString(REALTIME_AUDIO_CHANNEL_PATH_PREFIX.length + digest.size * 2) { + append(REALTIME_AUDIO_CHANNEL_PATH_PREFIX) + digest.forEach { byte -> + val value = byte.toInt() and 0xff + append(LOWER_HEX[value ushr 4]) + append(LOWER_HEX[value and 0x0f]) + } + } + } + + fun isRealtimeAudioChannelPath(path: String): Boolean { + if (path == LEGACY_REALTIME_AUDIO_CHANNEL_PATH) return true + return isAttemptScopedRealtimeAudioChannelPath(path) + } + + fun isAttemptScopedRealtimeAudioChannelPath(path: String): Boolean { + if (!path.startsWith(REALTIME_AUDIO_CHANNEL_PATH_PREFIX)) return false + val token = path.substring(REALTIME_AUDIO_CHANNEL_PATH_PREFIX.length) + return token.length == REALTIME_AUDIO_ATTEMPT_TOKEN_CHARS && + token.all { char -> char in '0'..'9' || char in 'a'..'f' } + } + + private const val REALTIME_AUDIO_ATTEMPT_TOKEN_CHARS = 64 + private const val LOWER_HEX = "0123456789abcdef" +} + +enum class WearProxyCapability( + val wireValue: String, +) { + AgentControls(wireValue = "agent-controls"), + GatewayControls(wireValue = "gateway-controls"), + ModelControls(wireValue = "model-controls"), + SessionSelectionLookup(wireValue = "session-selection-lookup"), + AttemptScopedRealtimeAudio(wireValue = "attempt-scoped-realtime-audio"), + ; + + companion object { + fun fromWireValue(value: String): WearProxyCapability? = entries.firstOrNull { capability -> capability.wireValue == value } + } +} + +enum class WearConnectionFailure( + val wireValue: String, +) { + GatewayOffline(wireValue = "gateway_offline"), + Incompatible(wireValue = "incompatible"), + ; + + companion object { + fun fromWireValue(value: String?): WearConnectionFailure? = entries.firstOrNull { failure -> failure.wireValue == value } + } +} + +@Serializable +enum class WearRpcMethod { + @SerialName("proxy.status") + ProxyStatus, + + @SerialName("sessions.list") + SessionsList, + + @SerialName("agents.list") + AgentsList, + + @SerialName("agents.select") + AgentsSelect, + + @SerialName("models.list") + ModelsList, + + @SerialName("models.select") + ModelsSelect, + + @SerialName("gateway.connect") + GatewayConnect, + + @SerialName("gateway.disconnect") + GatewayDisconnect, + + @SerialName("chat.history") + ChatHistory, + + @SerialName("chat.send") + ChatSend, + + @SerialName("chat.abort") + ChatAbort, + + @SerialName("talk.start") + TalkStart, + + @SerialName("talk.stop") + TalkStop, +} + +@Serializable +enum class WearEventType { + @SerialName("chat") + Chat, + + @SerialName("connection") + Connection, + + @SerialName("resync") + Resync, + + @SerialName("talk") + Talk, +} + +@Serializable +sealed interface WearMessage { + val version: Int + + @Serializable + @SerialName("request") + data class Request( + override val version: Int = WearProtocol.VERSION, + val requestId: String, + val method: WearRpcMethod, + val params: JsonObject = buildJsonObject {}, + ) : WearMessage + + @Serializable + @SerialName("response") + data class Response( + override val version: Int = WearProtocol.VERSION, + val requestId: String, + val ok: Boolean, + val result: JsonElement? = null, + val error: WearRpcError? = null, + val eventStreamId: String? = null, + val eventSequence: Long? = null, + ) : WearMessage + + @Serializable + @SerialName("event") + data class Event( + override val version: Int = WearProtocol.VERSION, + val streamId: String? = null, + val sequence: Long, + val event: WearEventType, + val payload: JsonElement? = null, + ) : WearMessage +} + +@Serializable +data class WearRpcError( + val code: String, + val message: String, +) + +enum class WearDecodeFailureReason { + Empty, + TooLarge, + TooDeep, + Malformed, + UnsupportedVersion, + InvalidEnvelope, +} + +sealed interface WearDecodeResult { + data class Success( + val message: WearMessage, + ) : WearDecodeResult + + data class Failure( + val reason: WearDecodeFailureReason, + ) : WearDecodeResult +} + +object WearProtocolCodec { + private val json = + Json { + classDiscriminator = "type" + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = true + } + + fun encode(message: WearMessage): ByteArray { + requireValid(message) + require(hasValidPayloadDepth(message)) { + "Wear message exceeds JSON depth ${WearProtocol.MAX_JSON_DEPTH}" + } + val encoded = json.encodeToString(WearMessage.serializer(), message) + require(!exceedsJsonDepth(encoded)) { + "Wear message exceeds JSON depth ${WearProtocol.MAX_JSON_DEPTH}" + } + val bytes = + encoded.encodeToByteArray(throwOnInvalidSequence = true) + require(bytes.size <= WearProtocol.MAX_MESSAGE_BYTES) { + "Wear message exceeds ${WearProtocol.MAX_MESSAGE_BYTES} bytes" + } + return bytes + } + + private fun hasValidPayloadDepth(message: WearMessage): Boolean { + val payloads = + when (message) { + is WearMessage.Request -> listOf(message.params) + is WearMessage.Response -> listOfNotNull(message.result) + is WearMessage.Event -> listOfNotNull(message.payload) + } + return payloads.all { element -> hasValidElementDepth(element, parentDepth = 1) } + } + + private fun hasValidElementDepth( + element: JsonElement, + parentDepth: Int, + ): Boolean { + val pending = ArrayDeque>() + pending.addLast(element to parentDepth) + while (pending.isNotEmpty()) { + val (current, parent) = pending.removeLast() + val children = + when (current) { + is JsonArray -> current + is JsonObject -> current.values + else -> continue + } + val depth = parent + 1 + if (depth > WearProtocol.MAX_JSON_DEPTH) return false + children.forEach { child -> pending.addLast(child to depth) } + } + return true + } + + fun decode(bytes: ByteArray): WearDecodeResult { + if (bytes.isEmpty()) return WearDecodeResult.Failure(WearDecodeFailureReason.Empty) + if (bytes.size > WearProtocol.MAX_MESSAGE_BYTES) { + return WearDecodeResult.Failure(WearDecodeFailureReason.TooLarge) + } + + val text = + try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: CharacterCodingException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } + if (exceedsJsonDepth(text)) { + return WearDecodeResult.Failure(WearDecodeFailureReason.TooDeep) + } + val root = + try { + json.parseToJsonElement(text).jsonObject + } catch (_: SerializationException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } catch (_: IllegalArgumentException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } + val version = + (root["version"] as? JsonPrimitive)?.intOrNull + ?: return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + if (version != WearProtocol.VERSION) { + return WearDecodeResult.Failure(WearDecodeFailureReason.UnsupportedVersion) + } + val message = + try { + json.decodeFromJsonElement(WearMessage.serializer(), root) + } catch (_: SerializationException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } catch (_: IllegalArgumentException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } + if (!isValid(message)) { + return WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope) + } + return WearDecodeResult.Success(message) + } + + private fun exceedsJsonDepth(text: String): Boolean { + var depth = 0 + var inString = false + var escaped = false + for (character in text) { + if (inString) { + when { + escaped -> escaped = false + character == '\\' -> escaped = true + character == '"' -> inString = false + } + continue + } + + when (character) { + '"' -> inString = true + '{', '[' -> { + depth += 1 + if (depth > WearProtocol.MAX_JSON_DEPTH) return true + } + '}', ']' -> depth -= 1 + } + } + return false + } + + private fun requireValid(message: WearMessage) { + require(message.version == WearProtocol.VERSION) { "Unsupported Wear protocol version: ${message.version}" } + require(isValid(message)) { "Invalid Wear protocol envelope" } + } + + private fun isValid(message: WearMessage): Boolean = + when (message) { + is WearMessage.Request -> message.requestId.isNotBlank() + is WearMessage.Response -> + message.requestId.isNotBlank() && + (message.eventStreamId == null || message.eventStreamId.isNotBlank()) && + (message.eventSequence == null || message.eventSequence >= 0) && + if (message.ok) { + message.error == null + } else { + message.error != null && message.result == null && message.error.code.isNotBlank() + } + is WearMessage.Event -> + (message.streamId == null || message.streamId.isNotBlank()) && + message.sequence >= 0 + } +} diff --git a/wear-shared/src/main/java/ai/openclaw/wear/shared/WearRealtimeTalk.kt b/wear-shared/src/main/java/ai/openclaw/wear/shared/WearRealtimeTalk.kt new file mode 100644 index 0000000..098bf46 --- /dev/null +++ b/wear-shared/src/main/java/ai/openclaw/wear/shared/WearRealtimeTalk.kt @@ -0,0 +1,121 @@ +package ai.openclaw.wear.shared + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException +import java.io.InputStream +import java.io.OutputStream + +@Serializable +data class WearRealtimeTalkSnapshot( + val attemptId: String? = null, + val active: Boolean = false, + val listening: Boolean = false, + val speaking: Boolean = false, + val status: WearRealtimeTalkStatus = WearRealtimeTalkStatus.OFF, + val statusText: String = "Off", + val conversation: List = emptyList(), +) + +@Serializable +data class WearRealtimeTalkEntry( + val id: String, + val role: WearRealtimeTalkRole, + val text: String, + val streaming: Boolean = false, +) + +@Serializable +enum class WearRealtimeTalkRole { USER, ASSISTANT } + +@Serializable +enum class WearRealtimeTalkStatus { OFF, CONNECTING, LISTENING, THINKING, SPEAKING, ERROR } + +object WearRealtimeTalkCodec { + private val json = + Json { + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = true + } + + fun encode(snapshot: WearRealtimeTalkSnapshot): JsonElement = json.encodeToJsonElement(WearRealtimeTalkSnapshot.serializer(), snapshot) + + fun decode(payload: JsonElement): WearRealtimeTalkSnapshot = json.decodeFromJsonElement(WearRealtimeTalkSnapshot.serializer(), payload) +} + +enum class WearRealtimeAudioFrameType( + val wireValue: Int, +) { + INPUT_PCM(1), + OUTPUT_PCM(2), + CLEAR_OUTPUT(3), + ; + + companion object { + fun fromWireValue(value: Int): WearRealtimeAudioFrameType? = entries.firstOrNull { it.wireValue == value } + } +} + +data class WearRealtimeAudioFrame( + val type: WearRealtimeAudioFrameType, + val payload: ByteArray, +) + +object WearRealtimeAudioFraming { + fun write( + output: OutputStream, + type: WearRealtimeAudioFrameType, + payload: ByteArray, + ) { + requireValid(type, payload) + DataOutputStream(output).apply { + writeByte(type.wireValue) + writeInt(payload.size) + write(payload) + flush() + } + } + + fun read(input: InputStream): WearRealtimeAudioFrame? { + val stream = DataInputStream(input) + val typeValue = stream.read() + if (typeValue < 0) return null + val type = + WearRealtimeAudioFrameType.fromWireValue(typeValue) + ?: throw IllegalArgumentException("Unknown Wear realtime audio frame type") + val size = + try { + stream.readInt() + } catch (err: EOFException) { + throw IllegalArgumentException("Truncated Wear realtime audio frame", err) + } + if (size < 0 || size > WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES) { + throw IllegalArgumentException("Invalid Wear realtime audio frame size") + } + val payload = ByteArray(size) + try { + stream.readFully(payload) + } catch (err: EOFException) { + throw IllegalArgumentException("Truncated Wear realtime audio payload", err) + } + requireValid(type, payload) + return WearRealtimeAudioFrame(type, payload) + } + + private fun requireValid( + type: WearRealtimeAudioFrameType, + payload: ByteArray, + ) { + require(payload.size <= WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES) + when (type) { + WearRealtimeAudioFrameType.INPUT_PCM, + WearRealtimeAudioFrameType.OUTPUT_PCM, + -> require(payload.isNotEmpty() && payload.size % 2 == 0) + WearRealtimeAudioFrameType.CLEAR_OUTPUT -> require(payload.isEmpty()) + } + } +} diff --git a/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt b/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt new file mode 100644 index 0000000..50bb5e4 --- /dev/null +++ b/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt @@ -0,0 +1,276 @@ +package ai.openclaw.wear.shared + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearProtocolTest { + @Test + fun realtimeTalkSnapshotCarriesAttemptCorrelation() { + val snapshot = + WearRealtimeTalkSnapshot( + attemptId = "attempt-7", + active = true, + listening = true, + status = WearRealtimeTalkStatus.LISTENING, + ) + + assertEquals(snapshot, WearRealtimeTalkCodec.decode(WearRealtimeTalkCodec.encode(snapshot))) + } + + @Test + fun roundTripsEveryEnvelopeKind() { + val messages = + listOf( + WearMessage.Request( + requestId = "req-1", + method = WearRpcMethod.ChatHistory, + params = buildJsonObject { put("sessionKey", "main") }, + ), + WearMessage.Response( + requestId = "req-1", + ok = true, + result = buildJsonObject { put("count", 2) }, + eventStreamId = "phone-process-1", + eventSequence = 7, + ), + WearMessage.Response( + requestId = "req-2", + ok = false, + error = WearRpcError(code = "unavailable", message = "Phone offline"), + ), + WearMessage.Event( + streamId = "phone-process-1", + sequence = 7, + event = WearEventType.Chat, + payload = buildJsonObject { put("state", "delta") }, + ), + ) + + messages.forEach { message -> + assertEquals(WearDecodeResult.Success(message), WearProtocolCodec.decode(WearProtocolCodec.encode(message))) + } + } + + @Test + fun usesStableWireNamesAndPaths() { + val methodNames = + mapOf( + WearRpcMethod.ProxyStatus to "proxy.status", + WearRpcMethod.SessionsList to "sessions.list", + WearRpcMethod.AgentsList to "agents.list", + WearRpcMethod.AgentsSelect to "agents.select", + WearRpcMethod.ModelsList to "models.list", + WearRpcMethod.ModelsSelect to "models.select", + WearRpcMethod.GatewayConnect to "gateway.connect", + WearRpcMethod.GatewayDisconnect to "gateway.disconnect", + WearRpcMethod.ChatHistory to "chat.history", + WearRpcMethod.ChatSend to "chat.send", + WearRpcMethod.ChatAbort to "chat.abort", + WearRpcMethod.TalkStart to "talk.start", + WearRpcMethod.TalkStop to "talk.stop", + ) + methodNames.forEach { (method, wireName) -> + val request = WearMessage.Request(requestId = "req-1", method = method) + val root = Json.parseToJsonElement(WearProtocolCodec.encode(request).decodeToString()).jsonObject + assertEquals("request", root.getValue("type").jsonPrimitive.content) + assertEquals(wireName, root.getValue("method").jsonPrimitive.content) + } + + val eventNames = + mapOf( + WearEventType.Chat to "chat", + WearEventType.Connection to "connection", + WearEventType.Resync to "resync", + WearEventType.Talk to "talk", + ) + eventNames.forEach { (event, wireName) -> + val message = WearMessage.Event(sequence = 1, event = event) + val root = Json.parseToJsonElement(WearProtocolCodec.encode(message).decodeToString()).jsonObject + assertEquals("event", root.getValue("type").jsonPrimitive.content) + assertEquals(wireName, root.getValue("event").jsonPrimitive.content) + } + + assertEquals("/openclaw/wear/v1/request", WearProtocol.REQUEST_PATH) + assertEquals("/openclaw/wear/v1/response", WearProtocol.RESPONSE_PATH) + assertEquals("/openclaw/wear/v1/event", WearProtocol.EVENT_PATH) + assertEquals(10_000L, WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS) + assertEquals(15_000L, WearProtocol.REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS) + assertTrue( + WearProtocol.REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS > + WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS, + ) + val realtimePath = WearProtocol.realtimeAudioChannelPath("attempt-7") + assertEquals( + "/openclaw/wear/v1/realtime/audio", + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ) + assertEquals( + "/openclaw/wear/v1/realtime/audio/9804dc90c374fd8e83c9b95a75611f9bec6e0c6ecdcbed5319d6491208417521", + realtimePath, + ) + assertEquals(realtimePath, WearProtocol.realtimeAudioChannelPath("attempt-7")) + assertTrue(WearProtocol.isRealtimeAudioChannelPath(realtimePath)) + assertTrue(WearProtocol.isAttemptScopedRealtimeAudioChannelPath(realtimePath)) + assertTrue(WearProtocol.isRealtimeAudioChannelPath(WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH)) + assertFalse( + WearProtocol.isAttemptScopedRealtimeAudioChannelPath( + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ), + ) + assertFalse(WearProtocol.isRealtimeAudioChannelPath("$realtimePath/extra")) + assertEquals("openclaw_phone_proxy_v1", WearProtocol.PHONE_CAPABILITY) + assertEquals("openclaw_wear_companion_v1", WearProtocol.WATCH_CAPABILITY) + assertEquals("gateway_offline", WearConnectionFailure.GatewayOffline.wireValue) + assertEquals("incompatible", WearConnectionFailure.Incompatible.wireValue) + assertEquals( + WearConnectionFailure.Incompatible, + WearConnectionFailure.fromWireValue("incompatible"), + ) + assertEquals(null, WearConnectionFailure.fromWireValue("future-failure")) + assertEquals("agent-controls", WearProxyCapability.AgentControls.wireValue) + assertEquals("gateway-controls", WearProxyCapability.GatewayControls.wireValue) + assertEquals("model-controls", WearProxyCapability.ModelControls.wireValue) + assertEquals("session-selection-lookup", WearProxyCapability.SessionSelectionLookup.wireValue) + assertEquals( + "attempt-scoped-realtime-audio", + WearProxyCapability.AttemptScopedRealtimeAudio.wireValue, + ) + assertEquals(WearProxyCapability.AgentControls, WearProxyCapability.fromWireValue("agent-controls")) + assertEquals(null, WearProxyCapability.fromWireValue("future-capability")) + } + + @Test + fun ignoresUnknownFieldsWithinCurrentVersion() { + val bytes = + """{"type":"request","version":1,"requestId":"req-1","method":"proxy.status","params":{},"future":true}""" + .encodeToByteArray() + + assertEquals( + WearDecodeResult.Success( + WearMessage.Request(requestId = "req-1", method = WearRpcMethod.ProxyStatus), + ), + WearProtocolCodec.decode(bytes), + ) + } + + @Test + fun rejectsMalformedUnsupportedAndInvalidMessages() { + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.Empty), + WearProtocolCodec.decode(byteArrayOf()), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.Malformed), + WearProtocolCodec.decode("not-json".encodeToByteArray()), + ) + val invalidUtf8 = + """{"type":"request","version":1,"requestId":"""".encodeToByteArray() + + byteArrayOf(0xc3.toByte(), 0x28) + + """","method":"proxy.status","params":{}}""".encodeToByteArray() + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.Malformed), + WearProtocolCodec.decode(invalidUtf8), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.UnsupportedVersion), + WearProtocolCodec.decode( + """{"type":"future-message","version":2,"futureRequiredField":true}""" + .encodeToByteArray(), + ), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope), + WearProtocolCodec.decode( + """{"type":"response","version":1,"requestId":"req-1","ok":false}""".encodeToByteArray(), + ), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope), + WearProtocolCodec.decode( + """{"type":"event","version":1,"streamId":"","sequence":1,"event":"connection"}""" + .encodeToByteArray(), + ), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope), + WearProtocolCodec.decode( + """{"type":"response","version":1,"requestId":"req-1","ok":true,"eventSequence":-1}""" + .encodeToByteArray(), + ), + ) + } + + @Test + fun rejectsOversizedMessagesOnEncodeAndDecode() { + val oversizedBytes = ByteArray(WearProtocol.MAX_MESSAGE_BYTES + 1) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.TooLarge), + WearProtocolCodec.decode(oversizedBytes), + ) + + val oversizedMessage = + WearMessage.Request( + requestId = "req-1", + method = WearRpcMethod.ChatSend, + params = buildJsonObject { put("message", "x".repeat(WearProtocol.MAX_MESSAGE_BYTES)) }, + ) + assertThrows(IllegalArgumentException::class.java) { + WearProtocolCodec.encode(oversizedMessage) + } + } + + @Test + fun rejectsExcessiveJsonDepthBeforeParsing() { + val nesting = WearProtocol.MAX_JSON_DEPTH + 1 + val deeplyNested = + """{"type":"request","version":1,"requestId":"req-1","method":"chat.send","params":{"payload":${"[".repeat(nesting)}0${"]".repeat(nesting)}}}""" + + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.TooDeep), + WearProtocolCodec.decode(deeplyNested.encodeToByteArray()), + ) + + val bracketsInString = + WearMessage.Request( + requestId = "req-2", + method = WearRpcMethod.ChatSend, + params = buildJsonObject { put("message", "[".repeat(WearProtocol.MAX_JSON_DEPTH + 1)) }, + ) + assertEquals( + WearDecodeResult.Success(bracketsInString), + WearProtocolCodec.decode(WearProtocolCodec.encode(bracketsInString)), + ) + + var nestedPayload: JsonElement = JsonPrimitive(0) + repeat(4_096) { + nestedPayload = JsonArray(listOf(nestedPayload)) + } + val deeplyNestedMessage = + WearMessage.Request( + requestId = "req-3", + method = WearRpcMethod.ChatSend, + params = buildJsonObject { put("payload", nestedPayload) }, + ) + assertThrows(IllegalArgumentException::class.java) { + WearProtocolCodec.encode(deeplyNestedMessage) + } + } + + @Test + fun encodingIsDeterministic() { + val message = WearMessage.Request(requestId = "req-1", method = WearRpcMethod.SessionsList) + assertArrayEquals(WearProtocolCodec.encode(message), WearProtocolCodec.encode(message)) + } +} diff --git a/wear-shared/src/test/java/ai/openclaw/wear/shared/WearRealtimeTalkTest.kt b/wear-shared/src/test/java/ai/openclaw/wear/shared/WearRealtimeTalkTest.kt new file mode 100644 index 0000000..f8d4ab4 --- /dev/null +++ b/wear-shared/src/test/java/ai/openclaw/wear/shared/WearRealtimeTalkTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.wear.shared + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream + +class WearRealtimeTalkTest { + @Test + fun audioFramesRoundTripAndPreserveBoundaries() { + val output = ByteArrayOutputStream() + WearRealtimeAudioFraming.write(output, WearRealtimeAudioFrameType.INPUT_PCM, byteArrayOf(1, 2, 3, 4)) + WearRealtimeAudioFraming.write(output, WearRealtimeAudioFrameType.CLEAR_OUTPUT, byteArrayOf()) + val input = ByteArrayInputStream(output.toByteArray()) + + val audio = WearRealtimeAudioFraming.read(input)!! + assertEquals(WearRealtimeAudioFrameType.INPUT_PCM, audio.type) + assertArrayEquals(byteArrayOf(1, 2, 3, 4), audio.payload) + assertEquals(WearRealtimeAudioFrameType.CLEAR_OUTPUT, WearRealtimeAudioFraming.read(input)!!.type) + assertNull(WearRealtimeAudioFraming.read(input)) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsOversizedFrameBeforeAllocatingPayload() { + val output = ByteArrayOutputStream() + DataOutputStream(output).apply { + writeByte(WearRealtimeAudioFrameType.INPUT_PCM.wireValue) + writeInt(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES + 1) + } + WearRealtimeAudioFraming.read(ByteArrayInputStream(output.toByteArray())) + } +} diff --git a/wear/BEHAVIOR.md b/wear/BEHAVIOR.md new file mode 100644 index 0000000..0ad5efa --- /dev/null +++ b/wear/BEHAVIOR.md @@ -0,0 +1,15 @@ +# Wear OS behavior contract + +The watch is a paired-phone companion. It never asks for, receives, or stores Gateway credentials, TLS pins, or device-signing identity. + +- Given a reachable paired phone and connected Gateway, opening the watch app lists recent non-global sessions. Selecting one shows the latest bounded text transcript. +- Given multiple reachable phones, RPC responses and events are accepted only from the currently preferred phone. A preferred-phone change reloads canonical state before replaying live events. +- Given an unavailable phone or offline Gateway, the app shows one clear recovery state and a refresh action. It does not fall back to direct Gateway access. +- Given a paired phone that advertises agent and Gateway controls, the watch can select the phone's active agent and connect or disconnect its Gateway. Older phones omit those controls and continue serving the original companion surface. +- Given a selected session, text input or Wear speech recognition sends one idempotent, non-delivering chat request through the phone. An ambiguous retry reuses its request identity. An active run can be aborted. +- Given a selected session and microphone permission, Real-Time Talk streams bounded PCM audio over one temporary bidirectional Data Layer channel to that session on the selected phone. One watch owns the relay at a time. Stopping Talk, changing phones, losing the Gateway, or losing the channel closes capture and playback without exposing Gateway credentials to the watch. +- Given ordered chat events, the transcript shows the phone's bounded canonical stream projection. Given a missing sequence or changed phone-process epoch, the app discards uncertain stream state and reloads canonical history. Events racing that snapshot replay only when they share its epoch and are newer than its response watermark, and stream text reconciles without duplication or a reload loop. A legacy phone without response watermarks lets the next event establish its live baseline. +- Given a final assistant message while the app is not visible and notifications are allowed, the watch shows one local-only notification with direct reply. Phone-process recreation rediscovers the reachable watch before delivery. If the preferred phone changes before a notification reply, recovery opens the app to reload the session instead of retrying the stale phone. +- Given Android 13 or newer without notification permission, Controls offers both an explicit request action and direct access to the watch's app-notification settings. Granting or revoking permission outside the app is reflected when the app resumes, and denying it leaves the rest of the companion usable. +- Given a theme or automatic-speech selection, the watch persists that local UI preference without sending it to the phone or Gateway. +- Given the OpenClaw Tile, tapping its mascot Talk action opens the watch app directly on Voice, while the secondary Open edge action starts on Chat. The tile uses the official Android app launcher artwork from the phone manifest's `@mipmap/ic_launcher_foreground` resource with the Core app's dark visual tokens. Neither launch starts microphone capture. Tile rendering performs no phone or network work and persists no cache. diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 0000000..c8fb696 --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,126 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +val openClawAndroidVersionFile = rootProject.file("Config/Version.properties") +val openClawAndroidVersionProperties = + Properties().apply { + if (!openClawAndroidVersionFile.isFile) { + error("Missing Android version properties. Run `pnpm android:version:sync`.") + } + openClawAndroidVersionFile.inputStream().use(::load) + } + +fun requireOpenClawAndroidVersionProperty(name: String): String = + openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() } + ?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.") + +val openClawAndroidPhoneVersionCode = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toInt() +val openClawAndroidBuildNumber = openClawAndroidPhoneVersionCode % 100 +check(openClawAndroidBuildNumber in 1..49) { + "Android build number must be 01 through 49; Wear reserves 51 through 99." +} +val openClawAndroidWearVersionCode = openClawAndroidPhoneVersionCode + 50 +check(openClawAndroidWearVersionCode <= 2_100_000_000) { "Wear versionCode exceeds the Android platform maximum." } + +// Data Layer delivery requires the phone and watch packages to share one certificate. +evaluationDependsOn(":app") +val phoneReleaseSigning = + project(":app") + .extensions + .getByType() + .signingConfigs + .findByName("release") + +android { + namespace = "ai.openclaw.wear" + compileSdk = 37 + + defaultConfig { + // Data Layer traffic is scoped to matching package names and signatures. + applicationId = "ai.openclaw.app" + minSdk = 31 + targetSdk = 36 + versionCode = openClawAndroidWearVersionCode + versionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME") + } + + buildTypes { + release { + if (phoneReleaseSigning != null) { + signingConfig = phoneReleaseSigning + } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + lint { + lintConfig = rootProject.file("app/lint.xml") + warningsAsErrors = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +ktlint { + android.set(true) + ignoreFailures.set(false) + filter { + exclude("**/build/**") + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + + implementation(project(":wear-shared")) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.androidx.wear.input) + implementation(libs.androidx.wear.tiles) + implementation(libs.androidx.wear.protolayout) + implementation(libs.androidx.wear.protolayout.material) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.play.services.wearable) + + debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) +} diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 0000000..72202e0 --- /dev/null +++ b/wear/proguard-rules.pro @@ -0,0 +1,2 @@ +-keepattributes *Annotation* +-keepclassmembers class ai.openclaw.wear.** { *; } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 0000000..040339c --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/ai/openclaw/wear/MainActivity.kt b/wear/src/main/java/ai/openclaw/wear/MainActivity.kt new file mode 100644 index 0000000..811fc19 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/MainActivity.kt @@ -0,0 +1,574 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearRealtimeTalkRole +import android.Manifest +import android.app.Activity +import android.app.RemoteInput +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.speech.RecognizerIntent +import android.view.HapticFeedbackConstants +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.input.RemoteInputIntentHelper +import kotlinx.coroutines.delay +import java.util.Locale + +internal const val extraWearLaunchTarget = "openclaw.wear.launchTarget" + +internal enum class WearLaunchTarget( + val rawValue: String, + val initialPage: WearHomePage, +) { + Chat("chat", WearHomePage.Chat), + Voice("voice", WearHomePage.Voice), + ; + + companion object { + fun fromRawValue(raw: String?): WearLaunchTarget = entries.firstOrNull { target -> target.rawValue == raw?.trim()?.lowercase() } ?: Chat + } +} + +internal fun parseWearLaunchTarget(intent: Intent?): WearLaunchTarget = WearLaunchTarget.fromRawValue(intent?.getStringExtra(extraWearLaunchTarget)) + +internal fun consumeWearLaunchTarget(intent: Intent?): WearLaunchTarget = + parseWearLaunchTarget(intent).also { + intent?.removeExtra(extraWearLaunchTarget) + } + +internal data class WearNavigationRequest( + val id: Int, + val target: WearLaunchTarget, +) + +internal data class WearLaunchState( + val initialTarget: WearLaunchTarget = WearLaunchTarget.Chat, + val navigationRequest: WearNavigationRequest? = null, + val nextRequestId: Int = 0, +) { + fun next(intent: Intent?): WearLaunchState { + val requestId = nextRequestId + 1 + return copy( + navigationRequest = + WearNavigationRequest( + id = requestId, + target = consumeWearLaunchTarget(intent), + ), + nextRequestId = requestId, + ) + } + + fun handled(requestId: Int): WearLaunchState = if (navigationRequest?.id == requestId) copy(navigationRequest = null) else this + + companion object { + fun initial(intent: Intent?): WearLaunchState = WearLaunchState(initialTarget = consumeWearLaunchTarget(intent)) + } +} + +@Composable +internal fun WearLaunchContent( + launchState: WearLaunchState, + content: @Composable (WearHomePage, WearNavigationRequest?) -> Unit, +) { + // Warm launches are pager events. Keeping this composition identity stable preserves + // pending-reply, autospeak, and real-time UI state owned below this boundary. + content(launchState.initialTarget.initialPage, launchState.navigationRequest) +} + +internal fun shouldRecreateForScreenshotMode( + currentScene: WearScreenshotScene?, + intent: Intent?, + screenshotModeEnabled: Boolean, +): Boolean = + screenshotModeEnabled && + (currentScene != null || parseWearScreenshotModeIntent(intent) != null) + +class MainActivity : ComponentActivity() { + private val viewModel: WearViewModel by viewModels() + private var screenshotScene: WearScreenshotScene? = null + private var launchState by mutableStateOf(WearLaunchState()) + + private val screenshotModeEnabled: Boolean + get() = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (screenshotModeEnabled) { + screenshotScene = parseWearScreenshotModeIntent(intent) + } + launchState = WearLaunchState.initial(intent) + setContent { + val scene = screenshotScene + if (scene == null) { + WearLaunchContent(launchState) { initialPage, navigationRequest -> + OpenClawWearApp( + viewModel = viewModel, + settingsStore = remember { WearSettingsStore(applicationContext) }, + speaker = remember { WearReplySpeaker(applicationContext) }, + initialPage = initialPage, + navigationRequest = navigationRequest, + onNavigationRequestHandled = { requestId -> + launchState = launchState.handled(requestId) + }, + ) + } + } else { + OpenClawWearScreenshotApp(scene) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (shouldRecreateForScreenshotMode(screenshotScene, intent, screenshotModeEnabled)) { + recreate() + return + } + launchState = launchState.next(intent) + } + + override fun onStart() { + super.onStart() + if (screenshotScene == null) { + (application as WearApplication).onActivityStarted() + } + } + + override fun onStop() { + if (screenshotScene == null) { + (application as WearApplication).onActivityStopped() + } + super.onStop() + } +} + +@Composable +internal fun OpenClawWearApp( + viewModel: WearViewModel, + settingsStore: WearSettingsStore, + speaker: WearReplySpeaker, + initialPage: WearHomePage = WearHomePage.Chat, + navigationRequest: WearNavigationRequest? = null, + onNavigationRequestHandled: (Int) -> Unit = {}, +) { + val state by viewModel.state.collectAsState() + val snapshot = state.toConversationSnapshot() + val speaking by speaker.isSpeaking.collectAsState() + val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val activity = LocalActivity.current + val initialSettings = remember(settingsStore) { settingsStore.read() } + var interaction by remember { mutableStateOf(WearInteractionState.READY) } + var themeMode by remember { mutableStateOf(initialSettings.themeMode) } + var autoSpeak by remember { mutableStateOf(initialSettings.autoSpeak) } + var notificationsGranted by remember { + mutableStateOf( + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(view.context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED, + ) + } + var expectedAssistantKey by remember { mutableStateOf(null) } + var awaitingReplySessionId by remember { mutableStateOf(null) } + var awaitingReply by remember { mutableStateOf(false) } + var previousRealtimeSnapshot by remember { mutableStateOf(snapshot) } + var realtimeThinkingTurnId by remember { mutableStateOf(null) } + val speakPrompt = stringResource(R.string.speak_to_agent) + val messageLabel = stringResource(R.string.message) + val messageTitle = stringResource(R.string.message_agent) + val sendLabel = stringResource(R.string.send) + + fun submitMessage(rawMessage: String) { + val message = rawMessage.trim() + val sessionId = snapshot?.activeSessionId + if (message.isEmpty()) { + interaction = WearInteractionState.READY + return + } + if (state.sending || !state.streamText.isNullOrBlank()) { + interaction = WearInteractionState.READY + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + return + } + if (!state.connected || sessionId == null) { + interaction = WearInteractionState.READY + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + return + } + expectedAssistantKey = snapshot.latestAssistantMessage()?.stableKey() + awaitingReplySessionId = sessionId + awaitingReply = true + interaction = WearInteractionState.SENDING + speaker.stop() + viewModel.sendReply(message) + } + + val speechLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val transcript = + result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + if (result.resultCode == Activity.RESULT_OK && !transcript.isNullOrBlank()) { + submitMessage(transcript) + } else { + interaction = WearInteractionState.READY + } + } + val textLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val text = + result.data + ?.let(RemoteInput::getResultsFromIntent) + ?.getCharSequence(REMOTE_INPUT_KEY) + ?.toString() + if (result.resultCode == Activity.RESULT_OK && !text.isNullOrBlank()) { + submitMessage(text) + } else { + interaction = WearInteractionState.READY + } + } + + fun startRealtimeTalk() { + speaker.stop() + viewModel.startRealtimeTalk() + } + + fun leaveConversationContext() { + awaitingReply = false + awaitingReplySessionId = null + expectedAssistantKey = null + interaction = WearInteractionState.READY + speaker.stop() + } + + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + startRealtimeTalk() + } else { + interaction = WearInteractionState.ERROR + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + } + } + + val notificationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + notificationsGranted = granted + } + + DisposableEffect(lifecycleOwner, view.context) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + notificationsGranted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(view.context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + fun toggleRealtimeTalk() { + if (state.talkBusy || state.controlBusy) return + if (state.realtimeTalk.active || state.realtimeCapturing) { + viewModel.stopRealtimeTalk() + return + } + if (!state.connected || state.selectedSession == null) return + if ( + ContextCompat.checkSelfPermission(view.context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + startRealtimeTalk() + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + + LaunchedEffect( + snapshot?.activeSessionId, + state.messages, + state.activeRunId, + state.sending, + state.failure, + awaitingReply, + ) { + if (!awaitingReply) return@LaunchedEffect + val activeSnapshot = snapshot + if ( + state.failure != null || + activeSnapshot == null || + activeSnapshot.activeSessionId != awaitingReplySessionId + ) { + awaitingReply = false + awaitingReplySessionId = null + expectedAssistantKey = null + interaction = WearInteractionState.READY + return@LaunchedEffect + } + if (state.sending || state.activeRunId != null) return@LaunchedEffect + val reply = + newAssistantReplyForSession( + awaitingSessionId = awaitingReplySessionId, + activeSessionId = activeSnapshot.activeSessionId, + expectedAssistantKey = expectedAssistantKey, + latestAssistantMessage = activeSnapshot.latestAssistantMessage(), + ) + if (reply != null) { + awaitingReply = false + awaitingReplySessionId = null + expectedAssistantKey = null + interaction = WearInteractionState.READY + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + if (autoSpeak) speaker.speak(reply.text) + } + } + + LaunchedEffect(snapshot?.realtimeTalk) { + val next = snapshot + realtimeThinkingTurnId = + if (next == null) { + null + } else { + nextRealtimeThinkingTurnId(previousRealtimeSnapshot, next, realtimeThinkingTurnId) + } + previousRealtimeSnapshot = next + } + LaunchedEffect(realtimeThinkingTurnId) { + val turnId = realtimeThinkingTurnId ?: return@LaunchedEffect + delay(MINIMUM_REALTIME_THINKING_VISIBLE_MILLIS) + if (realtimeThinkingTurnId == turnId) { + realtimeThinkingTurnId = null + } + } + + DisposableEffect(speaker) { + onDispose(speaker::shutdown) + } + + val failure = + state.failure + ?: WearConversationFailure.PHONE_UNAVAILABLE.takeIf { + state.phoneNodeId == null && !state.loading + } + val resolvedInteraction = + when { + state.failure != null -> WearInteractionState.ERROR + state.sending -> WearInteractionState.SENDING + state.activeRunId != null -> WearInteractionState.AGENT_WORKING + else -> interaction + } + + OpenClawWearTheme(themeMode = themeMode) { + AppScaffold { + OpenClawWearScreens( + snapshot = snapshot, + failure = failure, + loading = state.loading, + interaction = resolvedInteraction, + speaking = speaking, + realtimeCapturing = state.realtimeCapturing, + realtimePlaying = state.realtimePlaying, + realtimeMouthLevel = state.realtimeMouthLevel, + realtimePlaybackFailed = state.realtimePlaybackFailed, + realtimeThinkingOverride = realtimeThinkingTurnId != null, + actionBusy = + state.loading || + state.sending || + state.talkBusy || + state.controlBusy || + state.activeRunId != null || + !state.streamText.isNullOrBlank() || + state.realtimeTalk.active || + state.realtimeCapturing || + state.realtimePlaying, + inputEnabled = state.connected && snapshot?.activeSessionId != null, + canAbort = state.activeRunId != null || !state.streamText.isNullOrBlank(), + themeMode = themeMode, + autoSpeak = autoSpeak, + notificationsGranted = notificationsGranted, + initialPage = initialPage, + navigationRequest = navigationRequest, + onNavigationRequestHandled = onNavigationRequestHandled, + onTalk = { + if (!state.connected || snapshot?.activeSessionId == null) return@OpenClawWearScreens + interaction = WearInteractionState.LISTENING + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault().toLanguageTag()) + .putExtra(RecognizerIntent.EXTRA_PROMPT, speakPrompt) + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + interaction = WearInteractionState.ERROR + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + } + }, + onType = { + if (!state.connected || snapshot?.activeSessionId == null) return@OpenClawWearScreens + interaction = WearInteractionState.TYPING + val remoteInput = + RemoteInput + .Builder(REMOTE_INPUT_KEY) + .setLabel(messageLabel) + .build() + val intent = + RemoteInputIntentHelper + .createActionRemoteInputIntent() + .also { inputIntent -> + RemoteInputIntentHelper.putRemoteInputsExtra(inputIntent, listOf(remoteInput)) + RemoteInputIntentHelper.putTitleExtra(inputIntent, messageTitle) + RemoteInputIntentHelper.putConfirmLabelExtra(inputIntent, sendLabel) + } + textLauncher.launch(intent) + }, + onRealtimeTalk = ::toggleRealtimeTalk, + onAbort = { + awaitingReply = false + awaitingReplySessionId = null + expectedAssistantKey = null + interaction = WearInteractionState.READY + speaker.stop() + viewModel.abort() + }, + onSelectAgent = { agentId -> + leaveConversationContext() + viewModel.selectAgent(agentId) + }, + onSelectSession = { sessionKey -> + state.sessions.firstOrNull { it.key == sessionKey }?.let { session -> + leaveConversationContext() + viewModel.openSession(session) + } + }, + onSelectModel = { modelRef -> + leaveConversationContext() + viewModel.selectModel(modelRef) + }, + onRefresh = viewModel::refresh, + onGatewayEnabledChange = { enabled -> + speaker.stop() + viewModel.setGatewayEnabled(enabled) + }, + onThemeModeChange = { selectedMode -> + themeMode = selectedMode + settingsStore.writeThemeMode(selectedMode) + }, + onAutoSpeakChange = { enabled -> + autoSpeak = enabled + settingsStore.writeAutoSpeak(enabled) + if (!enabled) speaker.stop() + }, + onRequestNotifications = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + }, + onOpenNotificationSettings = { + if (activity != null) { + val notificationSettings = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName) + try { + activity.startActivity(notificationSettings) + } catch (_: ActivityNotFoundException) { + activity.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${activity.packageName}".toUri(), + ), + ) + } + } + }, + onSpeakLatest = { + snapshot.latestAssistantMessage()?.text?.let(speaker::speak) + }, + onStopSpeaking = speaker::stop, + ) + } + } +} + +private fun WearConversationSnapshot?.latestAssistantMessage(): WearChatMessage? = + this + ?.messages + ?.lastOrNull { message -> + message.chatRole == WearChatRole.ASSISTANT && message.text.isNotBlank() + } + +private fun WearChatMessage.stableKey(): String = id ?: role + ":" + timestamp + ":" + text.hashCode() + +internal fun newAssistantReplyForSession( + awaitingSessionId: String?, + activeSessionId: String?, + expectedAssistantKey: String?, + latestAssistantMessage: WearChatMessage?, +): WearChatMessage? = + latestAssistantMessage?.takeIf { message -> + awaitingSessionId != null && + awaitingSessionId == activeSessionId && + message.stableKey() != expectedAssistantKey + } + +internal fun nextRealtimeThinkingTurnId( + previous: WearConversationSnapshot?, + next: WearConversationSnapshot, + currentTurnId: String?, +): String? { + if (!next.realtimeTalk.active) return null + return newlyCompletedRealtimeUserTurnId(previous, next) ?: currentTurnId +} + +internal fun newlyCompletedRealtimeUserTurnId( + previous: WearConversationSnapshot?, + next: WearConversationSnapshot, +): String? { + if (previous?.realtimeTalk?.active != true || !next.realtimeTalk.active) return null + val previousFinalUserTurnIds = + previous.realtimeTalk.conversation + .asSequence() + .filter { entry -> entry.role == WearRealtimeTalkRole.USER && !entry.streaming } + .map { entry -> entry.id } + .toSet() + return next.realtimeTalk.conversation + .lastOrNull { entry -> + entry.role == WearRealtimeTalkRole.USER && + !entry.streaming && + entry.id !in previousFinalUserTurnIds + }?.id +} + +internal const val REPLY_RESULT_KEY = "openclaw_watch_message" +private const val REMOTE_INPUT_KEY = REPLY_RESULT_KEY +private const val MINIMUM_REALTIME_THINKING_VISIBLE_MILLIS = 900L diff --git a/wear/src/main/java/ai/openclaw/wear/OpenClawTileService.kt b/wear/src/main/java/ai/openclaw/wear/OpenClawTileService.kt new file mode 100644 index 0000000..d47af53 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/OpenClawTileService.kt @@ -0,0 +1,104 @@ +package ai.openclaw.wear + +import android.content.ComponentName +import android.content.Context +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.TimelineBuilders +import androidx.wear.protolayout.layout.androidImageResource +import androidx.wear.protolayout.layout.imageResource +import androidx.wear.protolayout.material3.ButtonDefaults.filledTonalButtonColors +import androidx.wear.protolayout.material3.ColorScheme +import androidx.wear.protolayout.material3.MaterialScope +import androidx.wear.protolayout.material3.avatarButton +import androidx.wear.protolayout.material3.avatarImage +import androidx.wear.protolayout.material3.primaryLayout +import androidx.wear.protolayout.material3.text +import androidx.wear.protolayout.material3.textEdgeButton +import androidx.wear.protolayout.modifiers.LayoutModifier +import androidx.wear.protolayout.modifiers.clickable +import androidx.wear.protolayout.modifiers.contentDescription +import androidx.wear.protolayout.types.argb +import androidx.wear.protolayout.types.layoutString +import androidx.wear.tiles.Material3TileService +import androidx.wear.tiles.RequestBuilders +import androidx.wear.tiles.TileBuilders + +class OpenClawTileService : + Material3TileService( + allowDynamicTheme = false, + defaultColorScheme = openClawTileColorScheme, + ) { + override suspend fun MaterialScope.tileResponse(requestParams: RequestBuilders.TileRequest): TileBuilders.Tile { + val talkAction = wearLaunchAction(this@OpenClawTileService, WearLaunchTarget.Voice) + val openAction = wearLaunchAction(this@OpenClawTileService, WearLaunchTarget.Chat) + val talkClickable = clickable(action = talkAction, id = "talk_openclaw") + val openClickable = clickable(action = openAction, id = "open_openclaw") + val layout = + primaryLayout( + titleSlot = { text(getString(R.string.app_name).layoutString) }, + mainSlot = { + avatarButton( + onClick = talkClickable, + modifier = LayoutModifier.contentDescription(getString(R.string.talk)), + avatarContent = { + avatarImage( + resource = + imageResource( + androidImage = androidImageResource(R.mipmap.ic_launcher_foreground), + ), + protoLayoutResourceId = "openclaw_core_mascot", + ) + }, + labelContent = { + text( + wearUppercase( + getString(R.string.talk), + resources.configuration.locales[0], + ).layoutString, + ) + }, + secondaryLabelContent = { text(getString(R.string.tile_phone_proxy).layoutString) }, + ) + }, + bottomSlot = { + textEdgeButton( + onClick = openClickable, + modifier = LayoutModifier.contentDescription(getString(R.string.tile_open)), + colors = filledTonalButtonColors(), + labelContent = { text(getString(R.string.tile_open).layoutString) }, + ) + }, + ) + return TileBuilders.Tile + .Builder() + .setTileTimeline(TimelineBuilders.Timeline.fromLayoutElement(layout)) + .build() + } +} + +private val openClawTileColorScheme = + ColorScheme( + primary = 0xFFFFFFFF.argb, + primaryDim = 0xFFA8A8A8.argb, + primaryContainer = 0xFFFFFFFF.argb, + onPrimary = 0xFF050505.argb, + onPrimaryContainer = 0xFF050505.argb, + surfaceContainerLow = 0xFF0A0A0A.argb, + surfaceContainer = 0xFF111111.argb, + surfaceContainerHigh = 0xFF1A1A1A.argb, + onSurface = 0xFFF8F8F8.argb, + onSurfaceVariant = 0xFFA8A8A8.argb, + outline = 0xFF3A3A3A.argb, + outlineVariant = 0xFF242424.argb, + background = 0xFF030303.argb, + onBackground = 0xFFF8F8F8.argb, + ) + +internal fun wearLaunchAction( + context: Context, + target: WearLaunchTarget, +): ActionBuilders.LaunchAction = + ActionBuilders.launchAction( + ComponentName(context, MainActivity::class.java), + mapOf(extraWearLaunchTarget to ActionBuilders.stringExtra(target.rawValue)), + ) diff --git a/wear/src/main/java/ai/openclaw/wear/WearApplication.kt b/wear/src/main/java/ai/openclaw/wear/WearApplication.kt new file mode 100644 index 0000000..3ec3425 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearApplication.kt @@ -0,0 +1,41 @@ +package ai.openclaw.wear + +import android.app.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import java.util.concurrent.atomic.AtomicInteger + +internal class VisibleActivityTracker { + private val count = AtomicInteger() + + fun onStarted() { + count.incrementAndGet() + } + + fun onStopped() { + count.updateAndGet { current -> (current - 1).coerceAtLeast(0) } + } + + fun isVisible(): Boolean = count.get() > 0 +} + +class WearApplication : Application() { + internal val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + internal val proxyClient: WearProxyClient by lazy { + WearProxyClient.create(context = this) + } + + internal val gatewayRepository: WearGatewayRepository by lazy { + WearGatewayRepository(proxyClient) + } + + private val visibleActivities = VisibleActivityTracker() + + internal fun onActivityStarted() = visibleActivities.onStarted() + + internal fun onActivityStopped() = visibleActivities.onStopped() + + internal fun isActivityVisible(): Boolean = visibleActivities.isVisible() +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearAudioFocusController.kt b/wear/src/main/java/ai/openclaw/wear/WearAudioFocusController.kt new file mode 100644 index 0000000..bc57080 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearAudioFocusController.kt @@ -0,0 +1,52 @@ +package ai.openclaw.wear + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager + +internal val wearSpeechAudioAttributes: AudioAttributes = + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + +internal class WearAudioFocusController( + context: Context, + private val onFocusLost: () -> Unit, +) { + private val audioManager = + context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val focusRequest = + AudioFocusRequest + .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes(wearSpeechAudioAttributes) + .setOnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, + -> { + hasFocus = false + onFocusLost() + } + } + }.build() + + @Volatile private var hasFocus = false + + fun request(): Boolean { + if (hasFocus) return true + hasFocus = + audioManager.requestAudioFocus(focusRequest) == + AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasFocus + } + + fun abandon() { + if (!hasFocus) return + audioManager.abandonAudioFocusRequest(focusRequest) + hasFocus = false + } +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt b/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt new file mode 100644 index 0000000..a864f33 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt @@ -0,0 +1,124 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot + +internal enum class WearGatewayState { + CONNECTED, + DISCONNECTED, +} + +internal enum class WearChatRole { + USER, + ASSISTANT, + SYSTEM, +} + +internal val WearChatMessage.chatRole: WearChatRole + get() = + when (role.lowercase()) { + "user" -> WearChatRole.USER + "assistant" -> WearChatRole.ASSISTANT + else -> WearChatRole.SYSTEM + } + +internal data class WearAgentSummary( + val id: String, + val name: String, + val emoji: String?, + val selected: Boolean, +) + +internal data class WearSessionSummary( + val id: String, + val title: String?, + val updatedAtEpochMillis: Long?, + val selected: Boolean, +) + +internal data class WearModelSummary( + val ref: String, + val name: String, + val selected: Boolean, +) + +internal data class WearConversationSnapshot( + val gatewayState: WearGatewayState, + val activeAgentId: String? = null, + val agents: List = emptyList(), + val agentControlsSupported: Boolean = false, + val gatewayControlsSupported: Boolean = false, + val activeSessionId: String? = null, + val sessions: List = emptyList(), + val models: List = emptyList(), + val modelControlsSupported: Boolean = false, + val messages: List = emptyList(), + val streamingAssistantText: String? = null, + val pendingRunCount: Int = 0, + val selectedModelRef: String? = null, + val failure: WearConversationFailure? = null, + val realtimeTalk: WearRealtimeTalkSnapshot = WearRealtimeTalkSnapshot(), +) + +internal enum class WearConversationFailure { + PHONE_UNAVAILABLE, + PHONE_NOT_READY, + GATEWAY_OFFLINE, + NOT_FOUND, + ACTION_REJECTED, + INCOMPATIBLE, + INTERNAL_ERROR, +} + +internal enum class WearInteractionState { + READY, + LISTENING, + TYPING, + SENDING, + AGENT_WORKING, + ERROR, +} + +internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { + if (phoneNodeId == null) return null + return WearConversationSnapshot( + gatewayState = if (connected) WearGatewayState.CONNECTED else WearGatewayState.DISCONNECTED, + activeAgentId = activeAgentId, + agents = + agents.map { agent -> + WearAgentSummary( + id = agent.id, + name = agent.name, + emoji = agent.emoji, + selected = agent.id == activeAgentId, + ) + }, + agentControlsSupported = WearProxyCapability.AgentControls in proxyCapabilities, + gatewayControlsSupported = WearProxyCapability.GatewayControls in proxyCapabilities, + activeSessionId = selectedSession?.key, + sessions = + sessions.map { session -> + WearSessionSummary( + id = session.key, + title = session.title, + updatedAtEpochMillis = session.updatedAt, + selected = session.key == selectedSession?.key, + ) + }, + models = + models.map { model -> + WearModelSummary( + ref = model.ref, + name = model.name, + selected = model.ref == selectedModelRef, + ) + }, + modelControlsSupported = WearProxyCapability.ModelControls in proxyCapabilities, + messages = messages, + streamingAssistantText = streamText, + pendingRunCount = if (activeRunId != null) 1 else 0, + selectedModelRef = selectedModelRef, + failure = failure, + realtimeTalk = realtimeTalk, + ) +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt b/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt new file mode 100644 index 0000000..11b2bcc --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt @@ -0,0 +1,520 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRealtimeTalkCodec +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import ai.openclaw.wear.shared.WearRpcMethod +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import java.util.UUID + +internal data class WearProxyStatus( + val connected: Boolean, + val activeAgentId: String?, + val activeSessionKey: String?, + val selectedModelRef: String?, + val capabilities: Set, + val eventSequence: Long?, + val phoneNodeId: String, + val eventStreamId: String? = null, +) + +internal data class WearAgent( + val id: String, + val name: String, + val emoji: String?, + val selected: Boolean, +) + +internal data class WearAgentList( + val agents: List, + val eventSequence: Long?, + val phoneNodeId: String, + val eventStreamId: String? = null, +) + +internal data class WearSession( + val key: String, + val title: String?, + val updatedAt: Long?, + val hasActiveRun: Boolean, + val phoneNodeId: String, + val agentId: String? = null, + val modelRef: String? = null, +) + +internal data class WearSessionList( + val sessions: List, + val eventSequence: Long?, + val phoneNodeId: String, + val eventStreamId: String? = null, + val activeAgentId: String? = null, + val selectedSessionValid: Boolean = false, +) + +internal data class WearModel( + val ref: String, + val name: String, +) + +internal data class WearModelList( + val models: List, + val eventSequence: Long?, + val phoneNodeId: String, + val eventStreamId: String? = null, +) + +internal data class WearModelSelection( + val selectedModelRef: String, + val eventSequence: Long?, + val phoneNodeId: String, + val eventStreamId: String? = null, +) + +internal data class WearChatMessage( + val id: String?, + val role: String, + val text: String, + val timestamp: Long?, +) + +internal data class WearTranscript( + val sessionKey: String, + val messages: List, + val activeRunId: String?, + val activeText: String?, + val selectedModelRef: String?, + val eventSequence: Long?, + val phoneNodeId: String, + val eventStreamId: String? = null, +) + +internal data class WearChatEvent( + val sessionKey: String?, + val runId: String?, + val state: String?, + val deltaText: String?, + val replace: Boolean, + val streamText: String?, + val streamTextComplete: Boolean, + val message: WearChatMessage?, +) + +internal data class WearSendAttempt( + val sessionKey: String, + val message: String, + val idempotencyKey: String, + val phoneNodeId: String, +) + +internal class WearSendAttemptTracker( + private val newId: () -> String = { UUID.randomUUID().toString() }, +) { + private var ambiguousAttempt: WearSendAttempt? = null + + fun begin( + sessionKey: String, + message: String, + phoneNodeId: String, + ): WearSendAttempt { + ambiguousAttempt + ?.takeIf { it.sessionKey == sessionKey && it.message == message && it.phoneNodeId == phoneNodeId } + ?.let { return it } + ambiguousAttempt = null + return WearSendAttempt(sessionKey, message, "wear-${newId()}", phoneNodeId) + } + + fun markAmbiguous(attempt: WearSendAttempt) { + ambiguousAttempt = attempt + } + + fun markSucceeded(attempt: WearSendAttempt) { + if (ambiguousAttempt == attempt) ambiguousAttempt = null + } +} + +internal class WearGatewayRepository( + private val requester: WearRpcRequester, +) { + suspend fun status(expectedNodeId: String? = null): WearProxyStatus { + val response = requester.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId) + val result = response.payload.asObject("proxy.status") + return WearProxyStatus( + connected = result.boolean("connected") ?: false, + activeAgentId = result.string("activeAgentId"), + activeSessionKey = result.string("activeSessionKey"), + selectedModelRef = result.string("selectedModelRef"), + capabilities = result.proxyCapabilities(), + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + ) + } + + suspend fun agents( + expectedNodeId: String, + capabilities: Set, + ): WearAgentList { + capabilities.require(WearProxyCapability.AgentControls) + val response = + requester.request( + WearRpcMethod.AgentsList, + buildJsonObject {}, + expectedNodeId, + requirePreferredNode = true, + ) + val result = response.payload.asObject("agents.list") + return WearAgentList( + agents = + (result["agents"] as? JsonArray) + .orEmpty() + .mapNotNull(::parseAgent), + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + ) + } + + suspend fun selectAgent( + agentId: String, + phoneNodeId: String, + capabilities: Set, + ) { + capabilities.require(WearProxyCapability.AgentControls) + requester.request( + WearRpcMethod.AgentsSelect, + buildJsonObject { put("agentId", agentId) }, + phoneNodeId, + requirePreferredNode = true, + ) + } + + suspend fun models( + expectedNodeId: String, + capabilities: Set, + selectedModelRef: String? = null, + ): WearModelList { + capabilities.require(WearProxyCapability.ModelControls) + val response = + requester.request( + WearRpcMethod.ModelsList, + buildJsonObject { + selectedModelRef?.let { put("selectedModelRef", it) } + }, + expectedNodeId, + requirePreferredNode = true, + ) + val result = response.payload.asObject("models.list") + return WearModelList( + models = + (result["models"] as? JsonArray) + .orEmpty() + .mapNotNull(::parseModel), + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + ) + } + + suspend fun selectModel( + sessionKey: String, + modelRef: String, + phoneNodeId: String, + capabilities: Set, + ): WearModelSelection { + capabilities.require(WearProxyCapability.ModelControls) + val response = + requester.request( + WearRpcMethod.ModelsSelect, + buildJsonObject { + put("sessionKey", sessionKey) + put("modelRef", modelRef) + }, + phoneNodeId, + requirePreferredNode = true, + ) + val selectedModelRef = + response.payload + .asObject("models.select") + .string("selectedModelRef") + ?: throw WearProxyException("invalid_response", "models.select returned invalid data") + return WearModelSelection( + selectedModelRef = selectedModelRef, + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + ) + } + + suspend fun setGatewayEnabled( + enabled: Boolean, + phoneNodeId: String, + capabilities: Set, + ): WearProxyStatus { + capabilities.require(WearProxyCapability.GatewayControls) + val method = if (enabled) WearRpcMethod.GatewayConnect else WearRpcMethod.GatewayDisconnect + val response = + requester.request( + method, + buildJsonObject {}, + phoneNodeId, + requirePreferredNode = true, + ) + val result = response.payload.asObject(if (enabled) "gateway.connect" else "gateway.disconnect") + return WearProxyStatus( + connected = result.boolean("connected") ?: false, + activeAgentId = result.string("activeAgentId"), + activeSessionKey = result.string("activeSessionKey"), + selectedModelRef = result.string("selectedModelRef"), + capabilities = result.proxyCapabilities(), + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + ) + } + + suspend fun sessions( + expectedNodeId: String? = null, + selectedSessionKey: String? = null, + capabilities: Set = emptySet(), + ): WearSessionList { + val response = + requester + .request( + WearRpcMethod.SessionsList, + buildJsonObject { + put("limit", 30) + if (WearProxyCapability.SessionSelectionLookup in capabilities) { + selectedSessionKey?.takeIf(String::isNotBlank)?.let { put("selectedSessionKey", it) } + } + }, + expectedNodeId, + ) + val result = response.payload.asObject("sessions.list") + return WearSessionList( + sessions = + (result["sessions"] as? JsonArray) + .orEmpty() + .mapNotNull { parseSession(it, response.sourceNodeId) }, + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + activeAgentId = result.string("activeAgentId"), + selectedSessionValid = result.boolean("selectedSessionValid") ?: false, + ) + } + + suspend fun history( + sessionKey: String, + expectedNodeId: String, + ): WearTranscript { + val response = + requester + .request( + WearRpcMethod.ChatHistory, + buildJsonObject { + put("sessionKey", sessionKey) + put("limit", 20) + put("maxChars", 2_000) + }, + expectedNodeId, + ) + val result = response.payload.asObject("chat.history") + val inFlight = result["inFlightRun"] as? JsonObject + return WearTranscript( + sessionKey = result.string("sessionKey") ?: sessionKey, + messages = (result["messages"] as? JsonArray).orEmpty().mapNotNull(::parseChatMessage), + activeRunId = inFlight?.string("runId"), + activeText = inFlight?.string("text"), + selectedModelRef = result.string("selectedModelRef"), + eventStreamId = response.eventStreamId, + eventSequence = response.eventSequence, + phoneNodeId = response.sourceNodeId, + ) + } + + suspend fun send( + attempt: WearSendAttempt, + requirePreferredPhone: Boolean = false, + ) { + requester.request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", attempt.sessionKey) + put("message", attempt.message) + put("idempotencyKey", attempt.idempotencyKey) + }, + attempt.phoneNodeId, + requirePreferredNode = requirePreferredPhone, + ) + } + + suspend fun abort( + sessionKey: String, + runId: String?, + phoneNodeId: String, + ) { + requester.request( + WearRpcMethod.ChatAbort, + buildJsonObject { + put("sessionKey", sessionKey) + runId?.let { put("runId", it) } + }, + phoneNodeId, + requirePreferredNode = true, + ) + } + + suspend fun startRealtimeTalk( + sessionKey: String, + attemptId: String, + language: String?, + phoneNodeId: String, + attemptScopedAudio: Boolean, + ): WearRealtimeTalkSnapshot { + val response = + requester.request( + WearRpcMethod.TalkStart, + buildJsonObject { + put("sessionKey", sessionKey) + put("attemptId", attemptId) + language?.let { put("language", it) } + if (attemptScopedAudio) put("attemptScopedAudio", true) + }, + phoneNodeId, + requirePreferredNode = true, + ) + return WearRealtimeTalkCodec.decode(response.payload) + } + + suspend fun stopRealtimeTalk( + phoneNodeId: String, + attemptId: String, + ): WearRealtimeTalkSnapshot { + val response = + requester.request( + WearRpcMethod.TalkStop, + buildJsonObject { put("attemptId", attemptId) }, + phoneNodeId, + requirePreferredNode = true, + ) + return WearRealtimeTalkCodec.decode(response.payload) + } +} + +internal fun parseWearChatEvent(payload: JsonElement?): WearChatEvent? { + val source = payload as? JsonObject ?: return null + return WearChatEvent( + sessionKey = source.string("sessionKey"), + runId = source.string("runId"), + state = source.string("state"), + deltaText = source.string("deltaText"), + replace = source.boolean("replace") ?: false, + streamText = source.string("streamText"), + streamTextComplete = source.boolean("streamTextComplete") ?: false, + message = parseChatMessage(source["message"]), + ) +} + +private fun parseSession( + element: JsonElement, + phoneNodeId: String, +): WearSession? { + val source = element as? JsonObject ?: return null + val key = source.string("key") ?: return null + val title = + source.string("displayName") + ?: source.string("label") + ?: key.substringAfterLast(':').ifBlank { "Session" } + return WearSession( + key = key, + title = title, + updatedAt = source.long("updatedAt") ?: source.long("lastActivityAt"), + hasActiveRun = source.boolean("hasActiveRun") ?: false, + phoneNodeId = phoneNodeId, + agentId = source.string("agentId"), + modelRef = source.string("modelRef"), + ) +} + +private fun parseModel(element: JsonElement): WearModel? { + val source = element as? JsonObject ?: return null + val ref = source.string("ref") ?: return null + return WearModel( + ref = ref, + name = source.string("name") ?: ref, + ) +} + +private fun parseAgent(element: JsonElement): WearAgent? { + val source = element as? JsonObject ?: return null + val id = source.string("id") ?: return null + return WearAgent( + id = id, + name = source.string("name") ?: id, + emoji = source.string("emoji"), + selected = source.boolean("selected") ?: false, + ) +} + +internal fun parseChatMessage(element: JsonElement?): WearChatMessage? { + val source = element as? JsonObject ?: return null + val role = source.string("role") ?: return null + val text = contentText(source["content"]) + if (text.isBlank()) return null + return WearChatMessage( + id = source.string("id"), + role = role, + text = text, + timestamp = source.long("timestamp"), + ) +} + +private fun contentText(element: JsonElement?): String = + when (element) { + is JsonPrimitive -> element.contentOrNull.orEmpty() + is JsonArray -> + element + .mapNotNull { part -> + when (part) { + is JsonPrimitive -> part.contentOrNull + is JsonObject -> part.string("text") + else -> null + } + }.filter { it.isNotBlank() } + .joinToString("\n") + else -> "" + } + +private fun JsonElement.asObject(method: String): JsonObject = this as? JsonObject ?: throw WearProxyException("invalid_response", "$method returned invalid data") + +private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + +private fun JsonObject.boolean(name: String): Boolean? = (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + +private fun JsonObject.long(name: String): Long? = (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull + +private fun JsonObject.proxyCapabilities(): Set = + (this["capabilities"] as? JsonArray) + .orEmpty() + .mapNotNull { element -> + (element as? JsonPrimitive) + ?.takeIf(JsonPrimitive::isString) + ?.contentOrNull + ?.let(WearProxyCapability::fromWireValue) + }.toSet() + +private fun Set.require(capability: WearProxyCapability) { + if (capability !in this) { + // Old phones omit capability negotiation. Fail before sending an RPC they + // cannot decode so the paired app remains usable during staggered updates. + throw WearProxyException("unsupported_peer", "Update OpenClaw on the paired phone") + } +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearLocaleText.kt b/wear/src/main/java/ai/openclaw/wear/WearLocaleText.kt new file mode 100644 index 0000000..2e5228c --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearLocaleText.kt @@ -0,0 +1,13 @@ +package ai.openclaw.wear + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import java.util.Locale + +@Composable +internal fun localizedWearUppercase(value: String): String = wearUppercase(value, LocalConfiguration.current.locales[0]) + +internal fun wearUppercase( + value: String, + locale: Locale = Locale.getDefault(), +): String = value.uppercase(locale) diff --git a/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt b/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt new file mode 100644 index 0000000..a199991 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt @@ -0,0 +1,573 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearDecodeResult +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearRpcMethod +import android.content.Context +import com.google.android.gms.tasks.Task +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal fun interface WearNodeResolver { + suspend fun reachablePhoneNodeId(): String? +} + +internal fun interface WearMessageTransport { + suspend fun send( + nodeId: String, + path: String, + data: ByteArray, + ) +} + +internal interface WearRpcRequester { + suspend fun request( + method: WearRpcMethod, + params: JsonObject, + expectedNodeId: String?, + requirePreferredNode: Boolean = false, + ): WearRpcResult +} + +internal data class WearRpcResult( + val payload: JsonElement, + val eventSequence: Long?, + val sourceNodeId: String, + val eventStreamId: String? = null, +) + +internal data class WearInboundEvent( + val sourceNodeId: String, + val sequence: Long, + val event: WearEventType, + val payload: JsonElement?, + val streamId: String? = null, +) + +internal class WearProxyException( + val code: String, + override val message: String, +) : IllegalStateException(message) + +internal class WearProxyClient private constructor( + private val nodeResolver: WearNodeResolver, + private val transport: WearMessageTransport, +) : WearRpcRequester { + private val pending = ConcurrentHashMap() + private val preferredPhoneLock = Any() + private var preferredPhoneGeneration = 0L + private var registeredPhone: PreferredPhoneRegistration? = null + private val inboundMutex = Mutex() + private val mutableEvents = + MutableSharedFlow( + extraBufferCapacity = MAX_BUFFERED_EVENTS, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val events: SharedFlow = mutableEvents + private val mutablePreferredPhoneChanges = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val preferredPhoneChanges: SharedFlow = mutablePreferredPhoneChanges + + override suspend fun request( + method: WearRpcMethod, + params: JsonObject, + expectedNodeId: String?, + requirePreferredNode: Boolean, + ): WearRpcResult { + var attemptedPreferredPhone: PreferredPhoneRegistration? = null + val result = + withTimeoutOrNull(WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS) { + requestBeforeDeadline(method, params, expectedNodeId, requirePreferredNode) { registration -> + attemptedPreferredPhone = registration + } + } + if (result != null) return result + + // MessageClient success only proves the request was queued. A silent phone + // must be rediscovered just like a node that rejected the send outright. + invalidatePreferredPhone(attemptedPreferredPhone) + throw WearProxyException("timeout", "Paired phone did not respond") + } + + private suspend fun requestBeforeDeadline( + method: WearRpcMethod, + params: JsonObject, + expectedNodeId: String?, + requirePreferredNode: Boolean, + recordPreferredPhoneAttempt: (PreferredPhoneRegistration?) -> Unit, + ): WearRpcResult { + // Stateful RPCs stay on the phone that supplied their session/transcript. + // Rediscovery here could route a shared session key to a different phone. + val preferredPhone = + when { + requirePreferredNode || expectedNodeId == null -> resolvePreferredPhone() + else -> preferredPhoneRegistration(expectedNodeId) + } + val nodeId = expectedNodeId ?: checkNotNull(preferredPhone).nodeId + if (requirePreferredNode && expectedNodeId != null && preferredPhone?.nodeId != expectedNodeId) { + throw WearProxyException("phone_changed", "Preferred phone changed during request") + } + recordPreferredPhoneAttempt(preferredPhone?.takeIf { it.nodeId == nodeId }) + val requestId = UUID.randomUUID().toString() + val response = CompletableDeferred() + val pendingRequest = + PendingWearRequest( + nodeId = nodeId, + response = response, + preferredPhone = preferredPhone?.takeIf { it.nodeId == nodeId }, + ) + check(pending.putIfAbsent(requestId, pendingRequest) == null) + return try { + try { + transport.send( + nodeId = nodeId, + path = WearProtocol.REQUEST_PATH, + data = + WearProtocolCodec.encode( + WearMessage.Request(requestId = requestId, method = method, params = params), + ), + ) + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + invalidatePreferredPhone(preferredPhone?.takeIf { it.nodeId == nodeId }) + throw WearProxyException("phone_unavailable", "Paired phone is unavailable") + } catch (_: Throwable) { + invalidatePreferredPhone(preferredPhone?.takeIf { it.nodeId == nodeId }) + throw WearProxyException("phone_unavailable", "Paired phone is unavailable") + } + val envelope = response.await() + if ( + (expectedNodeId == null || requirePreferredNode || method.requiresPreferredSnapshotSource()) && + currentPreferredPhone()?.nodeId != nodeId + ) { + throw WearProxyException("phone_changed", "Preferred phone changed during request") + } + if (!envelope.ok) { + val error = envelope.error + throw WearProxyException(error?.code ?: "unavailable", error?.message ?: "Phone proxy request failed") + } + WearRpcResult( + payload = envelope.result ?: buildJsonObject {}, + // Phone and watch can update independently. A missing v1 watermark means + // unknown, so the next event establishes the legacy phone's live baseline. + eventStreamId = envelope.eventStreamId, + eventSequence = envelope.eventSequence, + sourceNodeId = nodeId, + ) + } finally { + pending.remove(requestId, pendingRequest) + } + } + + suspend fun handleMessage( + sourceNodeId: String, + path: String, + data: ByteArray, + ): WearInboundEvent? = + inboundMutex.withLock { + val message = (WearProtocolCodec.decode(data) as? WearDecodeResult.Success)?.message ?: return@withLock null + when { + path == WearProtocol.RESPONSE_PATH && message is WearMessage.Response -> { + pending[message.requestId] + ?.takeIf { it.nodeId == sourceNodeId } + ?.let { request -> + // Correlation is the reachability proof. Advance the registration + // before a concurrently expiring request can invalidate it. + confirmPreferredPhoneResponse(request.preferredPhone) + request.response.complete(message) + } + null + } + path == WearProtocol.EVENT_PATH && message is WearMessage.Event -> { + if (!acceptEventSource(sourceNodeId)) return@withLock null + val inbound = + WearInboundEvent( + sourceNodeId = sourceNodeId, + streamId = message.streamId, + sequence = message.sequence, + event = message.event, + payload = message.payload, + ) + mutableEvents.tryEmit(inbound) + inbound + } + else -> null + } + } + + private suspend fun resolvePhoneNode(): String = + try { + nodeResolver.reachablePhoneNodeId() + } catch (_: CancellationException) { + // Play Services can cancel its Task while this request remains active. + // Preserve actual caller cancellation; map transport cancellation below. + currentCoroutineContext().ensureActive() + throw WearProxyException("phone_unavailable", "Paired phone is unavailable") + } catch (_: Throwable) { + throw WearProxyException("phone_unavailable", "Paired phone is unavailable") + } ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable") + + private suspend fun acceptEventSource(sourceNodeId: String): Boolean { + val preferredPhone = currentPreferredPhone() + if (preferredPhone != null) { + return preferredPhone.nodeId == sourceNodeId + } + return try { + resolvePreferredPhone().nodeId == sourceNodeId + } catch (err: CancellationException) { + throw err + } catch (_: WearProxyException) { + false + } + } + + /** A unique directly connected phone becomes the preferred routing source immediately. */ + fun updatePreferredPhoneNodeId(nodeId: String) { + val changed = + synchronized(preferredPhoneLock) { + val changed = registeredPhone?.nodeId != nodeId + preferredPhoneGeneration += 1 + registeredPhone = PreferredPhoneRegistration(nodeId, preferredPhoneGeneration) + changed + } + if (changed) mutablePreferredPhoneChanges.tryEmit(nodeId) + } + + /** Capability callbacks are not reachability-filtered, so ambiguous results force fresh discovery. */ + fun invalidatePreferredPhoneNode() { + val changed = + synchronized(preferredPhoneLock) { + val changed = registeredPhone != null + preferredPhoneGeneration += 1 + registeredPhone = null + changed + } + if (changed) mutablePreferredPhoneChanges.tryEmit(null) + } + + private suspend fun resolvePreferredPhone(): PreferredPhoneRegistration { + // Capability callbacks can invalidate the route while discovery suspends. + // Only a result from the same generation may repopulate it. + val discoveryGeneration = + synchronized(preferredPhoneLock) { + registeredPhone?.let { return it } + preferredPhoneGeneration + } + val resolved = resolvePhoneNode() + return synchronized(preferredPhoneLock) { + registeredPhone ?: if (preferredPhoneGeneration == discoveryGeneration) { + preferredPhoneGeneration += 1 + PreferredPhoneRegistration(resolved, preferredPhoneGeneration).also { registeredPhone = it } + } else { + null + } + } ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable") + } + + private fun currentPreferredPhone(): PreferredPhoneRegistration? = + synchronized(preferredPhoneLock) { + registeredPhone + } + + private fun preferredPhoneRegistration(nodeId: String): PreferredPhoneRegistration? = + synchronized(preferredPhoneLock) { + registeredPhone?.takeIf { it.nodeId == nodeId } + } + + private fun invalidatePreferredPhone(registration: PreferredPhoneRegistration?) { + if (registration == null) return + val invalidated = + synchronized(preferredPhoneLock) { + if (registeredPhone == registration) { + // A capability callback can refresh the same node while an older request + // is failing. Clear only the registration that owned this transport attempt. + preferredPhoneGeneration += 1 + registeredPhone = null + true + } else { + false + } + } + if (invalidated) mutablePreferredPhoneChanges.tryEmit(null) + } + + private fun confirmPreferredPhoneResponse(registration: PreferredPhoneRegistration?) { + if (registration == null) return + synchronized(preferredPhoneLock) { + if (registeredPhone == registration) { + // Any correlated response proves this registration is reachable. Advance + // it so an older parallel request cannot clear it on a later timeout. + preferredPhoneGeneration += 1 + registeredPhone = registration.copy(generation = preferredPhoneGeneration) + } + } + } + + private data class PreferredPhoneRegistration( + val nodeId: String, + val generation: Long, + ) + + private data class PendingWearRequest( + val nodeId: String, + val response: CompletableDeferred, + val preferredPhone: PreferredPhoneRegistration?, + ) + + companion object { + private const val MAX_BUFFERED_EVENTS = 64 + + fun create(context: Context): WearProxyClient { + val appContext = context.applicationContext + val capabilityClient = Wearable.getCapabilityClient(appContext) + val messageClient = Wearable.getMessageClient(appContext) + return WearProxyClient( + nodeResolver = + WearNodeResolver { + selectReachablePhoneNodeId( + capabilityClient + .getCapability(WearProtocol.PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE) + .await() + .nodes + .map { node -> WearReachablePhoneNode(id = node.id, isNearby = node.isNearby) }, + ) + }, + transport = + WearMessageTransport { nodeId, path, data -> + messageClient.sendMessage(nodeId, path, data).await() + }, + ) + } + + internal fun createForTests( + nodeResolver: WearNodeResolver, + transport: WearMessageTransport, + ): WearProxyClient = WearProxyClient(nodeResolver, transport) + } +} + +internal data class WearReachablePhoneNode( + val id: String, + val isNearby: Boolean, +) + +internal fun selectReachablePhoneNodeId(nodes: Collection): String? { + val distinctNodes = nodes.distinctBy(WearReachablePhoneNode::id) + val nearbyNodes = distinctNodes.filter(WearReachablePhoneNode::isNearby) + return when { + nearbyNodes.size == 1 -> nearbyNodes.single().id + nearbyNodes.isNotEmpty() -> null + distinctNodes.size == 1 -> distinctNodes.single().id + else -> null + } +} + +private fun WearRpcMethod.requiresPreferredSnapshotSource(): Boolean = this == WearRpcMethod.ProxyStatus || this == WearRpcMethod.SessionsList || this == WearRpcMethod.ChatHistory + +internal enum class WearSequenceDecision { + Accepted, + AwaitingSnapshot, + GapOrReset, +} + +internal data class WearResponseRequest( + val responseGeneration: Long, + val eventGeneration: Long, +) + +internal class WearEventSequenceTracker { + private var streamId: String? = null + private var lastSequence: Long? = null + private var awaitingSnapshot = false + private var responseGeneration = 0L + private var eventGeneration = 0L + + @Synchronized + fun adoptSnapshot( + streamId: String?, + sequence: Long?, + ) { + eventGeneration += 1 + if (sequence == null) { + this.streamId = streamId + lastSequence = null + awaitingSnapshot = false + return + } + val previous = lastSequence + val streamChanged = this.streamId != streamId && (this.streamId != null || streamId != null) + this.streamId = streamId + if (awaitingSnapshot || previous == null || streamChanged || sequence > previous) lastSequence = sequence + awaitingSnapshot = false + } + + @Synchronized + fun accept( + streamId: String?, + sequence: Long, + ): WearSequenceDecision { + if (awaitingSnapshot) return WearSequenceDecision.AwaitingSnapshot + val previous = lastSequence + if (previous == null) { + this.streamId = streamId + lastSequence = sequence + eventGeneration += 1 + return WearSequenceDecision.Accepted + } + if (this.streamId != streamId && (this.streamId != null || streamId != null)) { + awaitingSnapshot = true + eventGeneration += 1 + return WearSequenceDecision.GapOrReset + } + if (sequence == previous + 1) { + lastSequence = sequence + eventGeneration += 1 + return WearSequenceDecision.Accepted + } + // Stream epochs expose phone restarts even when the new process happens to + // produce the next numeric sequence. Legacy null epochs still use gap detection. + awaitingSnapshot = true + eventGeneration += 1 + return WearSequenceDecision.GapOrReset + } + + // Only the newest model RPC may mutate UI state. The event generation also + // rejects legacy unwatermarked responses when live state advanced meanwhile. + @Synchronized + fun beginResponseRequest(): WearResponseRequest { + responseGeneration += 1 + return WearResponseRequest(responseGeneration = responseGeneration, eventGeneration = eventGeneration) + } + + @Synchronized + fun invalidateResponseRequests() { + responseGeneration += 1 + } + + @Synchronized + fun isResponseCurrent( + request: WearResponseRequest, + streamId: String?, + sequence: Long?, + ): Boolean { + if (request.responseGeneration != responseGeneration) return false + if (awaitingSnapshot) return false + if (this.streamId != streamId && (this.streamId != null || streamId != null)) return false + val currentSequence = lastSequence + return if (sequence == null) { + request.eventGeneration == eventGeneration + } else { + sequence == currentSequence + } + } + + @Synchronized + fun requireSnapshot() { + awaitingSnapshot = true + eventGeneration += 1 + } +} + +internal class WearEventSourceTracker { + private var sourceNodeId: String? = null + + fun adopt(sourceNodeId: String) { + this.sourceNodeId = sourceNodeId + } + + fun reset() { + sourceNodeId = null + } + + fun changed(sourceNodeId: String): Boolean { + val previous = this.sourceNodeId + this.sourceNodeId = sourceNodeId + return previous != null && previous != sourceNodeId + } +} + +internal class WearEventResyncBuffer( + private val capacity: Int = MAX_BUFFERED_EVENTS, +) { + // The response watermark splits events already captured by a snapshot from + // later events that raced its delivery. A bounded overflow reappears as a gap. + private val events = LinkedHashMap, WearInboundEvent>() + private var buffering = false + + @Synchronized + fun begin() { + events.clear() + buffering = true + } + + @Synchronized + fun start(event: WearInboundEvent) { + begin() + appendLocked(event) + } + + @Synchronized + fun append(event: WearInboundEvent) { + if (buffering) appendLocked(event) + } + + @Synchronized + fun drainAfterSnapshot( + streamId: String?, + sequence: Long?, + ): List { + if (!buffering) return emptyList() + buffering = false + val pending = + if (sequence == null) { + // A legacy snapshot has no ordering boundary. It already represents + // pre-response state, so replay could duplicate it; the next live event + // establishes the new sequence baseline. + emptyList() + } else { + events.values + .filter { event -> event.streamId == streamId && event.sequence > sequence } + .sortedBy(WearInboundEvent::sequence) + } + events.clear() + return pending + } + + private fun appendLocked(event: WearInboundEvent) { + events[event.streamId to event.sequence] = event + while (events.size > capacity) events.remove(events.keys.first()) + } + + private companion object { + const val MAX_BUFFERED_EVENTS = 64 + } +} + +private suspend fun Task.await(): T = + suspendCancellableCoroutine { continuation -> + addOnSuccessListener { value -> if (continuation.isActive) continuation.resume(value) } + addOnFailureListener { error -> if (continuation.isActive) continuation.resumeWithException(error) } + addOnCanceledListener { continuation.cancel() } + } diff --git a/wear/src/main/java/ai/openclaw/wear/WearProxyListenerService.kt b/wear/src/main/java/ai/openclaw/wear/WearProxyListenerService.kt new file mode 100644 index 0000000..2933b9c --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearProxyListenerService.kt @@ -0,0 +1,46 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearProtocol +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.WearableListenerService +import kotlinx.coroutines.runBlocking + +class WearProxyListenerService : WearableListenerService() { + override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) { + if (capabilityInfo.name != WearProtocol.PHONE_CAPABILITY) return + val preferredNodeId = + selectReachablePhoneNodeId( + capabilityInfo.nodes.map { node -> + WearReachablePhoneNode(id = node.id, isNearby = node.isNearby) + }, + ) + val proxyClient = (application as? WearApplication)?.proxyClient ?: return + if (preferredNodeId == null) { + // Capability callbacks contain reachable nodes. Multiple routes remain + // ambiguous, so force the next request through fresh discovery. + proxyClient.invalidatePreferredPhoneNode() + } else { + proxyClient.updatePreferredPhoneNodeId(preferredNodeId) + } + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + if (messageEvent.path != WearProtocol.RESPONSE_PATH && messageEvent.path != WearProtocol.EVENT_PATH) return + val app = application as? WearApplication ?: return + // WearableListenerService callbacks use its background looper. Finish the + // bounded Data Layer work before returning so Android retains the service. + runBlocking { + val event = + app.proxyClient.handleMessage( + sourceNodeId = messageEvent.sourceNodeId, + path = messageEvent.path, + data = messageEvent.data, + ) ?: return@runBlocking + if (event.event == WearEventType.Chat && !app.isActivityVisible()) { + WearReplyNotifier(applicationContext).show(event) + } + } + } +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt b/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt new file mode 100644 index 0000000..0561dd9 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt @@ -0,0 +1,613 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRealtimeAudioFrameType +import ai.openclaw.wear.shared.WearRealtimeAudioFraming +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import android.annotation.SuppressLint +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import android.os.SystemClock +import com.google.android.gms.tasks.Task +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import java.io.InputStream +import java.io.OutputStream +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.ceil +import kotlin.math.sqrt + +internal class WearRealtimeTalkClient( + context: Context, + private val repository: WearGatewayRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val channelClient = Wearable.getChannelClient(context.applicationContext) + private val lifecycleLock = Mutex() + private val channelLock = Mutex() + private val audioLock = Any() + private val audioFocus = + WearAudioFocusController(context) { + activeAttempt?.let { attempt -> scope.launch { clearOutput(attempt, resumeCapture = true) } } + } + private val _isCapturing = MutableStateFlow(false) + val isCapturing: StateFlow = _isCapturing + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow = _isPlaying + private val _mouthLevel = MutableStateFlow(0f) + val mouthLevel: StateFlow = _mouthLevel + private val _channelFailed = MutableStateFlow(false) + val channelFailed: StateFlow = _channelFailed + + private val attemptGeneration = AtomicLong() + + @Volatile private var activeAttempt: ActiveAttempt? = null + + @Volatile private var audioRecord: AudioRecord? = null + private var captureJob: Job? = null + private var readJob: Job? = null + private var playbackIdleJob: Job? = null + private var mouthJob: Job? = null + private var mouthFrames: Channel? = null + private val mouthLevelAccumulator = Pcm16MouthLevelAccumulator() + private var audioTrack: AudioTrack? = null + private var playbackEndsAtMillis = 0L + + internal data class ChannelResources( + val channel: ChannelClient.Channel, + val input: InputStream, + val output: OutputStream, + ) + + internal data class ActiveAttempt( + val nodeId: String, + val attemptId: String, + val generation: Long, + val resources: ChannelResources, + ) + + suspend fun start( + session: WearSession, + attemptId: String, + capabilities: Set, + ): WearRealtimeTalkSnapshot = + lifecycleLock.withLock { + val nodeId = session.phoneNodeId + val attemptScopedAudio = WearProxyCapability.AttemptScopedRealtimeAudio in capabilities + var resources: ChannelResources? = null + var channelOpened = false + var activatedAttempt: ActiveAttempt? = null + try { + resources = openChannel(nodeId, attemptId, attemptScopedAudio) + channelOpened = true + val language = + Locale + .getDefault() + .language + .lowercase(Locale.ROOT) + .takeIf { value -> value.length == ISO_639_1_LANGUAGE_LENGTH } + val snapshot = + repository.startRealtimeTalk( + sessionKey = session.key, + attemptId = attemptId, + language = language, + phoneNodeId = nodeId, + attemptScopedAudio = attemptScopedAudio, + ) + val attempt = + ActiveAttempt( + nodeId = nodeId, + attemptId = attemptId, + generation = attemptGeneration.incrementAndGet(), + resources = checkNotNull(resources), + ) + activate(attempt) + activatedAttempt = attempt + resources = null + startReader(attempt) + startCapture(attempt) + snapshot + } catch (err: Throwable) { + closeChannel(resources) + activatedAttempt?.let(::closeLocal) + if (channelOpened) { + // Finish ambiguous-start cleanup before another attempt can acquire + // the lifecycle lock and create a replacement relay for this Watch. + withContext(NonCancellable) { runCatching { repository.stopRealtimeTalk(nodeId, attemptId) } } + } + throw err + } + } + + suspend fun stop(): WearRealtimeTalkSnapshot = + lifecycleLock.withLock { + val attempt = activeAttempt + try { + if (attempt == null) { + WearRealtimeTalkSnapshot() + } else { + repository.stopRealtimeTalk(attempt.nodeId, attempt.attemptId) + } + } finally { + closeLocal(attempt) + } + } + + fun shutdown() { + closeLocal() + scope.cancel() + } + + fun disconnectLocal() { + closeLocal() + } + + private suspend fun openChannel( + nodeId: String, + attemptId: String, + attemptScopedAudio: Boolean, + ): ChannelResources { + var lastError: Throwable? = null + repeat(CHANNEL_OPEN_ATTEMPTS) { attempt -> + var opened: ChannelClient.Channel? = null + var input: InputStream? = null + var output: OutputStream? = null + try { + opened = + channelClient + .openChannel(nodeId, wearRealtimeAudioChannelPath(attemptId, attemptScopedAudio)) + .awaitRealtimeTask() + input = channelClient.getInputStream(opened).awaitRealtimeTask() + output = channelClient.getOutputStream(opened).awaitRealtimeTask() + return ChannelResources(opened, input, output) + } catch (err: Throwable) { + withContext(NonCancellable) { + input.closeQuietly() + output.closeQuietly() + opened?.let { channel -> runCatching { channelClient.close(channel).awaitRealtimeTask() } } + } + if (err is CancellationException) throw err + lastError = err + if (attempt + 1 < CHANNEL_OPEN_ATTEMPTS) delay(CHANNEL_RETRY_DELAY_MILLIS) + } + } + throw WearProxyException("phone_unavailable", lastError?.message ?: "Unable to open Watch audio channel") + } + + private fun startReader(attempt: ActiveAttempt) { + val reader = + scope.launch(start = CoroutineStart.LAZY) { + try { + while (isCurrent(attempt)) { + val frame = WearRealtimeAudioFraming.read(attempt.resources.input) ?: break + if (!isCurrent(attempt)) break + when (frame.type) { + WearRealtimeAudioFrameType.OUTPUT_PCM -> writeOutput(attempt, frame.payload) + WearRealtimeAudioFrameType.CLEAR_OUTPUT -> clearOutput(attempt, resumeCapture = true) + WearRealtimeAudioFrameType.INPUT_PCM -> error("Phone sent an invalid Watch audio frame") + } + } + handleChannelFailure(attempt) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + handleChannelFailure(attempt) + } + } + val installed = + synchronized(audioLock) { + if (!isCurrent(attempt)) { + false + } else { + readJob?.cancel() + readJob = reader + true + } + } + if (installed) reader.start() else reader.cancel() + } + + @SuppressLint("MissingPermission") + private fun startCapture(attempt: ActiveAttempt) { + synchronized(audioLock) { startCaptureLocked(attempt) } + } + + @SuppressLint("MissingPermission") + private fun startCaptureLocked(attempt: ActiveAttempt) { + if (_isCapturing.value || _isPlaying.value || !isCurrent(attempt)) return + val frameBytes = + WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ * PCM_16_BYTES * + WearProtocol.REALTIME_AUDIO_FRAME_MILLIS / 1_000 + val minimumBuffer = + AudioRecord.getMinBufferSize( + WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + require(minimumBuffer > 0) + val recorder = + AudioRecord + .Builder() + .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) + .setAudioFormat( + AudioFormat + .Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .build(), + ).setBufferSizeInBytes(maxOf(minimumBuffer * 2, frameBytes * 4)) + .build() + check(recorder.state == AudioRecord.STATE_INITIALIZED) + audioRecord = recorder + recorder.startRecording() + _isCapturing.value = true + captureJob = + scope.launch { + val buffer = ByteArray(frameBytes) + try { + while ( + currentCoroutineContext().isActive && + _isCapturing.value && + audioRecord === recorder && + isCurrent(attempt) + ) { + val read = recorder.read(buffer, 0, buffer.size) + val evenBytes = read - (read and 1) + if (!_isCapturing.value || audioRecord !== recorder) break + check(read >= 0) { "Watch microphone read failed: $read" } + if (evenBytes == 0) { + yield() + continue + } + sendInputFrame(attempt, buffer.copyOf(evenBytes)) + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + handleChannelFailure(attempt) + } finally { + runCatching { recorder.stop() } + runCatching { recorder.release() } + if (audioRecord === recorder) audioRecord = null + } + } + } + + private suspend fun sendInputFrame( + attempt: ActiveAttempt, + payload: ByteArray, + ) { + channelLock.withLock { + if (!isCurrent(attempt)) return + withContext(Dispatchers.IO) { + WearRealtimeAudioFraming.write(attempt.resources.output, WearRealtimeAudioFrameType.INPUT_PCM, payload) + } + } + } + + private fun writeOutput( + attempt: ActiveAttempt, + bytes: ByteArray, + ) { + synchronized(audioLock) { + if (!isCurrent(attempt)) return + if (!_isPlaying.value) { + pauseCaptureLocked() + check(audioFocus.request()) + } + val mouthLevels = mouthLevelAccumulator.append(bytes) + val track = audioTrack ?: createAudioTrack(bytes.size).also { audioTrack = it } + var written = 0 + while (written < bytes.size) { + val count = track.write(bytes, written, bytes.size - written) + if (count <= 0) break + written += count + } + check(written == bytes.size) + if (track.playState != AudioTrack.PLAYSTATE_PLAYING) track.play() + _isPlaying.value = true + if (mouthLevels.isNotEmpty()) { + val timeline = mouthTimelineLocked() + mouthLevels.forEach { level -> timeline.trySend(level) } + } + val durationMillis = + ((written / PCM_16_BYTES.toDouble()) / WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ * 1_000.0) + .toLong() + .coerceAtLeast(1L) + playbackEndsAtMillis = maxOf(SystemClock.elapsedRealtime(), playbackEndsAtMillis) + durationMillis + schedulePlaybackIdle(attempt) + } + } + + private fun mouthTimelineLocked(): Channel { + mouthFrames?.let { return it } + val frames = + Channel( + capacity = MOUTH_QUEUE_CAPACITY, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + mouthFrames = frames + mouthJob = + scope.launch { + try { + for (level in frames) { + _mouthLevel.value = level + delay(MOUTH_FRAME_MILLIS.toLong()) + } + } finally { + if (mouthFrames === frames) _mouthLevel.value = 0f + } + } + return frames + } + + private fun createAudioTrack(frameBytes: Int): AudioTrack { + val minimumBuffer = + AudioTrack.getMinBufferSize( + WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + require(minimumBuffer > 0) + return AudioTrack + .Builder() + .setAudioAttributes(wearSpeechAudioAttributes) + .setAudioFormat( + AudioFormat + .Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build(), + ).setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(maxOf(minimumBuffer * 2, frameBytes * 4)) + .build() + .also { check(it.state == AudioTrack.STATE_INITIALIZED) } + } + + private fun schedulePlaybackIdle(attempt: ActiveAttempt) { + playbackIdleJob?.cancel() + val scheduledPlaybackEndMillis = playbackEndsAtMillis + val finalFrameDurationMillis = mouthLevelAccumulator.pendingFrameDurationMillis() + playbackIdleJob = + scope.launch { + if (finalFrameDurationMillis > 0L) { + val finalFrameStartsAtMillis = scheduledPlaybackEndMillis - finalFrameDurationMillis + // Emit the residual at its cumulative sample position; clear/reset below + // discards it only when the matching AudioTrack tail is also discarded. + while (SystemClock.elapsedRealtime() < finalFrameStartsAtMillis) delay(MOUTH_FRAME_MILLIS.toLong()) + synchronized(audioLock) { + if (isCurrent(attempt) && playbackEndsAtMillis == scheduledPlaybackEndMillis) { + mouthLevelAccumulator.flush().forEach { level -> mouthTimelineLocked().trySend(level) } + } + } + } + while (SystemClock.elapsedRealtime() < scheduledPlaybackEndMillis) delay(MOUTH_FRAME_MILLIS.toLong()) + delay(PLAYBACK_DRAIN_GRACE_MILLIS) + synchronized(audioLock) { + if ( + isCurrent(attempt) && + playbackEndsAtMillis == scheduledPlaybackEndMillis && + SystemClock.elapsedRealtime() >= scheduledPlaybackEndMillis + ) { + clearOutputLocked(attempt, resumeCapture = true) + } + } + } + } + + private fun clearOutput( + attempt: ActiveAttempt, + resumeCapture: Boolean, + ) { + synchronized(audioLock) { + if (isCurrent(attempt)) clearOutputLocked(attempt, resumeCapture) + } + } + + private fun clearOutputLocked( + attempt: ActiveAttempt?, + resumeCapture: Boolean, + ) { + playbackIdleJob?.cancel() + playbackIdleJob = null + playbackEndsAtMillis = 0L + val activeMouthFrames = mouthFrames + mouthFrames = null + activeMouthFrames?.close() + mouthJob?.cancel() + mouthJob = null + mouthLevelAccumulator.reset() + _mouthLevel.value = 0f + runCatching { + audioTrack?.pause() + audioTrack?.flush() + audioTrack?.stop() + audioTrack?.release() + } + audioTrack = null + _isPlaying.value = false + audioFocus.abandon() + if (resumeCapture && attempt != null && isCurrent(attempt)) { + runCatching { startCaptureLocked(attempt) } + } + } + + private fun pauseCaptureLocked() { + _isCapturing.value = false + captureJob?.cancel() + captureJob = null + val recorder = audioRecord + audioRecord = null + runCatching { recorder?.stop() } + runCatching { recorder?.release() } + } + + private fun handleChannelFailure(attempt: ActiveAttempt) { + if (!closeLocal(attempt, failed = true)) return + scope.launch { runCatching { repository.stopRealtimeTalk(attempt.nodeId, attempt.attemptId) } } + } + + private fun activate(attempt: ActiveAttempt) { + synchronized(audioLock) { + check(activeAttempt == null) + _channelFailed.value = false + activeAttempt = attempt + } + } + + private fun closeLocal( + expected: ActiveAttempt? = activeAttempt, + failed: Boolean = false, + ): Boolean { + val attempt = + synchronized(audioLock) { + val current = activeAttempt ?: return false + if (expected != null && current.generation != expected.generation) return false + activeAttempt = null + if (failed) _channelFailed.value = true + readJob?.cancel() + readJob = null + pauseCaptureLocked() + clearOutputLocked(attempt = null, resumeCapture = false) + current + } + scope.launch { closeChannel(attempt.resources) } + return true + } + + private suspend fun closeChannel(resources: ChannelResources?) { + if (resources == null) return + resources.input.closeQuietly() + resources.output.closeQuietly() + runCatching { channelClient.close(resources.channel).awaitRealtimeTask() } + } + + private fun isCurrent(attempt: ActiveAttempt): Boolean = activeAttempt?.generation == attempt.generation + + private companion object { + const val CHANNEL_OPEN_ATTEMPTS = 2 + const val CHANNEL_RETRY_DELAY_MILLIS = 250L + const val ISO_639_1_LANGUAGE_LENGTH = 2 + const val MOUTH_QUEUE_CAPACITY = 256 + const val PCM_16_BYTES = 2 + const val PLAYBACK_DRAIN_GRACE_MILLIS = 120L + } +} + +internal fun wearRealtimeAudioChannelPath( + attemptId: String, + attemptScopedAudio: Boolean, +): String = + if (attemptScopedAudio) { + WearProtocol.realtimeAudioChannelPath(attemptId) + } else { + // v2026.7.2 shipped the fixed path. Keep it for staggered phone/Watch updates. + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH + } + +internal fun pcm16LeMouthLevels( + pcm: ByteArray, + sampleRateHz: Int = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ, + frameMillis: Int = MOUTH_FRAME_MILLIS, +): List = + Pcm16MouthLevelAccumulator(sampleRateHz, frameMillis).run { + append(pcm) + flush() + } + +internal class Pcm16MouthLevelAccumulator( + private val sampleRateHz: Int = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ, + frameMillis: Int = MOUTH_FRAME_MILLIS, +) { + private val samplesPerFrame: Int + private var squareSum = 0.0 + private var sampleCount = 0 + + init { + require(sampleRateHz > 0 && frameMillis > 0) + samplesPerFrame = (sampleRateHz * frameMillis / 1_000).coerceAtLeast(1) + } + + fun append(pcm: ByteArray): List { + require(pcm.size % PCM_BYTES_PER_SAMPLE == 0) + return buildList { + var byteIndex = 0 + while (byteIndex < pcm.size) { + val low = pcm[byteIndex].toInt() and 0xff + val high = pcm[byteIndex + 1].toInt() + val sample = ((high shl 8) or low).toShort().toInt() + val normalized = sample / 32_768.0 + squareSum += normalized * normalized + sampleCount += 1 + byteIndex += PCM_BYTES_PER_SAMPLE + if (sampleCount == samplesPerFrame) add(finishFrame()) + } + } + } + + fun flush(): List = if (sampleCount == 0) emptyList() else listOf(finishFrame()) + + fun reset() { + squareSum = 0.0 + sampleCount = 0 + } + + fun pendingFrameDurationMillis(): Long = + if (sampleCount == 0) { + 0L + } else { + ceil(sampleCount * 1_000.0 / sampleRateHz).toLong() + } + + private fun finishFrame(): Float { + val rms = sqrt(squareSum / sampleCount) + val gated = ((rms - RMS_NOISE_GATE) / RMS_SPEECH_RANGE).coerceIn(0.0, 1.0) + reset() + return sqrt(gated).toFloat() + } +} + +internal const val MOUTH_FRAME_MILLIS = 20 +private const val PCM_BYTES_PER_SAMPLE = 2 +private const val RMS_NOISE_GATE = 0.015 +private const val RMS_SPEECH_RANGE = 0.2 + +private fun java.io.Closeable?.closeQuietly() { + runCatching { this?.close() } +} + +private suspend fun Task.awaitRealtimeTask(): T = + suspendCancellableCoroutine { continuation -> + addOnSuccessListener { value -> if (continuation.isActive) continuation.resume(value) } + addOnFailureListener { error -> if (continuation.isActive) continuation.resumeWithException(error) } + addOnCanceledListener { continuation.cancel() } + } diff --git a/wear/src/main/java/ai/openclaw/wear/WearReplyNotifier.kt b/wear/src/main/java/ai/openclaw/wear/WearReplyNotifier.kt new file mode 100644 index 0000000..64344f2 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearReplyNotifier.kt @@ -0,0 +1,286 @@ +package ai.openclaw.wear + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.security.MessageDigest + +internal class WearReplyNotifier( + private val context: Context, +) { + fun show(inbound: WearInboundEvent) { + val event = parseWearChatEvent(inbound.payload) ?: return + if (event.state != "final") return + val message = event.message ?: return + if (message.role != "assistant") return + val sessionKey = event.sessionKey ?: return + if (!notificationsAllowed()) return + + createChannel() + val fallbackIdentity = + event.runId + ?: listOf( + "source:${inbound.sourceNodeId}", + "stream:${inbound.streamId ?: "legacy"}", + "sequence:${inbound.sequence}", + ).joinToString("\u0000") + val notificationTag = replyNotificationTag(sessionKey, message, fallbackIdentity) + val requestCode = NOTIFICATION_ID + val replyAction = createReplyAction(sessionKey, notificationTag, inbound.sourceNodeId) + val openPendingIntent = createOpenAppIntent(requestCode) + val agent = Person.Builder().setName("OpenClaw").build() + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_title)) + .setContentText(message.text) + .setContentIntent(openPendingIntent) + .setAutoCancel(true) + .setLocalOnly(true) + .setOnlyAlertOnce(true) + .setStyle( + NotificationCompat + .MessagingStyle(agent) + .addMessage(message.text, message.timestamp ?: System.currentTimeMillis(), agent), + ).addAction(replyAction) + .build() + notify(notificationTag, notification) + } + + fun showReplyFailure( + sessionKey: String, + notificationTag: String, + phoneNodeId: String, + ) { + if (!notificationsAllowed()) return + createChannel() + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_reply_failed_title)) + .setContentText(context.getString(R.string.notification_reply_failed_text)) + .setAutoCancel(true) + .setLocalOnly(true) + .addAction(createReplyAction(sessionKey, notificationTag, phoneNodeId)) + .build() + notify(notificationTag, notification) + } + + fun showPreferredPhoneChanged(notificationTag: String) { + if (!notificationsAllowed()) return + createChannel() + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_phone_changed_title)) + .setContentText(context.getString(R.string.notification_phone_changed_text)) + .setContentIntent(createOpenAppIntent(NOTIFICATION_ID)) + .setAutoCancel(true) + .setLocalOnly(true) + .build() + notify(notificationTag, notification) + } + + private fun createOpenAppIntent(requestCode: Int): PendingIntent = + PendingIntent.getActivity( + context, + requestCode, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun createReplyAction( + sessionKey: String, + notificationTag: String, + phoneNodeId: String, + ): NotificationCompat.Action { + val replyIntent = + Intent(context, WearReplyReceiver::class.java).apply { + action = replyPendingIntentAction(sessionKey, notificationTag) + putExtra(EXTRA_SESSION_KEY, sessionKey) + putExtra(EXTRA_NOTIFICATION_TAG, notificationTag) + putExtra(EXTRA_PHONE_NODE_ID, phoneNodeId) + } + val replyPendingIntent = + PendingIntent.getBroadcast( + context, + NOTIFICATION_ID, + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT, + ) + val remoteInput = + RemoteInput + .Builder(REPLY_RESULT_KEY) + .setLabel(context.getString(R.string.notification_reply)) + .build() + return NotificationCompat.Action + .Builder( + R.drawable.ic_notification, + context.getString(R.string.notification_reply), + replyPendingIntent, + ).addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .build() + } + + private fun createChannel() { + val manager = context.getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + } + + private fun notificationsAllowed(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + + @SuppressLint("MissingPermission") + private fun notify( + notificationTag: String, + notification: android.app.Notification, + ) { + if (!notificationsAllowed()) return + try { + NotificationManagerCompat.from(context).notify(notificationTag, NOTIFICATION_ID, notification) + } catch (_: SecurityException) { + // Permission can be revoked between the explicit check and notify(). + } + } + + private companion object { + const val CHANNEL_ID = "openclaw_wear_replies" + } +} + +class WearReplyReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + val sessionKey = intent.getStringExtra(EXTRA_SESSION_KEY)?.takeIf { it.isNotBlank() } ?: return + val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)?.takeIf { it.isNotBlank() } ?: return + val phoneNodeId = intent.getStringExtra(EXTRA_PHONE_NODE_ID)?.takeIf { it.isNotBlank() } ?: return + val reply = + RemoteInput + .getResultsFromIntent(intent) + ?.getCharSequence(REPLY_RESULT_KEY) + ?.toString() + ?.trim() + ?.takeIf { it.isNotEmpty() } ?: return + val pendingResult = goAsync() + val app = + context.applicationContext as? WearApplication + ?: run { + pendingResult.finish() + return + } + app.processScope.launch { + try { + // Broadcast receivers have a short execution window. Bound discovery, + // transport, and response wait together so finish() always wins the race. + withTimeout(REPLY_BROADCAST_TIMEOUT_MS) { + app.gatewayRepository.send( + WearSendAttempt( + sessionKey = sessionKey, + message = reply, + idempotencyKey = notificationReplyIdempotencyKey(sessionKey, notificationTag, reply), + phoneNodeId = phoneNodeId, + ), + requirePreferredPhone = true, + ) + } + NotificationManagerCompat.from(context).cancel(notificationTag, NOTIFICATION_ID) + } catch (err: TimeoutCancellationException) { + Log.w(LOG_TAG, "Wear notification reply timed out", err) + WearReplyNotifier(context.applicationContext).showReplyFailure(sessionKey, notificationTag, phoneNodeId) + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + Log.w(LOG_TAG, "Wear notification reply failed", err) + val notifier = WearReplyNotifier(context.applicationContext) + when (notificationReplyFailureAction(err)) { + NotificationReplyFailureAction.RetrySamePhone -> + notifier.showReplyFailure(sessionKey, notificationTag, phoneNodeId) + NotificationReplyFailureAction.OpenApp -> notifier.showPreferredPhoneChanged(notificationTag) + } + } finally { + pendingResult.finish() + } + } + } +} + +internal const val EXTRA_SESSION_KEY = "openclaw_wear_session_key" +internal const val EXTRA_NOTIFICATION_TAG = "openclaw_wear_notification_tag" +internal const val EXTRA_PHONE_NODE_ID = "openclaw_wear_phone_node_id" + +internal fun replyNotificationTag( + sessionKey: String, + message: WearChatMessage, + fallbackIdentity: String, +): String { + val messageIdentity = + when { + message.id != null -> "id:${message.id}" + message.timestamp != null -> "timestamp:${message.timestamp}\u0000${message.role}\u0000${message.text}" + else -> "fallback:$fallbackIdentity" + } + return "ai.openclaw.wear.NOTIFICATION.${sha256("$sessionKey\u0000$messageIdentity")}" +} + +internal fun replyPendingIntentAction( + sessionKey: String, + notificationTag: String, +): String = "ai.openclaw.wear.REPLY.${sha256("$sessionKey\u0000$notificationTag")}" + +internal fun notificationReplyIdempotencyKey( + sessionKey: String, + notificationTag: String, + reply: String, +): String = "wear-notification-${sha256("$sessionKey\u0000$notificationTag\u0000$reply")}" + +internal enum class NotificationReplyFailureAction { + RetrySamePhone, + OpenApp, +} + +internal fun notificationReplyFailureAction(error: Throwable): NotificationReplyFailureAction = + if (error is WearProxyException && error.code == "phone_changed") { + NotificationReplyFailureAction.OpenApp + } else { + NotificationReplyFailureAction.RetrySamePhone + } + +private fun sha256(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } +} + +private const val LOG_TAG = "OpenClawWear" +private const val NOTIFICATION_ID = 7301 +private const val REPLY_BROADCAST_TIMEOUT_MS = 5_000L diff --git a/wear/src/main/java/ai/openclaw/wear/WearReplySpeaker.kt b/wear/src/main/java/ai/openclaw/wear/WearReplySpeaker.kt new file mode 100644 index 0000000..88b6ec2 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearReplySpeaker.kt @@ -0,0 +1,117 @@ +package ai.openclaw.wear + +import android.content.Context +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale +import java.util.UUID + +internal class WearReplySpeaker( + context: Context, +) { + private val _isSpeaking = MutableStateFlow(false) + val isSpeaking: StateFlow = _isSpeaking.asStateFlow() + private val audioFocus = WearAudioFocusController(context, ::stop) + + private var engine: TextToSpeech? = null + private var ready = false + private var pendingText: String? = null + + init { + val created = + TextToSpeech(context.applicationContext) { status -> + ready = status == TextToSpeech.SUCCESS + if (ready) { + engine?.language = Locale.getDefault() + engine?.setAudioAttributes(wearSpeechAudioAttributes) + pendingText?.let(::speak) + } else { + pendingText = null + _isSpeaking.value = false + audioFocus.abandon() + } + } + engine = created + created.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String) { + _isSpeaking.value = true + } + + override fun onDone(utteranceId: String) { + _isSpeaking.value = false + audioFocus.abandon() + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onError(utteranceId: String) { + _isSpeaking.value = false + audioFocus.abandon() + } + + override fun onError( + utteranceId: String, + errorCode: Int, + ) { + _isSpeaking.value = false + audioFocus.abandon() + } + + override fun onStop( + utteranceId: String, + interrupted: Boolean, + ) { + _isSpeaking.value = false + audioFocus.abandon() + } + }, + ) + if (ready) { + created.language = Locale.getDefault() + created.setAudioAttributes(wearSpeechAudioAttributes) + pendingText?.let(::speak) + } + } + + fun speak(text: String) { + val normalized = text.trim().takeIf(String::isNotEmpty) ?: return + if (!ready) { + pendingText = normalized + return + } + pendingText = null + audioFocus.request() + val result = + engine?.speak( + normalized, + TextToSpeech.QUEUE_FLUSH, + Bundle(), + UUID.randomUUID().toString(), + ) + if (result == TextToSpeech.ERROR) { + _isSpeaking.value = false + audioFocus.abandon() + } + } + + fun stop() { + pendingText = null + engine?.stop() + _isSpeaking.value = false + audioFocus.abandon() + } + + fun shutdown() { + pendingText = null + engine?.stop() + engine?.shutdown() + engine = null + ready = false + _isSpeaking.value = false + audioFocus.abandon() + } +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearScreens.kt b/wear/src/main/java/ai/openclaw/wear/WearScreens.kt new file mode 100644 index 0000000..25a555b --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearScreens.kt @@ -0,0 +1,2066 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearRealtimeTalkEntry +import ai.openclaw.wear.shared.WearRealtimeTalkRole +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import ai.openclaw.wear.shared.WearRealtimeTalkStatus +import android.os.SystemClock +import android.view.HapticFeedbackConstants +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.foundation.pager.HorizontalPager +import androidx.wear.compose.foundation.pager.rememberPagerState +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.HorizontalPagerScaffold +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.minimumInteractiveComponentSize +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import androidx.compose.ui.semantics.onClick as semanticsOnClick + +internal enum class WearHomePage { + Chat, + Voice, + Controls, +} + +private const val VOICE_MODE_COUNT = 2 +private const val VOICE_HOME_MODE = 0 +private const val VOICE_THREAD_MODE = 1 + +internal data class WearVoiceLayout( + val horizontalPadding: Dp, + val orbSize: Dp, + val contentHeight: Dp, +) + +internal fun wearVoiceLayout( + maxWidth: Dp, + fontScale: Float, +): WearVoiceLayout { + val compact = maxWidth <= 192.dp + val compactLargeText = compact && fontScale > 1.1f + return WearVoiceLayout( + horizontalPadding = if (fontScale > 1.1f) 4.dp else 6.dp, + orbSize = + when { + compactLargeText -> 68.dp + compact -> 80.dp + else -> 92.dp + }, + contentHeight = + when { + compactLargeText -> 132.dp + compact -> 144.dp + else -> 156.dp + }, + ) +} + +@Composable +internal fun OpenClawWearScreens( + snapshot: WearConversationSnapshot?, + failure: WearConversationFailure?, + loading: Boolean, + interaction: WearInteractionState, + speaking: Boolean, + realtimeCapturing: Boolean, + realtimePlaying: Boolean, + realtimeMouthLevel: Float, + realtimePlaybackFailed: Boolean, + realtimeThinkingOverride: Boolean, + actionBusy: Boolean, + inputEnabled: Boolean, + canAbort: Boolean, + themeMode: WearThemeMode, + autoSpeak: Boolean, + notificationsGranted: Boolean, + initialPage: WearHomePage = WearHomePage.Chat, + navigationRequest: WearNavigationRequest? = null, + voiceSwipeHintEnabled: Boolean = true, + onNavigationRequestHandled: (Int) -> Unit = {}, + onTalk: () -> Unit, + onType: () -> Unit, + onRealtimeTalk: () -> Unit, + onAbort: () -> Unit, + onSelectAgent: (String) -> Unit, + onSelectSession: (String) -> Unit, + onSelectModel: (String) -> Unit, + onRefresh: () -> Unit, + onGatewayEnabledChange: (Boolean) -> Unit, + onThemeModeChange: (WearThemeMode) -> Unit, + onAutoSpeakChange: (Boolean) -> Unit, + onRequestNotifications: () -> Unit, + onOpenNotificationSettings: () -> Unit, + onSpeakLatest: () -> Unit, + onStopSpeaking: () -> Unit, +) { + if (snapshot == null) { + ConnectionStateScreen( + loading = loading, + failure = failure, + onRefresh = onRefresh, + ) + return + } + + val colors = OpenClawWearTheme.colors + val pagerState = + rememberPagerState( + initialPage = initialPage.ordinal, + pageCount = { WearHomePage.entries.size }, + ) + val voicePagerState = rememberPagerState(pageCount = { VOICE_MODE_COUNT }) + val pagerScope = rememberCoroutineScope() + val realtimeActive = snapshot.realtimeTalk.active || realtimeCapturing + var showVoiceSwipeHint by remember { mutableStateOf(voiceSwipeHintEnabled) } + var realtimeStartedAtMillis by remember { mutableLongStateOf(0L) } + var realtimeElapsedSeconds by remember { mutableLongStateOf(0L) } + LaunchedEffect(navigationRequest?.id) { + val request = navigationRequest ?: return@LaunchedEffect + val destination = wearLaunchPage(request.target, realtimeActive) + pagerState.scrollToPage(destination.ordinal) + onNavigationRequestHandled(request.id) + } + LaunchedEffect(pagerState.currentPage, showVoiceSwipeHint) { + if (pagerState.currentPage == WearHomePage.Voice.ordinal && showVoiceSwipeHint) { + delay(1_800L) + showVoiceSwipeHint = false + } + } + LaunchedEffect(realtimeActive) { + if (!realtimeActive) { + realtimeStartedAtMillis = 0L + realtimeElapsedSeconds = 0L + return@LaunchedEffect + } + if (realtimeStartedAtMillis == 0L) { + realtimeStartedAtMillis = SystemClock.elapsedRealtime() + } + while (isActive) { + realtimeElapsedSeconds = + ((SystemClock.elapsedRealtime() - realtimeStartedAtMillis) / 1_000L) + .coerceAtLeast(0L) + delay(250L) + } + } + BackHandler(enabled = pagerState.currentPage == WearHomePage.Voice.ordinal) { + pagerScope.launch { + pagerState.animateScrollToPage(WearHomePage.Chat.ordinal) + } + } + HorizontalPagerScaffold( + pagerState = pagerState, + modifier = + Modifier + .fillMaxSize() + .background(colors.canvas), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + rotaryScrollableBehavior = null, + userScrollEnabled = + pagerState.currentPage != WearHomePage.Voice.ordinal || + voicePagerState.currentPage == VOICE_HOME_MODE || + voicePagerState.currentPage == VOICE_THREAD_MODE, + ) { page -> + when (page) { + WearHomePage.Chat.ordinal -> + ChatPage( + snapshot = snapshot, + interaction = interaction, + speaking = speaking, + actionBusy = actionBusy, + inputEnabled = inputEnabled, + canAbort = canAbort, + onTalk = onTalk, + onType = onType, + onAbort = onAbort, + onSelectAgent = onSelectAgent, + onSelectSession = onSelectSession, + onSelectModel = onSelectModel, + onSpeakLatest = onSpeakLatest, + onStopSpeaking = onStopSpeaking, + ) + WearHomePage.Controls.ordinal -> + ControlsPage( + snapshot = snapshot, + themeMode = themeMode, + autoSpeak = autoSpeak, + notificationsGranted = notificationsGranted, + gatewayControlSupported = snapshot.gatewayControlsSupported, + actionBusy = actionBusy, + onThemeModeChange = onThemeModeChange, + onAutoSpeakChange = onAutoSpeakChange, + onRequestNotifications = onRequestNotifications, + onOpenNotificationSettings = onOpenNotificationSettings, + onRefresh = onRefresh, + onGatewayEnabledChange = onGatewayEnabledChange, + ) + else -> + VoicePage( + voicePagerState = voicePagerState, + showSwipeHint = showVoiceSwipeHint && pagerState.currentPage == WearHomePage.Voice.ordinal, + realtimeTalk = snapshot.realtimeTalk, + speaking = speaking, + realtimeCapturing = realtimeCapturing, + realtimePlaying = realtimePlaying, + realtimeMouthLevel = realtimeMouthLevel, + realtimePlaybackFailed = realtimePlaybackFailed, + realtimeThinkingOverride = realtimeThinkingOverride, + realtimeElapsedSeconds = realtimeElapsedSeconds, + actionBusy = actionBusy, + inputEnabled = inputEnabled, + onTalk = onTalk, + onType = onType, + onRealtimeTalk = onRealtimeTalk, + onStopSpeaking = onStopSpeaking, + ) + } + } + } +} + +internal fun wearLaunchPage( + target: WearLaunchTarget, + realtimeActive: Boolean, +): WearHomePage = if (realtimeActive) WearHomePage.Voice else target.initialPage + +@Composable +private fun ChatPage( + snapshot: WearConversationSnapshot, + interaction: WearInteractionState, + speaking: Boolean, + actionBusy: Boolean, + inputEnabled: Boolean, + canAbort: Boolean, + onTalk: () -> Unit, + onType: () -> Unit, + onAbort: () -> Unit, + onSelectAgent: (String) -> Unit, + onSelectSession: (String) -> Unit, + onSelectModel: (String) -> Unit, + onSpeakLatest: () -> Unit, + onStopSpeaking: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + WearPage(pageLabel = stringResource(R.string.chat)) { + item { + ConversationIdentity( + snapshot = snapshot, + actionBusy = actionBusy, + onSelectAgent = onSelectAgent, + onSelectSession = onSelectSession, + onSelectModel = onSelectModel, + ) + } + item { + ConversationStatus( + interaction = interaction, + speaking = speaking, + gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED, + ) + } + item { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ActionButton( + label = stringResource(R.string.talk), + enabled = inputEnabled && !actionBusy && !speaking, + onClick = onTalk, + modifier = Modifier.weight(1f), + ) + ActionButton( + label = stringResource(R.string.type), + enabled = inputEnabled && !actionBusy && !speaking, + onClick = onType, + modifier = Modifier.weight(1f), + ) + } + } + if (canAbort) { + item { + SecondaryButton( + label = stringResource(R.string.abort_run), + enabled = true, + onClick = onAbort, + ) + } + } + if (snapshot.messages.isEmpty() && snapshot.streamingAssistantText.isNullOrBlank()) { + item { + EmptyConversation() + } + } else { + snapshot.messages + .takeLast(VISIBLE_MESSAGE_COUNT) + .forEach { message -> + item(key = message.id ?: "${message.role}:${message.timestamp}:${message.text.hashCode()}") { + MessageBubble(message = message) + } + } + snapshot.streamingAssistantText + ?.takeIf(String::isNotBlank) + ?.let { streaming -> + item { + StreamingBubble(text = streaming) + } + } + } + if (snapshot.messages.any { message -> message.chatRole == WearChatRole.ASSISTANT }) { + item { + SecondaryButton( + label = + if (speaking) { + stringResource(R.string.stop_speaking) + } else { + stringResource(R.string.speak_reply) + }, + enabled = !actionBusy || speaking, + onClick = if (speaking) onStopSpeaking else onSpeakLatest, + ) + } + } + snapshot.failure?.let { failure -> + item { + InlineError(text = failureDetail(failure)) + } + } + } +} + +@Composable +private fun VoicePage( + voicePagerState: androidx.wear.compose.foundation.pager.PagerState, + showSwipeHint: Boolean, + realtimeTalk: WearRealtimeTalkSnapshot, + speaking: Boolean, + realtimeCapturing: Boolean, + realtimePlaying: Boolean, + realtimeMouthLevel: Float, + realtimePlaybackFailed: Boolean, + realtimeThinkingOverride: Boolean, + realtimeElapsedSeconds: Long, + actionBusy: Boolean, + inputEnabled: Boolean, + onTalk: () -> Unit, + onType: () -> Unit, + onRealtimeTalk: () -> Unit, + onStopSpeaking: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + val voicePagerScope = rememberCoroutineScope() + val view = LocalView.current + var previousMode by remember { mutableIntStateOf(voicePagerState.currentPage) } + val swipeHintOffset = + if (showSwipeHint) { + val swipeHintTransition = rememberInfiniteTransition(label = "voice-swipe-hint") + swipeHintTransition + .animateFloat( + initialValue = -14f, + targetValue = 14f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 450), + repeatMode = RepeatMode.Reverse, + ), + label = "voice-swipe-hint-offset", + ).value + } else { + 0f + } + LaunchedEffect(voicePagerState.currentPage) { + if (voicePagerState.currentPage != previousMode) { + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + previousMode = voicePagerState.currentPage + } + } + val selectMode: (Int) -> Unit = { mode -> + voicePagerScope.launch { + voicePagerState.animateScrollToPage(mode) + } + } + Box( + modifier = + Modifier + .fillMaxSize() + .background(colors.canvas), + ) { + HorizontalPager( + state = voicePagerState, + modifier = + Modifier + .fillMaxSize() + .padding(top = 28.dp, bottom = 28.dp) + .graphicsLayer { + translationX = if (showSwipeHint) swipeHintOffset else 0f + }, + rotaryScrollableBehavior = null, + ) { mode -> + when (mode) { + VOICE_HOME_MODE -> + VoiceHomeMode( + realtimeTalk = realtimeTalk, + speaking = speaking, + realtimeCapturing = realtimeCapturing, + realtimePlaying = realtimePlaying, + realtimeMouthLevel = realtimeMouthLevel, + realtimePlaybackFailed = realtimePlaybackFailed, + realtimeThinkingOverride = realtimeThinkingOverride, + realtimeElapsedSeconds = realtimeElapsedSeconds, + actionBusy = actionBusy, + inputEnabled = inputEnabled, + onTalk = onTalk, + onRealtimeTalk = onRealtimeTalk, + onStopSpeaking = onStopSpeaking, + onOpenThread = { selectMode(VOICE_THREAD_MODE) }, + ) + else -> + ThreadVoiceMode( + conversation = realtimeTalk.conversation, + thinking = + realtimeThinkingOverride || realtimeTalk.status == WearRealtimeTalkStatus.THINKING, + realtimeActive = realtimeTalk.active || realtimeCapturing, + actionBusy = actionBusy, + inputEnabled = inputEnabled, + onType = onType, + onRealtimeTalk = onRealtimeTalk, + ) + } + } + if (showSwipeHint) { + Text( + text = stringResource(R.string.swipe_between_voice_modes), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 9.dp), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun VoiceHomeMode( + realtimeTalk: WearRealtimeTalkSnapshot, + speaking: Boolean, + realtimeCapturing: Boolean, + realtimePlaying: Boolean, + realtimeMouthLevel: Float, + realtimePlaybackFailed: Boolean, + realtimeThinkingOverride: Boolean, + realtimeElapsedSeconds: Long, + actionBusy: Boolean, + inputEnabled: Boolean, + onTalk: () -> Unit, + onRealtimeTalk: () -> Unit, + onStopSpeaking: () -> Unit, + onOpenThread: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + val realtimeActive = realtimeTalk.active || realtimeCapturing + val ttsOnly = speaking && !realtimeActive + val state = + realtimeVoiceButtonState( + realtimeTalk = realtimeTalk, + ttsOnly = ttsOnly, + realtimeCapturing = realtimeCapturing, + realtimePlaying = realtimePlaying, + realtimePlaybackFailed = realtimePlaybackFailed, + realtimeThinkingOverride = realtimeThinkingOverride, + ) + var dictatePreview by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val dictateActionEnabled = inputEnabled && !actionBusy && !speaking && !realtimeActive && !dictatePreview + val liveActionEnabled = + (realtimeActive || ttsOnly || (inputEnabled && !actionBusy)) && !dictatePreview + val startDictate: () -> Unit = { + if (dictateActionEnabled) { + coroutineScope.launch { + dictatePreview = true + delay(300L) + dictatePreview = false + onTalk() + } + } + } + val toggleLive: () -> Unit = { + if (liveActionEnabled) { + if (ttsOnly) { + onStopSpeaking() + } else { + onRealtimeTalk() + } + } + } + val label = + when (state) { + RealtimeVoiceButtonState.IDLE -> null + RealtimeVoiceButtonState.CONNECTING -> stringResource(R.string.connecting) + RealtimeVoiceButtonState.LISTENING -> stringResource(R.string.listening) + RealtimeVoiceButtonState.THINKING -> stringResource(R.string.thinking) + RealtimeVoiceButtonState.SPEAKING -> stringResource(R.string.speaking) + RealtimeVoiceButtonState.ERROR -> stringResource(R.string.real_time_audio_failed) + } + val statusText = + when { + dictatePreview -> stringResource(R.string.listening) + label == null -> null + realtimeActive -> "$label · ${formatVoiceElapsedTime(realtimeElapsedSeconds)}" + else -> label + } + val accent = + when { + dictatePreview || state == RealtimeVoiceButtonState.IDLE -> colors.voiceAccent + state == RealtimeVoiceButtonState.ERROR -> colors.danger + else -> colors.voiceAccent + } + val avatarState = if (dictatePreview) RealtimeVoiceButtonState.LISTENING else state + val liveVoiceDescription = stringResource(R.string.talk) + val liveClickLabel = + when { + ttsOnly -> stringResource(R.string.stop_speaking) + realtimeActive -> stringResource(R.string.stop_speaking) + else -> stringResource(R.string.speak_to_agent) + } + val dictateClickLabel = stringResource(R.string.dictate) + val orbClick = if (liveActionEnabled) toggleLive else startDictate + val orbClickLabel = if (liveActionEnabled) liveClickLabel else dictateClickLabel + val fontScale = LocalDensity.current.fontScale + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val layout = wearVoiceLayout(maxWidth = maxWidth, fontScale = fontScale) + val voiceControlOffset = if (fontScale > 1.1f) 20.dp else 16.dp + Row( + modifier = + Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = layout.horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + VoiceGestureLabel( + title = stringResource(R.string.hold), + detail = stringResource(R.string.dictate), + accent = colors.voiceAccent, + onClick = if (dictateActionEnabled) startDictate else null, + onClickLabel = dictateClickLabel, + modifier = + Modifier + .offset(y = voiceControlOffset) + .weight(1f), + ) + Box( + modifier = + Modifier + .width(layout.orbSize) + .height(layout.contentHeight), + ) { + VoiceGestureLabel( + title = stringResource(R.string.double_tap), + detail = stringResource(R.string.thread), + accent = colors.voiceAccent, + onDoubleClick = onOpenThread, + onClickLabel = stringResource(R.string.open_thread), + verticalPadding = 0.dp, + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .minimumInteractiveComponentSize(), + ) + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(layout.orbSize) + .offset(y = voiceControlOffset) + .combinedClickable( + // combinedClickable gates every gesture together; keep preview exclusive and fall back to Dictate. + enabled = !dictatePreview && (liveActionEnabled || dictateActionEnabled), + onClickLabel = orbClickLabel, + role = Role.Button, + onClick = orbClick, + onDoubleClick = onOpenThread, + onLongClickLabel = dictateClickLabel.takeIf { dictateActionEnabled }, + onLongClick = startDictate.takeIf { dictateActionEnabled }, + ).semantics { + contentDescription = liveVoiceDescription + }, + contentAlignment = Alignment.Center, + ) { + WearTalkAvatar( + state = avatarState, + mouthLevel = if (realtimePlaying) realtimeMouthLevel else 0f, + syntheticSpeech = ttsOnly, + accent = accent, + danger = colors.danger, + modifier = Modifier.fillMaxSize(), + ) + } + statusText?.let { status -> + Text( + text = status, + color = colors.textMuted, + fontSize = 12.sp, + lineHeight = 12.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = 1.dp), + ) + } + } + VoiceGestureLabel( + title = stringResource(R.string.tap), + detail = stringResource(R.string.live), + accent = colors.voiceAccent, + onClick = if (liveActionEnabled) toggleLive else null, + onClickLabel = liveClickLabel, + modifier = + Modifier + .offset(y = voiceControlOffset) + .weight(1f), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun VoiceGestureLabel( + title: String, + detail: String, + accent: Color, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClickLabel: String? = null, + verticalPadding: androidx.compose.ui.unit.Dp = 10.dp, +) { + val interactionModifier = + when { + onDoubleClick != null -> + Modifier + .pointerInput(onDoubleClick) { + detectTapGestures(onDoubleTap = { onDoubleClick() }) + }.semantics(mergeDescendants = true) { + role = Role.Button + semanticsOnClick(label = onClickLabel) { + onDoubleClick() + true + } + } + onClick != null -> + Modifier.clickable( + role = Role.Button, + onClickLabel = onClickLabel, + onClick = onClick, + ) + else -> Modifier + } + Column( + modifier = + modifier + .then(interactionModifier) + .then( + if (onClick != null || onDoubleClick != null) { + Modifier.minimumInteractiveComponentSize() + } else { + Modifier + }, + ).padding(vertical = verticalPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = title, + color = accent, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + text = detail, + color = OpenClawWearTheme.colors.textMuted, + fontSize = 12.sp, + lineHeight = 14.sp, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } +} + +@Composable +private fun ThreadVoiceMode( + conversation: List, + thinking: Boolean, + realtimeActive: Boolean, + actionBusy: Boolean, + inputEnabled: Boolean, + onType: () -> Unit, + onRealtimeTalk: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + val listState = rememberTransformingLazyColumnState() + val liveVoiceDescription = stringResource(R.string.talk) + val liveClickLabel = + if (realtimeActive) { + stringResource(R.string.stop_speaking) + } else { + stringResource(R.string.speak_to_agent) + } + val coroutineScope = rememberCoroutineScope() + val visibleConversation = conversation.takeLast(VISIBLE_REALTIME_ENTRY_COUNT) + val contentRevision = wearThreadContentRevision(visibleConversation, thinking) + val latestAnchorIndex = wearThreadLatestAnchorIndex(visibleConversation.size, thinking) + var followState by remember { mutableStateOf(WearThreadFollowState()) } + + LaunchedEffect(listState) { + snapshotFlow { + WearThreadViewport( + atLatest = !listState.canScrollForward, + scrollingBackward = listState.isScrollInProgress && listState.lastScrolledBackward, + ) + }.collect { viewport -> + followState = + nextWearThreadFollowForViewport( + state = followState, + atLatest = viewport.atLatest, + scrollingBackward = viewport.scrollingBackward, + ) + } + } + LaunchedEffect(realtimeActive, contentRevision) { + val update = + nextWearThreadFollowForContent( + state = followState, + contentRevision = contentRevision, + realtimeActive = realtimeActive, + ) + followState = update.state + if (update.scrollToLatest && latestAnchorIndex >= 0) { + listState.requestScrollToItem(latestAnchorIndex) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + TransformingLazyColumn( + modifier = + Modifier + .fillMaxSize() + .background(colors.canvas), + state = listState, + contentPadding = PaddingValues(top = 18.dp, bottom = 52.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + if (visibleConversation.isEmpty() && !thinking) { + item { + Text( + text = stringResource(R.string.no_live_conversation), + color = colors.textMuted, + fontSize = 12.sp, + lineHeight = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 30.dp, vertical = 20.dp), + ) + } + } else { + visibleConversation.forEach { entry -> + item(key = entry.id) { + RealtimeTalkBubble(entry) + } + } + if (thinking) { + item(key = "realtime-thinking") { + WearThreadThinking() + } + } + // Follow a trailing anchor: centering a growing bubble can hide its newly streamed tail. + item(key = "realtime-thread-end") { + Spacer(modifier = Modifier.height(1.dp)) + } + } + } + if (followState.hasNewContent) { + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 44.dp) + .minimumInteractiveComponentSize() + .background(colors.voiceAccentSoft, RoundedCornerShape(14.dp)) + .border(1.dp, colors.voiceAccent, RoundedCornerShape(14.dp)) + .clickable( + role = Role.Button, + onClickLabel = stringResource(R.string.show_new_messages), + ) { + followState = wearThreadFollowLatest(followState) + if (latestAnchorIndex >= 0) { + coroutineScope.launch { + listState.animateScrollToItem(latestAnchorIndex) + } + } + }.padding(horizontal = 10.dp, vertical = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.new_messages) + " ↓", + color = colors.voiceAccent, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + } + Row( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(70.dp) + .minimumInteractiveComponentSize() + .background(colors.surfaceRaised, RoundedCornerShape(24.dp)) + .border(1.dp, colors.borderStrong, RoundedCornerShape(24.dp)) + .clickable( + enabled = inputEnabled && !actionBusy && !realtimeActive, + onClickLabel = stringResource(R.string.type), + role = Role.Button, + onClick = onType, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.type), + color = + if (inputEnabled && !actionBusy && !realtimeActive) { + colors.text + } else { + colors.textMuted + }, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Box( + modifier = + Modifier + .minimumInteractiveComponentSize() + .background( + color = if (realtimeActive) colors.voiceAccent else colors.surfaceRaised, + shape = CircleShape, + ).border( + width = 1.dp, + color = colors.voiceAccent, + shape = CircleShape, + ).clickable( + enabled = realtimeActive || (inputEnabled && !actionBusy), + onClickLabel = liveClickLabel, + role = Role.Button, + onClick = onRealtimeTalk, + ).semantics { + contentDescription = liveVoiceDescription + }, + contentAlignment = Alignment.Center, + ) { + MicrophoneGlyph( + color = if (realtimeActive) colors.onVoiceAccent else colors.text, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +@Composable +private fun WearThreadThinking() { + val colors = OpenClawWearTheme.colors + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 28.dp) + .background(colors.surfaceRaised, RoundedCornerShape(14.dp)) + .border(1.dp, colors.borderStrong, RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.thinking) + "…", + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +internal data class WearThreadContentRevision( + val entryCount: Int, + val latestEntryId: String?, + val latestText: String?, + val latestStreaming: Boolean, + val thinking: Boolean, +) + +internal data class WearThreadFollowState( + val contentRevision: WearThreadContentRevision? = null, + val followingLatest: Boolean = true, + val hasNewContent: Boolean = false, +) + +internal data class WearThreadFollowUpdate( + val state: WearThreadFollowState, + val scrollToLatest: Boolean, +) + +private data class WearThreadViewport( + val atLatest: Boolean, + val scrollingBackward: Boolean, +) + +internal fun wearThreadContentRevision( + conversation: List, + thinking: Boolean, +): WearThreadContentRevision { + val latest = conversation.lastOrNull() + return WearThreadContentRevision( + entryCount = conversation.size, + latestEntryId = latest?.id, + latestText = latest?.text, + latestStreaming = latest?.streaming == true, + thinking = thinking, + ) +} + +internal fun wearThreadLatestAnchorIndex( + entryCount: Int, + thinking: Boolean, +): Int = if (entryCount == 0 && !thinking) -1 else entryCount + if (thinking) 1 else 0 + +internal fun nextWearThreadFollowForContent( + state: WearThreadFollowState, + contentRevision: WearThreadContentRevision, + realtimeActive: Boolean = true, +): WearThreadFollowUpdate { + if (!realtimeActive) { + return WearThreadFollowUpdate( + state = WearThreadFollowState(), + scrollToLatest = false, + ) + } + if (state.contentRevision == contentRevision) { + return WearThreadFollowUpdate(state = state, scrollToLatest = false) + } + return WearThreadFollowUpdate( + state = + state.copy( + contentRevision = contentRevision, + hasNewContent = !state.followingLatest, + ), + scrollToLatest = state.followingLatest, + ) +} + +internal fun nextWearThreadFollowForViewport( + state: WearThreadFollowState, + atLatest: Boolean, + scrollingBackward: Boolean, +): WearThreadFollowState = + when { + atLatest -> state.copy(followingLatest = true, hasNewContent = false) + scrollingBackward -> state.copy(followingLatest = false) + else -> state + } + +internal fun wearThreadFollowLatest(state: WearThreadFollowState): WearThreadFollowState = state.copy(followingLatest = true, hasNewContent = false) + +@Composable +private fun MicrophoneGlyph( + color: Color, + modifier: Modifier = Modifier, +) { + Canvas(modifier = modifier) { + val strokeWidth = size.minDimension * 0.085f + val stroke = + Stroke( + width = strokeWidth, + cap = StrokeCap.Round, + ) + drawRoundRect( + color = color, + topLeft = Offset(size.width * 0.33f, size.height * 0.08f), + size = Size(size.width * 0.34f, size.height * 0.52f), + cornerRadius = CornerRadius(size.width * 0.17f), + style = stroke, + ) + drawArc( + color = color, + startAngle = 0f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(size.width * 0.22f, size.height * 0.26f), + size = Size(size.width * 0.56f, size.height * 0.5f), + style = stroke, + ) + drawLine( + color = color, + start = Offset(size.width * 0.5f, size.height * 0.76f), + end = Offset(size.width * 0.5f, size.height * 0.9f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(size.width * 0.34f, size.height * 0.9f), + end = Offset(size.width * 0.66f, size.height * 0.9f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } +} + +private fun realtimeVoiceButtonState( + realtimeTalk: WearRealtimeTalkSnapshot, + ttsOnly: Boolean, + realtimeCapturing: Boolean, + realtimePlaying: Boolean, + realtimePlaybackFailed: Boolean, + realtimeThinkingOverride: Boolean, +): RealtimeVoiceButtonState = + when { + realtimePlaybackFailed || realtimeTalk.status == WearRealtimeTalkStatus.ERROR -> + RealtimeVoiceButtonState.ERROR + realtimeThinkingOverride -> + RealtimeVoiceButtonState.THINKING + realtimePlaying || realtimeTalk.speaking || ttsOnly -> + RealtimeVoiceButtonState.SPEAKING + realtimeTalk.status == WearRealtimeTalkStatus.THINKING -> + RealtimeVoiceButtonState.THINKING + realtimeCapturing || + realtimeTalk.listening || + realtimeTalk.status == WearRealtimeTalkStatus.LISTENING -> + RealtimeVoiceButtonState.LISTENING + realtimeTalk.status == WearRealtimeTalkStatus.CONNECTING -> + RealtimeVoiceButtonState.CONNECTING + else -> RealtimeVoiceButtonState.IDLE + } + +private fun formatVoiceElapsedTime(totalSeconds: Long): String { + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + +internal enum class RealtimeVoiceButtonState { + IDLE, + CONNECTING, + LISTENING, + THINKING, + SPEAKING, + ERROR, +} + +@Composable +private fun RealtimeTalkBubble(entry: WearRealtimeTalkEntry) { + val colors = OpenClawWearTheme.colors + val isUser = entry.role == WearRealtimeTalkRole.USER + val background = if (isUser) colors.surfacePressed else colors.surfaceRaised + val foreground = colors.text + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = if (isUser) 28.dp else 12.dp, + end = if (isUser) 12.dp else 28.dp, + ).background(background, RoundedCornerShape(14.dp)) + .then( + Modifier.border( + width = 1.dp, + color = colors.borderStrong, + shape = RoundedCornerShape(14.dp), + ), + ).padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Text( + text = + localizedWearUppercase( + if (isUser) { + stringResource(R.string.you) + } else { + stringResource(R.string.agent) + }, + ), + color = if (isUser) foreground.copy(alpha = 0.72f) else colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text( + text = entry.text, + color = foreground, + fontSize = 13.sp, + lineHeight = 17.sp, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + if (entry.streaming) { + Text( + text = localizedWearUppercase(stringResource(R.string.live)), + color = colors.warning, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} + +@Composable +private fun ControlsPage( + snapshot: WearConversationSnapshot, + themeMode: WearThemeMode, + autoSpeak: Boolean, + notificationsGranted: Boolean, + gatewayControlSupported: Boolean, + actionBusy: Boolean, + onThemeModeChange: (WearThemeMode) -> Unit, + onAutoSpeakChange: (Boolean) -> Unit, + onRequestNotifications: () -> Unit, + onOpenNotificationSettings: () -> Unit, + onRefresh: () -> Unit, + onGatewayEnabledChange: (Boolean) -> Unit, +) { + val gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED + WearPage(pageLabel = stringResource(R.string.controls)) { + item { + ConnectionPanel(snapshot = snapshot) + } + snapshot.failure?.let { failure -> + item { InlineError(text = failureDetail(failure)) } + } + item { + SelectionButton( + title = stringResource(R.string.gateway), + detail = + if (!gatewayControlSupported) { + stringResource(R.string.update_required) + } else if (gatewayConnected) { + stringResource(R.string.on) + } else { + stringResource(R.string.off) + }, + selected = gatewayConnected, + enabled = gatewayControlSupported && !actionBusy, + onClick = { onGatewayEnabledChange(!gatewayConnected) }, + ) + } + item { + ThemeModeSelector( + themeMode = themeMode, + onThemeModeChange = onThemeModeChange, + ) + } + item { + SelectionButton( + title = stringResource(R.string.reply_alerts), + detail = + if (notificationsGranted) { + stringResource(R.string.on) + } else { + stringResource(R.string.enable_alerts) + }, + selected = notificationsGranted, + enabled = !notificationsGranted && !actionBusy, + onClick = onRequestNotifications, + ) + } + if (!notificationsGranted) { + item { + SecondaryButton( + label = stringResource(R.string.open_notification_settings), + enabled = !actionBusy, + onClick = onOpenNotificationSettings, + ) + } + } + item { + SelectionButton( + title = stringResource(R.string.auto_speak), + detail = + if (autoSpeak) { + stringResource(R.string.on) + } else { + stringResource(R.string.off) + }, + selected = autoSpeak, + enabled = !actionBusy, + onClick = { onAutoSpeakChange(!autoSpeak) }, + ) + } + item { + PhoneBoundaryPanel() + } + item { + SecondaryButton( + label = stringResource(R.string.refresh), + enabled = !actionBusy, + onClick = onRefresh, + ) + } + } +} + +@Composable +private fun ConnectionStateScreen( + loading: Boolean, + failure: WearConversationFailure?, + onRefresh: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + val listState = rememberTransformingLazyColumnState() + ScreenScaffold(scrollState = listState) { contentPadding -> + TransformingLazyColumn( + modifier = + Modifier + .fillMaxSize() + .background(colors.canvas), + state = listState, + contentPadding = contentPadding, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + OpenClawHeader(pageLabel = stringResource(R.string.chat)) + } + item { + EmptyPanel( + title = + if (loading) { + stringResource(R.string.checking_phone) + } else { + failureTitle(failure) + }, + detail = + if (loading) { + stringResource(R.string.reading_conversation) + } else { + failureDetail(failure) + }, + ) + } + item { + SecondaryButton( + label = stringResource(R.string.retry), + enabled = !loading, + onClick = onRefresh, + ) + } + } + } +} + +@Composable +private fun WearPage( + pageLabel: String, + content: androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope.() -> Unit, +) { + val colors = OpenClawWearTheme.colors + val listState = rememberTransformingLazyColumnState() + ScreenScaffold(scrollState = listState) { contentPadding -> + TransformingLazyColumn( + modifier = + Modifier + .fillMaxSize() + .background(colors.canvas), + state = listState, + contentPadding = contentPadding, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + OpenClawHeader(pageLabel = pageLabel) + } + content() + } + } +} + +@Composable +private fun OpenClawHeader(pageLabel: String) { + val colors = OpenClawWearTheme.colors + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = localizedWearUppercase(stringResource(R.string.app_name)), + color = colors.text, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.4.sp, + textAlign = TextAlign.Center, + maxLines = 1, + ) + Text( + text = localizedWearUppercase(pageLabel), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.4.sp, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun ConversationIdentity( + snapshot: WearConversationSnapshot, + actionBusy: Boolean, + onSelectAgent: (String) -> Unit, + onSelectSession: (String) -> Unit, + onSelectModel: (String) -> Unit, +) { + val agentIndex = snapshot.agents.indexOfFirst(WearAgentSummary::selected) + val sessionIndex = snapshot.sessions.indexOfFirst(WearSessionSummary::selected) + val modelIndex = snapshot.models.indexOfFirst(WearModelSummary::selected) + val agent = snapshot.agents.getOrNull(agentIndex) ?: snapshot.agents.firstOrNull() + val session = snapshot.sessions.getOrNull(sessionIndex) ?: snapshot.sessions.firstOrNull() + val model = snapshot.models.getOrNull(modelIndex) + Panel { + ContextPickerRow( + label = stringResource(R.string.agent), + value = + listOfNotNull( + agent?.emoji?.takeIf(String::isNotBlank), + agent?.name ?: stringResource(R.string.agent), + ).joinToString(" "), + previous = + snapshot.agents + .getOrNull(agentIndex - 1) + ?.takeIf { snapshot.agentControlsSupported && !actionBusy } + ?.let { previous -> ({ onSelectAgent(previous.id) }) }, + next = + snapshot.agents + .getOrNull(if (agentIndex < 0) 0 else agentIndex + 1) + ?.takeIf { snapshot.agentControlsSupported && !actionBusy } + ?.let { next -> ({ onSelectAgent(next.id) }) }, + ) + ContextPickerRow( + label = stringResource(R.string.session), + value = session?.title ?: stringResource(R.string.current_session), + previous = + snapshot.sessions + .getOrNull(sessionIndex - 1) + ?.takeIf { !actionBusy } + ?.let { previous -> ({ onSelectSession(previous.id) }) }, + next = + snapshot.sessions + .getOrNull(if (sessionIndex < 0) 0 else sessionIndex + 1) + ?.takeIf { !actionBusy } + ?.let { next -> ({ onSelectSession(next.id) }) }, + ) + ContextPickerRow( + label = stringResource(R.string.model), + value = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model), + previous = + snapshot.models + .getOrNull(modelIndex - 1) + ?.takeIf { snapshot.modelControlsSupported && !actionBusy } + ?.let { previous -> ({ onSelectModel(previous.ref) }) }, + next = + snapshot.models + .getOrNull(if (modelIndex < 0) 0 else modelIndex + 1) + ?.takeIf { snapshot.modelControlsSupported && !actionBusy } + ?.let { next -> ({ onSelectModel(next.ref) }) }, + ) + } +} + +@Composable +private fun ContextPickerRow( + label: String, + value: String, + previous: (() -> Unit)?, + next: (() -> Unit)?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + PickerChevron( + glyph = "‹", + contentDescription = stringResource(R.string.previous_item, label), + onClick = previous, + ) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = localizedWearUppercase(label), + color = OpenClawWearTheme.colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + maxLines = 1, + ) + Text( + text = value, + color = OpenClawWearTheme.colors.text, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + PickerChevron( + glyph = "›", + contentDescription = stringResource(R.string.next_item, label), + onClick = next, + ) + } +} + +@Composable +private fun PickerChevron( + glyph: String, + contentDescription: String, + onClick: (() -> Unit)?, +) { + val colors = OpenClawWearTheme.colors + val enabled = onClick != null + Box( + modifier = + Modifier + // Foundation clickable expands hit testing to the system minimum touch target. + // Compact visual bounds keep picker values readable on 192dp round screens. + .width(32.dp) + .height(30.dp) + .semantics { this.contentDescription = contentDescription } + .clickable( + enabled = enabled, + role = Role.Button, + onClick = { onClick?.invoke() }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = glyph, + color = if (enabled) colors.primary else colors.textMuted.copy(alpha = 0.42f), + fontSize = 24.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun ConversationStatus( + interaction: WearInteractionState, + speaking: Boolean, + gatewayConnected: Boolean, +) { + val colors = OpenClawWearTheme.colors + val (label, color) = + when { + speaking -> stringResource(R.string.speaking) to colors.success + interaction == WearInteractionState.LISTENING -> + stringResource(R.string.listening) to colors.danger + interaction == WearInteractionState.TYPING -> + stringResource(R.string.typing) to colors.warning + interaction == WearInteractionState.SENDING -> + stringResource(R.string.sending) to colors.warning + interaction == WearInteractionState.AGENT_WORKING -> + stringResource(R.string.agent_working) to colors.warning + interaction == WearInteractionState.ERROR -> + stringResource(R.string.error) to colors.danger + gatewayConnected -> stringResource(R.string.ready) to colors.success + else -> stringResource(R.string.gateway_offline) to colors.danger + } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background(colors.surface, RoundedCornerShape(12.dp)) + .border(1.dp, colors.borderStrong, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(8.dp) + .background(color, CircleShape), + ) + Spacer(modifier = Modifier.size(7.dp)) + Text( + text = label, + color = colors.text, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun MessageBubble(message: WearChatMessage) { + val colors = OpenClawWearTheme.colors + val isUser = message.chatRole == WearChatRole.USER + val background = + when (message.chatRole) { + WearChatRole.USER -> colors.surfacePressed + WearChatRole.ASSISTANT -> colors.surfaceRaised + WearChatRole.SYSTEM -> colors.surface + } + val foreground = colors.text + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = if (isUser) 28.dp else 12.dp, + end = if (isUser) 12.dp else 28.dp, + ).background(background, RoundedCornerShape(14.dp)) + .then( + Modifier.border( + width = 1.dp, + color = colors.borderStrong, + shape = RoundedCornerShape(14.dp), + ), + ).padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Text( + text = + localizedWearUppercase( + when (message.chatRole) { + WearChatRole.USER -> stringResource(R.string.you) + WearChatRole.ASSISTANT -> stringResource(R.string.agent) + WearChatRole.SYSTEM -> stringResource(R.string.system) + }, + ), + color = if (isUser) foreground.copy(alpha = 0.72f) else colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text( + text = message.text, + color = foreground, + fontSize = 13.sp, + lineHeight = 17.sp, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun StreamingBubble(text: String) { + val colors = OpenClawWearTheme.colors + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background(colors.surfaceRaised, RoundedCornerShape(14.dp)) + .border(1.dp, colors.warning, RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Text( + text = localizedWearUppercase(stringResource(R.string.agent_working)), + color = colors.warning, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text( + text = text, + color = colors.text, + fontSize = 13.sp, + lineHeight = 17.sp, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun EmptyConversation() { + EmptyPanel( + title = stringResource(R.string.start_conversation), + detail = stringResource(R.string.start_conversation_detail), + ) +} + +@Composable +private fun ConnectionPanel(snapshot: WearConversationSnapshot) { + val connected = snapshot.gatewayState == WearGatewayState.CONNECTED + val colors = OpenClawWearTheme.colors + Panel { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(8.dp) + .background( + if (connected) colors.success else colors.danger, + CircleShape, + ), + ) + Spacer(modifier = Modifier.size(7.dp)) + Text( + text = localizedWearUppercase(stringResource(R.string.connection)), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + } + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = + if (connected) { + stringResource(R.string.gateway_connected) + } else { + stringResource(R.string.gateway_offline) + }, + color = colors.text, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.phone_ready), + color = colors.textMuted, + fontSize = 12.sp, + ) + } +} + +@Composable +private fun PhoneBoundaryPanel() { + Panel { + Text( + text = localizedWearUppercase(stringResource(R.string.security_boundary)), + color = OpenClawWearTheme.colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = stringResource(R.string.phone_controlled), + color = OpenClawWearTheme.colors.text, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.phone_controlled_detail), + color = OpenClawWearTheme.colors.textMuted, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + } +} + +@Composable +private fun ThemeModeSelector( + themeMode: WearThemeMode, + onThemeModeChange: (WearThemeMode) -> Unit, +) { + val colors = OpenClawWearTheme.colors + val shape = RoundedCornerShape(12.dp) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + ) { + Text( + text = localizedWearUppercase(stringResource(R.string.appearance)), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .background(colors.surface, shape) + .border(width = 1.dp, color = colors.borderStrong, shape = shape) + .padding(3.dp), + ) { + ThemeModeOption( + label = stringResource(R.string.theme_dark), + selected = themeMode == WearThemeMode.Dark, + colors = colors, + onClick = { onThemeModeChange(WearThemeMode.Dark) }, + modifier = Modifier.weight(1f), + ) + ThemeModeOption( + label = stringResource(R.string.theme_light), + selected = themeMode == WearThemeMode.Light, + colors = colors, + onClick = { onThemeModeChange(WearThemeMode.Light) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun ThemeModeOption( + label: String, + selected: Boolean, + colors: WearColors, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .minimumInteractiveComponentSize() + .background( + color = if (selected) colors.primary else Color.Transparent, + shape = RoundedCornerShape(9.dp), + ).selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected) colors.primaryText else colors.textMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } +} + +@Composable +private fun SelectionButton( + title: String, + detail: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + Button( + onClick = onClick, + enabled = enabled, + colors = + ButtonDefaults.buttonColors( + containerColor = if (selected) colors.primary else colors.surfaceRaised, + contentColor = if (selected) colors.primaryText else colors.text, + disabledContainerColor = + if (selected) colors.primary else colors.surface, + disabledContentColor = + if (selected) colors.primaryText else colors.textMuted, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .border( + width = 1.dp, + color = + when { + selected -> colors.primary + enabled -> colors.borderStrong + else -> colors.border + }, + shape = RoundedCornerShape(26.dp), + ), + label = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = detail, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) +} + +@Composable +private fun ActionButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = OpenClawWearTheme.colors + Button( + onClick = onClick, + enabled = enabled, + colors = + ButtonDefaults.buttonColors( + containerColor = colors.primary, + contentColor = colors.primaryText, + disabledContainerColor = colors.surface, + disabledContentColor = colors.textMuted, + ), + modifier = modifier, + label = { + Text( + text = label, + modifier = Modifier.fillMaxWidth(), + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + }, + ) +} + +@Composable +private fun SecondaryButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val colors = OpenClawWearTheme.colors + Button( + onClick = onClick, + enabled = enabled, + colors = + ButtonDefaults.buttonColors( + containerColor = colors.surfaceRaised, + contentColor = colors.text, + disabledContainerColor = colors.surface, + disabledContentColor = colors.textMuted, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .border( + width = 1.dp, + color = if (enabled) colors.borderStrong else colors.border, + shape = RoundedCornerShape(26.dp), + ), + label = { + Text( + text = label, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + }, + ) +} + +@Composable +private fun EmptyPanel( + title: String, + detail: String, +) { + Panel { + Text( + text = title, + color = OpenClawWearTheme.colors.text, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = detail, + color = OpenClawWearTheme.colors.textMuted, + fontSize = 12.sp, + lineHeight = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun InlineError(text: String) { + val colors = OpenClawWearTheme.colors + Text( + text = text, + color = colors.danger, + fontSize = 12.sp, + lineHeight = 16.sp, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun Panel(content: @Composable ColumnScope.() -> Unit) { + val colors = OpenClawWearTheme.colors + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background(colors.surfaceRaised, RoundedCornerShape(12.dp)) + .border( + width = 1.dp, + color = colors.borderStrong, + shape = RoundedCornerShape(12.dp), + ).padding(horizontal = 14.dp, vertical = 12.dp), + content = content, + ) +} + +@Composable +private fun failureTitle(failure: WearConversationFailure?): String = + when (failure) { + WearConversationFailure.PHONE_UNAVAILABLE -> + stringResource(R.string.phone_unavailable) + WearConversationFailure.PHONE_NOT_READY -> + stringResource(R.string.open_phone_app) + WearConversationFailure.GATEWAY_OFFLINE -> + stringResource(R.string.gateway_offline) + WearConversationFailure.NOT_FOUND -> + stringResource(R.string.selection_not_found) + WearConversationFailure.ACTION_REJECTED -> + stringResource(R.string.message_not_sent) + WearConversationFailure.INCOMPATIBLE -> + stringResource(R.string.update_required) + WearConversationFailure.INTERNAL_ERROR, + null, + -> stringResource(R.string.something_went_wrong) + } + +@Composable +private fun failureDetail(failure: WearConversationFailure?): String = + when (failure) { + WearConversationFailure.PHONE_UNAVAILABLE -> + stringResource(R.string.phone_unavailable_detail) + WearConversationFailure.PHONE_NOT_READY -> + stringResource(R.string.phone_not_ready_detail) + WearConversationFailure.GATEWAY_OFFLINE -> + stringResource(R.string.gateway_offline_detail) + WearConversationFailure.NOT_FOUND -> + stringResource(R.string.refresh_and_try_again) + WearConversationFailure.ACTION_REJECTED -> + stringResource(R.string.try_again) + WearConversationFailure.INCOMPATIBLE -> + stringResource(R.string.update_required_detail) + WearConversationFailure.INTERNAL_ERROR, + null, + -> stringResource(R.string.try_again) + } + +private const val VISIBLE_MESSAGE_COUNT = 8 +private const val VISIBLE_REALTIME_ENTRY_COUNT = 6 diff --git a/wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt b/wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt new file mode 100644 index 0000000..e73b325 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt @@ -0,0 +1,124 @@ +package ai.openclaw.wear + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.wear.compose.material3.AppScaffold + +internal const val extraWearScreenshotMode = "openclaw.screenshotMode" +internal const val extraWearScreenshotScene = "openclaw.screenshotScene" + +internal enum class WearScreenshotScene( + val rawValue: String, + val initialPage: WearHomePage, +) { + Chat("chat", WearHomePage.Chat), + Voice("voice", WearHomePage.Voice), + Controls("controls", WearHomePage.Controls), + ; + + companion object { + fun fromRawValue(raw: String?): WearScreenshotScene = entries.firstOrNull { scene -> scene.rawValue == raw?.trim()?.lowercase() } ?: Chat + } +} + +internal fun parseWearScreenshotModeIntent(intent: Intent?): WearScreenshotScene? { + if (intent?.getBooleanExtra(extraWearScreenshotMode, false) != true) return null + return WearScreenshotScene.fromRawValue(intent.getStringExtra(extraWearScreenshotScene)) +} + +internal object WearScreenshotFixture { + val snapshot = + WearConversationSnapshot( + gatewayState = WearGatewayState.CONNECTED, + activeAgentId = "main", + agents = + listOf( + WearAgentSummary( + id = "main", + name = "Molty", + emoji = "M", + selected = true, + ), + ), + agentControlsSupported = true, + gatewayControlsSupported = true, + activeSessionId = "release-planning", + sessions = + listOf( + WearSessionSummary( + id = "release-planning", + title = "Release planning", + updatedAtEpochMillis = 1_783_555_320_000, + selected = true, + ), + ), + models = + listOf( + WearModelSummary( + ref = "openai/gpt-5.2", + name = "GPT-5.2", + selected = true, + ), + ), + modelControlsSupported = true, + messages = + listOf( + WearChatMessage( + id = "release-question", + role = "user", + text = "Is the Android release ready?", + timestamp = 1_783_555_260_000, + ), + WearChatMessage( + id = "release-answer", + role = "assistant", + text = "Ready after the final store checks.", + timestamp = 1_783_555_320_000, + ), + ), + selectedModelRef = "openai/gpt-5.2", + ) +} + +@Composable +internal fun OpenClawWearScreenshotApp(scene: WearScreenshotScene) { + OpenClawWearTheme(themeMode = WearThemeMode.Dark) { + AppScaffold { + OpenClawWearScreens( + snapshot = WearScreenshotFixture.snapshot, + failure = null, + loading = false, + interaction = WearInteractionState.READY, + speaking = false, + realtimeCapturing = false, + realtimePlaying = false, + realtimeMouthLevel = 0f, + realtimePlaybackFailed = false, + realtimeThinkingOverride = false, + actionBusy = false, + inputEnabled = true, + canAbort = false, + themeMode = WearThemeMode.Dark, + autoSpeak = false, + notificationsGranted = true, + initialPage = scene.initialPage, + voiceSwipeHintEnabled = false, + onTalk = {}, + onType = {}, + onRealtimeTalk = {}, + onAbort = {}, + onSelectAgent = {}, + onSelectSession = {}, + onSelectModel = {}, + onRefresh = {}, + onGatewayEnabledChange = {}, + onThemeModeChange = {}, + onAutoSpeakChange = {}, + onRequestNotifications = {}, + onOpenNotificationSettings = {}, + onSpeakLatest = {}, + onStopSpeaking = {}, + ) + } + } +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt b/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt new file mode 100644 index 0000000..4bc654c --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearTalkAvatar.kt @@ -0,0 +1,606 @@ +package ai.openclaw.wear + +import android.animation.ValueAnimator +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.PowerManager +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.flow.collect +import kotlin.coroutines.coroutineContext +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.sin + +// Canonical 120x120 mascot geometry from ui/public/favicon.svg. Parts stay +// separate so the original silhouette can react without substituting artwork. +private val BodyPath by lazy { + PathParser() + .parsePathString( + "M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 " + + "C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z", + ).toPath() +} +private val LeftClawPath by lazy { + PathParser().parsePathString("M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z").toPath() +} +private val RightClawPath by lazy { + PathParser().parsePathString("M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z").toPath() +} +private val LeftAntennaPath by lazy { PathParser().parsePathString("M45 15 Q35 5 30 8").toPath() } +private val RightAntennaPath by lazy { PathParser().parsePathString("M75 15 Q85 5 90 8").toPath() } + +private val CoralBright = Color(0xFFFF4D4D) +private val CoralDark = Color(0xFF991B1B) +private val EyeDark = Color(0xFF050810) +private val EyeGlow = Color(0xFF00E5CC) +private val Tongue = Color(0xFFFF9EAE) +private val LeftClawPivot = Offset(26f, 53f) +private val RightClawPivot = Offset(94f, 53f) +private val LeftAntennaPivot = Offset(37.5f, 11f) +private val RightAntennaPivot = Offset(82.5f, 11f) +private val LeftEyeCenter = Offset(45f, 35f) +private val RightEyeCenter = Offset(75f, 35f) + +internal data class WearAvatarPose( + val floatOffset: Float, + val bodyTilt: Float, + val bodyStretch: Float, + val antennaDegrees: Float, + val antennaDroop: Float, + val leftClawDegrees: Float, + val rightClawDegrees: Float, + val eyeOpenness: Float, + val gaze: Offset, + val mouthLevel: Float, + val haloPulse: Float, +) + +@Composable +internal fun WearTalkAvatar( + state: RealtimeVoiceButtonState, + mouthLevel: Float, + syntheticSpeech: Boolean, + accent: Color, + danger: Color, + modifier: Modifier = Modifier, + animatorScaleSource: WearAnimatorScaleSource? = null, + motionDurationScale: MotionDurationScale? = null, + frameClock: WearAvatarFrameClock = ComposeWearAvatarFrameClock, + onAnimationStateChanged: ((WearAvatarAnimationState) -> Unit)? = null, +) { + val animationScale = rememberAnimatorDurationScale(animatorScaleSource, motionDurationScale) + val animationsEnabled = animationScale > 0f + val latestState by rememberUpdatedState(state) + val latestMouthLevel by rememberUpdatedState(mouthLevel) + val latestSyntheticSpeech by rememberUpdatedState(syntheticSpeech) + var animationSeconds by remember { mutableFloatStateOf(0f) } + var smoothedMouth by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(animationScale, frameClock) { + if (!animationsEnabled) { + animationSeconds = 0f + smoothedMouth = 0f + return@LaunchedEffect + } + var lastFrameNanos = 0L + while (true) { + frameClock.awaitFrame { frameNanos -> + if (lastFrameNanos != 0L) { + val deltaSeconds = + scaledAvatarDeltaSeconds( + deltaSeconds = (frameNanos - lastFrameNanos) / 1_000_000_000f, + durationScale = animationScale, + ) + animationSeconds = (animationSeconds + deltaSeconds) % AVATAR_ANIMATION_CYCLE_SECONDS + val targetMouth = + if (latestState == RealtimeVoiceButtonState.SPEAKING) { + max( + latestMouthLevel.coerceIn(0f, 1f), + if (latestSyntheticSpeech) syntheticSpeechMouth(animationSeconds) else 0f, + ) + } else { + 0f + } + smoothedMouth = smoothAvatarMouth(smoothedMouth, targetMouth, deltaSeconds) + } + lastFrameNanos = frameNanos + } + } + } + + val motionInputs = avatarMotionInputs(animationsEnabled, animationSeconds, smoothedMouth) + val pose = avatarPoseAt(state, motionInputs.animationSeconds, motionInputs.mouthLevel) + val stateColor = if (state == RealtimeVoiceButtonState.ERROR) danger else accent + + SideEffect { + onAnimationStateChanged?.invoke( + WearAvatarAnimationState( + durationScale = animationScale, + animationSeconds = motionInputs.animationSeconds, + mouthLevel = motionInputs.mouthLevel, + ), + ) + } + + Canvas(modifier = modifier) { + val unit = size.minDimension + val center = Offset(size.width / 2f, size.height / 2f) + drawCircle( + color = stateColor.copy(alpha = 0.3f + (0.28f * pose.haloPulse)), + radius = unit * (0.455f + (0.012f * pose.haloPulse)), + center = center, + style = Stroke(width = unit * 0.025f), + ) + + val artScale = unit / CANONICAL_ART_BOX + val artLeft = center.x - ((CANONICAL_ART_SIZE * artScale) / 2f) + val artTop = center.y - ((CANONICAL_ART_SIZE * artScale) / 2f) + (unit * 0.025f) + withTransform({ translate(left = artLeft, top = artTop) }) { + withTransform({ scale(artScale, artScale, pivot = Offset.Zero) }) { + drawCanonicalAvatar(pose, state, motionInputs.animationSeconds) + } + } + } +} + +@Composable +internal fun rememberAnimatorDurationScale( + animatorScaleSource: WearAnimatorScaleSource? = null, + motionDurationScale: MotionDurationScale? = null, +): Float { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val effectiveScaleSource = + animatorScaleSource + ?: remember(context, lifecycleOwner) { + AndroidWearAnimatorScaleSource(context.applicationContext, lifecycleOwner) + } + val effectiveScale = rememberEffectiveAnimatorScale(effectiveScaleSource) + var canonicalScale by remember(motionDurationScale) { + mutableFloatStateOf(motionDurationScale?.scaleFactor?.coerceAtLeast(0f) ?: 1f) + } + + LaunchedEffect(motionDurationScale) { + val composeScale = motionDurationScale ?: coroutineContext[MotionDurationScale] + if (composeScale == null) { + canonicalScale = 1f + return@LaunchedEffect + } + // Compose lazily starts its Android scale observer from this getter, which + // may write snapshot state and therefore must run before snapshotFlow. + canonicalScale = composeScale.scaleFactor.coerceAtLeast(0f) + snapshotFlow { composeScale.scaleFactor.coerceAtLeast(0f) } + .collect { scale -> canonicalScale = scale } + } + + return resolvedAvatarAnimationScale(canonicalScale, effectiveScale) +} + +@Composable +private fun rememberEffectiveAnimatorScale(source: WearAnimatorScaleSource): Float { + var effectiveScale by remember(source) { mutableFloatStateOf(source.currentScale()) } + + DisposableEffect(source) { + effectiveScale = source.currentScale() + val subscription = source.subscribe { scale -> effectiveScale = scale.coerceAtLeast(0f) } + onDispose { subscription.dispose() } + } + + return effectiveScale +} + +internal fun resolvedAvatarAnimationScale( + canonicalScale: Float, + effectiveScale: Float, +): Float = if (canonicalScale > 0f && effectiveScale > 0f) canonicalScale else 0f + +internal fun interface WearAnimatorScaleSubscription { + fun dispose() +} + +internal interface WearAnimatorScaleSource { + fun currentScale(): Float + + fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription +} + +internal class AndroidWearAnimatorScaleSource( + private val context: Context, + private val lifecycleOwner: LifecycleOwner, +) : WearAnimatorScaleSource { + private val powerManager = context.getSystemService(PowerManager::class.java) + + override fun currentScale(): Float = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ValueAnimator.getDurationScale().coerceAtLeast(0f) + } else { + // Compose owns the user duration scale. Legacy Android exposes no listener + // for Battery Saver's separate override, so keep only that signal here. + if (powerManager.isPowerSaveMode) 0f else 1f + } + + override fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + subscribeToDurationScale(onScaleChanged) + } else { + subscribeToLegacyEffectiveScale(onScaleChanged) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun subscribeToDurationScale( + onScaleChanged: (Float) -> Unit, + ): WearAnimatorScaleSubscription { + val listener = + ValueAnimator.DurationScaleChangeListener { scale -> + onScaleChanged(scale.coerceAtLeast(0f)) + } + ValueAnimator.registerDurationScaleChangeListener(listener) + onScaleChanged(currentScale()) + return WearAnimatorScaleSubscription { + ValueAnimator.unregisterDurationScaleChangeListener(listener) + } + } + + @Suppress("UnspecifiedRegisterReceiverFlag") + private fun subscribeToLegacyEffectiveScale(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription { + val refresh = { onScaleChanged(currentScale()) } + val receiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + refresh() + } + } + val lifecycleObserver = + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + refresh() + } + + override fun onResume(owner: LifecycleOwner) { + refresh() + } + } + + context.registerReceiver(receiver, IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)) + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + refresh() + + return WearAnimatorScaleSubscription { + context.unregisterReceiver(receiver) + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + } + } +} + +internal fun interface WearAvatarFrameClock { + suspend fun awaitFrame(onFrame: (Long) -> Unit) +} + +private val ComposeWearAvatarFrameClock = WearAvatarFrameClock { onFrame -> withFrameNanos(onFrame) } + +internal data class WearAvatarAnimationState( + val durationScale: Float, + val animationSeconds: Float, + val mouthLevel: Float, +) + +internal data class WearAvatarMotionInputs( + val animationSeconds: Float, + val mouthLevel: Float, +) + +internal fun avatarMotionInputs( + animationsEnabled: Boolean, + animationSeconds: Float, + mouthLevel: Float, +): WearAvatarMotionInputs = + if (animationsEnabled) { + WearAvatarMotionInputs( + animationSeconds = animationSeconds, + mouthLevel = mouthLevel.coerceIn(0f, 1f), + ) + } else { + WearAvatarMotionInputs(animationSeconds = 0f, mouthLevel = 0f) + } + +private fun DrawScope.drawCanonicalAvatar( + pose: WearAvatarPose, + state: RealtimeVoiceButtonState, + animationSeconds: Float, +) { + val stretchX = (1f + ((1f - pose.bodyStretch) * 0.5f)).coerceIn(0.96f, 1.04f) + withTransform({ translate(top = pose.floatOffset) }) { + withTransform({ + scale(stretchX, pose.bodyStretch, pivot = Offset(60f, 110f)) + rotate(pose.bodyTilt, pivot = Offset(60f, 60f)) + }) { + drawPath( + path = BodyPath, + brush = + Brush.linearGradient( + colors = listOf(CoralBright, CoralDark), + start = Offset(15f, 10f), + end = Offset(105f, 110f), + ), + ) + withTransform({ rotate(pose.leftClawDegrees, pivot = LeftClawPivot) }) { + drawPath( + path = LeftClawPath, + brush = + Brush.linearGradient( + colors = listOf(CoralBright, CoralDark), + start = Offset(3.125f, 43.67f), + end = Offset(26.197f, 65.451f), + ), + ) + } + withTransform({ rotate(pose.rightClawDegrees, pivot = RightClawPivot) }) { + drawPath( + path = RightClawPath, + brush = + Brush.linearGradient( + colors = listOf(CoralBright, CoralDark), + start = Offset(93.803f, 43.67f), + end = Offset(116.875f, 65.451f), + ), + ) + } + + val antennaStroke = Stroke(width = 2f, cap = StrokeCap.Round) + val wiggle = pose.antennaDegrees * (1f - pose.antennaDroop) + withTransform({ rotate((-pose.antennaDroop * 40f), pivot = Offset(45f, 15f)) }) { + withTransform({ rotate(wiggle, pivot = LeftAntennaPivot) }) { + drawPath(LeftAntennaPath, CoralBright, style = antennaStroke) + } + } + withTransform({ rotate((pose.antennaDroop * 40f), pivot = Offset(75f, 15f)) }) { + withTransform({ rotate(wiggle, pivot = RightAntennaPivot) }) { + drawPath(RightAntennaPath, CoralBright, style = antennaStroke) + } + } + + drawCanonicalEye(LeftEyeCenter, pose.eyeOpenness, pose.gaze) + drawCanonicalEye(RightEyeCenter, pose.eyeOpenness, pose.gaze) + drawCanonicalMouth(state, pose.mouthLevel, animationSeconds) + } + } +} + +private fun DrawScope.drawCanonicalEye( + center: Offset, + openness: Float, + gaze: Offset, +) { + val eyeHeight = max(1.2f, 12f * openness) + val eyeCenterY = center.y - 6f + ((12f - eyeHeight) * 0.65f) + (eyeHeight / 2f) + drawOval( + color = EyeDark, + topLeft = Offset(center.x - 6f, eyeCenterY - (eyeHeight / 2f)), + size = Size(12f, eyeHeight), + ) + if (openness <= 0.16f) return + + val pupil = + Offset( + x = center.x + (gaze.x * 2.7f), + y = center.y - 1f + (gaze.y * 2.1f), + ) + drawCircle( + color = EyeGlow, + radius = 2.1f, + center = pupil, + alpha = ((openness - 0.16f) / 0.84f).coerceIn(0f, 1f), + ) +} + +private fun DrawScope.drawCanonicalMouth( + state: RealtimeVoiceButtonState, + mouthLevel: Float, + animationSeconds: Float, +) { + if (state == RealtimeVoiceButtonState.ERROR) { + val frown = + Path().apply { + moveTo(52.5f, 54f) + quadraticTo(60f, 47f, 67.5f, 54f) + } + drawPath(frown, EyeDark, style = Stroke(width = 2.2f, cap = StrokeCap.Round)) + return + } + if (state != RealtimeVoiceButtonState.SPEAKING || mouthLevel <= 0.025f) return + + val vowelShape = 0.5f + (0.5f * sin(animationSeconds * 2f * PI.toFloat() / 0.31f)) + val radiusX = 2.2f + (mouthLevel * (4.7f + (1.6f * vowelShape))) + val radiusY = 1.1f + (mouthLevel * (6.5f - (1.3f * vowelShape))) + drawOval( + color = EyeDark, + topLeft = Offset(60f - radiusX, 52f - radiusY), + size = Size(radiusX * 2f, radiusY * 2f), + ) + if (mouthLevel > 0.48f) { + drawOval( + color = Tongue, + topLeft = Offset(60f - (radiusX * 0.55f), 52f + (radiusY * 0.24f)), + size = Size(radiusX * 1.1f, radiusY * 0.42f), + alpha = ((mouthLevel - 0.48f) / 0.52f).coerceIn(0f, 0.82f), + ) + } +} + +internal fun avatarPoseAt( + state: RealtimeVoiceButtonState, + animationSeconds: Float, + mouthLevel: Float, +): WearAvatarPose { + val tau = 2f * PI.toFloat() + val breathing = sin(animationSeconds * tau / 3.8f) + var floatOffset = -2.6f * (1f - cos(animationSeconds * tau / 4.2f)) + var bodyTilt = 0.8f * sin(animationSeconds * tau / 6.4f) + var bodyStretch = 1f + (0.012f * breathing) + var antennaDegrees = -3f * sin(animationSeconds * tau / 2.1f) + var antennaDroop = 0f + var leftClawDegrees = 0f + var rightClawDegrees = 0f + var gaze = Offset(0.45f * sin(animationSeconds * tau / 7.5f), 0.2f * sin(animationSeconds * tau / 5.8f)) + var eyeOpenness = 1f - (0.96f * avatarBlinkClosure(animationSeconds)) + var haloPulse = 0.5f + (0.5f * sin(animationSeconds * tau / 2.4f)) + + when (state) { + RealtimeVoiceButtonState.IDLE -> Unit + RealtimeVoiceButtonState.CONNECTING -> { + val orbit = animationSeconds * tau / 1.65f + gaze = Offset(cos(orbit) * 1.05f, sin(orbit) * 0.82f) + bodyTilt = 2f * sin(animationSeconds * tau / 2.8f) + antennaDegrees = -7f * sin(animationSeconds * tau / 1.1f) + leftClawDegrees = 3f * sin(animationSeconds * tau / 1.4f) + rightClawDegrees = -leftClawDegrees + haloPulse = 0.5f + (0.5f * sin(animationSeconds * tau / 0.9f)) + } + RealtimeVoiceButtonState.LISTENING -> { + val attentivePulse = 0.5f + (0.5f * sin(animationSeconds * tau / 1.25f)) + gaze = Offset(0.2f * sin(animationSeconds * tau / 3.2f), 0.34f) + bodyStretch += 0.018f * attentivePulse + leftClawDegrees = 4f + (2f * attentivePulse) + rightClawDegrees = -leftClawDegrees + antennaDegrees = -4f * sin(animationSeconds * tau / 1.45f) + haloPulse = attentivePulse + } + RealtimeVoiceButtonState.THINKING -> { + val orbit = animationSeconds * tau / 2.15f + gaze = Offset(cos(orbit) * 1.15f, sin(orbit) * 0.92f) + bodyTilt = 2.8f * sin(animationSeconds * tau / 4.5f) + antennaDegrees = -7f * sin(animationSeconds * tau / 1.25f) + leftClawDegrees = 5f + (2f * sin(animationSeconds * tau / 2.7f)) + rightClawDegrees = -10f - (3f * sin(animationSeconds * tau / 2.2f)) + haloPulse = 0.5f + (0.5f * sin(animationSeconds * tau / 1.4f)) + } + RealtimeVoiceButtonState.SPEAKING -> { + val speechBeat = sin(animationSeconds * tau / 0.72f) + floatOffset -= mouthLevel * 2.2f + bodyStretch += (mouthLevel * 0.055f) + (speechBeat * 0.008f) + bodyTilt = 1.5f * sin(animationSeconds * tau / 2.1f) + antennaDegrees = -5f * sin(animationSeconds * tau / 0.95f) + leftClawDegrees = 4f + (mouthLevel * 10f) + (speechBeat * 2f) + rightClawDegrees = -leftClawDegrees + gaze = Offset(0.18f * sin(animationSeconds * tau / 2.6f), 0.12f) + haloPulse = (0.25f + (mouthLevel * 0.75f)).coerceIn(0f, 1f) + } + RealtimeVoiceButtonState.ERROR -> { + bodyTilt = 2.2f * sin(animationSeconds * tau / 0.42f) + antennaDroop = 0.72f + leftClawDegrees = -5f + rightClawDegrees = 5f + gaze = Offset(0f, 0.7f) + eyeOpenness *= 0.72f + haloPulse = 0.72f + (0.28f * sin(animationSeconds * tau / 0.8f)) + } + } + + return WearAvatarPose( + floatOffset = floatOffset, + bodyTilt = bodyTilt, + bodyStretch = bodyStretch.coerceIn(0.94f, 1.08f), + antennaDegrees = antennaDegrees, + antennaDroop = antennaDroop, + leftClawDegrees = leftClawDegrees, + rightClawDegrees = rightClawDegrees, + eyeOpenness = eyeOpenness.coerceIn(0.04f, 1f), + gaze = gaze, + mouthLevel = mouthLevel.coerceIn(0f, 1f), + haloPulse = haloPulse.coerceIn(0f, 1f), + ) +} + +internal fun smoothAvatarMouth( + current: Float, + target: Float, + deltaSeconds: Float, +): Float { + val safeCurrent = current.coerceIn(0f, 1f) + val safeTarget = target.coerceIn(0f, 1f) + val safeDelta = deltaSeconds.coerceIn(0f, 0.05f) + if (safeDelta == 0f) return safeCurrent + + val responseSeconds = if (safeTarget > safeCurrent) MOUTH_ATTACK_SECONDS else MOUTH_RELEASE_SECONDS + val blend = (1.0 - exp((-safeDelta / responseSeconds).toDouble())).toFloat() + return (safeCurrent + ((safeTarget - safeCurrent) * blend)).coerceIn(0f, 1f) +} + +internal fun scaledAvatarDeltaSeconds( + deltaSeconds: Float, + durationScale: Float, +): Float { + if (durationScale <= 0f) return 0f + return (deltaSeconds / durationScale).coerceIn(0f, 0.05f) +} + +private fun syntheticSpeechMouth(animationSeconds: Float): Float { + val tau = 2f * PI.toFloat() + val syllable = 0.5f + (0.5f * sin(animationSeconds * tau / 0.19f)) + val phrase = 0.68f + (0.32f * sin(animationSeconds * tau / 0.83f)) + return (0.1f + (0.72f * syllable * phrase)).coerceIn(0.08f, 0.86f) +} + +private fun avatarBlinkClosure(animationSeconds: Float): Float { + val phase = animationSeconds % BLINK_CYCLE_SECONDS + return when { + phase in FIRST_BLINK_START..FIRST_BLINK_END -> + smoothBell((phase - FIRST_BLINK_START) / (FIRST_BLINK_END - FIRST_BLINK_START)) + phase in SECOND_BLINK_START..SECOND_BLINK_END -> + smoothBell((phase - SECOND_BLINK_START) / (SECOND_BLINK_END - SECOND_BLINK_START)) + else -> 0f + } +} + +private fun smoothBell(value: Float): Float { + val mirrored = if (value < 0.5f) value * 2f else (1f - value) * 2f + val clamped = mirrored.coerceIn(0f, 1f) + return clamped * clamped * (3f - (2f * clamped)) +} + +private const val CANONICAL_ART_SIZE = 120f +private const val CANONICAL_ART_BOX = 126f +private const val AVATAR_ANIMATION_CYCLE_SECONDS = 60f +private const val MOUTH_ATTACK_SECONDS = 0.045f +private const val MOUTH_RELEASE_SECONDS = 0.11f +private const val BLINK_CYCLE_SECONDS = 5.4f +private const val FIRST_BLINK_START = 3.58f +private const val FIRST_BLINK_END = 3.76f +private const val SECOND_BLINK_START = 4.02f +private const val SECOND_BLINK_END = 4.17f diff --git a/wear/src/main/java/ai/openclaw/wear/WearTheme.kt b/wear/src/main/java/ai/openclaw/wear/WearTheme.kt new file mode 100644 index 0000000..9e3e57e --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearTheme.kt @@ -0,0 +1,179 @@ +package ai.openclaw.wear + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.core.content.edit +import androidx.wear.compose.material3.MaterialTheme + +internal enum class WearThemeMode( + val rawValue: String, +) { + Dark(rawValue = "dark"), + Light(rawValue = "light"), + ; + + companion object { + fun fromRawValue(value: String?): WearThemeMode = entries.firstOrNull { mode -> mode.rawValue == value?.trim()?.lowercase() } ?: Dark + } +} + +internal data class WearSettings( + val themeMode: WearThemeMode, + val autoSpeak: Boolean, +) + +internal class WearSettingsStore internal constructor( + private val preferences: SharedPreferences, +) { + constructor(context: Context) : + this( + context.applicationContext.getSharedPreferences( + PREFERENCES_NAME, + Context.MODE_PRIVATE, + ), + ) + + fun read(): WearSettings = + WearSettings( + themeMode = WearThemeMode.fromRawValue(preferences.getString(THEME_MODE_KEY, null)), + autoSpeak = preferences.getBoolean(AUTO_SPEAK_KEY, DEFAULT_AUTO_SPEAK), + ) + + fun writeThemeMode(mode: WearThemeMode) { + preferences.edit { + putString(THEME_MODE_KEY, mode.rawValue) + } + } + + fun writeAutoSpeak(enabled: Boolean) { + preferences.edit { + putBoolean(AUTO_SPEAK_KEY, enabled) + } + } + + private companion object { + // One Watch-owned store is the durable owner for local UI preferences. These + // keys have not shipped in a tagged release, so defaults are the only upgrade path. + const val DEFAULT_AUTO_SPEAK = false + const val PREFERENCES_NAME = "openclaw.wear.settings" + const val THEME_MODE_KEY = "appearance.themeMode" + const val AUTO_SPEAK_KEY = "conversation.autoSpeak" + } +} + +@Immutable +internal data class WearColors( + val canvas: Color, + val surface: Color, + val surfaceRaised: Color, + val surfacePressed: Color, + val border: Color, + val borderStrong: Color, + val text: Color, + val textMuted: Color, + val primary: Color, + val primaryText: Color, + val voiceAccent: Color, + val voiceAccentSoft: Color, + val onVoiceAccent: Color, + val success: Color, + val warning: Color, + val danger: Color, +) + +// Keep the companion surfaces aligned with the canonical Phone ClawTheme. +// Voice blue comes from the Phone MobileUiTokens and is intentionally not the +// general control or panel color. +private val DarkWearColors = + WearColors( + canvas = Color(0xFF030303), + surface = Color(0xFF0A0A0A), + surfaceRaised = Color(0xFF111111), + surfacePressed = Color(0xFF1A1A1A), + border = Color(0xFF242424), + borderStrong = Color(0xFF3A3A3A), + text = Color(0xFFF8F8F8), + textMuted = Color(0xFFA8A8A8), + primary = Color(0xFFFFFFFF), + primaryText = Color(0xFF050505), + voiceAccent = Color(0xFF6EA8FF), + voiceAccentSoft = Color(0xFF1A2A44), + onVoiceAccent = Color(0xFF050505), + success = Color(0xFF3EDB82), + warning = Color(0xFFE6B956), + danger = Color(0xFFFF6B6B), + ) + +private val LightWearColors = + WearColors( + canvas = Color(0xFFFAFBFC), + surface = Color(0xFFFFFEFB), + surfaceRaised = Color(0xFFFFFFFF), + surfacePressed = Color(0xFFE9EDF3), + border = Color(0xFFDDE3EC), + borderStrong = Color(0xFFC7D0DC), + text = Color(0xFF111318), + textMuted = Color(0xFF505865), + primary = Color(0xFF111827), + primaryText = Color(0xFFFFFFFF), + voiceAccent = Color(0xFF1B5ACB), + voiceAccentSoft = Color(0xFFEAF2FF), + onVoiceAccent = Color(0xFFFFFFFF), + success = Color(0xFF217747), + warning = Color(0xFFA56F17), + danger = Color(0xFFB82929), + ) + +internal fun wearColorsFor(themeMode: WearThemeMode): WearColors = + when (themeMode) { + WearThemeMode.Dark -> DarkWearColors + WearThemeMode.Light -> LightWearColors + } + +private val LocalWearColors = staticCompositionLocalOf { DarkWearColors } + +internal object OpenClawWearTheme { + val colors: WearColors + @Composable + @ReadOnlyComposable + get() = LocalWearColors.current +} + +@Composable +internal fun OpenClawWearTheme( + themeMode: WearThemeMode, + content: @Composable () -> Unit, +) { + val colors = wearColorsFor(themeMode) + val colorScheme = + MaterialTheme.colorScheme.copy( + primary = colors.primary, + primaryContainer = colors.surfaceRaised, + onPrimary = colors.primaryText, + onPrimaryContainer = colors.text, + surfaceContainerLow = colors.surface, + surfaceContainer = colors.surface, + surfaceContainerHigh = colors.surfaceRaised, + onSurface = colors.text, + onSurfaceVariant = colors.textMuted, + outline = colors.borderStrong, + outlineVariant = colors.border, + background = colors.canvas, + onBackground = colors.text, + error = colors.danger, + onError = colors.primaryText, + ) + + MaterialTheme(colorScheme = colorScheme) { + CompositionLocalProvider( + LocalWearColors provides colors, + content = content, + ) + } +} diff --git a/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt b/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt new file mode 100644 index 0000000..bdbb8e8 --- /dev/null +++ b/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt @@ -0,0 +1,1424 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearConnectionFailure +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRealtimeTalkCodec +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import java.util.UUID + +internal data class WearUiState( + val loading: Boolean = true, + val connected: Boolean = false, + val phoneNodeId: String? = null, + val agents: List = emptyList(), + val activeAgentId: String? = null, + val selectedModelRef: String? = null, + val models: List = emptyList(), + val proxyCapabilities: Set = emptySet(), + val sessions: List = emptyList(), + val selectedSession: WearSession? = null, + val messages: List = emptyList(), + val streamText: String? = null, + val activeRunId: String? = null, + val sending: Boolean = false, + val realtimeTalk: WearRealtimeTalkSnapshot = WearRealtimeTalkSnapshot(), + val realtimeCapturing: Boolean = false, + val realtimePlaying: Boolean = false, + val realtimeMouthLevel: Float = 0f, + val realtimePlaybackFailed: Boolean = false, + val talkBusy: Boolean = false, + val controlBusy: Boolean = false, + val failure: WearConversationFailure? = null, +) + +internal fun WearUiState.resetForPhoneChange(): WearUiState = + copy( + loading = true, + connected = false, + phoneNodeId = null, + agents = emptyList(), + activeAgentId = null, + selectedModelRef = null, + models = emptyList(), + proxyCapabilities = emptySet(), + sessions = emptyList(), + selectedSession = null, + messages = emptyList(), + streamText = null, + activeRunId = null, + sending = false, + realtimeTalk = WearRealtimeTalkSnapshot(), + realtimeCapturing = false, + realtimePlaying = false, + realtimeMouthLevel = 0f, + realtimePlaybackFailed = false, + talkBusy = false, + controlBusy = false, + failure = null, + ) + +internal fun WearUiState.switchAgentContext(agentId: String): WearUiState = + copy( + activeAgentId = agentId, + sessions = emptyList(), + selectedSession = null, + messages = emptyList(), + streamText = null, + activeRunId = null, + selectedModelRef = null, + models = emptyList(), + ) + +internal fun WearUiState.switchSessionContext(session: WearSession): WearUiState = + copy( + selectedSession = session, + messages = emptyList(), + streamText = null, + activeRunId = null, + selectedModelRef = session.modelRef, + models = emptyList(), + realtimeTalk = WearRealtimeTalkSnapshot(), + realtimeMouthLevel = 0f, + talkBusy = false, + failure = null, + ) + +internal fun WearUiState.switchModelContext(modelRef: String): WearUiState { + val currentSession = selectedSession ?: return this + val updatedSession = currentSession.copy(modelRef = modelRef) + return copy( + selectedModelRef = modelRef, + selectedSession = updatedSession, + // The phone preserves the selected model in its bounded catalog slice. + // A model change therefore invalidates the previous slice. + models = emptyList(), + sessions = sessions.map { session -> if (session.key == updatedSession.key) updatedSession else session }, + ) +} + +internal fun shouldAcceptWearTalkSnapshot( + snapshot: WearRealtimeTalkSnapshot, + attemptId: String?, +): Boolean = snapshot.attemptId != null && snapshot.attemptId == attemptId + +internal data class WearTerminalChatTransition( + val state: WearUiState, + val reloadHistory: Boolean, + val observedMessage: WearChatMessage? = null, +) + +internal fun reduceWearTerminalChatEvent( + current: WearUiState, + event: WearChatEvent, +): WearTerminalChatTransition { + if (event.sessionKey != current.selectedSession?.key) { + return WearTerminalChatTransition(state = current, reloadHistory = false) + } + val finalMessage = event.message?.takeIf { event.state == "final" } + val preservedState = + finalMessage?.let { message -> + current.copy(messages = mergeEventMessage(current.messages, message)) + } ?: current + if (current.activeRunId != null && event.runId != null && current.activeRunId != event.runId) { + // Preserve older finals and notifications without canceling another + // identified run or replacing it with a stale history snapshot. + return WearTerminalChatTransition(state = preservedState, reloadHistory = false) + } + val hasLiveReply = current.activeRunId != null || !current.streamText.isNullOrBlank() + if (hasLiveReply && (current.activeRunId == null || event.runId == null)) { + // Missing run identity cannot distinguish a delayed terminal from the + // live run's first identified terminal. Preserve the live reply until + // authoritative history resolves which run actually remains active. + return WearTerminalChatTransition( + state = preservedState, + reloadHistory = true, + observedMessage = finalMessage, + ) + } + return when (event.state) { + "final" -> + WearTerminalChatTransition( + state = + current.copy( + messages = event.message?.let { mergeEventMessage(current.messages, it) } ?: current.messages, + streamText = if (event.message == null) current.streamText else null, + activeRunId = null, + ), + reloadHistory = true, + observedMessage = event.message, + ) + "aborted", "error" -> + WearTerminalChatTransition( + state = current.copy(streamText = null, activeRunId = null), + reloadHistory = true, + ) + else -> WearTerminalChatTransition(state = current, reloadHistory = false) + } +} + +internal class WearViewModel( + application: Application, +) : AndroidViewModel(application) { + private val app = application as WearApplication + private val repository = app.gatewayRepository + private val realtimeTalkClient = WearRealtimeTalkClient(app, repository) + private val mutableState = MutableStateFlow(WearUiState()) + private val eventSequenceTracker = WearEventSequenceTracker() + private val eventSourceTracker = WearEventSourceTracker() + private val resyncEventBuffer = WearEventResyncBuffer() + private val historyLoadTracker = WearHistoryLoadTracker() + private val sendAttemptTracker = WearSendAttemptTracker() + private val controlBusyOwner = WearControlBusyOwner() + private var loadJob: Job? = null + private var phoneRouteGeneration = 0L + + // Session switches clear the prior bounded catalog; only the matching phone/session may refill it. + private var modelLoadJob: Job? = null + private var talkStartJob: Job? = null + private var talkAttemptId: String? = null + + val state: StateFlow = mutableState.asStateFlow() + + init { + viewModelScope.launch { + app.proxyClient.events.collect(::handleEvent) + } + viewModelScope.launch { + app.proxyClient.preferredPhoneChanges.collect(::reloadForPreferredPhone) + } + viewModelScope.launch { + realtimeTalkClient.channelFailed.collect { failed -> + mutableState.update { it.copy(realtimePlaybackFailed = failed) } + if (failed) { + talkAttemptId = null + mutableState.update { + it.copy( + realtimeTalk = WearRealtimeTalkSnapshot(), + realtimeCapturing = false, + realtimePlaying = false, + realtimeMouthLevel = 0f, + talkBusy = false, + failure = WearConversationFailure.INTERNAL_ERROR, + ) + } + } + } + } + viewModelScope.launch { + realtimeTalkClient.isCapturing.collect { capturing -> + mutableState.update { it.copy(realtimeCapturing = capturing) } + } + } + viewModelScope.launch { + realtimeTalkClient.isPlaying.collect { playing -> + mutableState.update { it.copy(realtimePlaying = playing) } + } + } + viewModelScope.launch { + realtimeTalkClient.mouthLevel.collect { level -> + mutableState.update { it.copy(realtimeMouthLevel = level) } + } + } + refresh() + } + + fun refresh() { + loadSessions() + } + + fun openSession(session: WearSession) { + val current = mutableState.value + if ( + current.controlBusy || + current.talkBusy || + current.realtimeTalk.active || + current.realtimeCapturing || + current.realtimePlaying || + current.selectedSession?.key == session.key + ) { + return + } + endRealtimeTalkForNavigation() + cancelModelLoad() + mutableState.update { it.switchSessionContext(session) } + loadModels(session) + loadHistory(session) + } + + fun closeSession() { + endRealtimeTalkForNavigation() + cancelModelLoad() + mutableState.update { + it.copy( + selectedSession = null, + messages = emptyList(), + streamText = null, + activeRunId = null, + selectedModelRef = null, + realtimeTalk = WearRealtimeTalkSnapshot(), + talkBusy = false, + failure = null, + ) + } + loadSessions() + } + + private fun endRealtimeTalkForNavigation() { + if (talkStartJob?.isActive == true) { + talkStartJob?.cancel() + talkStartJob = null + realtimeTalkClient.disconnectLocal() + } else if (mutableState.value.realtimeTalk.active) { + stopRealtimeTalk() + } + talkAttemptId = null + } + + fun startRealtimeTalk() { + val current = mutableState.value + val selectedSession = current.selectedSession ?: return + if (current.talkBusy || current.realtimeTalk.active) return + val capabilities = current.proxyCapabilities + val attemptId = "wear-${UUID.randomUUID()}" + talkAttemptId = attemptId + val startJob = + viewModelScope.launch(start = CoroutineStart.LAZY) { + mutableState.update { it.copy(talkBusy = true, failure = null) } + try { + val snapshot = + realtimeTalkClient.start( + selectedSession, + attemptId, + capabilities, + ) + if (talkAttemptId != attemptId) return@launch + mutableState.update { it.copy(realtimeTalk = snapshot, talkBusy = false) } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + if (talkAttemptId != attemptId) return@launch + talkAttemptId = null + mutableState.update { + it.copy(talkBusy = false, failure = err.toWearConversationFailure()) + } + } finally { + if (talkStartJob === coroutineContext[Job]) talkStartJob = null + } + } + talkStartJob = startJob + startJob.start() + } + + fun stopRealtimeTalk() { + if (mutableState.value.talkBusy) return + val attemptId = talkAttemptId + viewModelScope.launch { + mutableState.update { it.copy(talkBusy = true) } + try { + val snapshot = realtimeTalkClient.stop() + if (talkAttemptId != attemptId) return@launch + if (talkAttemptId == snapshot.attemptId) talkAttemptId = null + mutableState.update { it.copy(realtimeTalk = snapshot, talkBusy = false) } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + if (talkAttemptId != attemptId) return@launch + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + mutableState.update { + it.copy( + realtimeTalk = WearRealtimeTalkSnapshot(), + talkBusy = false, + failure = err.toWearConversationFailure(), + ) + } + } + } + } + + fun sendReply(text: String) { + val session = mutableState.value.selectedSession ?: return + val routeGeneration = phoneRouteGeneration + val normalized = text.trim() + if (normalized.isEmpty() || mutableState.value.sending) return + val attempt = sendAttemptTracker.begin(session.key, normalized, session.phoneNodeId) + viewModelScope.launch { + if (!isCurrentSessionAction(session, routeGeneration)) return@launch + mutableState.update { it.copy(sending = true, failure = null) } + try { + repository.send(attempt, requirePreferredPhone = true) + sendAttemptTracker.markSucceeded(attempt) + reloadHistoryIfSelected(session, routeGeneration) + } catch (err: CancellationException) { + sendAttemptTracker.markAmbiguous(attempt) + throw err + } catch (err: Throwable) { + sendAttemptTracker.markAmbiguous(attempt) + recordFailureForSession(err, session, routeGeneration) + } finally { + mutableState.update { state -> + if (isCurrentSessionAction(session, routeGeneration, state)) { + state.copy(sending = false) + } else { + state + } + } + } + } + } + + fun abort() { + val current = mutableState.value + val session = current.selectedSession ?: return + val routeGeneration = phoneRouteGeneration + viewModelScope.launch { + if (!isCurrentSessionAction(session, routeGeneration)) return@launch + try { + repository.abort(session.key, current.activeRunId, session.phoneNodeId) + if (!isCurrentSessionAction(session, routeGeneration)) return@launch + mutableState.update { it.copy(streamText = null, activeRunId = null, failure = null) } + reloadHistoryIfSelected(session, routeGeneration) + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + recordFailureForSession(err, session, routeGeneration) + } + } + } + + fun selectAgent(agentId: String) { + val current = mutableState.value + val phoneNodeId = current.phoneNodeId ?: return + val routeGeneration = phoneRouteGeneration + if ( + current.controlBusy || + current.talkBusy || + current.realtimeTalk.active || + current.realtimeCapturing || + current.realtimePlaying || + current.activeAgentId == agentId || + WearProxyCapability.AgentControls !in current.proxyCapabilities + ) { + return + } + val controlAction = beginControlAction(phoneNodeId, routeGeneration) ?: return + viewModelScope.launch { + try { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + repository.selectAgent(agentId, phoneNodeId, current.proxyCapabilities) + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + mutableState.update { state -> + if (isCurrentControlRoute(phoneNodeId, routeGeneration, state)) { + state.switchAgentContext(agentId) + } else { + state + } + } + refresh() + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + recordFailureForControlRoute(err, phoneNodeId, routeGeneration, loading = false) + } finally { + finishControlAction(controlAction) + } + } + } + + fun selectModel(modelRef: String) { + val current = mutableState.value + val phoneNodeId = current.phoneNodeId ?: return + val session = current.selectedSession ?: return + val routeGeneration = phoneRouteGeneration + if ( + current.controlBusy || + current.talkBusy || + current.realtimeTalk.active || + current.realtimeCapturing || + current.realtimePlaying || + current.selectedModelRef == modelRef || + current.models.none { model -> model.ref == modelRef } || + WearProxyCapability.ModelControls !in current.proxyCapabilities + ) { + return + } + val controlAction = beginControlAction(phoneNodeId, routeGeneration) ?: return + viewModelScope.launch { + try { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + cancelModelLoad() + val responseRequest = eventSequenceTracker.beginResponseRequest() + val selection = + repository.selectModel( + sessionKey = session.key, + modelRef = modelRef, + phoneNodeId = phoneNodeId, + capabilities = current.proxyCapabilities, + ) + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + val currentSession = mutableState.value.selectedSession ?: return@launch + if (!wearSessionRequestIsCurrent(session, currentSession, selection.phoneNodeId)) return@launch + if ( + !eventSequenceTracker.isResponseCurrent( + responseRequest, + selection.eventStreamId, + selection.eventSequence, + ) + ) { + // A response older than the accepted event stream cannot overwrite newer session state. + loadSessions(selection.phoneNodeId) + return@launch + } + val acceptedModelRef = selection.selectedModelRef + val updatedSession = currentSession.copy(modelRef = acceptedModelRef) + mutableState.update { state -> + if (!isCurrentControlRoute(phoneNodeId, routeGeneration, state)) return@update state + val selectedSession = state.selectedSession ?: return@update state + if (!wearSessionRequestIsCurrent(session, selectedSession, selection.phoneNodeId)) return@update state + state.switchModelContext(acceptedModelRef) + } + if (isCurrentControlRoute(phoneNodeId, routeGeneration)) { + loadModels(updatedSession) + } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + recordFailureForControlRoute(err, phoneNodeId, routeGeneration) + } finally { + finishControlAction(controlAction) + } + } + } + + fun setGatewayEnabled(enabled: Boolean) { + val current = mutableState.value + val phoneNodeId = current.phoneNodeId ?: return + val routeGeneration = phoneRouteGeneration + if ( + current.controlBusy || + current.connected == enabled || + WearProxyCapability.GatewayControls !in current.proxyCapabilities + ) { + return + } + val controlAction = beginControlAction(phoneNodeId, routeGeneration) ?: return + viewModelScope.launch { + try { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + if (!enabled) { + talkStartJob?.cancel() + talkStartJob = null + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + } + val status = repository.setGatewayEnabled(enabled, phoneNodeId, current.proxyCapabilities) + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + mutableState.update { state -> + if (!isCurrentControlRoute(phoneNodeId, routeGeneration, state)) { + state + } else { + applyWearGatewayControlStatus(state, status, enabled) + } + } + refresh() + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + recordFailureForControlRoute(err, phoneNodeId, routeGeneration, loading = false) + } finally { + finishControlAction(controlAction) + } + } + } + + private fun loadSessions(expectedNodeId: String? = null) { + cancelLoad() + cancelModelLoad() + loadJob = + viewModelScope.launch { + mutableState.update { it.copy(loading = true, failure = null) } + try { + val status = repository.status(expectedNodeId) + val agentList = + if (status.connected && WearProxyCapability.AgentControls in status.capabilities) { + repository.agents(status.phoneNodeId, status.capabilities) + } else { + WearAgentList( + agents = emptyList(), + eventStreamId = status.eventStreamId, + eventSequence = status.eventSequence, + phoneNodeId = status.phoneNodeId, + ) + } + val previousSession = mutableState.value.selectedSession + val sessionList = + if (status.connected) { + repository.sessions( + expectedNodeId = status.phoneNodeId, + selectedSessionKey = previousSession?.key, + capabilities = status.capabilities, + ) + } else { + WearSessionList( + sessions = emptyList(), + eventStreamId = status.eventStreamId, + eventSequence = status.eventSequence, + phoneNodeId = status.phoneNodeId, + ) + } + val activeSessionKey = + coherentWearActiveSessionKey( + statusAgentId = status.activeAgentId, + statusSessionKey = status.activeSessionKey, + sessionListAgentId = sessionList.activeAgentId, + ) + val retainedSession = + previousSession?.takeIf { previous -> + sessionList.selectedSessionValid && + previous.phoneNodeId == sessionList.phoneNodeId && + sessionList.sessions.none { session -> session.key == previous.key } + } + val listedSessions = retainedSession?.let { listOf(it) + sessionList.sessions } ?: sessionList.sessions + val projectedSessions = + activeSessionKey + ?.takeIf { activeKey -> listedSessions.none { session -> session.key == activeKey } } + ?.let { activeKey -> + listOf( + WearSession( + key = activeKey, + title = null, + updatedAt = null, + hasActiveRun = false, + phoneNodeId = sessionList.phoneNodeId, + agentId = sessionList.activeAgentId, + ), + ) + listedSessions + } ?: listedSessions + val selectedSession = + projectedSessions.firstOrNull { session -> session.key == previousSession?.key } + ?: projectedSessions.firstOrNull { session -> session.key == activeSessionKey } + ?: projectedSessions.firstOrNull() + val selectedModelRef = + selectedSession?.modelRef + ?: wearSelectedModelRef(selectedSession?.key, activeSessionKey, status.selectedModelRef) + val modelList = + if (status.connected && WearProxyCapability.ModelControls in status.capabilities) { + repository.models( + expectedNodeId = status.phoneNodeId, + capabilities = status.capabilities, + selectedModelRef = selectedModelRef, + ) + } else { + WearModelList( + models = emptyList(), + eventStreamId = sessionList.eventStreamId, + eventSequence = sessionList.eventSequence, + phoneNodeId = sessionList.phoneNodeId, + ) + } + if ( + !wearSnapshotSourcesMatch( + firstPhoneNodeId = sessionList.phoneNodeId, + firstStreamId = sessionList.eventStreamId, + secondPhoneNodeId = modelList.phoneNodeId, + secondStreamId = modelList.eventStreamId, + ) + ) { + loadSessions(status.phoneNodeId) + return@launch + } + if (mutableState.value.selectedSession != previousSession) return@launch + val selectionChanged = selectedSession?.key != previousSession?.key + val pendingEvents = + finishSequenceSnapshot( + // sessions.list owns the state snapshot. models.list is fetched later and cannot + // cover session or transcript events emitted between the two responses. + streamId = sessionList.eventStreamId, + sequence = sessionList.eventSequence, + sourceNodeId = sessionList.phoneNodeId, + ) + loadJob = null + mutableState.update { + it.copy( + loading = false, + connected = status.connected, + phoneNodeId = status.phoneNodeId, + agents = agentList.agents, + activeAgentId = + sessionList.activeAgentId + ?: status.activeAgentId + ?: agentList.agents.firstOrNull(WearAgent::selected)?.id, + selectedModelRef = selectedModelRef, + models = modelList.models, + proxyCapabilities = status.capabilities, + sessions = projectedSessions, + selectedSession = selectedSession, + messages = if (selectionChanged || !status.connected) emptyList() else it.messages, + streamText = if (selectionChanged || !status.connected) null else it.streamText, + activeRunId = if (selectionChanged || !status.connected) null else it.activeRunId, + ) + } + pendingEvents.forEach(::handleEvent) + if (status.connected && selectedSession != null) { + loadHistory(selectedSession) + } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + if (err is WearProxyException && err.code == "phone_changed") { + loadJob = null + reloadForPreferredPhone(nodeId = null) + return@launch + } + mutableState.update { + it.copy( + loading = false, + connected = false, + phoneNodeId = null, + agents = emptyList(), + activeAgentId = null, + selectedModelRef = null, + models = emptyList(), + proxyCapabilities = emptySet(), + sessions = emptyList(), + selectedSession = null, + messages = emptyList(), + failure = err.toWearConversationFailure(), + ) + } + loadJob = null + } + } + } + + private fun loadHistory( + session: WearSession, + observedMessage: WearChatMessage? = null, + ) { + cancelLoad() + val loadToken = historyLoadTracker.start(session.key) + loadJob = + viewModelScope.launch { + mutableState.update { it.copy(loading = true, failure = null) } + try { + val transcript = repository.history(session.key, session.phoneNodeId) + val currentSession = mutableState.value.selectedSession ?: return@launch + if ( + !wearTranscriptRequestIsCurrent(session, currentSession, transcript.phoneNodeId) || + !historyLoadTracker.isCurrent(loadToken) + ) { + return@launch + } + val loadResult = historyLoadTracker.finish(loadToken) + val loadedSession = + currentSession.copy( + phoneNodeId = transcript.phoneNodeId, + modelRef = + if (currentSession.modelRef != session.modelRef) { + currentSession.modelRef + } else { + transcript.selectedModelRef ?: session.modelRef + }, + ) + val catalogScopeChanged = wearModelCatalogScopeChanged(currentSession, loadedSession) + val pendingEvents = + finishSequenceSnapshot( + streamId = transcript.eventStreamId, + sequence = transcript.eventSequence, + sourceNodeId = transcript.phoneNodeId, + ) + loadJob = null + mutableState.update { + it.copy( + loading = false, + connected = true, + selectedSession = loadedSession, + selectedModelRef = loadedSession.modelRef, + models = if (catalogScopeChanged) emptyList() else it.models, + sessions = + it.sessions.map { item -> + if (item.key == session.key) { + item.copy(modelRef = loadedSession.modelRef) + } else { + item + } + }, + messages = + observedMessage?.let { message -> + mergeObservedMessageIntoSnapshot(transcript.messages, message) + } ?: transcript.messages, + streamText = + loadResult.liveStream?.let { live -> + reconcileWearStreamSnapshot(transcript.activeText, live.text, live.complete) + } ?: transcript.activeText, + activeRunId = loadResult.liveStream?.runId ?: transcript.activeRunId, + ) + } + pendingEvents.forEach(::handleEvent) + if (catalogScopeChanged) loadModels(loadedSession) + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + val currentLoad = historyLoadTracker.isCurrent(loadToken) + if (currentLoad) { + historyLoadTracker.cancel() + loadJob = null + } + if (currentLoad && err is WearProxyException && err.code == "phone_changed") { + reloadForPreferredPhone(nodeId = null) + return@launch + } + if (currentLoad && mutableState.value.selectedSession?.key == session.key) { + recordFailure(err, loading = false) + } + } + } + } + + private fun loadModels(session: WearSession) { + val current = mutableState.value + val capabilities = current.proxyCapabilities + if ( + WearProxyCapability.ModelControls !in capabilities || + !wearSessionRequestIsCurrent(session, current.selectedSession, session.phoneNodeId) + ) { + return + } + cancelModelLoad() + val responseRequest = eventSequenceTracker.beginResponseRequest() + modelLoadJob = + viewModelScope.launch { + try { + val modelList = + repository.models( + expectedNodeId = session.phoneNodeId, + capabilities = capabilities, + selectedModelRef = session.modelRef, + ) + val selectedSession = mutableState.value.selectedSession + if (!wearSessionRequestIsCurrent(session, selectedSession, modelList.phoneNodeId)) return@launch + if ( + !eventSequenceTracker.isResponseCurrent( + responseRequest, + modelList.eventStreamId, + modelList.eventSequence, + ) + ) { + // Rebuild from a canonical snapshot instead of exposing a catalog from an old cursor. + loadSessions(modelList.phoneNodeId) + return@launch + } + mutableState.update { state -> + if (!wearSessionRequestIsCurrent(session, state.selectedSession, modelList.phoneNodeId)) { + state + } else { + state.copy(models = modelList.models) + } + } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + val selectedSession = mutableState.value.selectedSession + if (wearSessionRequestIsCurrent(session, selectedSession, session.phoneNodeId)) { + recordFailure(err) + } + } + } + } + + private fun handleEvent(event: WearInboundEvent) { + if (eventSourceTracker.changed(event.sourceNodeId)) { + beginSequenceResync(event, sourceChanged = true) + return + } + when (eventSequenceTracker.accept(event.streamId, event.sequence)) { + WearSequenceDecision.GapOrReset -> { + beginSequenceResync(event, sourceChanged = false) + return + } + WearSequenceDecision.AwaitingSnapshot -> { + resyncEventBuffer.append(event) + return + } + WearSequenceDecision.Accepted -> Unit + } + when (event.event) { + WearEventType.Connection -> handleConnectionEvent(event.payload as? JsonObject) + WearEventType.Chat -> handleChatEvent(event) + WearEventType.Resync -> refresh() + WearEventType.Talk -> { + val payload = event.payload ?: return + runCatching { WearRealtimeTalkCodec.decode(payload) } + .getOrNull() + ?.let { snapshot -> + if (!shouldAcceptWearTalkSnapshot(snapshot, talkAttemptId)) return@let + if (!snapshot.active) { + talkStartJob?.cancel() + talkStartJob = null + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + } + mutableState.update { + it.copy( + realtimeTalk = snapshot, + talkBusy = talkStartJob?.isActive == true, + ) + } + } + } + } + } + + private fun beginSequenceResync( + event: WearInboundEvent, + sourceChanged: Boolean, + ) { + // A source switch or sequence gap invalidates the old phone's live state. + // Buffer this boundary event until the selected phone supplies a watermark. + eventSequenceTracker.requireSnapshot() + resyncEventBuffer.start(event) + if (sourceChanged) { + // Session keys are phone-local identities. Resolve the new phone's catalog + // before issuing any history, reply, or abort request against that source. + talkStartJob?.cancel() + talkStartJob = null + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + resetForPhoneRouteChange() + loadSessions(event.sourceNodeId) + return + } + val selected = mutableState.value.selectedSession + if (selected != null) { + mutableState.update { it.copy(streamText = null, activeRunId = null) } + loadHistory(selected) + } else { + mutableState.update { it.copy(streamText = null, activeRunId = null) } + loadSessions(event.sourceNodeId) + } + } + + private fun reloadForPreferredPhone(nodeId: String?) { + talkStartJob?.cancel() + talkStartJob = null + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + cancelLoad() + eventSequenceTracker.requireSnapshot() + resyncEventBuffer.begin() + if (nodeId == null) { + eventSourceTracker.reset() + } else { + eventSourceTracker.adopt(nodeId) + } + resetForPhoneRouteChange() + loadSessions(nodeId) + } + + private fun resetForPhoneRouteChange() { + phoneRouteGeneration += 1 + controlBusyOwner.reset() + mutableState.update(WearUiState::resetForPhoneChange) + } + + private fun finishSequenceSnapshot( + streamId: String?, + sequence: Long?, + sourceNodeId: String, + ): List { + eventSourceTracker.adopt(sourceNodeId) + val pendingEvents = resyncEventBuffer.drainAfterSnapshot(streamId, sequence) + eventSequenceTracker.adoptSnapshot(streamId, sequence) + return pendingEvents + } + + private fun handleConnectionEvent(payload: JsonObject?) { + cancelLoad() + val connected = payload.boolean("connected") ?: false + if (!connected) { + talkStartJob?.cancel() + talkStartJob = null + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + } + mutableState.update { + it.copy( + loading = false, + connected = connected, + streamText = if (connected) it.streamText else null, + activeRunId = if (connected) it.activeRunId else null, + realtimeTalk = if (connected) it.realtimeTalk else WearRealtimeTalkSnapshot(), + talkBusy = if (connected) it.talkBusy else false, + failure = wearConversationFailureForConnection(payload), + ) + } + if (connected) refresh() + } + + private fun handleChatEvent(inbound: WearInboundEvent) { + val event = parseWearChatEvent(inbound.payload) ?: return + val selected = mutableState.value.selectedSession ?: return + if (event.sessionKey != selected.key) return + when (event.state) { + "delta" -> { + mutableState.update { current -> + val projectedText = event.streamText ?: event.message?.text + val projectedComplete = event.streamTextComplete || event.message != null || event.replace + val nextText = + if (projectedText != null) { + reconcileWearStreamSnapshot(current.streamText, projectedText, projectedComplete) + } else { + updateWearStreamText(current = current.streamText, delta = event.deltaText, replace = event.replace) + } + historyLoadTracker.observeDelta( + sessionKey = selected.key, + text = nextText, + complete = projectedComplete, + runId = event.runId, + ) + current.copy( + loading = false, + streamText = nextText, + activeRunId = event.runId ?: current.activeRunId, + ) + } + } + "final", "aborted", "error" -> { + val transition = reduceWearTerminalChatEvent(mutableState.value, event) + if (transition.reloadHistory) cancelLoad() + mutableState.value = transition.state + if (transition.reloadHistory) { + loadHistory(selected, observedMessage = transition.observedMessage) + } + } + else -> + event.message?.let { message -> + cancelLoad() + mutableState.update { it.copy(messages = mergeEventMessage(it.messages, message)) } + loadHistory(selected, observedMessage = message) + } + } + } + + private fun cancelLoad() { + historyLoadTracker.cancel() + loadJob?.cancel() + loadJob = null + } + + private fun cancelModelLoad() { + modelLoadJob?.cancel() + modelLoadJob = null + eventSequenceTracker.invalidateResponseRequests() + } + + private fun reloadHistoryIfSelected( + session: WearSession, + routeGeneration: Long, + ) { + val current = mutableState.value + if (!isCurrentSessionAction(session, routeGeneration, current)) return + val selected = current.selectedSession ?: return + loadHistory(selected) + } + + private fun isCurrentSessionAction( + session: WearSession, + routeGeneration: Long, + state: WearUiState = mutableState.value, + ): Boolean = + wearSessionActionIsCurrent( + requestedSession = session, + currentState = state, + requestedRouteGeneration = routeGeneration, + currentRouteGeneration = phoneRouteGeneration, + ) + + private fun isCurrentControlRoute( + phoneNodeId: String, + routeGeneration: Long, + state: WearUiState = mutableState.value, + ): Boolean = + wearControlRouteIsCurrent( + requestedPhoneNodeId = phoneNodeId, + currentState = state, + requestedRouteGeneration = routeGeneration, + currentRouteGeneration = phoneRouteGeneration, + ) + + private fun beginControlAction( + phoneNodeId: String, + routeGeneration: Long, + ): Long? { + val owner = controlBusyOwner.claim() ?: return null + while (true) { + val state = mutableState.value + if (state.controlBusy || !isCurrentControlRoute(phoneNodeId, routeGeneration, state)) { + controlBusyOwner.release(owner) + return null + } + if (mutableState.compareAndSet(state, state.copy(controlBusy = true, failure = null))) { + return owner + } + } + } + + private fun finishControlAction(owner: Long) { + if (!controlBusyOwner.release(owner)) return + mutableState.update { state -> state.copy(controlBusy = false) } + } + + private fun recordFailure( + error: Throwable, + loading: Boolean = mutableState.value.loading, + ) { + val disconnected = error.isConnectivityFailure() + if (disconnected) { + talkStartJob?.cancel() + talkStartJob = null + talkAttemptId = null + realtimeTalkClient.disconnectLocal() + } + mutableState.update { + it.copy( + loading = loading, + connected = if (disconnected) false else it.connected, + streamText = if (disconnected) null else it.streamText, + activeRunId = if (disconnected) null else it.activeRunId, + realtimeTalk = if (disconnected) WearRealtimeTalkSnapshot() else it.realtimeTalk, + talkBusy = if (disconnected) false else it.talkBusy, + failure = error.toWearConversationFailure(), + ) + } + } + + private fun recordFailureForSession( + error: Throwable, + session: WearSession, + routeGeneration: Long, + ) { + val current = mutableState.value + if (routeGeneration != phoneRouteGeneration || current.phoneNodeId != session.phoneNodeId) return + if (error.isConnectivityFailure() || isCurrentSessionAction(session, routeGeneration, current)) { + recordFailure(error) + } + } + + private fun recordFailureForControlRoute( + error: Throwable, + phoneNodeId: String, + routeGeneration: Long, + loading: Boolean = mutableState.value.loading, + ) { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return + recordFailure(error, loading) + } + + override fun onCleared() { + modelLoadJob?.cancel() + talkStartJob?.cancel() + realtimeTalkClient.shutdown() + } +} + +internal fun coherentWearActiveSessionKey( + statusAgentId: String?, + statusSessionKey: String?, + sessionListAgentId: String?, +): String? { + // A phone-side agent switch can land between status and sessions.list. The + // later list owns session selection; never attach its agent to a stale key. + return statusSessionKey.takeIf { sessionListAgentId == null || sessionListAgentId == statusAgentId } +} + +internal fun wearSelectedModelRef( + selectedSessionKey: String?, + activeSessionKey: String?, + selectedModelRef: String?, +): String? = selectedModelRef.takeIf { selectedSessionKey != null && selectedSessionKey == activeSessionKey } + +internal fun wearModelCatalogScopeChanged( + requestedSession: WearSession, + loadedSession: WearSession, +): Boolean = + loadedSession.phoneNodeId != requestedSession.phoneNodeId || + loadedSession.modelRef != requestedSession.modelRef + +internal fun wearSessionRequestIsCurrent( + requestedSession: WearSession, + currentSession: WearSession?, + responsePhoneNodeId: String, +): Boolean = + currentSession?.key == requestedSession.key && + currentSession.phoneNodeId == requestedSession.phoneNodeId && + responsePhoneNodeId == requestedSession.phoneNodeId && + currentSession.modelRef == requestedSession.modelRef + +internal fun wearTranscriptRequestIsCurrent( + requestedSession: WearSession, + currentSession: WearSession?, + responsePhoneNodeId: String, +): Boolean = + currentSession?.key == requestedSession.key && + currentSession.phoneNodeId == requestedSession.phoneNodeId && + responsePhoneNodeId == requestedSession.phoneNodeId + +internal fun wearSessionActionIsCurrent( + requestedSession: WearSession, + currentState: WearUiState, + requestedRouteGeneration: Long, + currentRouteGeneration: Long, +): Boolean = + requestedRouteGeneration == currentRouteGeneration && + currentState.phoneNodeId == requestedSession.phoneNodeId && + wearTranscriptRequestIsCurrent( + requestedSession, + currentState.selectedSession, + requestedSession.phoneNodeId, + ) + +internal fun wearControlRouteIsCurrent( + requestedPhoneNodeId: String, + currentState: WearUiState, + requestedRouteGeneration: Long, + currentRouteGeneration: Long, +): Boolean = + requestedRouteGeneration == currentRouteGeneration && + currentState.phoneNodeId == requestedPhoneNodeId + +internal class WearControlBusyOwner { + private var nextOwner = 0L + private var activeOwner: Long? = null + + fun claim(): Long? { + if (activeOwner != null) return null + nextOwner += 1 + return nextOwner.also { owner -> activeOwner = owner } + } + + fun release(owner: Long): Boolean { + if (activeOwner != owner) return false + activeOwner = null + return true + } + + fun reset() { + activeOwner = null + } +} + +internal fun applyWearGatewayControlStatus( + state: WearUiState, + status: WearProxyStatus, + enabled: Boolean, +): WearUiState = + state.copy( + connected = status.connected, + phoneNodeId = status.phoneNodeId, + activeAgentId = status.activeAgentId ?: state.activeAgentId, + selectedModelRef = + wearSelectedModelRef( + state.selectedSession?.key, + status.activeSessionKey, + status.selectedModelRef ?: state.selectedModelRef, + ), + proxyCapabilities = status.capabilities, + realtimeTalk = if (enabled) state.realtimeTalk else WearRealtimeTalkSnapshot(), + ) + +internal fun wearSnapshotSourcesMatch( + firstPhoneNodeId: String, + firstStreamId: String?, + secondPhoneNodeId: String, + secondStreamId: String?, +): Boolean = firstPhoneNodeId == secondPhoneNodeId && firstStreamId == secondStreamId + +internal fun mergeEventMessage( + messages: List, + message: WearChatMessage, +): List { + val matchIndex = + messages.indexOfFirst { existing -> + when { + message.id != null -> existing.id == message.id + message.timestamp != null -> + existing.id == null && + existing.timestamp == message.timestamp && + existing.role == message.role + else -> false + } + } + val merged = + if (matchIndex >= 0) { + messages.toMutableList().also { it[matchIndex] = message } + } else { + messages + message + } + return merged.takeLast(MAX_TRANSCRIPT_MESSAGES) +} + +internal fun mergeObservedMessageIntoSnapshot( + messages: List, + message: WearChatMessage, +): List { + val canonicalTail = messages.lastOrNull() + if ( + message.id == null && + canonicalTail != null && + canonicalTail.role == message.role && + canonicalTail.text == message.text && + (message.timestamp == null || canonicalTail.timestamp == message.timestamp) + ) { + // History is authoritative after a final event. A matching tail may have + // gained an ID that the event lacked; appending it would duplicate the reply. + return messages.takeLast(MAX_TRANSCRIPT_MESSAGES) + } + return mergeEventMessage(messages, message) +} + +internal fun updateWearStreamText( + current: String?, + delta: String?, + replace: Boolean, +): String? { + val next = if (replace) delta else current.orEmpty() + delta.orEmpty() + if (next.isNullOrEmpty()) return next + val codePointCount = next.codePointCount(0, next.length) + if (codePointCount <= MAX_STREAM_CODE_POINTS) return next + val start = next.offsetByCodePoints(0, codePointCount - MAX_STREAM_CODE_POINTS) + return next.substring(start) +} + +internal data class WearLiveStreamSnapshot( + val text: String?, + val complete: Boolean, + val runId: String?, +) + +internal fun reconcileWearStreamSnapshot( + snapshot: String?, + live: String?, + liveComplete: Boolean, +): String? { + if (live.isNullOrEmpty()) return snapshot + if (snapshot.isNullOrEmpty()) return live + val merged = + if (liveComplete) { + when { + live.startsWith(snapshot) -> live + snapshot.startsWith(live) -> snapshot + else -> live + } + } else { + if (snapshot.startsWith(live)) { + snapshot + } else { + val maxOverlap = minOf(snapshot.length, live.length) + val overlap = + (maxOverlap downTo 1).firstOrNull { count -> + snapshot.hasCodePointBoundary(snapshot.length - count) && + live.hasCodePointBoundary(count) && + snapshot.endsWith(live.take(count)) + } ?: 0 + snapshot + live.drop(overlap) + } + } + return updateWearStreamText(current = null, delta = merged, replace = true) +} + +private fun String.hasCodePointBoundary(index: Int): Boolean = index <= 0 || index >= length || !(this[index - 1].isHighSurrogate() && this[index].isLowSurrogate()) + +internal data class WearHistoryLoadResult( + val liveStream: WearLiveStreamSnapshot?, +) + +internal class WearHistoryLoadTracker { + private var generation = 0L + private var sessionKey: String? = null + private var liveStream: WearLiveStreamSnapshot? = null + + fun start(sessionKey: String): Long { + generation += 1 + this.sessionKey = sessionKey + liveStream = null + return generation + } + + fun cancel() { + generation += 1 + sessionKey = null + liveStream = null + } + + fun observeDelta( + sessionKey: String, + text: String?, + complete: Boolean, + runId: String?, + ) { + if (this.sessionKey == sessionKey) { + liveStream = WearLiveStreamSnapshot(text = text, complete = complete, runId = runId) + } + } + + fun isCurrent(token: Long): Boolean = token == generation && sessionKey != null + + fun finish( + token: Long, + ): WearHistoryLoadResult { + if (!isCurrent(token)) return WearHistoryLoadResult(liveStream = null) + val result = WearHistoryLoadResult(liveStream) + sessionKey = null + liveStream = null + return result + } +} + +internal fun Throwable.toWearConversationFailure(): WearConversationFailure = + when ((this as? WearProxyException)?.code) { + "phone_unavailable", "unavailable", "timeout" -> WearConversationFailure.PHONE_UNAVAILABLE + "unsupported_peer" -> WearConversationFailure.INCOMPATIBLE + "not_found", "selection_not_found" -> WearConversationFailure.NOT_FOUND + "action_rejected", "rejected" -> WearConversationFailure.ACTION_REJECTED + else -> WearConversationFailure.INTERNAL_ERROR + } + +internal fun wearConversationFailureForConnection(payload: JsonObject?): WearConversationFailure? { + if (payload.boolean("connected") == true) return null + return when (WearConnectionFailure.fromWireValue(payload.string("failure"))) { + WearConnectionFailure.Incompatible -> WearConversationFailure.INCOMPATIBLE + WearConnectionFailure.GatewayOffline -> WearConversationFailure.GATEWAY_OFFLINE + null -> + if (payload.string("status")?.contains("update", ignoreCase = true) == true) { + // Older protocol-v1 phones only sent status text for incompatibility. + WearConversationFailure.INCOMPATIBLE + } else { + WearConversationFailure.GATEWAY_OFFLINE + } + } +} + +private fun Throwable.isConnectivityFailure(): Boolean = this is WearProxyException && code in setOf("phone_unavailable", "unavailable", "timeout") + +private fun JsonObject?.string(name: String): String? = (this?.get(name) as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + +private fun JsonObject?.boolean(name: String): Boolean? = (this?.get(name) as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + +private const val MAX_TRANSCRIPT_MESSAGES = 20 +private const val MAX_STREAM_CODE_POINTS = 2_000 diff --git a/wear/src/main/res/drawable-round/tile_preview.xml b/wear/src/main/res/drawable-round/tile_preview.xml new file mode 100644 index 0000000..9e096a1 --- /dev/null +++ b/wear/src/main/res/drawable-round/tile_preview.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/drawable/ic_notification.xml b/wear/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..7fc3985 --- /dev/null +++ b/wear/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/drawable/tile_preview.xml b/wear/src/main/res/drawable/tile_preview.xml new file mode 100644 index 0000000..854a0ea --- /dev/null +++ b/wear/src/main/res/drawable/tile_preview.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f37998 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f37998 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..c5fc6ff3f572132e63887d1cd9d23a97c7fa0ba0 GIT binary patch literal 12694 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 Cyggz7 literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..c96fe64391e897b1500cace0c36e04231b85f128 GIT binary patch literal 17954 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 z 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 Nc^MVy8cCDT{{eAi!K?rP literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..4dea7ba8bef2678068b20dece1379a3201b221b2 GIT binary patch literal 46094 zcmeFY^;27I)HRH|yL)kWcP&nfySo<%65L&iJG8jF1a}RT;_ei8mzVqb=6U~t@7Fgo zXU;jvm6>E`ueJAD`%09WiX18u5fTIh1ge6(v<3tOWZi!U0^H{_qblr-pC?2Yd0qF< z^M4OWh){3*&l?N~1!;-zfSl7@_?)%DyOI3ymd@1^>zC=SAmTO%Cmd)v2l$9U=t7!8 z7z9KbjO4^b$rZ6DXxmq8jX(%Utgi@&Q)pcoJCgOTu*9nbtA|Y8-L0BuAF@nmw#|&b za8TAg7b7RDsj`1;Xu38*nS9h}2*DHxW)M{VYlIY^myvHlFtMK(1c`}(m@pC;&{$aP z|1A)ggp$R8kwB4`@BMETBqAb#1O^NT1tlc)=RF(^jmhWY!NAb}_vZf>u>apl5{ZtB zCZ>mZ^7aQ4I>M}m!O&}`I1ncGvG?*(J?>(XANKOwh%1Gy-n2=*rSgn5XtF2gjE{r8 z&WP`X$8sWKOAZYtaMycl&Ngzp3uCd#fGGSfk23Nt3?_ETS;^HbT)r1f8Qgn@6tR8s zkK3z@S&F&_&8&g@oiscEJkpZ@AEFjF9H;ub$uU||lDF5TLuD5R@~=zANg@qDs+gAz z(W2j85)q#b#^U8s@k(mO0XkrcE|O|AGxna$$ZM!Utm9zt{Sz;z_*SS{11on&eIf0; z4`(5pGE@*F86G}GRLm0>V%s%vEJM7LLU}=HGD=6YN)EmF1Iy46BF%MV>Azm^mZ&Uc(!-;i@iN9 z7lGp5?OHF-CyB%M#u{31EL~lfZxSI}5h=7m4L&?xU*3)4d%T*OOpug&4i5Ygi^M*G z{w9mhCS}8#!5#H6Q6zJm3l>41opOd|HY9pm26j&<2;;ePvc5PK<$lq?@@kHAPgi~pOwd}^y2y|r`!s*I34?kW{#L4mF zW^pNLw!k-$*CBif|I_-d+v{t}6Y={zI1+iy={wshfin1NlOi$SJ`EN>#Rzzp8(&e8XZfup@U|bhA3YppHqY5fs~!&>Qn*Yy{H*6| zAnf**G49aGAf?o!F*l!kAH%nwyNfkpS=vVBN5c0@Ql_JcUnnLNlh;Ldmp!bF+5;CE zsG_T;3+sr(VJ}R+&`?=(&Rwcx(4MV&J&~8HZa$A-t31xVMv(bz6@?+|*ThS2m83Fx zTos9O{7>S^m=mS3k@_G6{Ha<#R*o8+IaJ}_o{n#6`8Gbg%(%^uh)RUbU!v!CUt5o> zRWCDxgUN5T#(i7!s3a(2r1-nLGF7g2!|(YY`gV&~y!Y!BUr}o6I-hbLiWDPbXbp@0 z-w%^#Ebc@G9Fgl`QN5)wWb@yYLH&0OpybpDvTiV|O6@BrhZw@?~$JG$pZx8w2i9aCKzYqR4rRevPr zae(KU2BZvgs3)iL#*&;Q0-JhP6uwcKDk>SX!OEZhd8KUHfU{-mkP{VQSJqHTV-+v2 zeB>@yghm1~&9S4!pS#jsj=B*{YxVwTFAi~K^lm)hFIOA7kEa5M2EFUvhp18CROgL0 z8Y5O0Ru%En>4~t|i|CaqlhbHy931|Jr>1I3IbYOU7L^qM!fw%CL``QeJCmC&Np#X0 zzkp0iC3IPob*Q?FRy&qe~isgFT-y<>En~NmZ+Dt=U z`JJ?eskZ*Arm+o^(a^5@FQn)4Pqe=#9hL=TSo#n}FYc9p8?rs3UnfNAFNq+}s{>x` zOi-k>GpDM^^^-(6)diRKO2?odoYAZX{q|jYcAhS~x5&=b9s1(C?!Bfp-w(JLotgJw z-cW2kUG8kZV1xdR+$_rkZJa)1v)cAuy8_dAitg)Z;Bwly-;DBu!Z|7a*o6Y6x z-c<7uTDqdSl6X=kmep>+1_bcm{B8yhXbO_qoFJ(wG^pa zbmpg054x`8S@p(-o31@ouebhX=vO(Xb>`{jx}K)GtYkuILhdj3;tGz+2Mm`1uP(9znYj2`{$?!!O2d`_Y6vDJ z15Xu|Kqm1weZKQbBS`+?KHmPSE#;gx3ld$GQye5@L6+tpt!S= zSea`)vstptAV)~{;HK>y2GiGf*##>lC12EcF}NOD_fk^^0hZ3#{RSMdYyp**+zp?K z{fm)q;{}qKL%eMr3CpnfD9DHj&XO%4gpRTHSBxK5g2f;M;m(R^U$_3y$&S=Y+L}zU z4}A<2qu9m3{Y*rBd)g*n1I~j8DN4NWs5cC76yG(jMbqYu3>N#M(ZQ6li(O>NV*@*x zu>_;CM0Tfnre6XVFM}>v^!~n#+yuypj0{2f44=)CPOg(~IPnGmdPPMD)xXnq z*L+iWFY!WBVeGe!jhtz6JO#L?$@ArY#2;inav~MdCqk&Hyw%~tPEfU;{WT8cyEDj; zdUw}26G&P;B>};kfD-)Mh60x%M32s(;o!6}!EU+v#5%qE{kaEUB@YF^ z-0t$BQrkLjC{&%hpQE!EfH9tnQ5%DQ6Z+@iKAeD)k}N79AfkO)$caSTXOwd7oK%P( z^F}veLgR=KXRMm9$U8if*^X zIG=~9jo9O8qjfWUH9`1#Qo`NJxYW~=w8rv}UHQ2tK0JxeGU_Vq@P)4t5@dD!h45Z9tr_o<6IBxSp@QO-6vm?A zlurcE;@oMmOgEqscrJ}t@J>keHJfi%4ZHvg!xVb={9HL99Dj<+f*I?wr!%l>IMHLdZtl$Jj9s~7i6dUza{^Vm~F!->## z^6hb0;ygJ#=#SHC#nN5coLO4>Jzfs9#~)aATQ9NKAU!z4a!cm1x_Q7{l<$2J;z^TAlag%OYi)?bQEj><*XDgGEaHD;mEwM&n^yPGMLO zCLDe_w9;kC;ehU*nZ-1k{gb>x7MHf}x|g)Lps8()ySk{Q6|OgV(np9#oOHIEoJ?;+ zg`8enV}yKmmcR0A6-tiHdLWjFeZt5;K-t2T8+_%2qNmTz-osmb?u$ZZub@@W%jL9&Fg40w~h|vrWE5Ug(*P47k9Q@yKCzclSES>9C&E zVTmQ}BoY)1)UOXFJS`A(4aPH}ys09L_0|#w+a@81d8`X66LUx+#u3zCud(QBou3~) z9x~k=0C_Q3I@#xzc?5f18Kd*6(QiY zJjSE7)8U0o1FP9vno$0l4w#a&1va^w%cF!qI1m3qzK&ZdeFG*GjY~5HxE0aZ~R{` z-~vw~(){Tbj=LIKvGQ#F?xa()P_4pif%!|fDYTumkaYbTQ$|~J>ka*5Py{~PfhRXl zUz?gdrjf6*F7t$*N@8Owq*`c|RCYa}v??|Oz!E9jj{H?|fX&5#v)+EOEPEuOr?95t z`1HFlntEP3#p>H4d>(XkQsV4ilV25X@Pj@VqE`R@1jLoqY&5}$%>J-v0y}P!sgDzrdwsL^o%)zp_-vVnTwPwV>u{*BAFou9)3NT3xi9I>&4+ zUXSAv8Mf}Wa(&H@#+`5Z9p9rz*fe1&F(hk|Zv%r20yHQl-79{|+enR1fZtb(WXxNf zS3URnc%2OHQZcFfk2kt9t%ZcVhZTV1;t~ANz@l)QwM6XOA&@K@mh0sszG}5!` zm#9WW(cAD<>lBJkXT8VXSaW67ooGcLF0&}OxM|`B=t@alPNNbh_hfV1PG_hBj~`}M zeSiIoWZHHJe;J&(?9hNB$C(j9N=;kmJ)9kJs@!|xV#6Le$e{vl1frqHEe$m z;WL0n??9UX*3?Em>*^9dG5)DrC(BNcK5fyot^^8%W5<4r_!M1yll)wm_QT+Ln?RE} zT0}7*Vg`I*01cjt3A34JqQ{n4U;z12eaM+)SK=MKhgXcTL7g}OICfQW&9H8XdJ=LI4v1u$hn8kio|eMxATc?rML+Mel>ql zRO<@g_TgWy=`DR<-=X7lqn&Am()sL-0OM#lsB08jmlQha=?a4;8XlcJNiYYVptF9$rc@WXTshHzvXtLYt_%F5T(G0_pCaOep`GDU7@569o zY4X?+l*RN|1e~sBgKMZmw6X8V+zR$y|K0u8PJOb^yC86;leZ&C;GTcf-AyzS~1bunQ0TZ_1oj3GmT* z0fJshW0|R-m&BpCO@F#+roi)a7(x5TR{iozs6@4=qPt;dWDEde9Jh))W-{jL!7|dO ztf3G3Gd{km*5*IQ5Un2&mbaTSHDy-Sso!pWsM3|0c-O=5Ot|f_$b4vTd9< zha%6o>;0lPt17WFLK4GAC*eb8?{ro`6_Xqh;`J-HyWz}+6#RFz=$%4G~=%cASvZ&89&aTNzW*SXu4J0=~t zSO7Uu4;i4+>2x$Rq?2~`*wrkM;;nk{g!^S|@VespS#vx`;9q4rONia=K}oUNx}f>+ z$KUjH<2XzFs7sRUmkCxQLIsRqJBk@iA~a*)zYUfTuVsalx{bCeF7E7QB6Ys4WGvX+ zwEC>HP|1c~bLL+n(UGy(cg&^u>}()1BkR##U%W1~d}kEP?Ci1qMg0J7*T9GM2HU~N zJC{k00HseFF%QX^g@9w3!;3O)D*Puc%-Pg%-6!DV)#%Zw_R3+4%33MGkq0=NDMYr=bX1or>{1@vt8hjq199UZI6VH% znB64RQ+M?iB*WLuFA^;d-xt1=C%nxINor_lzPwh{R@!Lx-B*qvM|)^$LNL^^n((|> zkTzHIIl$&6MsmHBfRgn)&6o|U(sd=|8fRho>P%11+_Q(Zf4Ayvd=CGy(h2~^DU z`3k_PMN}NFNm9zgxkCK(MIHwW2&SSZHAEZiTQwSfFnBrhPv#owbpSU4-rdKpWn%7W zcdlwvkKW7}oiWiw8kChwXS>~~X_Uh@z7xR|*P}H|{dlHPF5z$U)==S(N>9u01?GN< zJK=DIIwD9qq^xUaD9iBAbZ$C`E#F>_rP86F6)(`*ebQse{S#u_OJ&s2TU;>2!}8t8 z$p6MwG5y_UY^%>uE_~p%T`}D*ftSyE2szM*^Q&JG1thhoI=iB6H{@gYfmL;75mee# zHhz_#z2jp2#}jzbSk(5tB$nnQ^G>&Lf4_Q;_iFf|fI`w1fg(*s=$5TLGGx_5~`)~K}%PRS4O%jHA_0`inaMsK}{3Rc(8(j4&f2*s`&-8kOwGYclf8# zyjL4AHCa)$N1o5)?y9W{E|=h-h$*7KL{s(>Y!k`;k?|I1$+<;iwrsJHmulQtIM=IR zN$_uk#muF~yeGpdH1SAsuY=O2g>#g26~U4`bO#qTjDiobo`nV3Ox;z}+PRj*-r4xa z$gikQHlx<_e@w>SUs5J5+Vt_*@Vf6_Z@%Vq>8|F<1wUqdPK87{j>6S3wr0Pn;>P=( za#$W=tI`(_n7&V$Cy12NrL;G2VhB?ekDArexK3*YR7z4hv zR&Cb43ix>`nm>R@9<~=8Z12I|fQb7N)t3$==6oA|6r$|q4NCH7n1Qw$2E5%mzadU_ zVUa=Cm;f%T$t(?ZkmYO^Pr%D~iYjBLMJBQT6i?1$z(*-#@}A!g%oT_DC%haoHjp|I zGY5Yd`?8?xx7gq&2p7eo!F{rP!UyPAg|;><)VB$kpw)Dh6We7^higOB04~hkb!ilx zvQ`L%#M=ju4)s&c<#qRK7AbPe!*W0X6<+T1+38FDMfPtt2}rLAI&3z=iMsMu?a$tR z`2jccWS&Be91V_PX=>ii0D01z`c!;J2|HUsf#Z6NAkFR9qXw1o(~MBA{A@BEr$fYG&X`wVLDq1Td0q>+CxSp4YS`#|gwD^{@lRQez#EeJ$B)tM%6; zetqb%2b1$OI=RtYv)sbfm~yx>XwGZc2tTcnL~gC*4-!M$nTJPOb|C_5P~Pr> zZJO8zKg$0HEH`QM%M*6q>B^>`FMWGsYpq_VYQ{ZJ2QCv)bgbp5Z~?4i*EYPV-&$!H zgni$lc|%-m?r zoC0%&_$6IMS6LiRb@Mcevf_B*rH+mN!tdg|*KV48$Pj8#+(3;$>A;mibTL5GV)YlM z#Cm#-NudQ^jMC-%y+*FA+~&*0?F6(rkxI|nZy*AtC=qswKSwo>qcfWmeZn-{7GF|R zUA}3jyBrW&)2A4D%ef~%QbB}FfS|dwgFiS3H*vCmXa9lUw!*?vrJVD?gaeVw_cg(_2R&)8M ze?~uVVO3P+qf5N$5ojv=nk&!y<eO`Yua1BZ=RkAPE-&%nXd_)xJdsn25j z;p_L~W6@5!YCg;Q#Rd3lJ!F=br{2O-(bd(Vk@IC7hfUS1@sq~u9P3$u0N_cCv)+%j z9!qi+%d$*5M~hiY03c7k;a%Zi#uS!jj#43n=qr_%%_j}ro(Xxr-rg%`d%iiW@t-eO z>xWfmm7E4v(XoCGvdB30ypDRF$bv>66%|N+#_BXt zJPbQsl(Rp+4@MyT7q0^J92sa_IcUvzv5!xCG)P;ceDe3$_kWdgq*rn6Jk4T`F(a@^ zyMInu27{*n50zr0&Y55w)ggp}l*PFVdYRgH_F^zYr+E#9s=uXLqFgH_gi~I6`?Oy~ zYzSZ(+*dsbv$gd1UN}$HPM=X^KPM|If~i#bWxEYE+dsfgO6oW(Ik}g76Cf2|UNL;0 zM6*G?7lTO-_Zc#H*^6EF!Af47or?_4umG3C>B**!4%ZcHl}=0_-}ELr>?*LdEcKYt zmVOgp`6cjv&W25Z5|Bocx=P$rCV~* zDIdM==5uZLd`t4YHyIfi3xMNAX8?5^U78BVqWfgnM<+MBk9WX^92gvN&d?cI|8HGa zSl%;kwnszIQebXj9XRbZl0g3Pk}Fi}T~hup?4(5XuyYfr-BwsCm*h&E6(9Xr@Sn_5 zu2F>^I&lizBK-0tGeu8-J0@v#2>0Ia$tf;c^*`dC2Fzp) znOy=TXGw2n&}+wBeZj@}i=~!^=gWtdPpkP>VpAp2{v&mK$_MdfxFtN>LD*`8OF}Ej z*=F_B-5h3vB5U_&i>wJl=Z0EXL2P%oFtpI}gh$!%DHQkcSnOs!>_e5zB;g;qTy&4w z_(r2^bAOD7CFS{U}7^e9B>r`vG%bHhRa*-;Nh~Fp*TO~Fn1F6J(_VoJ39}? zGD;NSZJA&&>F0b2tE|(R^E2UIloE(C!Hl^x7#mZ#^aeERArtGZl;1CGAkAPC9!(mRY<{ zoO*<|vD+(u?^!3uj)_KaG!%TH^6jcj2Hqmw)N`(T^Bp?7WttixHP*ENXv{yB@V?t*L40zF+?%*o!@s zLqFWwJ>yH$+SJYEZ8W#NwsLpi{aG({0$y#u*-zxTHs=fJpu=5?=>_AwTC@6nup^)pESU4OO6dh zQBGqfE%sUZqb2#6hacu|4&nVfyc?T-)znQ!VLR9r4U@8G0jcmC(N~ee_xN46uZOFzM_h>l z5d-1}YgbjfLrB*+BzCmB`+p(s$EmOdQkQ1xm z-CuH9z$bM0`v(p>fPF{WjwrHe1gONV8|6|0Ntpz#r{PbnpivTUn`2_QSKTGVw0hs`_KX-f?dFIiRi)+3h zls}HTo`=&wcO#>y6t8_C2HRDLK~Ju>0DyOl5IPb#G`zM!yJYml%x@$HB?w9rIv>C> zOFvs8l9_un(hWDaw|9Qkq_}+7JXv^SqBGlz!bJNlh~9OXygH8Qgz>ucMju(cXOEfl z^8vA?bHPR4Ju8FfZ+pWG?Rw(q15S5>py!@v!k_AG-i0nUtS7nGTliluZe^{&Z%=#m z6aisT$Sod74X&%xHx5;)NUgv#Ig{v2S$vMyAM(k7@7_^%yPd>*RqHSRh9n1e z6>+Zz5rk+=q~i-H#AVeB?p8Ok;@eN0o-V$XqGju9-aUlUgw@?o+>Yi*4vf%e_M00f zf2=pUZ`^hWqbG@pRurgKL`KT?L`oQM(;pC|y>`@iVf7in7MRfFp4`XpkKIib+u-LH z!ECeyES^}MkpB?&n%a$nmL+O2>Nj?kVGt{7xXu@gls?DT~Bv^fav} z>VKd5la!CXXbz}ivhwvMVypKw$hleayH{75t57eg*)iu9!sDrr01GZ=3hJE6-CU8q z-1pV{vi7=~mqzb&E#?Du#`Ix=d|%2-+oCe0d$eOlnOwI%wfzxhM2CbpHC`qUlgp7~ zz}V|qBw;{?uA#ewHsfzFx@PQ%>BkwJG)j*|N7yO>+-j-uB-3SIKpHBGy~7KKR;73% zkP5Hbyglc>UX1e1|HKe>0W5TTtldaDGR4&5e6Ib^6t~1kCaAA(OX1>t`KEHh>#XBo zc)2m8&Q6nd=wS8lwehSJNls!Q>0ZY6^<*UIQ|=djA?7Q&5Z;N5v0C_Ru#)@rU-;lg ztc4k$+faDmAgY; z)y@u0-Mt^ch;OdbeNt*+-%Y4n|MzHC2~SFpuBN8(OISts`znh**&5(_td9XKoT3g3 z3BcjUst+N?stX~oqJU9{(M&5m;mSv3^?tj%b*59v@%y1U&(@hTX0m`dt0|#+VaFL` z6Ug|IaiAvCxjEut!R>_C|MTxMXM8;SQp@kn9V~paA!TV9)ac{;#RY+$^1s0)n%UP` zh`PG{=HOnjX0KW|@agjFjPwo|QMV%Ovy$PSjOldc`xE=KT})e&TM^A;s2Ij(N^nsr ze`gW0#MB2L{521c_X2DeeG?_oetKnv6T7IU-@Zgj3p^FD&*IRVJZH{6x2wh!zBpbUm_D= zGuWN^nUy{)=l6VqyF5qFJV=s2zUK|35OgMyw|bLdwigyx`&obtkTxFyAciQ~Rk<7~VR&4&hAT*W*a(CAZ#)43#81q{ieuD)=A|ozVzw z;^tX47Q@9oQ$Qs$O1nG}w}w?WjHR3glp^2fTaNQXo-pFi%>LJ%Cq66AI+o0E z(sGp33HfJlco)M|`uCXj}rqIl86yOt}*4I<-*b={d>-&x@+94HpE^ z6*e3DeaxbQy~=MW>-j}vvhB9ge0v8!kNNG02o>Ef!S#^CUIrwdFJz&gXbk;XSQt|X zsE(Bm?TV5>G&L z)?(nK0wr=LSoSIEUPWhR0nvGIr;d%Ku%SF0w^^QysMR%zg2a$4Z0@AO6z#nl2lglp zcO83zt3&1K3c0L~(5J_?BIkaaXK8tHH^>1)MSyrOhU`F5#8qcu1GTbV$+IBR(oiGY zE6mou4y80zc5AP~EyGwHu9@CJq@t+DRlm+q>?OP0kcoyK4ix>VB0gNT^Rlv%9KEd}ePSDF-qu5MAIh-2@pmYD&EZJ=L(H=WQO{@G*aMW$sLL`iEzX@Dmy;ZxH{?B7+1Xmot^ zXYtCR#E*Nls7nmK)$~TdN%5DdzBAOhy<@~p#NR$#DfodU7X(3z<=B(BLc7dIhfx#*l>`DIkR+O;meg`+2bJ55UZN1Rb4pX&n{a{`W=o=4>_>=V%39 z#ctF0!m1zxsb$Il@9mbBJEvx!Y)Eq>iKU~H=iNN3$0y%T%J|_5Zh{$9_m%{?xfgdO z=O8I{^E^1AL#!ZgT&iC)18wH2*j2~zeRt-vy`TzCn-rWb^r0T~$!g)8i9Rpmb`$sn zT0U(3o}{PrR7IZX#fc-2Hw$ZOhbTJAz`XCe^ckps*ZxJ^{;%znqts$il+xr?|83 zNlRa}sA3y-(HCKP5j`D_nJ>?@9unm{T|wq60Kftui|7X@M=MOSQ=w&)6LjQDUCjx< z=RtoV7#1QG;Z`E=| zFb2n!5CAxL^yhTH%PwyWr)J^;YQ@1Vt~|DU1(`c?5dIr#hM=ilzU@{@AMg;Um=gZ81{EUP?53E`w<_M1~7(eR~vh^5dRL;-W7*Qfk1UEE9aM)9G|M8rW1OD^N%KjNG}4!S@w*|w z^(Nb(=lJ+NN|IOuVaJtpAw9h!dIUG84tnURYnet>Gx%xQ)!A=j)t(PJIsO@MLh9;) znj##J96p4QRyk^*KhYHl&f|qK_~W{4NSXsCYN|qrO__5Lc9lU=&d9tDu>Q;Lz15ot z25koUyi8^=!3K>$K4D?+oDJb}_$WXwHGgCR>NFnX`8@Z(zs43kdn&8-@0EdMwq^Yu z7km(hm60MK24Q3&vcWoa`Lv3bum|eNs01zf4rQ~a$LI1 z=N~x}^gU9$B9Dt}5Mbc>ue0>T=oQALHM+60Ys<~%y}~b@a~W&9N|&-e+=c?1hiPH2 z!{ui+lCm41y4C}Y?uVY~gf0q14dz?oc}_T>ecoPtDOnXTP$MX8rO^*4Il;(7_6I-I z{5^SWMkd9X*zO6z;7RzqwIK?F59_Uq01_GIB4i1St`xpb^UKl_t;hFEpQV^!TK=?< zG{>?Z$e5ph!pSqpHl{SrxyL_{Xeb#l=4H%*LBBWE4gI21u;&DKV793t+W{7I@an*BHA_9*p@_|@rFjeT>#a+;!F07TKWMdB_d zJXo0#DO39rzbg%@O^vs>z9jYVNEgdcGSc6ip@?1N-e2$XU-8gXS zxad^Tn2C584ZGd;Ijp(4b2ew^suz=xVJPMNv6G5Nw(#%IcD?s=gcLd5$q@0{u9WoQ zg*P`~e-{>PsG5_9P8Km_0{Yr!3)XI>W)4)~m*!w7#)csS!x*4q?I8Bi#UOiuSE_9A z^b8aCsf8m~4+p&bwKcNnk@AE_I{r0Xv}3{j-Nf)C{pY%_3;SEnON2^gU+a`Se{)_Y zEKED}q}}!GLwx4LAf~DzlwYn_sT38(yY4yH>Y@5SUdaHu!A%6A$$=1T zUJw09IHM_y#cg^_lcm}A%L!$QZ1oQ(eCDZ)u-G2s4KAjZ`}m#x+aq{m*U=+p^;>LL zrxSH3W}lsw@aMr_WgHQ;*kn5J`0z!+dvD#V-B~%q#nf8$0pmxf#k?o_Eg@s|)*b%B z-Sfj6YARS}t9<|@i}&kF%|4QEt*E}#E&79fkTVmCZL%?7_#bF}dbxo8e#RFLMI?;u zy{3=iw)?n;UG3MBhZ>tGa+|d=r2o^`8K^#o zQ4unGT{-kgCbkLL+AO1zMHfgQ8CwE(JNX9m7BECK_RU;W2BdYp8YMjtHV|R_*vhw| zqpq)2GEKrA8Q}QhnY#W$h*b|wgjEMkU$`RBNLA-|u zKr2k3fR8bTZu-wdH`lvL>L21;idA#vOqHq#BI^vl!URo2T`1hhCip`&c2zq z1(B$=@MlYzItaDqsj^V8BA$>;@U&i1I=B$Kj^9s>mcM_0)SyS?g90ATx55A?JxaBP0$Y@jLV>1m2{Kjl=%kEmsVWo1 z4`;|9KHL3`LaVD4{PF4a_l$yB(^OEbuf~VlPE9wHKSK(4Z$Z!w$~1+_tZngl7#MX0CZv3> zKX`t{uy4b`0>4~&b$|g#&+~1kp`90v6fl}nQ&$|4lZ|TC{weSdV$szq@gv6)=2OA^ zORrIgUxe-_`E>fMespCG<%|m~(j5LhwZhcCS2m6R;v+MqfwWVi_L5s#ful|%3*|n} zK#g4m)kj7AP-luT+W=yJ4t94Fg7+oz#j9SGEX?RMbhYDlGc`cvpw{ei@?M2mN5vXcVzG)z^w%oqcWQ^`|60thEJ7I za~pLM92xkVfIj;{xlTMv-A1c4Jr9w(}g8W_N+5V7Il) z%b6MEU9RZhZo!|x`l8oZMP6tB`;PK&DW47EjlI|T_qURMDmd0pHlvuNP<+e`%C~+v z-(MYxSEnWO-gDKEJJgksP}7K}!U?T@0T|UZR#|Iz#6P*Nr0;SzUcgKerg3*IPvWLU zdQ<(jgqVu!73slhYUjHBmmD|+OTXfD#!qw@F3~G;hvY)cS1g1*E=wtyZ(XSS?geiA z5Vu~m>RT(%GLqXwCS4z#=*S77dUjv(WIo0+jKlov~imSaw9^5ZndID z=;ldTiYq4do@k2mwS3Becjh(I0)B$A?a%P%PUcMZv+EKHR zk5$^&gcosJbnsMKxVI_R#_Bo={*#{a6d|4CYRl{Ka^swyIrYcU`8xN|Uq~dxCCEsD zOTb}`;aZLfMvZZkja5bQ4XBR30`a!H3a*&oY8(y+fU?Ik*LF_VgLHm86l6J0^}3FM zWHA6*M@cvjD{R%&K$;2K`7xv_KGbuZ{0-t(05h(H{lK<*8D+=I3Bu`Z)h1QZamu|a zKp{|YA{OtB$7AT%!;L}1L{s;eD+?lknxK%C;@DdgcdUzCDPacZmR|W?H2`|ydxe2J zhfjgWa>LBXa3MQSYtUu!CS<@%2t<&JXXbWcc{r0W!i2PO5ur0-on@6yQVZ)vg80+f z=JA>Ba@+T2sWfmdY1Gd+j7XZEBo-EgKfeM zFDdeA`bUWdZn|UQz|S()q8*0JBb zhz2bq!k1XxmyIf^wp4aVX~b&l7b2P?DCQS$>I63O5);G;ADH;MHI+y3v*56hf9k=y zKbDQStg@ibf8QfeTid!c!)!tqAwQB#_7@EpaBv+x5g(f-`6YUvOaR(+gV7`hbL^Z& z1$IMbJ4(F&`kD7IH{DBsa_?eUS21DNZqJ3YfS_TS7!{j@A=&FJ z+QNUFROoR}@GSg1#CXgNZ!N?you7LHV%&Dj;T!-25{LocVPPJBD1QMh&liKyw%Hw8 z*}+Oh)Jff>(X!J^f)Va!?o_hsZFt(+L>IkoE7h%!0B>R~Q@6FhubY?GYoUFUcoVxt z6>g8#`gAl+RAeUZ0n9dG=y&b)kX+GpJq8Vu1OKMt>9nQm9hN3PkaVn(U2(|rM<=>_ zab1j@Cu&n7>_zBW8-^>{7f4S53GHBnw*6H4*=mn9>1xVF%zDi zJTc%P0Xu*&AL>Xv&MDirse%|7hKB2`kR<7I{Phja^{I(^hD5ukDaedvI*UAUa~gkU z`L2)c!TqylEGA%o+S>|knGA%`dyu@mFsGeq_63&XuzV;o%*A8ODKCX~N!BN?&GV-G z#HSzY-Hmo%T1PLzC~KCtPV}5+qE|XIS_aQ|c4s}RV%-X><#wr{FkMT?DhxuwJGrt9 zWZX!#YL8olrDZZ>%Vp25V&991QuP;laBf=Ej2jI9GpD^`gTBiI;mk-P@yN;VunL$jWxfvY^!<9n^!w8@PYD zFd)FYnasA{OOPG0c_h~3TJvGupP8Cj;jay;h1X)e5LYeE)Ca`$zBKf>zBH&xZ$6Bh zVfTJbhn9OHPq?73EZw}gfP*Suae6Bhd@{@{l^XX-~5rarj{vNJT1Wq)#b1R zz0!=P>YlK5%3w#Y+b596vpL%4xjF9N;Z-$-JIwQ5Ijo#0C4wNt73DNJnS5o#j2Wr! zty_7B#i#}G0D=VU0Kt4J8)7lovvw_vA2etbNYd$Q_Q4i?qqA8ybG>a@zs1Xz z7dAF4iQmh30d5>K0$R|xu0yXQ90&l*Nq==r_b^yLAqtnQsPKjQI=E`+AlS#64lUwk z^}hT(BRfvbh6Unh|Ke7zrZu)Eg{+9Rp@?09URWav?>}1FK|~SD4SVw{1PAn}dfaCm z%VBb~1ip_RDI0H=P}H1wxp{*Hd3K>6;^UIU{oZkjEEL#sSyewfq^#^uquzY;of@A! z5(5tsumb?|;h!#w+^qr+!&x)X+_`gFVzEJJ>?AJRq*90_n8LO8n@j3%7VWdk`x*KI zqvc@uSRTqp zcZpR7upDNfS9kr$dKelMp$Emwd@q_)NWW9lq0@QzRdU>BcyYTg3R1bO!ZH%DQkU74 z$uzt=cI=UZ-+ud@EA@A&rMNxn2Xx_yPYfhr2N13JpjS+{^Q)AD2o!e$n*l-GAS*1zpPo z^@ANCkboTk=noIv=^_sYQ(-5UYK7}TFR!-@xI2Uk#Oro_w~-SJVR1o z6s$(b;TV1%hq;~J_an(rf(GeN<3_@QstUMmZwqYbP3Yc2&aB(r1^kaSkFf#3^D3io z#n3@8wX_79(a`Ij442!|^&E5GZGBRCS?q^+Ih||HlzUiJ`}XD>e_bdzmkz3d+0jzC zy`>Fa?T$m6F3y@|0G4e>xGahyH?cGVU#P2wg_UKHKsE11K@52GIa%{i@I9BvSmsqG z#dv34>&@*wj*)FT2d!CAPJX_+df1+|YrBud<&U}Sn;3YIfE^%AEGgmcU$_7&{{1kg ztypoqSW*&6%5nycchF4ZXD|xZ`Sy!xPL7l7{+)!v?)5nNVdkr1dL5wr!JooX8 z%tbEW-Im#HOyPPTBj`mgyt;3Z`Bq}~ml-t@G;_%l$4gQqQprz1|zfz~xNbyHSK#ex{;V#l$+M zDA0<_n1^Q0vr)qFFbD;#8pWN>ZOF!zv9~U>2u$9LAoXi1wWZ0cn7H0oed=6{czO4W zdEC49^3gPoO^H=5NuTCYsrz@0AK&wSJkIHFttJLMBw(LV1n6rb;+eN#_G1|jo<5EV zvJa|&M*6JDli{hi-h#i689lT$ot_g>RkTmpH!c?=EOv_VjGTXE&6(fq^F~G-j$v%i zSxlJCaI-ZLG)ipj785e__xMOC_ns`4j`i^*vAnwUKn?URnAcz%MD0};o?Zkvilx$od@$;q3;_(KqI0*Eco!Si;~u~>_dag71cI$#b!)~1_IpO zM;!&+=FOl^pK)BMyE~FbedF=jEY=6I*Rs9D%X@;}zS%xJk7(BR!9GopxxjJ^2g7KN zPcr8%WV6UK@5@Zd{%7l1lNmGQc2CrP4vzWsFs6Nm-TPdN8no@Ue-q0wG|PIGaKo+JBZdS=FNQ@EIiE8|CC%Ph-@j~5cKj}c~I z*#!t-tuKxbj`>iqzh|QnI-Tdahh&+XFl!bxR98cW8J!nj1TB%!nBaXAy(}5i?n)-L zJuNNxBxcIEWO4!P`^n&TPOsarH-Q!MLBy+=^|nmd)A`Ji=w@V@Cy|Yu7s(M@|J6(=oJ*Jb#k&m+>EPj0v-bOZ2epy)3IfGREp$M#&l zL|eUvyls4=Q{06mPdz!e=2Z9VQA|15(>R9XTfAJyeHG~yx#rPjxzjrLV>p*%-tW8% z>XIK1k<2zD>hFe|cTqz_<&mly|KCxg!d1I>tKZH(gPO$Z1L-tqfq=@c89sA-7e1E> z+-ekl&aJBAh7KRDP1w6v{cBsB@~XLee2&?;!c4J2C&Y&e3D|!b7X8K^ra0CB$K&tY z>g&a3Sr)qDarNayLQ17lu&bjJ67}^EudRh~y}i)Bb0;)z-Kt+Kb=Yu~y}OBZxW{+w zfIYbE-Hs0U;{Er-W5*nQXh(0n3eA>`+9u`;Lw-K`OqX-tMqv%Pf9L6F#C(pUn|hB) zU)|2bDnkBKJQW5)6bLA1-^Tho-@9`{v#fWF)GNlr^a|OxZRHgh>3P{)y0!93(90us zG1`6uoi*DJKku(#neDfac(EBuc=ViPNz;a0J*aNXb=^IUC5c38Gzt`}9KJkqBt-C; z(pbOXQ%`{s2taV$ICU%v+P_LlU>81v^^J`Hg9k$gKA#ujWBmCMBlvgHY2}WV7MbmX z&4j}G%@h){|0*P4|0Q&<3m=lWBtcb01-!ImiTKh}PYL%mH>be$Qn)%DnM1sP>DjIsGO>) zDOFaILO9&9ES+wv-RrNXhn1GfgC|X@X^QuraPG8e$IKr*cnV0; zaNJ~ADjqLsLQjlCk4??QfJK$5)@U@`=MTn?Wxq1O^KGCgZF|${VQH*IV!D7HiFL{)(|nl*!1{hLPoo0<2BaXppSW56IyO`8N7H0sdUx^iGdHB_t8tv>c{HCaK+_x=_v-mi>eRi+O?F_v{)bGBq=g~_X%}Q2ZRB1LMlkHPcvy%|B zbu5HFgqjoy1jJIT+(iQ7MJJ9Nxe!;@;xki=8wB^DS0!gMxmjBnYPQJ{c&snZVKbE+ zHYI@~xJS^m-T$bpT>tNumRC=!s(N8tAlUK8#;x$LY12Yi?Ae`ZZfn;9#(Vi_*5HpG z60naL!MK@AN=o3~#ixpAJor!TsrTPcACgQCJ8tmcOHUm$=0uR?Nsvlax1blsLYifb z^;81Y5-H8Nyj#(6N1N*RbtoCQtK=tMm^uz>xY9T=v zv+O#^3pclmo4MSn`ODYX+kg9cfBmejIvO>%eP~+#tnW_8x1@D@Wk=3P`tHO37^d&y zSt)Q_=PWFFX)K~q7{1e&2d887?ZY__e%t#AWWo1hS_K^7xjvCna@UI8^PDje3kTtV z2xY+#l;YnoidGQseSzoATfeR)nXJnR9d3-zhbH2Oeb?u7Vb2K`H4-+>%gx{D1 z)-(!!^8$h;U%B6XD~gIHbTzWp^cG=pT@T>gpOY6S?r)TxjHR9&tu@xdVh`-oraBCOVj51fiML1w}DZ2hrs#FF)a%88fd;Wz7? zJXRuA(>nu;u;dJ*rcY*T&KTIY4_ztsW=duRBCN(Lh}pb5oKv3B@iNM0u z_IlUZ9de$PTL%2S7B{17O*c0n?&6uty~}az;^V$|u*a|T zt;q2FG zto^`-c8zAe-j7LMJW-ta{PVH|&i4iJ;XnfRQNfz+@J8RXXpwOC%9ZksT2dxevby}_;hR9fyqRH5%7vwlF!7c{ptI$AbAocYk2t!kR#abc~N^_|5t83>62 zREEM3#F}l+8w2kL(8K0>Y?N;#{0x6y9ykCOOP`;CV#Ld{iA2e%(dc!5)C`p?ICSVuf<^#EFxR8#HKHGMyfVH8O?I zHVq2ahrREC=@TzkfwFJolE!TtvlwyK)Dq7~kdzguF>1MrgJCHqqA+if%eIc| zEqOC3Fw8#ovFJG%-Mkj3v;}GURK?Xcdovs(b z_5r#+>J?FKZ#u1tJU{jLXmpvFOrCP>>#uKHE=l}6j#K?BT*UrB0`?KW#)PcD>@t4L zKORs%GilP`8P(PQPGvH~GO8kDjax5s>w{oCf_>rzE)Sh`8$aGw>)1URZCj- z9}_fYG+N?u7+hKcC4pcrBFk>boW!eOQQm6Jb)6TE$1Em4Zp*y{*ddLXPO~p?+1}c0 z=PEvyW4MctcO;(IGB5GzTW=Y@050WRWM#lpj5LpOHjd#YUaZM*e08<{TQd4}oeti2 zzxN$1ui4#8m@N4&NaONX&D{XG=aJ2PBcQh^_W+EZTT}DU&oyoSah;v*UsqNB4=7^& z4Ok-lAp!f4VeR#@qQJyNLWTAYVMblut%@ukno?E$&B#u|!?22%qX%Y9>*j%h^TXPg z)^ZHU7cc9+^yzo-Z)-dOL!*M;L7wY~c6ljffzk58)d>2DoW+B5JeDaQHt#2wKOgbR z6MV=&r|kDW1L-PWmi!eH?(Qr;&Q{+pEPev%Dr}Y?Uf0lbdzWMMYacG>p-1ip0x+hk zO7}*s^MSexi)Oh!Y!yBGcIJ1hfcc1)y_F=tVhHBFC#Dod;ss&qoSK@O8{T{G3pX!b ztlqqIX*Qt4UVg*}f&}bC#0-AeaoTD8p!@Dscm98SUjk=GQRiRP{jQnGeTN)CKsg0O z4p(JW5ZHB9R@YTP*9-7OMHgM&!$nzl*T1s59-yG&y6!572e2q-Al!j)CLsw)Am?Nz znIv=1yw^wltNL~KtFEr=O(u{x`F%f`yw`Pizpmf6e!u!v)tP60xD*8E4f{SNFE%A6 zpRYm)WymWub-Fl%U1#G*nQU@BRHoxiO{}xAxd*oMz}$|G3~?kd3Ac<8Yl1kG<>~6? z<0K<`6M4z%&!@|hmq_5Ud{RNKAs@aTW&F61S3bA6gN6~x=M^{JbjYXVKKOyg!YNZw z$#qbbohE=9!e9Nav-;}qcNmx7R3G8mlkKBQ^P#j3iX!dvetV&C!4F^cs#`yB@4dhL z4wVU)-EvC^4jtnQAh2;lGP>GI``^MlZVw+h<5$BvI8d#k>6J>Pg(c~8ozNiojpSw0x+G~#&=H&|t-1}3?-}w+ zv_7kbnaHR99>_}Le^V2AiEWhEB+1L=SC${AQ4RvqwRf7|OmQEH_b$`E*n8*2=ez02 zt3fclo+dRLDn6zCD*2w2Ez!7C{gAf9VJ`m*ip8)0c=6)@d-v_P54@Yn1{Ap)gC;gk z00J8)B+d}?U-vp^&ZCb8FLZQ#WRl~|+f%JFcUwpGq9=kiBo-q@iMFI9G42luojp^R zqzqm-v3ZAK&qGto<%SU9Kbs&gX}TuKD~pAIPbQCw5GjF;20=KbP&j;EsdV8i-~Z8XU;lasJ@imuI$I534W}Qlb4u2Y1^>xM+7EoM=nTnWr9p- zi)7W%D(b?XUk|1f`Z{63Yfgs6*RuAC>#&rBt^i=A_A(2=x612hpJ5yORpGjs8L zU9xZuQuA%R7ltV>OU5)X(Is9z=*qs=NR>zY6Uj-@pHj_BtjAC`L$$tR$R~gI`q%&D z=m#F~`DX~&cL0Hn1K2rK%)NKuFaOdxqus{cQaQBQTTs0>x$u^=K~B znWUZCLQ4w?H-ePaqZoC1+$dNDS-jX!Gr}bin6mAgl6aISgvJ$)*R$0HBM}-Ql2<|$ zxi;`=q=JRW%avR!30Ofs=`+`mFyB69{ffol;yow#XKCzgTUK{&{MPCsIc8Qr%Ho#N1i)vvbl)x&EbHd)fzSk^5X!);- zMfZHq3zCzb0s96ZuyMqbTesq>=OK@v^P+_z_4OY)^I?SaB`4VqsOmsAqf3bfrZk-x z>fz%8!F}0i7DOmrG%bcl-xZ_%6e6)8m1i5_lSvxG)14rAt;~if!(TbUuhWVQ;j(sE zk)a_?rf5D)(pGWMAZnm#e&P3!*2&+8?73LHGs$;{?L&yMQ3Go@Y9xI~LoAB-9a-cF z)0d#p9wfGhYnSjDlwHRSYyR7i>n@*s$|)E<@<=-60N6JGf$bv_oBd?^x$v>af+eLQ zdpX}w^8>bz8cu`)|5Bs4^Q9d_GcwF{U{#uUq$;N2J9mh*olIFz)K-pUWaPz%V3ciJ zei1U!j^<@`3tRmJS3QSoW#5znwAPd)to>a(bNV4%93_>h^mRi^JM2j;Js zuI*1}((6Aw@<=$F-sdhNU;74V4nSc0h$3R=nIP0ZWr|bo?+-V;?)CGgxz3W|FfzMg zS;vSFzrZqYz+C6DNF$452$Rl@EX*j9GI(z(nK@7AP$4C+_}-Ba$Cqgg;{v4SFrD@% zORp(I*&dxxjvCf5jp}#F$mWv~$9V0sA$<-_C<1&NmA|c|t|YO%er_XZ&S6FX|xlFPD(K{TWvY-Qzqkpty$&?Rm*wEiY>cfphb^yi> zAh3M|Vdjo7L@%6uwlnj#+rs`ZJYufnP8#vNdOXsRM%?jYA)@LSX*tp|@?reBVVQai znio=(741~d?W~`<|IBxd5R>GSY8BjwTuL5E8&qjb+5%Sxi3bt7HcG=Z!Ws){M=nNv z000mGNklfZ2L<39HoOnl&J@4`lyb?e9C?$?9A*hl@?RdTD4*NbO-I-8|eB6zz#rQ`-tQv z-L!o>9u5NbKo^%dbBc{EUQcqUK}4D~VyCHp=FKan&X$I81jcr%PvT_T4)M=H8W-O~ z?%CxZX8I`?tR$0`7goly#WpGkKxQJ%q|<2{D0{Ca0@M+kwtNkYgv8ZhEL+k}(_&{S zd8rzP$GPF~UhUlV;rT;G+x`zrEcf6NrZ5l|0EEmGdIi5%nYGR8hA%8O@949c>p&>6FY=}xE z4s9MSLDj$Mb`mjaXT|}EIlt~1v85Z?cUVtee4A{^OZx7|e>n|LR)(TX$zYQ0WPmB( z&34)1=55>R3knp2C4fDEz{Uw92qh#{<~vpPrEY?}n$*CMP*0|lBQuHROqFpM)4kd# zfhjc;feB2cFeZlzN%^9%RzW3jb;4g3$1m)a3VVZBS^9KIo`6lnWRpfmUitPa!kcV^ zu8b&Lb=I3f%Vi#V%mnYn+DW1z=`^pTPL(!gvR3fnVcf)AJ$xVG^B4%So`xOuho`V0 zuUM4cKcIO4fsH?Kmb~hnAn6p1a6>0UNZZNOBS4Y7I1*EgK9QnhvNY{fKk@N^SmFCrKUr}pbEG*eC)R5jn6HD$b za!LuqQgnZyd5s%@z{Uw}D0QhHWiNZkX4`;c( zy)n)b-)pYTSu?%eyD#=9okwscecI2GF#RC0L1a`maODB^9YA3Fh~!mV>wAd#9l8kh zN6UT{FFQ)Ykbb0?8&yjGk`5hm8ipNtrNU8CUs5(JlAVAn6-ZnsY&M=>?;|50kr>ou zS^r<=Sdzx4%pfzXkzC=qx)g0tk(W&1E6Zk(Y+ohDtDi%IBDHPg+DXI+14;9xNw~D} zo%afr5~O@Fw2DC4XFnyd8H~WV`o(@#&!!<7B)X>=0tG@6#GKJDF)2a5w z)`0o(WI9HTQEze&Ihe>zNR!WQLw=h2Ez02AAS0g)36p%96cWuPqYr6G7%87aruA}u zs>f-yvm}`XL2_keo&lG#8S=4dgY=rKn0;kt>ppfK-K0s;J1Fk*k`M^WTx-ZHPMz$4 zW7}64a;C{N9m=<-@uH_`M#C_mq`VCU0ot{BGcju304o52?IR)rqc7v+HEYNgdOg2X z+8PkD*Ts0Y$IKP!3$D?8kfig7qfCS{T9)cGWn1#fn?@S_j?F)?wO>=)Ds1A}deXTW z;*h2@)T0ynvhqv>D#qu?OI4oLeQJnHC_|p#KpY9vXwx)DUIIjLI~LXvIIKJ}Fxk=1*6G5}0GCYc1NyU>%}Z3u1a zrLy8Wc0dK~SFi$X-vI=+kBH7kpl|A5J5Ko1Fm%5B+;an8JMOsk(+Y*zwJ>0t#^A)v zk-&z$P&#Ui^B{f%neRl}4lJh6j4m7XFj8a_=!|$5bWBDSw@^RIJsB<9jQACCI$|)D zXOmw{GZx~pAxPsraPhhwFtlY@#>vj?YKFY@8bF@Ba;a?y(iEo|&@eM1JCbP6$OxL< z-d?u^D0ZzuC?Cf684_m6uU?M51}3vs;Gf>_Q5w2;w+vBiKvGcHX#&cCKF^afI`cjT^%+1;JCZOQqM<`x!~y>QhfY)kuzyYe=H3 z<|XDqjZQW;4Xn}8BeM-V{(%zLEGjVg-M|sbG%5qV;EqeS6#Pozw-4kaE_9aWmE^Z2 zlcugU_h}*@KBZ}kDrMQ4G<>>P#w)6~y9Z^x-0t>Tf<*N;>>V9NlbJa?+0HC|uT|vL z7?j0YP?|1%r^B)I6G^o=9ZN_wHz3dzU^x&_-*;UC zYxk+t=GQch{g)^?#`797(0N##sSG3(cF8NhTCaJTT(1O(Chny4#tx_LQ)MN zAt8j+q>^^JSdiq%OVin!8BOx0m(+Af1;vz73C(1#%b`h-qJ}`@gpF3Vn#vmkWa5}7 z49uB72@y8$6|@0$dpYvrzUH2VkT1GNz79qE#kn`JCAv6S8fBbf?VZf=B$uB-v4L{# zn&dS^{(IyMq{%Pd2P=!Y)K8}GW~ER_cj!=>51Z)+{GVii7xJIEG!61oU7#Y?r0;~Z9HV%k1F#4jN*u67c{>C@rzj)@EuAdxt?4whQ zrMGyV$KGuxBZM-Yha0(}M#o6VtC`MblLm2VleVzbVQf;FKWS3^glTS*Q5D(91#5gW z7UxshXjbCfTdsZ~b4D;q4q42_$M;}Ms8Wv5X*CLS8J}K9J{d@!skP$?mbW3(4w7F} zXCkY7VS4gHD6c`9cDKi|>9A*H1RX-(&w4f7{c;*l^vP|sE88Q~etsAsv9Dy)pv-5K zhBr+T5>y<=9q|1Juh_M#=gM=>#b@1dM~DO*_zS3QsRuYlxpZl*DUT5QQ!#hs4 z!Z^7!bYPKim31mKpNw{eG$t`!yr!LEyz==Z@wzz=6Zs&bd}jPTE4S*3o`b0Oh~=0M zSWMm()4)Ud#E2oi=KgTJF06NS6iub?XsU&AZOio&lqaIZqJb>>ia8BG_$0Y`wJ=h^ z37b;5X*Ja#r#_OqnvclJ%K|z*A(pB+V6Saaxv}*NW52u zii{G^DnwFOUnHhO``X!5j+0D2>*^w_TT4R!1U;O=*y>4Fd7$ z+9``u2~<@*dMZ|wCDAOEHFM}-ry~?y zH@`MaAgNs6XtJ*$ZJPS;63WuouL8E$xe$q+**HI|Ex7YL7i(8PBNkU!?5{uAGCo^`oY<+2EsJ>vC z$0|hB%lQdE&H3I(9P=+dvQS(6!GL)u?2}C9QQ*iS>DY;tKpX8Hn zmBr7CDHpZYg`~E9mhH%woUKC{&;927XKpUh5HHg}a|9*RQaMc;$EI!ORzIhsJ&McC zO(C@@&{p$O&aI`NdGg|kF|I3U!?-{R?B-9eUAyzgR3=>f$Ri=leT^@Gz{U~m<=jgD zCO`QE-dL>#Q_AHZ^;fI!CEOfXp1gQdM8kZDr0F!Bt%Di9V=)TPmxYo&t<$#Y1*3C=f+%OX=^wT7ma%g7IcG`)&gGK9p3%hG5DiF_Tp!DPHn z(_shPmI{SyJL#bIq9>ok7bC!U0|;y!k#v&xQ}XJ*{Bm^ccfRxJiqlVD?qYoUP!KRJ z%vGB65@9k1Ge=UlP1@v>34~GKM>E>mqPefA;}Me67F45W{(fz)dGpgtL0&clJeD$4 z@lDsKA)oYJ&_SaO^TJv-;wRh}QyYzrEt=mf8Pu4ZG;9V1jX^|xE{|<7#hi_pS7KsH zl+GCo1CeG2g-A?`>c3CAnE;nA(FViVd4uM8=zSYDJaH`zn^CQXrh5f64o%=o;np390z$}5DNzZ z7@GwUXXX*(8^Y4ibv49cCGe6qD2RctqiG=$kvRJzzsFoVWWmNX^%tQ&Sg<2RF-~XJ zOHxI_IHgG~jL%*2ta-0Uty77DfQgVs24+fRgDvLJF_R9!qWwBqeVIWaZ!+h}$G%@7 z{#ux)mYe1q697I5*#-yS9`gOs_Vdnj_TF+!XzT-krU3-DKR{p9OK!P^bbaYdXwJX= z+aI@_amJIx_fH-Pe5!?EH~ZozYe*XA!~BRZ-^r4C(kdls#Cj4T$JTo>(mHI~wCKP} zC3&T{qwaV*f|A8ag_eoD3>{zgJZ#!$+fLpUUfM=MD34{%s^Pt<5$uw7a=9&ji+UZ2 zhG+83RMsD-Q~s7Xf4pUyRgycao zGo+*v>~Y)EbV%|$@#RRuGsI(Zl~3y9>Pp{@WX>ugN`*!&Xjn$VR)n$r1eqm)ZTQR1 ztz#)H;CuG3>01()ILe&q@7jD0u z{Oa6u&|lp4hr6FR)Owy;Yl|0CRmev_Qgv^#p(AN{mNNq1qRv)NojJzkq!P5ZHbK>$C&(Z*oTm`SR(f;~y?vy6(oK zkN(@kIy$cLYqh{f#KFwsfTI7bPln|?ja3@@$-K1KoWbFYygE8k^3tx662me9I&q!f ztkq_e&8IbL0XF3e^+<&x7bi9A*|fn%unT2n({ObV;k&{yMs}?PN3d8$A|{eH4FVKl z$@`U(fx)FwJxC#Pa_7D$CpfcyB$8N?)@VKy(lY4CD`^kgsDPOcvFoBOp7*(b-1x$} z%W2RdaAL}S2N2kP0(-TiGk=DL$ba8{JAUZ2)5w|Y*8TjxLl>V~s#Gra{h&rW2kmL~ z?Pi)GuXKcKL0)X4yA(TYFpsTTdb;u$$%_k<ggD z3ooE0ZPB$;71vb$G)caFJpD^I*wED}2~XcG)$bA$U~~v(L}DST6pGOvrOoUqnjy7> zypS?4+XdTi$G9}=IDdTYwryX$D-5egVeERe%fq3S`yW7H`xy>>(YMh~^{SDP;L2so z3h(Ra=(}lP;Qj9{7H_C|-W#i7n4G%B`kJ|OA%1k24QHL4QB)8w?3~@vjuypRMAc}g z#yX9_Knl8%q)oUDK^n=6OQRzeUAuDSN#iwjXr2?l1}e!fdUA?o>7Ij@I5h1biZrId zMTUS)WosaN4%++{0}^c+d_1WiVdBwV6@;~D-$b@fEOLkp)#R1$PaJs>p43n>SPV!Q z;!2@VEQjIU5A^na;An&fZa}C=HNa>>4#;c&0|;zCgT2H~tQ~#Lb^ZH>hKd`Xd8Qj3 zd+dAOUoKzUUvSUss@B-90v>a}#ZhugTYXe+bjAEYFx9x6#C$Su(xm7Zc6QA)FGsO6 zyNozQVZnQmi9*R*SQ?1MsQsyP#@P+!qwKxP$$_L1&1eXjIMwsXHkP|5Ng0-QCw&{u zL@)iF4QWheq@PWYmokmSlxy3DWU({zP-GkbvUQKid(8R^>o?JHU9t58HR4jzYTO3b z-)J`4mjXISEY7C=%pDo|^)ChoKYsh(y?dS^q_`9zuZF;M_)-A~>?J@%VyM1=_;E^9 z7x{kavDK@0E?cnRf_HaxeDy6;ru@x@k&$`KmKYG^$H$pC(cCbKd_1C_6piS@kOZjC zTlMui(QoK7b77HoQnob|bJSoY#^$>SbKr22`IO8@d|@7?i>}knl9$mL!T_avdlO}- zy$K^XnoL2;@)h?Xr=x6snLCDxN-^EZ3sUzaFV<;nI|Zi z45f9LOxm&Y3Uira+iA1@f$KZVIO&`wAwd{17dt(jt=X#gw|Y?BOvvI%y&Gei(XG<_ zi;Kn12ddSd-PhUqwOPJjJs;!Jb+jr*USK+W=>P=w5`n$Y>|yuQuJA{UYu5xp@mpKA zj9icL7l#fzZ0URE&i&+0&wKNZYITwud$u|C?i<7%h+Si!l9j_;@Z+5b7MIH*)!bI} z(av-D)TwW1D-@!mTdMKy8aUpXD1nN+lme&;%}N%pb3-=~gX!8Pzp;!|a_TfQLmeqQ zsI?AW!_#R+2+2q9s=-1%E^a^*zo%B~5#nT;nY3+U`9enBJ;)ZxYt7aMb90&|+I*zG zOGh;b2-Ud+zjo^lir}{vr%(%?E$ty6pqkiE z330EVKE3ekQt9^pTD|)I_s^Jd-n-_{KdTl5C%GhCvU#*ReIN{7b^u-&n?&Xn3aH(6 zchf$$#q<32-|yJp>aMS6h&)P9O`~=m+D0LCn!OlL7UYjK^t>%_X!)rYT@)F9B z3bi=UhJbD|l-8-)%E^46e0_Xa zqh-cHhD6iHnP@Y4HEe*5{FIK^V9%X|(5Jq-b!*==*Zqr2=gxhz>$*o$)><^^`!jZU zo||a=Y?qpL`YaYzD!YA*UmO`2c=C@uJ$FCgy0^G{_f}5}f^rYW{^!Cln)3qkdg%cK z_7a1w$}ge%u`6d&?P_sHNBD`6k&=th$OG-|zr11f>R-;o_~;(jJ^FJC7cM-dRGPEX z_ouLaIJs2n{oalpU2A%KJBj0LtX3*(9@@Q|ymQtp^e-DX4qr5H-maqK%%&X1OpH!a zr;S#UPoNjtx}#I-*){Qk#v;~X6y*GOqr-!qWHSV5ATMQmv?jT%AZ;dZe)#~B-vm#* z+`6=syv6loS++ok8%*$ok284)5-hE`@3QMgSiSM$?V6~Fh@U3%Qq9W|5>oApX{nrFCFLicxdp|`*4c(kDAp(X3q zIco}q($|(O@n7}WW5bs%T)2ER4Bu8|d#_;}5txluBaU>90xF&CHm8?`>n7$^g1s4` zX-z&R@>6OXS)s2lL)nBnl*VVuY z%xQ(vC$ewm?0^b8r-8$9v(ZqgHM&l~!K8awU3-j{VA> zhep|)^6)U4H#&-@(sEB|Z$rQC>@AGAuJh3_4E}=`2pGi zXoo^iIu3f*3oit3qMht5ixxTSJ+F9ZTLoX+-;X}s-%lvvk;iHJk5N&v21S0lxEMx9 zDF-7|sk`TpL-3qZX_R)ldqYCr#zetb7b^1vB?XsXIY(h4ACu-~Bd9EnPT^=q+Xii2 zQm8k(m1zT7k|%j`$ml=1c3KffmKgH!@;XE%jo@sY)Zs$ehI|?sx#<=u8-EvrX_JMs zB__mimQHisk-MII4vho>TU+ALzEq%pcs7mx@UC6xNA&Ylnzq#6kH1Tsa?$kZ$fwPp zM~he?dUCfd)YHAl?zXAyCWroL`HG%-O(aw7x``fxTMC^}&Icm`V2k0MLB({Tg zEE++6yLxq+e=;2@zW1r8@MX1HIIFGgffp$0mFOrjIvl${3nZeI5<22hrIFRc z3mU24o~=WMPUu?Bt7(OzCXFti&CVjZ@^j>sO{1>UUh7e|$wWS>S+Nn9$*WgeFxqf}r-)7P>I+>>-{p+XK zlUY`b$xrs~P4BfpBIl}z3~SQb052tgzzzVg-ZMly26NV6>$O;CWbW^5p6Q32Mdk50 zJzLWfbyoT}MALm`^JenV(@sO@Kl$XQtB*NmeT|S;cwrb|jMK%@JjCnhj9cZ&Q!*jzd!( ziITAVbI%W)nkFwp8XD!h4lYc=c+Ho#Z{Ko}=OIR3%=8xtK=yvqzumMiT@gR-w6DeQ zKf7<{Lyw4&;Z~RVLT9wV_|lor}(Y?6HAsk2~(Z zAPirD>hoZ(PS~YFQ6oUa`6@~BIM6^|Hn0@@V=2Q#9O+{1IEC^a6lLgkpU9PAic@&u z>7J{N)?+olDQSnUou)yA)|tnWyof4JQNHB92%B>*xbD4o4-5?5dBO?koadgS9m!7* z!9?+Zf%P}m4{`kGC-Hy-5!u6x&}TJ4hD!HUZH z5_RV^3GG}uO&W1dDjQ`%enxYeyq#mY2Wk7PT;=8MmznIaCXdK8Id4Q{c;+U}iz_D= z7vHO_gv<{4CXHN0EL<5f*{0+H=;`55b0Md!Ed7# zC25pegeJc&d8yiAHEx!eNOR9YbDW51*3-^`DNK*K_GsE_gvFdzE=v-Q^GD%i@+)@8 zxA{%^GHlmzHFF=b5=mXb3)wkc9T@NU=+tAo|L(6; z&<~C|27UVJr+3m(=3OPvyRb$TILclnxzNv=Z=|BpL@xVCKDuk81`)U4bkqpnhTI4r zAv>3T?n>HbUaL^$H<6FV%$vl^1_8M`w9g~H2bngmnCDayi*i8J+(1IqM(aiBc~A5fiuEUDj8!|ch)?QLYOsjgh!@qjUmFer zwu8-W?WNN2+BIt&w^p-IWdW@KAg~F74Pi*m9~r^R>Gxw-ta$v%lTO;b)AMHfgrsIf z5ji1{2q8g;NJsq=M*~^BjM-$GiH>~qrlfqF=nbeWUsEnW?`6_@w9>E{v!Ugnp}|8$ zPZpUfYc3!>DTm_h(!jzIF*3YX){WpY-*=TsGHg_9*0^g%5Ml=EX`vgp7feGe~#hc%=GSv z=!I$4ifqzkN)ui?)1XOSp<-T)BVisPAwdx%x8xRX+cY%Pb;YTtqI*}bCTMsVjystk z00Nspm_a+{2~R%hIBjhME<(Q>41)I;9S29}Kp?e%l7&eHvq9U?X%V)>(Tqo;iY#o& z%Z7Yx2{3`lV=%qu93pZaqeA&Q?XU4w^W2o=A}1D^X;KU0{GR4gV!9mjyC-~SCiIaY zL|)1HeOtLa^w<+mI9~iPfV?IKfWRgIq7F_4!7AE0KRkavUiMdig%0`t_n+Q&&N=J1 zkB+>;4?;GPUEtG;`AtGddnT9Bd4NlYkd!FIJhlRcAVKIhsd@2Gc?@RC&}cN7G`e`9 ze5-A##A}WAr%RJ0fc z|8sQRBafi&-d^c8Yk<}S5ZFXPEChBB4UtukK8pN8VVi@{??ZyVfS5H!wtj~RhIsBE zQ48 zIt`{nsR_BsI@IN7$xBEZvIWUSf_~>Aw5_wJ2ag5;@YVza5ZDAmdw)MUX2lBEoj$$R zJu-4fm5|FTuIqSVK(MgwePvf%O|UKy+~q|F0t9z=mmtA|1$PT-1xlwY|n%2$}s6k~N*w9(` zLPp8R7d?hvbLNue-B+gi7D4L2?)#J8e9egpMw<`EbC`RGRwaIqW}A;NRg`JX+GDp$okAw^6SkQwv!p*aKtYjYn1bQ z3Gr{zjg1>ltMv?;{tp8j2@9Q_Dx@msmY*zzpnfTkQIfP2G^a2gox9>O{g~cZ zS$jFn{UNyI>R@$VyGW2u37>#R#v~`l*JF0f4mg)JWDoT+t zjMkE)<-e>!zckB-BGl=wibe z1i!V`c3*W18h<*IAy6bj3z(MWe#E_9L>bl}J5lygs&(Pp$Mco*F(){)(Hd#206J7&H6&xwCwOBhe*2wh)X4AHdlx~^4X(Yg@< zczKKbVlzh=Iplt9B+0m;OkwHQH;G%wpkUS`0=})hez#Rr38fUv94GVOa;!Vm^~+~f z=dK-0@KOw?{2)pS2Qp>qjOz$;jjG4j7W=^8vWN0YHoBWvD07w05BnAF&EE< z6L|fUVg5NE;?U}2PC1SKF1<9(BkRUD- z@z72^pTrIA&F5vkbq62*dFIeo=Q8Ihx!xW!1o0tQt)LglzK;*k3_1vSFp)9)$}B|5qhp}PFXj7Ii9ds;IM|b`z$B)T-Q;%;GpYE*T$Ul zsW(2$=eGostU=O6!6^@2jNGDs#PnJlfhMy_{H&G~In6FN*Gh8klLg!_Hs6?wgX4an zL>S8IKg~F5ykHxB)h808qNp-A-W_@BPzpZ4`JSoGxY^WFej2S+Z@!CCtbmbIu&S9>Tt>8tY^ev) zZo_#?kZT!Ulg(K2wyJ7z;w0a5>id-avcn#(Gbp>2;fltaoE+UgYb?o-^@Ge<*sT!B zzBiMMy8@R3_%$sa&R!f4)i1^H9fA5>hlei)65}(YjFQxy?dB}Z)#K$c9&@Shv zs!ziy;Cl3+ygw3wKL6$1+AFUH1lZW%c^}-BkS@MIW3&Yj|6xN3!iNl^#QEnfh?zNR zr>AI&74Hq&%{YV?Z|$>vmW?hqXO#!bVx{r4M7?LgQaX38t~Yn@Zc+l*QKIHCjY{k^RbjD6r^j)HQGc6shd{Xl>40L++{P3 zMD!YEfJ2OWE7jgTbPcv_hcuwDEij)>P23c$X__?_JT0{jL{Yh{Jc#1*Hmlw_`U*pmkh)3|M@ml`G6JUjKltl1HlAx<{fh4*h%A|BT11G!!H;e7|CE`=%|=YE6WEs&cy))Rwl^A!~#{MG~M!fZTS); zi$inQt1fTcR;?K&X$Q3XZDw^?Qw*?KY>TNyzD2+71c*xp#FBxMY?Bm3E;l{T6Cu+) zv3+p5#S&}Dojn4ZXroqiXZd(=CSWJg#(l=C>f`GkG_K zECFuxCkn(gCsVU){cv;J(Dgz#J{(kjOcioU}h^@vpO9@lJf} z@qEHYRqGbw?@XFx6eS4rgWSuF$Zfr%95C88%Pa^|E0Sl=T$cH_YV? z*4W05`WI21iyCh&pbOeSRp=;??M`Za}L4`dd?%>jz_YgtYWj z?mBLKs);<21fElWyp4_Bm(NAb*&5FALtI5%z}c|tr|pX3Iwe=cg;LJXm8sm88pdIY z<=+RbbLw}_*cfi&Qw>iq5zw)mGnw#6^C`hO2Iw&E^Nv=UGcMOkB_&z|Io!df zzsY>23-kUT{{4foONzIu<#0(#)4dMDQQ=!%@9@L%DlZsUKr81f$L9!>-(m6@>3qDZ zGw|wfXPP}hE%o#VRXJd=Z-Mg**sv;aj1;4vp5c>xC;}-2xZ)mSm`Le+niZv8rvo@+ zOP_u3CMdG9E(`m10>`=d`2VXP;bZZ(Rba?vcRk`kRD`uPiTk1Gz_31^WUK6js%_(9 z%Ew~i_mTD>VjD6P)B(C;WOykD|#(q)Qdze2Bq(6^*YYp1Y{ULd9k=6a7@BOkt z>e&yVPZ50FrgdI-=)?SV`nMOdy9mUrC`bX8RDb^>V0JiuRSAh}m#}*9*oKb?`*Wls1yiTL5^e!bz?}Fpvh;R%=^${R_ zL+Hk=^(Z)ojoue!z6tTm*}c6vE)VxPbDNv**;rE%Zw;rzX%(@GhS>VhdlHT5Iutc3 zUF*aPv~q1@!BI4;1dzV-*^_A0xa7RHP|oyHy_MD$HVpd6frKUbBHRCV*>BSnCX0R_ z)Qc$={SXxZU%X9N_g{tdtyrXdTvxS2l9D|zKW<%>KOvrx!LJ=P$OQMKiHUaYwe@1~ z*ox;IA3dI}t*$9K6Z@nZZ28i1u@Xv+T;sQ6`OKN-hN&X^nYy9Z%+Q-2P)+rI6IjTS zHY4Lr19G^>G+kWXEU&BAx7!Spy=}B~`j|yijN*N-c|&I)_k8zTdVBp{#r&G(eQ{k~ zsA9HU@sJCtjEkWkPWQ^3)d^hQ`TLF16*2JGooOr6zr6v>r`|`w_-`fXUQ{m?yO}SElLehpMVw6;aPjh4WA9u#7C979OM{yJO5!&=}4m zDXM({^ROc~b$fo)t&`j5{S+x%^D8Sag#3qI>I7ake2B65P>86QS!wf!ZAYINH0GN+ z?kf*gAlEKCsn$LggMo9x{A%S?fae7qoyg&${dh#(Po5(U$O*@@lDUgd!R+z>pbP z#v02>PIoVTU#Y!_vLCqgc)gzKxfOm+aXwtv>ht(r z;(VYeh#aRIOK9YT%w`GmvfG*BwKeklD>3;hvhQAYEI&c^r#cFBHeKml!gKW*w<2&c zP~tzlloWE)kvb%-$n4)XIs{7_x61xoRNZ$zb%&80eQOsjffJz%Q!SsMFfj>!ep(y! z#$M=)ZhHe8?$MPbZnk*d-qf2At-zDrkM*4q}dTlc$Y%L$DJ{7A>SPEw(v^;3k14)P*Qh$pZMJHZ$xi@2TFm^?v6d6h# z4GZ(4zSCwGIohU9e@-g_dNHjWOIQ&}-hh;q^2r8m_{3Hm*I7}kSC$v<6LP+&d4aES zxg?P$Fkr^op@|uxU_P1lNJ`E;%(R#Gv^-U{cWg~&pSx+6=R8gazzumu-&(OC!pOr# zj;>Q#X&9%^Hzexnw4^aC$zF@EiR#Z1$p@@R2_&ZWyHK|iGd`d<%osNil~~y+u+b#R zCJ0i#79D;O`QwR8>)DGm289IyC9&Z1k{DO* zZE2?Geh|*^i)Ixu@kYl}UIs?9!MWhQ*6LnC;jqBIKdUXvrjf~|$(I9H@Htn;^og!N9TEE)Nf6r()V z1e@g+Loyes!q@N#8YTMIbwp);6zAM^5{Yah&7;Te>uYX@uZvRaeIk&sA^J4~Nf zRVq=k!1-05d)18iyM0Mtz0&lL0k692_`hL5Q(Jam3SI`gNuXQGV&3|Q!CU6CeP8q? zoiXuri8`ZV`d!frk4=j&lQ?rFk73`)+$QukWQZVvD2fQ`%wXbxRk=oRjLDg9oB2fd z2Z(KmxQPi>_z`tP)qR>m76D5GL5b|Ybg(n10@`QncM z>f)?!9@r7yk$8_~%aJNVP6D+6^Nh|kFxmXx)%eksB=7*fp+1+pHKh(3k{0VUkcgWTK?{wO=0F3!% ztQIWx;pCE_n;=a1@eAoQ4fQYk3LSi(oneZNO)J+81kK9l#}8uv*QxBIOOdV~8PvTH zYV*vo5tRFN*E)@{zA;52QiNFD%BXS=rdnNA00_MM%9~YY{RdUO9RQnQ#TYW~H9=1o zh*7`NDV#t`h&Zn-C6un_i=7-}ZS4=f1@QP8&#bp*n72iHCtXUb>gN^eLW#mbpui} zl?FYF=Q0f#9N@ihdrA>OzE*P^uREWfe+j38H4`LokI3&Pw7}GTg5|+yOS9yGX$1lY{-I8YSw@o%LRmbo$5E4K` zO+fYx)0`gIFH`rYGZwVrd337D-?3lzKe|rTMf^It?Q0{lh;UPieC%3THITsO@=nyZ z$;UzFwIp7Z;ul}4I7Vbgq~RcE1>VBPlD$!i?`2134ZZ$5U3&X0+p(_(BBH3qSzR;e z@VQNnA#HWo@wE-&z1uJvf$UZcD`?hSi{6@07x{Bsl-Krs>(GAQQ2gFgDz)KsGlPK&>sVLZJ zN)K(|S8Ct)l9IZw$K-q__M!6awSwJiqb??6Nm!(4%eUrY31*y6+jY@a?aF>tSl(|Y zZ;5oH36g6dvh9cppFg@_9*`T6d*~qWB>&FeRC)e1#$(sv(fj&@F_T;EncH7uQFG_JjsQ5>AlrQzhdqulJ{`^nX@>ykU3GdNK3kO|$F9f+aG# z3!VczqN%f=joA;+&uQ_{uYd!cqim-)Al%fLf80!^r2(0}0|vNqmm z+x~45?I_*nu#mD;?GMt7;oWT`(LY~jmGl!0i(u=%5B9Q?nx9Mz&=c1u%#*)q@5>lB zi{e}f4H_i`sZO3R>(YxPmzEx*b?`Rf|?atI6jchn2jNC?`Hj0;9PsR z&!f@SdATQPRftjH?`ul)FQjY=i;~46VJhmONUe9Ws$oAw;?rpq(vw^Ts|k%)e(dP>_rNp5X-gvs1V@YSJeRwjJu;pg5%5M^?HLq(BHHIgoWnw`xP^(N zfdFt1nL0OB*<^KDMC3vDs(q<*@@a1}OXA~ww>U<($#Mps=pL5_b*w;b`x+EZRYOG@ zh1a9E?D;8u)>3bRX(=I15i+V2pd`po^B+$5$@;Z|5nDKRmC(!*uO7i#2t%}LLH9?} z)>g-p0r;~MqOcj*22_qAGOl=49Nt%qfCcpv34#bm1ROPJT&J%~UoU+gcdiamn0sRE z){nZ3pKko$-c_V4{a@~mMUJjJR~lo3TI|&0Qz%;6Bhpi>HY1d((VC|-T?EuP8`0%J zSGi>dD1*k9fSZ{#Qs?tM{3H-)Qv93#WTbc=2)wx|_u)De4R|LqdEh!-O898@^Chqy z2Lx=wEsH=Ta>fG*|80jHH_>=vt@~KX@b%Pq&v!!oH#%KUBS>`{jx74S{MT6(`e6%4 z)w_;8t9iSTi}|P=J5%Yp(fK&C8KZtJ*GPJgP3Rnt#$+kQb4MI1?Qv^5cFAWbcO2lL z*+aU41FlCi9xgifSF5THFpPAUrNTb+)wX^YSy_@o)v){{LX#Exa2o58rj#2Tp7T2J zF;i}!_c~Ig$B;RBZtpj>?kSS0%G_B$bc=GfEkA&wL&6v~Xo{CGSUwyAIT88V>Ok_8 zDxdP|Yk63h=t^__8onZ~39}$~^h5Ns`fXy(_#^ajRJx9#NYAcAe zRM1n1iX}OuY(Z52=NlRsYmc8aCyNwUCHFUDQtuPG%eDk1W?cw7vaRedy%m}9KEQ>t z30;CeugB@<{z}Rjp#lQxR*Ziu(Nt1kJ=4cGq{;2xo^)WV+p6)(nfBQXOP6kKi&>&Z z_yNQnRveWH_w>r8rQSWwl%}{&ONm?e7ijQ>yNrXCxY#+7Cy9QhAVScQ0xM4dX+YJ< z`^oHiBl}@6#W$n}9;O()T(r{kwVla-+2Z$oZZ=OEtKQPDYzueAq&YPUb}B>(gv@Kf z;aj#6tn-TZ6i8Z3D4gkC7MAsz!&5S``t%^bV!x|=ldjGfLSgi{UDw+?^?nLISIUV| zzWyNpFSE_~>#|t5KjL9K6bm^-QCR4td`MlVRi3xDJT0y;aedzW!4^gS9?!=^-bkLe z#9JSuYljmr16A6#=*}=mj{AjSuI<8{1q-FK<-a4o^(JpZ7knGIyuPo|JJuQKq|LF1kbO8$&FYzEh2g z7bN9!x-xj)yrhxsthgiRzNO&Cr0o&BwBWkr=?v1>a>-n>CnBW7Wkoj$81`g18UHP% zAdg*41DJRZeqAw17C4q$Gty?DE*$ZVf2AI51LZR0B;)cE80Bnkyc*9u$UY8dwYwWQ zy1GBi0ufvNBOG_Wf754_3x0>#Z+-NCWZ=!yhT|U7I5+w)WVt^xl;&n$gY20)&lhz_ zPuA-}6~KiPkVu1JFI6KMua$RYh;C#68Rc?#xcvN3gLbq^c^QBy2_2tyN`Tv0(KyqM z;ZH5Ii{eQlquXShW?M#>u>#SY7nZRcU|HNbrO`c4XW8Mlr{{h{>}sN2aOwpRDc6tq zltV#DJLDSq=K=|8c?zO79X5u5LpIG9xmJjPE=BoE|8lyIi*n$IawLm z2xVyH(wQ2qqVJcek(Y0mRaFLp`)%Eo=c~b=A?3pZ&Wlr*$ZJ>a5IHWhrt%OLTNbFR z*-wb54x+slRp2|0TuC<|rbig|vK|RZkwK-mux_pC$mQn!>uqzUI&PRoIjv)+@*9VJAt=pG5Os`vM zd`6Gc4nmH}O?FO~U48n*HQ?v!i^y1_NlB-;XO^oi%3G`TH0^PQ*^+d$gj2gZ4JJ7| znL349Wq@oWxfJJFX1qe%x6&XR(?m740yi!J-D_o1!l z_t|5)sPh~%W@fW!&+f0zi@9C(tgzjA;q0-D^Y^U@eqY z`14a6s!P8{y%?1&sUZjJ4K?F_em>aTe5pD;ZhZ*q^v>B3vc}`LFzoqCK~dzXTJqez z+}8M9ew1glKg-{v^2vehe_#<u(7PqHk7;3BsiEDu9eV{p ztn~8R`>Q_W?)JVb$6@jLdi8kj!`U4(^E2R>RY_kP0V#MSCJr})#aKWk(%5I})!1>! zX2^3c#L9ICbK!aP+I(RABU1zKyN2-uwQKPo01BJ5gW7`eX}|_OQURBmvEj{g2GYIRfR7+gIR%=L{w6t=h8zr%Jd#*Uc8 zcf%c@MfgOCMoHK$HkJbTo63k_d@K0stanDm9AjUpEPC;0t#d7P$~RMVaRHy96?V8i zP%`*I{{0uS*d(asvRlWVhGWow=KwF(-A2T_Ywfap~2w;7)3Xgqj+ zfrvHTlyTI42R+Rce?qzOu!uv8TV0A`QdlTnQHY7LBxLlP=k)8EE=90*jI9ye@ ze{!9e! zv(HRPZkZbk%z`JFo3&^I1eV66GRgLMX=BUX3+-JZ( z)TT7@*aM4;wX)4nVJOo95TDXO{AD3Ra&o&=9FEnQm6yoG$=AM`R6adulg$F1@J4Wu zo}pgAN4U%Glc}|@oUQGJgoMOVwakwvi>X`x=MznYLbX+{B8|01r-g4Wji7>Ri2OPI zIRl$)d8k6P?h>qYE$uOLR{{f%CW^4V?ydC$HC){bmh4v59_NgIv>01j-tB)I3@;vX z7!W5M!ro9MbIrkOwV?WQNjIVhgP5~ikI>oTUj4xnEuma75m1IXJ^GJoPV5__)qb^S zP1lWK(-hHi-?YWwd5o>KPBgd?JGL1-1sowEozj-m6Iodv>3h6(1iR4bcb@4NAI~o% zpLSvt-6kaH$G#@c^B_4jWI3ApF8kofT;S#uR@ zExrjl)J4n92-}f2oe5)u(7+=y{@A|Usz;z2yJ{(@++YYCQZZ!|ku6s7;0q7jhTmUV~B(bV!JHStJW zTt6&cb3dCjW5c`#@f*60LC?=IXM_|_G|j*DrHY>!>tt@(IA}Tcs0QM{<0(nDWr<61J zLG{2w@@ad`D2nU#}b}Q3hxTo7lvN;JMFxlc?+U#J#9RUf+-c;epyy@bHp7l5mg}b|ttF zOxc+}BbtAe?Ns2iaX?9f{P~|33<{3I31y5qdyx}|ZV`4*c}4ndG6#!n4*3w0SvBnI zwbDzxcuX8PP>x*N+VrWdWZ&Qma_EgOECxp&s98YeCjAe?WVi?hrJA*G#QEO8+<$ps zAm85aHl;zICu2Ik%c%Jvk1v9TqUMF{zHxBa*V2GX$Vn2?;@B@h=J2FZ@b^2asDugs zaBu40$MM{^#j0dHJ(94H)dR%I02vz~Tq8%v^sm}4=u(EzcD!#4IMGJ^Nd#&MjUyDjV9ay%-yZQFx?_9o=sl0#ykh)9k9h36v&`GyoC9y z)3DKBC19ph`xCuD%i>a^9s1{|fAM&-RKCJ1M$+O~X~+BW#m3QobTnX5gMJNJj8*ca zDvbB~!X#Z2?f4AnIT3jcdrli<~F?nY2*Y*~SYW`_NG zdz&I+^dP6jhE(W&5-e;bHghx`;J?(L=1BFz4^U8_1Z5?~)L2}N ztpG}t7q>5gu=!L_rJLZl8N1@}Un`ibGyk;J!BSmpLZ%q(!~+9mNe=k~6cki06+|l_ z&!5Q<#e+Pdv>_)z{yi;a|NjgBpAY}<<6+pF literal 0 HcmV?d00001 diff --git a/wear/src/main/res/raw/ai_openclaw_app_wear_keep.xml b/wear/src/main/res/raw/ai_openclaw_app_wear_keep.xml new file mode 100644 index 0000000..c0fa637 --- /dev/null +++ b/wear/src/main/res/raw/ai_openclaw_app_wear_keep.xml @@ -0,0 +1,3 @@ + + diff --git a/wear/src/main/res/values-ar/strings.xml b/wear/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..ac9e88b --- /dev/null +++ b/wear/src/main/res/values-ar/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "الدردشة" + "الجلسة" + "النموذج" + "%1$s السابق" + "%1$s التالي" + "عناصر التحكم" + "التحدث" + "فشل صوت الساعة" + "الإملاء" + "مباشر" + "سلسلة المحادثة" + "فتح سلسلة المحادثة" + "← اسحب →" + "اضغط مطولًا" + "انقر" + "انقر نقرًا مزدوجًا" + "إظهار الرسائل الجديدة" + "ابدأ البث المباشر لرؤية المحادثة هنا." + "جديد" + "اكتب" + "رسالة" + "مراسلة الوكيل" + "إرسال" + "تحدث إلى وكيلك" + "إيقاف التحدث" + "نطق أحدث رد" + "جارٍ التحدث" + "جارٍ الاستماع" + "جارٍ الاتصال" + "جارٍ التفكير" + "جارٍ الكتابة" + "جارٍ الإرسال" + "الوكيل يعمل" + "خطأ" + "جاهز" + "أنت" + "الوكيل" + "النظام" + "بدء محادثة" + "تحدّث أو اكتب على ساعتك. يرسل الهاتف المقترن الرسالة عبر جلسة OpenClaw المصادق عليها." + "الجلسة الحالية" + "المظهر" + "داكن" + "فاتح" + "نطق الردود تلقائيًا" + "تنبيهات الردود" + "تفعيل التنبيهات" + "إيقاف التشغيل" + "فتح إعدادات الإشعارات" + "مفعّل" + "متوقف" + "الاتصال" + "Gateway" + "الأمان" + "يتحكم فيه الهاتف" + "تبقى بيانات اعتماد Gateway وهويته على الهاتف المقترن. لا تستخدم الساعة سوى Wear Data Layer." + "جارٍ التحقق من الهاتف" + "جارٍ قراءة الوكلاء والجلسات والدردشة" + "الهاتف جاهز" + "Gateway متصل" + "Gateway غير متصل" + "أعِد توصيل Gateway في OpenClaw على الهاتف المقترن." + "افتح OpenClaw على الهاتف" + "لا تبدأ الساعة تشغيل Gateway أو تصادق عليه بنفسها مطلقًا." + "يتعذر الوصول إلى الهاتف" + "أبقِ الهاتف المقترن قريبًا وثبّت تطبيق OpenClaw المطابق." + "لم يعد التحديد متاحًا" + "لم يتم قبول الإجراء" + "حدث خطأ ما" + "حدّث القوائم وحاول مرة أخرى." + "حاول مرة أخرى من الساعة." + "التحديث مطلوب" + "حدّث OpenClaw على كلٍ من الهاتف والساعة." + "تحديث" + "إعادة المحاولة" + "ردود OpenClaw" + "رد" + "رد OpenClaw" + "لم يُرسل الرد" + "الهاتف غير متاح. اضغط على رد للمحاولة مرة أخرى." + "افتح OpenClaw للرد" + "تم تغيير هاتفك المفضل. افتح التطبيق لإعادة تحميل الجلسة قبل الرد." + "OpenClaw" + "افتح الجلسات ورد عبر هاتفك المقترن" + "فتح" + "وكيل الهاتف" + diff --git a/wear/src/main/res/values-de/strings.xml b/wear/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..69d06fa --- /dev/null +++ b/wear/src/main/res/values-de/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Chat" + "Sitzung" + "Modell" + "Vorherige %1$s" + "Nächste %1$s" + "Steuerelemente" + "Sprechen" + "Watch-Audio fehlgeschlagen" + "Diktieren" + "Live" + "Thread" + "Thread öffnen" + "← Wischen →" + "Halten" + "Tippen" + "Doppeltippen" + "Neue Nachrichten anzeigen" + "Starte Live, um die Unterhaltung hier zu sehen." + "Neu" + "Eingeben" + "Nachricht" + "Nachricht an den Agenten" + "Senden" + "Mit deinem Agenten sprechen" + "Sprechen beenden" + "Neueste Antwort vorlesen" + "Spricht" + "Hört zu" + "Verbindung wird hergestellt" + "Denkt nach" + "Schreibt" + "Wird gesendet" + "Agent arbeitet" + "Fehler" + "Bereit" + "Du" + "Agent" + "System" + "Unterhaltung beginnen" + "Sprechen oder tippen Sie auf Ihrer Uhr. Das gekoppelte Smartphone sendet die Nachricht über seine authentifizierte OpenClaw-Sitzung." + "Aktuelle Sitzung" + "Darstellung" + "Dunkel" + "Hell" + "Antworten automatisch vorlesen" + "Antwortbenachrichtigungen" + "Benachrichtigungen aktivieren" + "Ausführung abbrechen" + "Benachrichtigungseinstellungen öffnen" + "Ein" + "Aus" + "Verbindung" + "Gateway" + "Sicherheit" + "Vom Smartphone gesteuert" + "Gateway-Anmeldedaten und Identität verbleiben auf dem gekoppelten Smartphone. Die Uhr verwendet ausschließlich den Wear Data Layer." + "Telefon wird überprüft" + "Agenten, Sitzungen und Chat werden gelesen" + "Telefon bereit" + "Gateway verbunden" + "Gateway offline" + "Verbinden Sie das Gateway in OpenClaw auf dem gekoppelten Telefon erneut." + "OpenClaw auf dem Telefon öffnen" + "Die Smartwatch startet oder authentifiziert das Gateway niemals selbst." + "Telefon nicht erreichbar" + "Lassen Sie das gekoppelte Telefon in der Nähe und installieren Sie die passende OpenClaw-App." + "Auswahl nicht mehr verfügbar" + "Aktion nicht akzeptiert" + "Etwas ist schiefgelaufen" + "Aktualisieren Sie die Listen und versuchen Sie es erneut." + "Versuchen Sie es erneut über die Smartwatch." + "Aktualisierung erforderlich" + "Aktualisieren Sie OpenClaw auf dem Telefon und der Smartwatch." + "Aktualisieren" + "Erneut versuchen" + "OpenClaw-Antworten" + "Antworten" + "OpenClaw-Antwort" + "Antwort nicht gesendet" + "Telefon nicht verfügbar. Tippe auf „Antworten“, um es erneut zu versuchen." + "OpenClaw öffnen, um zu antworten" + "Dein bevorzugtes Telefon hat sich geändert. Öffne die App, um die Sitzung vor dem Antworten neu zu laden." + "OpenClaw" + "Sitzungen öffnen und über dein gekoppeltes Telefon antworten" + "ÖFFNEN" + "TELEFON-PROXY" + diff --git a/wear/src/main/res/values-es/strings.xml b/wear/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..1a61aa2 --- /dev/null +++ b/wear/src/main/res/values-es/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Chat" + "Sesión" + "Modelo" + "%1$s anterior" + "%1$s siguiente" + "Controles" + "Hablar" + "Error en el audio del reloj" + "Dictar" + "En vivo" + "Hilo" + "Abrir hilo" + "← Desliza →" + "Mantén pulsado" + "Toca" + "Toca dos veces" + "Mostrar mensajes nuevos" + "Inicia Live para ver la conversación aquí." + "Nuevo" + "Escribir" + "Mensaje" + "Enviar mensaje al agente" + "Enviar" + "Habla con tu agente" + "Dejar de hablar" + "Leer en voz alta la última respuesta" + "Hablando" + "Escuchando" + "Conectando" + "Pensando" + "Escribiendo" + "Enviando" + "El agente está trabajando" + "Error" + "Listo" + "Tú" + "Agente" + "Sistema" + "Iniciar una conversación" + "Habla o escribe en tu reloj. El teléfono vinculado envía el mensaje a través de su sesión autenticada de OpenClaw." + "Sesión actual" + "Apariencia" + "Oscuro" + "Claro" + "Leer las respuestas automáticamente" + "Alertas de respuesta" + "Activar alertas" + "Cancelar ejecución" + "Abrir ajustes de notificaciones" + "Activado" + "Desactivado" + "Conexión" + "Gateway" + "Seguridad" + "Controlado por el teléfono" + "Las credenciales y la identidad de Gateway permanecen en el teléfono vinculado. El reloj solo usa Wear Data Layer." + "Comprobando el teléfono" + "Leyendo agentes, sesiones y chat" + "Teléfono listo" + "Gateway conectado" + "Gateway sin conexión" + "Vuelve a conectar el Gateway en OpenClaw desde el teléfono emparejado." + "Abrir OpenClaw en el teléfono" + "El reloj nunca inicia ni autentica el Gateway por sí mismo." + "No se puede acceder al teléfono" + "Mantén cerca el teléfono emparejado e instala la aplicación de OpenClaw correspondiente." + "La selección ya no está disponible" + "Acción no aceptada" + "Algo salió mal" + "Actualiza las listas e inténtalo de nuevo." + "Inténtalo de nuevo desde el reloj." + "Actualización necesaria" + "Actualiza OpenClaw tanto en el teléfono como en el reloj." + "Actualizar" + "Reintentar" + "Respuestas de OpenClaw" + "Responder" + "Respuesta de OpenClaw" + "No se envió la respuesta" + "Teléfono no disponible. Toca Responder para volver a intentarlo." + "Abrir OpenClaw para responder" + "Tu teléfono preferido cambió. Abre la aplicación para volver a cargar la sesión antes de responder." + "OpenClaw" + "Abre sesiones y responde mediante tu teléfono vinculado" + "ABRIR" + "PROXY DEL TELÉFONO" + diff --git a/wear/src/main/res/values-fa/strings.xml b/wear/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..8006a46 --- /dev/null +++ b/wear/src/main/res/values-fa/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "گفتگو" + "نشست" + "مدل" + "%1$s قبلی" + "%1$s بعدی" + "کنترل‌ها" + "صحبت" + "پخش صدا در ساعت ناموفق بود" + "دیکته" + "زنده" + "رشته گفتگو" + "باز کردن رشته گفتگو" + "← بکشید →" + "نگه دارید" + "ضربه بزنید" + "دو بار ضربه بزنید" + "نمایش پیام‌های جدید" + "برای مشاهده گفتگو در اینجا، Live را شروع کنید." + "جدید" + "تایپ" + "پیام" + "پیام به عامل" + "ارسال" + "با عامل خود صحبت کنید" + "توقف صحبت" + "خواندن آخرین پاسخ" + "در حال صحبت" + "در حال گوش‌دادن" + "در حال اتصال" + "در حال فکرکردن" + "در حال تایپ" + "در حال ارسال" + "عامل در حال کار است" + "خطا" + "آماده" + "شما" + "عامل" + "سیستم" + "شروع گفتگو" + "با ساعت خود صحبت کنید یا تایپ کنید. تلفن جفت‌شده پیام را از طریق نشست احراز هویت‌شده OpenClaw ارسال می‌کند." + "نشست فعلی" + "ظاهر" + "تیره" + "روشن" + "خواندن خودکار پاسخ‌ها" + "هشدارهای پاسخ" + "فعال‌کردن هشدارها" + "لغو اجرا" + "بازکردن تنظیمات اعلان‌ها" + "روشن" + "خاموش" + "اتصال" + "Gateway" + "امنیت" + "کنترل‌شده با تلفن" + "اعتبارنامه‌ها و هویت Gateway روی تلفن جفت‌شده باقی می‌مانند. ساعت فقط از Wear Data Layer استفاده می‌کند." + "در حال بررسی تلفن" + "در حال خواندن عامل‌ها، نشست‌ها و گفت‌وگو" + "تلفن آماده است" + "Gateway متصل است" + "Gateway آفلاین است" + "Gateway را در OpenClaw روی تلفن جفت‌شده دوباره متصل کنید." + "OpenClaw را روی تلفن باز کنید" + "ساعت هیچ‌گاه Gateway را راه‌اندازی یا احراز هویت نمی‌کند." + "تلفن در دسترس نیست" + "تلفن جفت‌شده را نزدیک نگه دارید و برنامه سازگار OpenClaw را نصب کنید." + "گزینه انتخاب‌شده دیگر در دسترس نیست" + "عملیات پذیرفته نشد" + "مشکلی پیش آمد" + "فهرست‌ها را تازه‌سازی و دوباره تلاش کنید." + "از ساعت دوباره تلاش کنید." + "به‌روزرسانی لازم است" + "OpenClaw را هم در تلفن و هم در ساعت به‌روزرسانی کنید." + "تازه‌سازی" + "تلاش دوباره" + "پاسخ‌های OpenClaw" + "پاسخ" + "پاسخ OpenClaw" + "پاسخ ارسال نشد" + "تلفن در دسترس نیست. برای تلاش دوباره، روی پاسخ بزنید." + "برای پاسخ دادن، OpenClaw را باز کنید" + "تلفن ترجیحی شما تغییر کرده است. پیش از پاسخ دادن، برنامه را باز کنید تا نشست دوباره بارگیری شود." + "OpenClaw" + "نشست‌ها را باز کنید و از طریق تلفن جفت‌شده خود پاسخ دهید" + "باز کردن" + "پروکسی تلفن" + diff --git a/wear/src/main/res/values-fr/strings.xml b/wear/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..79c08f3 --- /dev/null +++ b/wear/src/main/res/values-fr/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Discussion" + "Session" + "Modèle" + "%1$s précédent" + "%1$s suivant" + "Commandes" + "Parler" + "Échec de l’audio de la montre" + "Dicter" + "En direct" + "Fil" + "Ouvrir le fil" + "← Balayer →" + "Maintenir" + "Toucher" + "Appuyer deux fois" + "Afficher les nouveaux messages" + "Démarrez Live pour voir la conversation ici." + "Nouveau" + "Saisir" + "Message" + "Envoyer un message à l’agent" + "Envoyer" + "Parler à votre agent" + "Arrêter de parler" + "Lire la dernière réponse" + "En train de parler" + "Écoute en cours" + "Connexion en cours" + "Réflexion en cours" + "Saisie en cours" + "Envoi en cours" + "L’agent travaille" + "Erreur" + "Prêt" + "Vous" + "Agent" + "Système" + "Démarrer une conversation" + "Parlez ou saisissez du texte sur votre montre. Le téléphone associé envoie le message via sa session OpenClaw authentifiée." + "Session actuelle" + "Apparence" + "Sombre" + "Clair" + "Lire automatiquement les réponses" + "Alertes de réponse" + "Activer les alertes" + "Interrompre l’exécution" + "Ouvrir les paramètres de notification" + "Activé" + "Désactivé" + "Connexion" + "Gateway" + "Sécurité" + "Contrôlé par le téléphone" + "Les identifiants et l’identité du Gateway restent sur le téléphone associé. La montre utilise uniquement la Wear Data Layer." + "Vérification du téléphone" + "Lecture des agents, des sessions et du chat" + "Téléphone prêt" + "Gateway connecté" + "Gateway hors ligne" + "Reconnectez le Gateway dans OpenClaw sur le téléphone associé." + "Ouvrir OpenClaw sur le téléphone" + "La montre ne démarre ni n’authentifie jamais le Gateway elle-même." + "Téléphone inaccessible" + "Gardez le téléphone associé à proximité et installez l’application OpenClaw correspondante." + "Sélection désormais indisponible" + "Action non acceptée" + "Une erreur s’est produite" + "Actualisez les listes et réessayez." + "Réessayez depuis la montre." + "Mise à jour requise" + "Mettez à jour OpenClaw sur le téléphone et la montre." + "Actualiser" + "Réessayer" + "Réponses d’OpenClaw" + "Répondre" + "Réponse OpenClaw" + "Réponse non envoyée" + "Téléphone indisponible. Touchez Répondre pour réessayer." + "Ouvrez OpenClaw pour répondre" + "Votre téléphone préféré a changé. Ouvrez l’application pour recharger la session avant de répondre." + "OpenClaw" + "Ouvrez des sessions et répondez via votre téléphone jumelé" + "OUVRIR" + "RELAIS TÉLÉPHONIQUE" + diff --git a/wear/src/main/res/values-hi/strings.xml b/wear/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..51b37af --- /dev/null +++ b/wear/src/main/res/values-hi/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "चैट" + "सत्र" + "मॉडल" + "पिछला %1$s" + "अगला %1$s" + "नियंत्रण" + "बोलें" + "वॉच ऑडियो विफल रहा" + "बोलकर लिखें" + "लाइव" + "थ्रेड" + "थ्रेड खोलें" + "← स्वाइप करें →" + "दबाकर रखें" + "टैप करें" + "दो बार टैप करें" + "नए संदेश दिखाएँ" + "बातचीत यहाँ देखने के लिए Live शुरू करें।" + "नया" + "टाइप करें" + "संदेश" + "एजेंट को संदेश भेजें" + "भेजें" + "अपने एजेंट से बात करें" + "बोलना बंद करें" + "नवीनतम उत्तर सुनें" + "बोल रहा है" + "सुन रहा है" + "कनेक्ट हो रहा है" + "सोच रहा है" + "टाइप कर रहा है" + "भेज रहा है" + "एजेंट काम कर रहा है" + "त्रुटि" + "तैयार" + "आप" + "एजेंट" + "सिस्टम" + "बातचीत शुरू करें" + "अपनी घड़ी पर बोलें या टाइप करें। युग्मित फ़ोन अपने प्रमाणित OpenClaw सत्र के माध्यम से संदेश भेजता है।" + "वर्तमान सत्र" + "दिखावट" + "गहरा" + "हल्का" + "जवाब अपने-आप बोलकर सुनाएँ" + "जवाब के अलर्ट" + "अलर्ट चालू करें" + "रन रोकें" + "नोटिफ़िकेशन सेटिंग खोलें" + "चालू" + "बंद" + "कनेक्शन" + "Gateway" + "सुरक्षा" + "फ़ोन द्वारा नियंत्रित" + "Gateway के क्रेडेंशियल और पहचान युग्मित फ़ोन पर ही रहते हैं। घड़ी केवल Wear Data Layer का उपयोग करती है।" + "फ़ोन की जाँच की जा रही है" + "एजेंट, सत्र और चैट पढ़े जा रहे हैं" + "फ़ोन तैयार है" + "Gateway कनेक्ट है" + "Gateway ऑफ़लाइन है" + "जोड़े गए फ़ोन पर OpenClaw में Gateway को फिर से कनेक्ट करें।" + "फ़ोन पर OpenClaw खोलें" + "घड़ी कभी भी स्वयं Gateway को शुरू या प्रमाणित नहीं करती है।" + "फ़ोन से संपर्क नहीं हो पा रहा है" + "जोड़े गए फ़ोन को पास रखें और उससे मेल खाने वाला OpenClaw ऐप इंस्टॉल करें।" + "चयन अब उपलब्ध नहीं है" + "कार्रवाई स्वीकार नहीं की गई" + "कुछ गड़बड़ी हुई" + "सूचियाँ रीफ़्रेश करके फिर से प्रयास करें।" + "घड़ी से फिर से प्रयास करें।" + "अपडेट आवश्यक है" + "फ़ोन और घड़ी, दोनों पर OpenClaw अपडेट करें।" + "रीफ़्रेश करें" + "फिर से प्रयास करें" + "OpenClaw के जवाब" + "जवाब दें" + "OpenClaw का जवाब" + "जवाब नहीं भेजा गया" + "फ़ोन उपलब्ध नहीं है। फिर से कोशिश करने के लिए जवाब दें पर टैप करें।" + "जवाब देने के लिए OpenClaw खोलें" + "आपका पसंदीदा फ़ोन बदल गया है। जवाब देने से पहले सत्र को फिर से लोड करने के लिए ऐप खोलें।" + "OpenClaw" + "सत्र खोलें और अपने युग्मित फ़ोन के ज़रिए जवाब दें" + "खोलें" + "फ़ोन प्रॉक्सी" + diff --git a/wear/src/main/res/values-in/strings.xml b/wear/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..4ef2a7f --- /dev/null +++ b/wear/src/main/res/values-in/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Obrolan" + "Sesi" + "Model" + "%1$s sebelumnya" + "%1$s berikutnya" + "Kontrol" + "Bicara" + "Audio jam tangan gagal" + "Dikte" + "Langsung" + "Utas" + "Buka utas" + "← Geser →" + "Tahan" + "Ketuk" + "Ketuk dua kali" + "Tampilkan pesan baru" + "Mulai Live untuk melihat percakapan di sini." + "Baru" + "Ketik" + "Pesan" + "Kirim pesan kepada agen" + "Kirim" + "Bicara dengan agen Anda" + "Berhenti berbicara" + "Bacakan balasan terbaru" + "Sedang berbicara" + "Sedang mendengarkan" + "Sedang menghubungkan" + "Sedang berpikir" + "Sedang mengetik" + "Sedang mengirim" + "Agen sedang bekerja" + "Kesalahan" + "Siap" + "Anda" + "Agen" + "Sistem" + "Mulai percakapan" + "Bicara atau ketik di jam tangan Anda. Ponsel yang disandingkan mengirim pesan melalui sesi OpenClaw yang telah diautentikasi." + "Sesi saat ini" + "Tampilan" + "Gelap" + "Terang" + "Ucapkan balasan secara otomatis" + "Notifikasi balasan" + "Aktifkan notifikasi" + "Batalkan proses" + "Buka pengaturan notifikasi" + "Aktif" + "Nonaktif" + "Koneksi" + "Gateway" + "Keamanan" + "Dikendalikan ponsel" + "Kredensial dan identitas Gateway tetap berada di ponsel yang disandingkan. Jam tangan hanya menggunakan Wear Data Layer." + "Memeriksa ponsel" + "Membaca agen, sesi, dan chat" + "Ponsel siap" + "Gateway terhubung" + "Gateway offline" + "Hubungkan kembali Gateway di OpenClaw pada ponsel yang disandingkan." + "Buka OpenClaw di ponsel" + "Jam tangan tidak pernah memulai atau mengautentikasi Gateway itu sendiri." + "Ponsel tidak dapat dijangkau" + "Letakkan ponsel yang disandingkan di dekat Anda dan instal aplikasi OpenClaw yang sesuai." + "Pilihan tidak lagi tersedia" + "Tindakan tidak diterima" + "Terjadi kesalahan" + "Segarkan daftar dan coba lagi." + "Coba lagi dari jam tangan." + "Pembaruan diperlukan" + "Perbarui OpenClaw di ponsel dan jam tangan." + "Segarkan" + "Coba lagi" + "Balasan OpenClaw" + "Balas" + "Balasan OpenClaw" + "Balasan tidak terkirim" + "Ponsel tidak tersedia. Ketuk Balas untuk mencoba lagi." + "Buka OpenClaw untuk membalas" + "Ponsel pilihan Anda telah berubah. Buka aplikasi untuk memuat ulang sesi sebelum membalas." + "OpenClaw" + "Buka sesi dan balas melalui ponsel yang telah dipasangkan" + "BUKA" + "PROKSI PONSEL" + diff --git a/wear/src/main/res/values-it/strings.xml b/wear/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..72c1ecb --- /dev/null +++ b/wear/src/main/res/values-it/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Chat" + "Sessione" + "Modello" + "%1$s precedente" + "%1$s successivo" + "Controlli" + "Parla" + "Audio dell\'orologio non riuscito" + "Detta" + "In diretta" + "Conversazione" + "Apri conversazione" + "← Scorri →" + "Tieni premuto" + "Tocca" + "Tocca due volte" + "Mostra nuovi messaggi" + "Avvia Live per vedere qui la conversazione." + "Nuovo" + "Scrivi" + "Messaggio" + "Invia un messaggio all\'agente" + "Invia" + "Parla con il tuo agente" + "Interrompi la conversazione" + "Leggi l\'ultima risposta" + "Riproduzione vocale" + "In ascolto" + "Connessione" + "Elaborazione" + "Scrittura" + "Invio" + "Agente al lavoro" + "Errore" + "Pronto" + "Tu" + "Agente" + "Sistema" + "Avvia una conversazione" + "Parla o digita sul tuo orologio. Il telefono associato invia il messaggio tramite la sua sessione OpenClaw autenticata." + "Sessione corrente" + "Aspetto" + "Scuro" + "Chiaro" + "Leggi automaticamente le risposte" + "Avvisi per le risposte" + "Abilita avvisi" + "Interrompi esecuzione" + "Apri le impostazioni delle notifiche" + "Attivo" + "Disattivo" + "Connessione" + "Gateway" + "Sicurezza" + "Controllato dal telefono" + "Le credenziali e l\'identità del Gateway rimangono sul telefono associato. L\'orologio utilizza solo Wear Data Layer." + "Verifica del telefono" + "Lettura di agenti, sessioni e chat" + "Telefono pronto" + "Gateway connesso" + "Gateway offline" + "Riconnetti il Gateway in OpenClaw sul telefono associato." + "Apri OpenClaw sul telefono" + "L\'orologio non avvia né autentica mai autonomamente il Gateway." + "Telefono non raggiungibile" + "Tieni vicino il telefono associato e installa l\'app OpenClaw corrispondente." + "Selezione non più disponibile" + "Azione non accettata" + "Si è verificato un errore" + "Aggiorna gli elenchi e riprova." + "Riprova dall\'orologio." + "Aggiornamento richiesto" + "Aggiorna OpenClaw sia sul telefono che sull\'orologio." + "Aggiorna" + "Riprova" + "Risposte di OpenClaw" + "Rispondi" + "Risposta di OpenClaw" + "Risposta non inviata" + "Telefono non disponibile. Tocca Rispondi per riprovare." + "Apri OpenClaw per rispondere" + "Il telefono preferito è cambiato. Apri l\'app per ricaricare la sessione prima di rispondere." + "OpenClaw" + "Apri le sessioni e rispondi tramite il telefono associato" + "APRI" + "PROXY TELEFONO" + diff --git a/wear/src/main/res/values-ja/strings.xml b/wear/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..b193150 --- /dev/null +++ b/wear/src/main/res/values-ja/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "チャット" + "セッション" + "モデル" + "前の%1$s" + "次の%1$s" + "操作" + "話す" + "音声の視聴に失敗しました" + "音声入力" + "ライブ" + "スレッド" + "スレッドを開く" + "← スワイプ →" + "長押し" + "タップ" + "ダブルタップ" + "新着メッセージを表示" + "Liveを開始すると、ここに会話が表示されます。" + "新規" + "入力" + "メッセージ" + "エージェントにメッセージを送信" + "送信" + "エージェントに話しかける" + "読み上げを停止" + "最新の返信を読み上げる" + "読み上げ中" + "聞き取り中" + "接続中" + "思考中" + "入力中" + "送信中" + "エージェントが作業中" + "エラー" + "準備完了" + "あなた" + "エージェント" + "システム" + "会話を開始" + "ウォッチで話すか入力してください。ペアリング済みのスマートフォンが、認証済みのOpenClawセッションを通じてメッセージを送信します。" + "現在のセッション" + "外観" + "ダーク" + "ライト" + "返信を自動的に読み上げる" + "返信通知" + "通知を有効にする" + "実行を中止" + "通知設定を開く" + "オン" + "オフ" + "接続" + "Gateway" + "セキュリティ" + "スマートフォンで管理" + "Gatewayの認証情報とIDはペアリング済みのスマートフォンに保持されます。ウォッチはWear Data Layerのみを使用します。" + "スマートフォンを確認中" + "エージェント、セッション、チャットを読み込み中" + "スマートフォンの準備ができました" + "Gatewayに接続しました" + "Gatewayはオフラインです" + "ペアリングしたスマートフォンのOpenClawでGatewayに再接続してください。" + "スマートフォンでOpenClawを開く" + "ウォッチ自体がGatewayを起動したり、認証したりすることはありません。" + "スマートフォンに接続できません" + "ペアリングしたスマートフォンを近くに置き、対応するOpenClawアプリをインストールしてください。" + "選択した項目は利用できなくなりました" + "操作を受け付けられませんでした" + "問題が発生しました" + "リストを更新して、もう一度お試しください。" + "ウォッチからもう一度お試しください。" + "アップデートが必要です" + "スマートフォンとウォッチの両方でOpenClawをアップデートしてください。" + "更新" + "再試行" + "OpenClawからの返信" + "返信" + "OpenClawの返信" + "返信を送信できませんでした" + "スマートフォンを利用できません。もう一度試すには「返信」をタップしてください。" + "OpenClawを開いて返信" + "優先するスマートフォンが変更されました。返信する前にアプリを開いてセッションを再読み込みしてください。" + "OpenClaw" + "セッションを開き、ペアリング済みのスマートフォン経由で返信" + "開く" + "スマートフォンプロキシ" + diff --git a/wear/src/main/res/values-ko/strings.xml b/wear/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..20cace3 --- /dev/null +++ b/wear/src/main/res/values-ko/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "채팅" + "세션" + "모델" + "이전 %1$s" + "다음 %1$s" + "제어" + "말하기" + "Watch 오디오 실패" + "받아쓰기" + "라이브" + "스레드" + "스레드 열기" + "← 스와이프 →" + "길게 누르기" + "탭" + "두 번 탭" + "새 메시지 표시" + "대화를 보려면 Live를 시작하세요." + "새로 만들기" + "입력" + "메시지" + "에이전트에게 메시지 보내기" + "보내기" + "에이전트에게 말하기" + "말하기 중지" + "최근 답변 읽기" + "말하는 중" + "듣는 중" + "연결 중" + "생각하는 중" + "입력 중" + "보내는 중" + "에이전트 작업 중" + "오류" + "준비됨" + "나" + "에이전트" + "시스템" + "대화 시작" + "시계에서 말하거나 입력하세요. 페어링된 휴대전화가 인증된 OpenClaw 세션을 통해 메시지를 전송합니다." + "현재 세션" + "화면 모드" + "다크" + "라이트" + "답변 자동 음성 재생" + "답변 알림" + "알림 사용" + "실행 중단" + "알림 설정 열기" + "켜짐" + "꺼짐" + "연결" + "Gateway" + "보안" + "휴대전화에서 제어" + "Gateway 자격 증명과 ID는 페어링된 휴대전화에 유지됩니다. 시계는 Wear Data Layer만 사용합니다." + "휴대전화 확인 중" + "에이전트, 세션 및 채팅을 불러오는 중" + "휴대전화 준비 완료" + "Gateway 연결됨" + "Gateway 오프라인" + "페어링된 휴대전화의 OpenClaw에서 Gateway를 다시 연결하세요." + "휴대전화에서 OpenClaw 열기" + "시계 자체에서는 Gateway를 시작하거나 인증하지 않습니다." + "휴대전화에 연결할 수 없음" + "페어링된 휴대전화를 가까이 두고 호환되는 OpenClaw 앱을 설치하세요." + "선택 항목을 더 이상 사용할 수 없음" + "작업이 수락되지 않음" + "문제가 발생했습니다" + "목록을 새로 고친 후 다시 시도하세요." + "시계에서 다시 시도하세요." + "업데이트 필요" + "휴대전화와 시계 모두에서 OpenClaw를 업데이트하세요." + "새로 고침" + "다시 시도" + "OpenClaw 답변" + "답장" + "OpenClaw 답장" + "답장을 보내지 못했습니다" + "휴대전화를 사용할 수 없습니다. 다시 시도하려면 답장을 탭하세요." + "답장하려면 OpenClaw 열기" + "기본 휴대전화가 변경되었습니다. 답장하기 전에 앱을 열어 세션을 다시 불러오세요." + "OpenClaw" + "세션을 열고 페어링된 휴대전화를 통해 답장하세요" + "열기" + "휴대전화 프록시" + diff --git a/wear/src/main/res/values-nl/strings.xml b/wear/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..1df334b --- /dev/null +++ b/wear/src/main/res/values-nl/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Chat" + "Sessie" + "Model" + "Vorige %1$s" + "Volgende %1$s" + "Bediening" + "Praten" + "Watch-audio mislukt" + "Dicteren" + "Live" + "Gespreksdraad" + "Discussie openen" + "← Veeg →" + "Ingedrukt houden" + "Tikken" + "Dubbeltik" + "Nieuwe berichten weergeven" + "Start Live om het gesprek hier te zien." + "Nieuw" + "Typen" + "Bericht" + "Stuur agent een bericht" + "Verzenden" + "Praat met je agent" + "Stop met spreken" + "Spreek het laatste antwoord uit" + "Aan het spreken" + "Aan het luisteren" + "Verbinding maken" + "Aan het nadenken" + "Aan het typen" + "Aan het verzenden" + "Agent is bezig" + "Fout" + "Gereed" + "Jij" + "Agent" + "Systeem" + "Een gesprek starten" + "Praat of typ op je horloge. De gekoppelde telefoon verstuurt het bericht via de geverifieerde OpenClaw-sessie." + "Huidige sessie" + "Weergave" + "Donker" + "Licht" + "Antwoorden automatisch uitspreken" + "Meldingen bij antwoorden" + "Meldingen inschakelen" + "Uitvoering afbreken" + "Meldingsinstellingen openen" + "Aan" + "Uit" + "Verbinding" + "Gateway" + "Beveiliging" + "Beheerd via telefoon" + "De Gateway-inloggegevens en identiteit blijven op de gekoppelde telefoon. Het horloge gebruikt alleen de Wear Data Layer." + "Telefoon controleren" + "Agents, sessies en chat lezen" + "Telefoon gereed" + "Gateway verbonden" + "Gateway offline" + "Verbind de Gateway opnieuw via OpenClaw op de gekoppelde telefoon." + "Open OpenClaw op de telefoon" + "Het horloge start of verifieert de Gateway nooit zelf." + "Telefoon niet bereikbaar" + "Houd de gekoppelde telefoon in de buurt en installeer de bijbehorende OpenClaw-app." + "Selectie niet meer beschikbaar" + "Actie niet geaccepteerd" + "Er is iets misgegaan" + "Vernieuw de lijsten en probeer het opnieuw." + "Probeer het opnieuw vanaf het horloge." + "Update vereist" + "Werk OpenClaw bij op zowel de telefoon als het horloge." + "Vernieuwen" + "Opnieuw proberen" + "Antwoorden van OpenClaw" + "Beantwoorden" + "Antwoord via OpenClaw" + "Antwoord niet verzonden" + "Telefoon niet beschikbaar. Tik op Beantwoorden om het opnieuw te proberen." + "Open OpenClaw om te antwoorden" + "Je voorkeurstelefoon is gewijzigd. Open de app om de sessie opnieuw te laden voordat je antwoordt." + "OpenClaw" + "Open sessies en antwoord via je gekoppelde telefoon" + "OPENEN" + "TELEFOONPROXY" + diff --git a/wear/src/main/res/values-pl/strings.xml b/wear/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..8f9f33a --- /dev/null +++ b/wear/src/main/res/values-pl/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Czat" + "Sesja" + "Model" + "Poprzednie: %1$s" + "Następne: %1$s" + "Sterowanie" + "Rozmawiaj" + "Nie udało się odtworzyć dźwięku na zegarku" + "Dyktuj" + "Na żywo" + "Wątek" + "Otwórz wątek" + "← Przesuń →" + "Przytrzymaj" + "Dotknij" + "Stuknij dwukrotnie" + "Pokaż nowe wiadomości" + "Uruchom Live, aby zobaczyć tutaj rozmowę." + "Nowa" + "Wpisz" + "Wiadomość" + "Napisz do agenta" + "Wyślij" + "Porozmawiaj ze swoim agentem" + "Przestań mówić" + "Odczytaj najnowszą odpowiedź" + "Mówi" + "Słucha" + "Łączenie" + "Myśli" + "Pisze" + "Wysyłanie" + "Agent pracuje" + "Błąd" + "Gotowe" + "Ty" + "Agent" + "System" + "Rozpocznij rozmowę" + "Mów lub pisz na zegarku. Sparowany telefon wysyła wiadomość za pośrednictwem uwierzytelnionej sesji OpenClaw." + "Bieżąca sesja" + "Wygląd" + "Ciemny" + "Jasny" + "Automatycznie odczytuj odpowiedzi" + "Powiadomienia o odpowiedziach" + "Włącz powiadomienia" + "Przerwij działanie" + "Otwórz ustawienia powiadomień" + "Wł." + "Wył." + "Połączenie" + "Gateway" + "Bezpieczeństwo" + "Sterowane przez telefon" + "Dane uwierzytelniające Gateway i informacje o tożsamości pozostają na sparowanym telefonie. Zegarek korzysta wyłącznie z Wear Data Layer." + "Sprawdzanie telefonu" + "Odczytywanie agentów, sesji i czatu" + "Telefon jest gotowy" + "Połączono z Gateway" + "Gateway jest offline" + "Połącz ponownie Gateway w OpenClaw na sparowanym telefonie." + "Otwórz OpenClaw na telefonie" + "Zegarek nigdy samodzielnie nie uruchamia ani nie uwierzytelnia Gateway." + "Telefon jest nieosiągalny" + "Trzymaj sparowany telefon w pobliżu i zainstaluj na nim odpowiednią aplikację OpenClaw." + "Wybrana opcja nie jest już dostępna" + "Działanie nie zostało zaakceptowane" + "Coś poszło nie tak" + "Odśwież listy i spróbuj ponownie." + "Spróbuj ponownie na zegarku." + "Wymagana aktualizacja" + "Zaktualizuj OpenClaw na telefonie i zegarku." + "Odśwież" + "Spróbuj ponownie" + "Odpowiedzi OpenClaw" + "Odpowiedz" + "Odpowiedź OpenClaw" + "Nie wysłano odpowiedzi" + "Telefon jest niedostępny. Dotknij opcji Odpowiedz, aby spróbować ponownie." + "Otwórz OpenClaw, aby odpowiedzieć" + "Preferowany telefon został zmieniony. Przed udzieleniem odpowiedzi otwórz aplikację, aby ponownie wczytać sesję." + "OpenClaw" + "Otwieraj sesje i odpowiadaj za pomocą sparowanego telefonu" + "OTWÓRZ" + "PROXY TELEFONU" + diff --git a/wear/src/main/res/values-pt-rBR/strings.xml b/wear/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..fd1e680 --- /dev/null +++ b/wear/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Chat" + "Sessão" + "Modelo" + "%1$s anterior" + "Próximo %1$s" + "Controles" + "Falar" + "Falha no áudio do relógio" + "Ditar" + "Ao vivo" + "Conversa" + "Abrir conversa" + "← Deslize →" + "Mantenha pressionado" + "Toque" + "Toque duas vezes" + "Mostrar novas mensagens" + "Inicie o Live para ver a conversa aqui." + "Novo" + "Digitar" + "Mensagem" + "Enviar mensagem ao agente" + "Enviar" + "Fale com seu agente" + "Parar de falar" + "Reproduzir a resposta mais recente" + "Falando" + "Ouvindo" + "Conectando" + "Pensando" + "Digitando" + "Enviando" + "Agente trabalhando" + "Erro" + "Pronto" + "Você" + "Agente" + "Sistema" + "Iniciar uma conversa" + "Fale ou digite no relógio. O telefone pareado envia a mensagem por meio da sessão autenticada do OpenClaw." + "Sessão atual" + "Aparência" + "Escuro" + "Claro" + "Falar respostas automaticamente" + "Alertas de resposta" + "Ativar alertas" + "Interromper execução" + "Abrir configurações de notificações" + "Ativado" + "Desativado" + "Conexão" + "Gateway" + "Segurança" + "Controlado pelo telefone" + "As credenciais e a identidade do Gateway permanecem no telefone pareado. O relógio usa apenas a Wear Data Layer." + "Verificando o telefone" + "Lendo agentes, sessões e conversas" + "Telefone pronto" + "Gateway conectado" + "Gateway offline" + "Reconecte o Gateway no OpenClaw no telefone pareado." + "Abra o OpenClaw no telefone" + "O relógio nunca inicia nem autentica o Gateway por conta própria." + "Não foi possível acessar o telefone" + "Mantenha o telefone pareado por perto e instale o aplicativo OpenClaw correspondente." + "A seleção não está mais disponível" + "Ação não aceita" + "Algo deu errado" + "Atualize as listas e tente novamente." + "Tente novamente pelo relógio." + "Atualização necessária" + "Atualize o OpenClaw no telefone e no relógio." + "Atualizar" + "Tentar novamente" + "Respostas do OpenClaw" + "Responder" + "Resposta do OpenClaw" + "Resposta não enviada" + "Telefone indisponível. Toque em Responder para tentar novamente." + "Abra o OpenClaw para responder" + "Seu telefone preferido mudou. Abra o app para recarregar a sessão antes de responder." + "OpenClaw" + "Abra sessões e responda usando seu telefone pareado" + "ABRIR" + "PROXY DO TELEFONE" + diff --git a/wear/src/main/res/values-ru/strings.xml b/wear/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..08541be --- /dev/null +++ b/wear/src/main/res/values-ru/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Чат" + "Сеанс" + "Модель" + "Предыдущий %1$s" + "Следующий %1$s" + "Управление" + "Говорить" + "Сбой аудио на часах" + "Диктовать" + "В прямом эфире" + "Ветка" + "Открыть ветку" + "← Проведите пальцем →" + "Удерживайте" + "Коснитесь" + "Двойное нажатие" + "Показать новые сообщения" + "Запустите Live, чтобы увидеть здесь переписку." + "Новое" + "Введите текст" + "Сообщение" + "Сообщение агенту" + "Отправить" + "Поговорить с агентом" + "Перестать говорить" + "Озвучить последний ответ" + "Говорит" + "Слушает" + "Подключение" + "Обдумывает" + "Печатает" + "Отправка" + "Агент работает" + "Ошибка" + "Готово" + "Вы" + "Агент" + "Система" + "Начать разговор" + "Говорите или вводите текст на часах. Сопряжённый телефон отправит сообщение через аутентифицированный сеанс OpenClaw." + "Текущий сеанс" + "Оформление" + "Тёмное" + "Светлое" + "Автоматически озвучивать ответы" + "Уведомления об ответах" + "Включить уведомления" + "Прервать выполнение" + "Открыть настройки уведомлений" + "Вкл." + "Выкл." + "Подключение" + "Gateway" + "Безопасность" + "Управляется телефоном" + "Учётные данные и идентификационные данные Gateway хранятся на сопряжённом телефоне. Часы используют только Wear Data Layer." + "Проверка телефона" + "Чтение агентов, сеансов и чата" + "Телефон готов" + "Gateway подключён" + "Gateway не в сети" + "Повторно подключите Gateway в OpenClaw на сопряжённом телефоне." + "Открыть OpenClaw на телефоне" + "Часы никогда не запускают и не аутентифицируют Gateway самостоятельно." + "Телефон недоступен" + "Держите сопряжённый телефон поблизости и установите соответствующее приложение OpenClaw." + "Выбранный элемент больше недоступен" + "Действие не принято" + "Что-то пошло не так" + "Обновите списки и повторите попытку." + "Повторите попытку с часов." + "Требуется обновление" + "Обновите OpenClaw на телефоне и часах." + "Обновить" + "Повторить" + "Ответы OpenClaw" + "Ответить" + "Ответ OpenClaw" + "Ответ не отправлен" + "Телефон недоступен. Нажмите «Ответить», чтобы повторить попытку." + "Открыть OpenClaw, чтобы ответить" + "Предпочитаемый телефон изменён. Прежде чем отвечать, откройте приложение, чтобы перезагрузить сеанс." + "OpenClaw" + "Открывайте сеансы и отвечайте через сопряжённый телефон" + "ОТКРЫТЬ" + "ПРОКСИ-ТЕЛЕФОН" + diff --git a/wear/src/main/res/values-sv/strings.xml b/wear/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..b916296 --- /dev/null +++ b/wear/src/main/res/values-sv/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Chatt" + "Session" + "Modell" + "Föregående %1$s" + "Nästa %1$s" + "Kontroller" + "Prata" + "Watch-ljudet misslyckades" + "Diktera" + "Live" + "Tråd" + "Öppna tråd" + "← Svep →" + "Håll" + "Tryck" + "Dubbeltryck" + "Visa nya meddelanden" + "Starta Live för att se konversationen här." + "Ny" + "Skriv" + "Meddelande" + "Skicka meddelande till agenten" + "Skicka" + "Prata med din agent" + "Sluta prata" + "Läs upp det senaste svaret" + "Pratar" + "Lyssnar" + "Ansluter" + "Tänker" + "Skriver" + "Skickar" + "Agenten arbetar" + "Fel" + "Redo" + "Du" + "Agent" + "System" + "Starta en konversation" + "Prata eller skriv på din klocka. Den parkopplade telefonen skickar meddelandet via sin autentiserade OpenClaw-session." + "Aktuell session" + "Utseende" + "Mörkt" + "Ljust" + "Läs upp svar automatiskt" + "Svarsaviseringar" + "Aktivera aviseringar" + "Avbryt körning" + "Öppna aviseringsinställningar" + "På" + "Av" + "Anslutning" + "Gateway" + "Säkerhet" + "Styrs av telefonen" + "Inloggningsuppgifter och identitet för Gateway lagras på den parkopplade telefonen. Klockan använder endast Wear Data Layer." + "Kontrollerar telefonen" + "Läser in agenter, sessioner och chatt" + "Telefonen är redo" + "Gateway är ansluten" + "Gateway är offline" + "Återanslut Gateway i OpenClaw på den parkopplade telefonen." + "Öppna OpenClaw på telefonen" + "Klockan startar eller autentiserar aldrig Gateway på egen hand." + "Telefonen kan inte nås" + "Ha den parkopplade telefonen i närheten och installera motsvarande OpenClaw-app." + "Valet är inte längre tillgängligt" + "Åtgärden godkändes inte" + "Något gick fel" + "Uppdatera listorna och försök igen." + "Försök igen från klockan." + "Uppdatering krävs" + "Uppdatera OpenClaw på både telefonen och klockan." + "Uppdatera" + "Försök igen" + "Svar från OpenClaw" + "Svara" + "OpenClaw-svar" + "Svaret skickades inte" + "Telefonen är inte tillgänglig. Tryck på Svara för att försöka igen." + "Öppna OpenClaw för att svara" + "Din föredragna telefon har ändrats. Öppna appen för att läsa in sessionen igen innan du svarar." + "OpenClaw" + "Öppna sessioner och svara via din parkopplade telefon" + "ÖPPNA" + "TELEFONPROXY" + diff --git a/wear/src/main/res/values-th/strings.xml b/wear/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..d6c26a3 --- /dev/null +++ b/wear/src/main/res/values-th/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "แชท" + "เซสชัน" + "โมเดล" + "%1$s ก่อนหน้า" + "%1$s ถัดไป" + "การควบคุม" + "พูด" + "เสียงจากนาฬิกาล้มเหลว" + "ป้อนตามคำบอก" + "สด" + "เธรด" + "เปิดเธรด" + "← ปัด →" + "กดค้าง" + "แตะ" + "แตะสองครั้ง" + "แสดงข้อความใหม่" + "เริ่ม Live เพื่อดูการสนทนาที่นี่" + "ใหม่" + "พิมพ์" + "ข้อความ" + "ส่งข้อความถึงเอเจนต์" + "ส่ง" + "พูดคุยกับเอเจนต์ของคุณ" + "หยุดพูด" + "อ่านคำตอบล่าสุด" + "กำลังพูด" + "กำลังฟัง" + "กำลังเชื่อมต่อ" + "กำลังคิด" + "กำลังพิมพ์" + "กำลังส่ง" + "เอเจนต์กำลังทำงาน" + "ข้อผิดพลาด" + "พร้อม" + "คุณ" + "เอเจนต์" + "ระบบ" + "เริ่มการสนทนา" + "พูดหรือพิมพ์บนนาฬิกาของคุณ โทรศัพท์ที่จับคู่ไว้จะส่งข้อความผ่านเซสชัน OpenClaw ที่ผ่านการยืนยันตัวตน" + "เซสชันปัจจุบัน" + "รูปลักษณ์" + "มืด" + "สว่าง" + "พูดข้อความตอบกลับโดยอัตโนมัติ" + "การแจ้งเตือนข้อความตอบกลับ" + "เปิดใช้การแจ้งเตือน" + "ยกเลิกการทำงาน" + "เปิดการตั้งค่าการแจ้งเตือน" + "เปิด" + "ปิด" + "การเชื่อมต่อ" + "Gateway" + "ความปลอดภัย" + "ควบคุมโดยโทรศัพท์" + "ข้อมูลประจำตัวและข้อมูลยืนยันตัวตนของ Gateway จะอยู่ในโทรศัพท์ที่จับคู่ไว้ นาฬิกาจะใช้เฉพาะ Wear Data Layer เท่านั้น" + "กำลังตรวจสอบโทรศัพท์" + "กำลังอ่านเอเจนต์ เซสชัน และแชต" + "โทรศัพท์พร้อมใช้งาน" + "เชื่อมต่อ Gateway แล้ว" + "Gateway ออฟไลน์" + "เชื่อมต่อ Gateway อีกครั้งใน OpenClaw บนโทรศัพท์ที่จับคู่ไว้" + "เปิด OpenClaw บนโทรศัพท์" + "นาฬิกาจะไม่เริ่มต้นหรือตรวจสอบสิทธิ์ Gateway ด้วยตัวเอง" + "ไม่สามารถติดต่อโทรศัพท์ได้" + "วางโทรศัพท์ที่จับคู่ไว้ใกล้ ๆ และติดตั้งแอป OpenClaw เวอร์ชันที่ตรงกัน" + "ตัวเลือกนี้ไม่พร้อมใช้งานแล้ว" + "ไม่ยอมรับการดำเนินการ" + "เกิดข้อผิดพลาดบางอย่าง" + "รีเฟรชรายการแล้วลองอีกครั้ง" + "ลองอีกครั้งจากนาฬิกา" + "จำเป็นต้องอัปเดต" + "อัปเดต OpenClaw ทั้งบนโทรศัพท์และนาฬิกา" + "รีเฟรช" + "ลองอีกครั้ง" + "การตอบกลับจาก OpenClaw" + "ตอบกลับ" + "การตอบกลับจาก OpenClaw" + "ไม่ได้ส่งการตอบกลับ" + "โทรศัพท์ไม่พร้อมใช้งาน แตะตอบกลับเพื่อลองอีกครั้ง" + "เปิด OpenClaw เพื่อตอบกลับ" + "โทรศัพท์ที่คุณเลือกใช้มีการเปลี่ยนแปลง เปิดแอปเพื่อโหลดเซสชันใหม่ก่อนตอบกลับ" + "OpenClaw" + "เปิดเซสชันและตอบกลับผ่านโทรศัพท์ที่จับคู่ไว้" + "เปิด" + "พร็อกซีโทรศัพท์" + diff --git a/wear/src/main/res/values-tr/strings.xml b/wear/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..90cbca2 --- /dev/null +++ b/wear/src/main/res/values-tr/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Sohbet" + "Oturum" + "Model" + "Önceki %1$s" + "Sonraki %1$s" + "Kontroller" + "Konuş" + "Saatteki ses başarısız oldu" + "Dikte et" + "Canlı" + "Konu" + "Yazışmayı aç" + "← Kaydır →" + "Basılı tut" + "Dokun" + "Çift dokunun" + "Yeni mesajları göster" + "Konuşmayı burada görmek için Canlı\'yı başlatın." + "Yeni" + "Yazın" + "Mesaj" + "Temsilciye mesaj gönderin" + "Gönder" + "Temsilcinizle konuşun" + "Konuşmayı durdur" + "Son yanıtı seslendir" + "Konuşuyor" + "Dinliyor" + "Bağlanıyor" + "Düşünüyor" + "Yazıyor" + "Gönderiliyor" + "Temsilci çalışıyor" + "Hata" + "Hazır" + "Siz" + "Aracı" + "Sistem" + "Bir sohbet başlatın" + "Saatinizde konuşun veya yazın. Eşleştirilmiş telefon, mesajı kimliği doğrulanmış OpenClaw oturumu üzerinden gönderir." + "Geçerli oturum" + "Görünüm" + "Koyu" + "Açık" + "Yanıtları otomatik olarak seslendir" + "Yanıt uyarıları" + "Uyarıları etkinleştir" + "Çalıştırmayı iptal et" + "Bildirim ayarlarını aç" + "Açık" + "Kapalı" + "Bağlantı" + "Gateway" + "Güvenlik" + "Telefon tarafından denetlenir" + "Gateway kimlik bilgileri ve kimliği eşleştirilmiş telefonda kalır. Saat yalnızca Wear Data Layer\'ı kullanır." + "Telefon kontrol ediliyor" + "Aracılar, oturumlar ve sohbet okunuyor" + "Telefon hazır" + "Gateway bağlı" + "Gateway çevrimdışı" + "Eşleştirilmiş telefondaki OpenClaw üzerinden Gateway\'i yeniden bağlayın." + "Telefonda OpenClaw\'u açın" + "Saat, Gateway\'i hiçbir zaman kendisi başlatmaz veya doğrulamaz." + "Telefona ulaşılamıyor" + "Eşleştirilmiş telefonu yakında tutun ve uyumlu OpenClaw uygulamasını yükleyin." + "Seçim artık kullanılamıyor" + "İşlem kabul edilmedi" + "Bir sorun oluştu" + "Listeleri yenileyip tekrar deneyin." + "Saatten tekrar deneyin." + "Güncelleme gerekli" + "OpenClaw\'u hem telefonda hem de saatte güncelleyin." + "Yenile" + "Tekrar dene" + "OpenClaw yanıtları" + "Yanıtla" + "OpenClaw yanıtı" + "Yanıt gönderilmedi" + "Telefon kullanılamıyor. Tekrar denemek için Yanıtla\'ya dokunun." + "Yanıtlamak için OpenClaw\'ı açın" + "Tercih ettiğiniz telefon değişti. Yanıtlamadan önce oturumu yeniden yüklemek için uygulamayı açın." + "OpenClaw" + "Oturumları açın ve eşleştirilmiş telefonunuz üzerinden yanıtlayın" + "AÇ" + "TELEFON PROXY\'Sİ" + diff --git a/wear/src/main/res/values-uk/strings.xml b/wear/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..38584f9 --- /dev/null +++ b/wear/src/main/res/values-uk/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Чат" + "Сеанс" + "Модель" + "Попередній %1$s" + "Наступний %1$s" + "Елементи керування" + "Говорити" + "Не вдалося відтворити аудіо на годиннику" + "Диктувати" + "Наживо" + "Гілка" + "Відкрити гілку" + "← Проведіть →" + "Утримуйте" + "Торкніться" + "Двічі торкніться" + "Показати нові повідомлення" + "Запустіть Live, щоб побачити розмову тут." + "Нове" + "Введіть" + "Повідомлення" + "Написати агенту" + "Надіслати" + "Говорити зі своїм агентом" + "Припинити говорити" + "Озвучити останню відповідь" + "Говорить" + "Слухає" + "Підключення" + "Обмірковує" + "Вводить текст" + "Надсилання" + "Агент працює" + "Помилка" + "Готово" + "Ви" + "Агент" + "Система" + "Почати розмову" + "Говоріть або вводьте текст на годиннику. Спарений телефон надсилає повідомлення через автентифікований сеанс OpenClaw." + "Поточний сеанс" + "Вигляд" + "Темна" + "Світла" + "Автоматично озвучувати відповіді" + "Сповіщення про відповіді" + "Увімкнути сповіщення" + "Перервати виконання" + "Відкрити налаштування сповіщень" + "Увімкнено" + "Вимкнено" + "Підключення" + "Gateway" + "Безпека" + "Керується телефоном" + "Облікові дані та ідентифікаційна інформація Gateway зберігаються на спареному телефоні. Годинник використовує лише Wear Data Layer." + "Перевірка телефона" + "Зчитування агентів, сеансів і чату" + "Телефон готовий" + "Gateway підключено" + "Gateway не в мережі" + "Повторно підключіть Gateway в OpenClaw на спареному телефоні." + "Відкрийте OpenClaw на телефоні" + "Годинник ніколи не запускає та не автентифікує Gateway самостійно." + "Телефон недоступний" + "Тримайте спарений телефон поруч і встановіть відповідний застосунок OpenClaw." + "Вибраний елемент більше недоступний" + "Дію не прийнято" + "Щось пішло не так" + "Оновіть списки та спробуйте ще раз." + "Спробуйте ще раз із годинника." + "Потрібне оновлення" + "Оновіть OpenClaw на телефоні та годиннику." + "Оновити" + "Повторити" + "Відповіді OpenClaw" + "Відповісти" + "Відповідь OpenClaw" + "Відповідь не надіслано" + "Телефон недоступний. Натисніть «Відповісти», щоб повторити спробу." + "Відкрийте OpenClaw, щоб відповісти" + "Ваш бажаний телефон змінено. Відкрийте застосунок, щоб перезавантажити сеанс перед відповіддю." + "OpenClaw" + "Відкривайте сеанси та відповідайте через спарений телефон" + "ВІДКРИТИ" + "ПРОКСІ ТЕЛЕФОНУ" + diff --git a/wear/src/main/res/values-vi/strings.xml b/wear/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..059386e --- /dev/null +++ b/wear/src/main/res/values-vi/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "Trò chuyện" + "Phiên" + "Mô hình" + "%1$s trước" + "%1$s tiếp theo" + "Điều khiển" + "Nói" + "Âm thanh trên đồng hồ bị lỗi" + "Đọc chính tả" + "Trực tiếp" + "Luồng" + "Mở chuỗi hội thoại" + "← Vuốt →" + "Giữ" + "Chạm" + "Nhấn đúp" + "Hiển thị tin nhắn mới" + "Bắt đầu Live để xem cuộc trò chuyện tại đây." + "Mới" + "Nhập" + "Tin nhắn" + "Nhắn tin cho tác nhân" + "Gửi" + "Nói chuyện với tác nhân của bạn" + "Dừng nói" + "Đọc câu trả lời mới nhất" + "Đang nói" + "Đang nghe" + "Đang kết nối" + "Đang suy nghĩ" + "Đang nhập" + "Đang gửi" + "Tác nhân đang làm việc" + "Lỗi" + "Sẵn sàng" + "Bạn" + "Tác nhân" + "Hệ thống" + "Bắt đầu cuộc trò chuyện" + "Nói hoặc nhập trên đồng hồ. Điện thoại đã ghép đôi sẽ gửi tin nhắn qua phiên OpenClaw đã xác thực." + "Phiên hiện tại" + "Giao diện" + "Tối" + "Sáng" + "Tự động đọc câu trả lời" + "Thông báo trả lời" + "Bật thông báo" + "Hủy lượt chạy" + "Mở cài đặt thông báo" + "Bật" + "Tắt" + "Kết nối" + "Gateway" + "Bảo mật" + "Do điện thoại kiểm soát" + "Thông tin đăng nhập Gateway và danh tính được lưu trên điện thoại đã ghép đôi. Đồng hồ chỉ sử dụng Wear Data Layer." + "Đang kiểm tra điện thoại" + "Đang đọc tác nhân, phiên và cuộc trò chuyện" + "Điện thoại đã sẵn sàng" + "Gateway đã kết nối" + "Gateway ngoại tuyến" + "Hãy kết nối lại Gateway trong OpenClaw trên điện thoại đã ghép đôi." + "Mở OpenClaw trên điện thoại" + "Đồng hồ không bao giờ tự khởi động hoặc xác thực Gateway." + "Không thể kết nối với điện thoại" + "Hãy để điện thoại đã ghép đôi ở gần và cài đặt ứng dụng OpenClaw tương ứng." + "Lựa chọn không còn khả dụng" + "Thao tác không được chấp nhận" + "Đã xảy ra lỗi" + "Hãy làm mới danh sách và thử lại." + "Hãy thử lại từ đồng hồ." + "Cần cập nhật" + "Hãy cập nhật OpenClaw trên cả điện thoại và đồng hồ." + "Làm mới" + "Thử lại" + "Phản hồi của OpenClaw" + "Trả lời" + "Trả lời qua OpenClaw" + "Chưa gửi được câu trả lời" + "Điện thoại không khả dụng. Nhấn Trả lời để thử lại." + "Mở OpenClaw để trả lời" + "Điện thoại ưu tiên của bạn đã thay đổi. Hãy mở ứng dụng để tải lại phiên trước khi trả lời." + "OpenClaw" + "Mở các phiên và trả lời qua điện thoại đã ghép đôi" + "MỞ" + "PROXY ĐIỆN THOẠI" + diff --git a/wear/src/main/res/values-zh-rCN/strings.xml b/wear/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..e247c3e --- /dev/null +++ b/wear/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "聊天" + "会话" + "模型" + "上一个 %1$s" + "下一个 %1$s" + "控制" + "说话" + "手表音频失败" + "听写" + "实时" + "对话串" + "打开话题" + "← 滑动 →" + "按住" + "点按" + "双击" + "显示新消息" + "启动实时模式后,即可在此处查看对话。" + "新建" + "输入" + "消息" + "向智能体发送消息" + "发送" + "与智能体对话" + "停止朗读" + "朗读最新回复" + "正在朗读" + "正在聆听" + "正在连接" + "正在思考" + "正在输入" + "正在发送" + "智能体正在工作" + "错误" + "就绪" + "你" + "智能体" + "系统" + "开始对话" + "在手表上说话或输入文字。已配对的手机会通过其已认证的 OpenClaw 会话发送消息。" + "当前会话" + "外观" + "深色" + "浅色" + "自动朗读回复" + "回复提醒" + "启用提醒" + "中止运行" + "打开通知设置" + "开" + "关" + "连接" + "Gateway" + "安全" + "由手机控制" + "Gateway 凭据和身份信息保留在已配对的手机上。手表仅使用 Wear Data Layer。" + "正在检查手机" + "正在读取智能体、会话和聊天" + "手机已就绪" + "Gateway 已连接" + "Gateway 离线" + "请在已配对手机的 OpenClaw 中重新连接 Gateway。" + "在手机上打开 OpenClaw" + "手表本身不会启动 Gateway,也不会对其进行身份验证。" + "无法连接手机" + "请将已配对的手机放在附近,并安装匹配的 OpenClaw 应用。" + "所选项目已不可用" + "操作未被接受" + "出现错误" + "请刷新列表后重试。" + "请在手表上重试。" + "需要更新" + "请更新手机和手表上的 OpenClaw。" + "刷新" + "重试" + "OpenClaw 回复" + "回复" + "OpenClaw 回复" + "回复未发送" + "手机不可用。点按“回复”重试。" + "打开 OpenClaw 进行回复" + "您的首选手机已更改。回复前请打开应用以重新加载会话。" + "OpenClaw" + "打开会话并通过已配对的手机回复" + "打开" + "手机代理" + diff --git a/wear/src/main/res/values-zh-rTW/strings.xml b/wear/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..1fd8847 --- /dev/null +++ b/wear/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,89 @@ + + "OpenClaw" + "聊天" + "工作階段" + "模型" + "上一個 %1$s" + "下一個 %1$s" + "控制項" + "說話" + "手錶音訊失敗" + "語音輸入" + "即時" + "對話串" + "開啟討論串" + "← 滑動 →" + "按住" + "點一下" + "輕觸兩下" + "顯示新訊息" + "啟動 Live 即可在此查看對話。" + "新增" + "輸入" + "訊息" + "傳訊息給代理程式" + "傳送" + "與您的代理程式交談" + "停止說話" + "朗讀最新回覆" + "正在說話" + "正在聆聽" + "正在連線" + "正在思考" + "正在輸入" + "正在傳送" + "代理程式正在處理" + "錯誤" + "就緒" + "您" + "代理程式" + "系統" + "開始對話" + "在手錶上說話或輸入文字。配對的手機會透過已驗證的 OpenClaw 工作階段傳送訊息。" + "目前的工作階段" + "外觀" + "深色" + "淺色" + "自動朗讀回覆" + "回覆通知" + "啟用通知" + "中止執行" + "開啟通知設定" + "開啟" + "關閉" + "連線" + "Gateway" + "安全性" + "由手機控制" + "Gateway 憑證和身分資訊會保留在配對的手機上。手錶僅使用 Wear Data Layer。" + "正在檢查手機" + "正在讀取代理程式、工作階段和聊天" + "手機已就緒" + "Gateway 已連線" + "Gateway 離線" + "請在已配對手機上的 OpenClaw 中重新連線 Gateway。" + "在手機上開啟 OpenClaw" + "手錶本身不會啟動 Gateway 或進行驗證。" + "無法連上手機" + "請將已配對的手機放在附近,並安裝相符的 OpenClaw 應用程式。" + "所選項目已無法使用" + "操作未被接受" + "發生錯誤" + "請重新整理清單,然後再試一次。" + "請從手錶再試一次。" + "需要更新" + "請更新手機和手錶上的 OpenClaw。" + "重新整理" + "重試" + "OpenClaw 回覆" + "回覆" + "OpenClaw 回覆" + "回覆未傳送" + "手機無法使用。點選「回覆」以重試。" + "開啟 OpenClaw 以回覆" + "您的偏好手機已變更。回覆前,請開啟應用程式以重新載入工作階段。" + "OpenClaw" + "開啟工作階段,並透過已配對的手機回覆" + "開啟" + "手機代理" + diff --git a/wear/src/main/res/values/colors.xml b/wear/src/main/res/values/colors.xml new file mode 100644 index 0000000..f42ada6 --- /dev/null +++ b/wear/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000..4145345 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,91 @@ + + + OpenClaw + Chat + Session + Model + Previous %1$s + Next %1$s + Controls + Talk + Watch audio failed + Dictate + Live + Thread + + Open thread + ← Swipe → + Hold + Tap + Double tap + Show new messages + Start Live to see the conversation here. + New + Type + Message + Message agent + Send + Speak to your agent + Stop speaking + Speak latest reply + Speaking + Listening + Connecting + Thinking + Typing + Sending + Agent working + Error + Ready + You + Agent + System + Start a conversation + Talk or type on your watch. The paired phone sends the message through its authenticated OpenClaw session. + Current session + Appearance + Dark + Light + Speak replies automatically + Reply alerts + Enable alerts + Abort run + Open notification settings + On + Off + Connection + Gateway + Security + Phone-controlled + Gateway credentials and identity stay on the paired phone. The watch only uses the Wear Data Layer. + Checking phone + Reading agents, sessions, and chat + Phone ready + Gateway connected + Gateway offline + Reconnect the Gateway in OpenClaw on the paired phone. + Open OpenClaw on phone + The watch never starts or authenticates the Gateway itself. + Phone not reachable + Keep the paired phone nearby and install the matching OpenClaw app. + Selection no longer available + Action not accepted + Something went wrong + Refresh the lists and try again. + Try again from the watch. + Update required + Update OpenClaw on both phone and watch. + Refresh + Retry + OpenClaw replies + Reply + OpenClaw reply + Reply not sent + Phone unavailable. Tap Reply to try again. + Open OpenClaw to reply + Your preferred phone changed. Open the app to reload the session before replying. + OpenClaw + Open sessions and reply through your paired phone + OPEN + PHONE PROXY + diff --git a/wear/src/main/res/values/themes.xml b/wear/src/main/res/values/themes.xml new file mode 100644 index 0000000..13abbfc --- /dev/null +++ b/wear/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml new file mode 100644 index 0000000..da62ff7 --- /dev/null +++ b/wear/src/main/res/values/wear.xml @@ -0,0 +1,6 @@ + + + + openclaw_wear_companion_v1 + + diff --git a/wear/src/main/res/xml/backup_rules.xml b/wear/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..24bad93 --- /dev/null +++ b/wear/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wear/src/main/res/xml/data_extraction_rules.xml b/wear/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..3c332df --- /dev/null +++ b/wear/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt b/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt new file mode 100644 index 0000000..28dfcd2 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt @@ -0,0 +1,261 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearRealtimeTalkEntry +import ai.openclaw.wear.shared.WearRealtimeTalkRole +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MainActivityTest { + @Test + fun assistantReplyMustBelongToOriginatingSession() { + val reply = WearChatMessage(id = "reply-2", role = "assistant", text = "Second", timestamp = 2L) + + assertNull( + newAssistantReplyForSession( + awaitingSessionId = "session-a", + activeSessionId = "session-b", + expectedAssistantKey = "reply-1", + latestAssistantMessage = reply, + ), + ) + assertEquals( + reply, + newAssistantReplyForSession( + awaitingSessionId = "session-a", + activeSessionId = "session-a", + expectedAssistantKey = "reply-1", + latestAssistantMessage = reply, + ), + ) + } + + @Test + fun realtimeThinkingOverrideSurvivesUnrelatedActiveUpdates() { + val streaming = realtimeSnapshot(entryStreaming = true) + val completed = realtimeSnapshot(entryStreaming = false) + val unrelatedUpdate = + completed.copy( + realtimeTalk = completed.realtimeTalk.copy(statusText = "Still active"), + ) + + val newTurnId = nextRealtimeThinkingTurnId(streaming, completed, currentTurnId = null) + + assertEquals("user-1", newTurnId) + assertEquals("user-1", nextRealtimeThinkingTurnId(completed, unrelatedUpdate, newTurnId)) + assertNull( + nextRealtimeThinkingTurnId( + unrelatedUpdate, + unrelatedUpdate.copy(realtimeTalk = unrelatedUpdate.realtimeTalk.copy(active = false)), + newTurnId, + ), + ) + } + + @Test + fun threadFollowKeepsStreamingContentVisibleAtLatest() { + val first = + nextWearThreadFollowForContent( + state = WearThreadFollowState(), + contentRevision = threadRevision(text = "Hel", streaming = true), + ) + val continued = + nextWearThreadFollowForContent( + state = first.state, + contentRevision = threadRevision(text = "Hello", streaming = true), + ) + + assertTrue(first.scrollToLatest) + assertTrue(continued.scrollToLatest) + assertTrue(continued.state.followingLatest) + assertFalse(continued.state.hasNewContent) + } + + @Test + fun threadFollowTargetsTrailingAnchorAfterLatestContent() { + assertEquals(-1, wearThreadLatestAnchorIndex(entryCount = 0, thinking = false)) + assertEquals(1, wearThreadLatestAnchorIndex(entryCount = 1, thinking = false)) + assertEquals(3, wearThreadLatestAnchorIndex(entryCount = 2, thinking = true)) + } + + @Test + fun threadFollowPreservesManualScrollUntilLatestIsRequested() { + val initial = + nextWearThreadFollowForContent( + state = WearThreadFollowState(), + contentRevision = threadRevision(text = "First", streaming = false), + ) + val scrolledBack = + nextWearThreadFollowForViewport( + state = initial.state, + atLatest = false, + scrollingBackward = true, + ) + val newContent = + nextWearThreadFollowForContent( + state = scrolledBack, + contentRevision = threadRevision(text = "Second", streaming = false), + ) + + assertFalse(newContent.scrollToLatest) + assertFalse(newContent.state.followingLatest) + assertTrue(newContent.state.hasNewContent) + + val latest = wearThreadFollowLatest(newContent.state) + assertTrue(latest.followingLatest) + assertFalse(latest.hasNewContent) + } + + @Test + fun threadFollowClearsNewContentWhenUserScrollsToLatest() { + val away = + WearThreadFollowState( + followingLatest = false, + hasNewContent = true, + ) + + val latest = + nextWearThreadFollowForViewport( + state = away, + atLatest = true, + scrollingBackward = false, + ) + + assertTrue(latest.followingLatest) + assertFalse(latest.hasNewContent) + } + + @Test + fun threadFollowResetsWhenRealtimeStops() { + val revision = threadRevision(text = "Old", streaming = false) + val away = + WearThreadFollowState( + contentRevision = revision, + followingLatest = false, + hasNewContent = true, + ) + val stopped = + nextWearThreadFollowForContent( + state = away, + contentRevision = revision, + realtimeActive = false, + ) + + assertFalse(stopped.scrollToLatest) + assertTrue(stopped.state.followingLatest) + assertFalse(stopped.state.hasNewContent) + + val restarted = + nextWearThreadFollowForContent( + state = stopped.state, + contentRevision = revision, + ) + assertTrue(restarted.scrollToLatest) + } + + @Test + fun proxyErrorsMapToLocalizedFailureCodes() { + assertEquals( + WearConversationFailure.PHONE_UNAVAILABLE, + WearProxyException("phone_unavailable", "Raw phone error").toWearConversationFailure(), + ) + assertEquals( + WearConversationFailure.INCOMPATIBLE, + WearProxyException("unsupported_peer", "Raw compatibility error").toWearConversationFailure(), + ) + assertEquals( + WearConversationFailure.INTERNAL_ERROR, + IllegalStateException("Raw internal error").toWearConversationFailure(), + ) + } + + @Test + fun conversationSnapshotCarriesSemanticFailureAndUntitledSession() { + val session = + WearSession( + key = "session-1", + title = null, + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-1", + ) + val snapshot = + WearUiState( + connected = true, + phoneNodeId = "phone-1", + sessions = listOf(session), + selectedSession = session, + failure = WearConversationFailure.ACTION_REJECTED, + ).toConversationSnapshot() + + assertEquals(WearConversationFailure.ACTION_REJECTED, snapshot?.failure) + assertNull(snapshot?.sessions?.single()?.title) + } + + @Test + fun connectionEventsPreserveTypedAndLegacyIncompatibilityReasons() { + assertEquals( + WearConversationFailure.INCOMPATIBLE, + wearConversationFailureForConnection( + buildJsonObject { + put("connected", false) + put("failure", "incompatible") + put("status", "Offline") + }, + ), + ) + assertEquals( + WearConversationFailure.INCOMPATIBLE, + wearConversationFailureForConnection( + buildJsonObject { + put("connected", false) + put("status", "Update required") + }, + ), + ) + assertEquals( + WearConversationFailure.GATEWAY_OFFLINE, + wearConversationFailureForConnection( + buildJsonObject { + put("connected", false) + put("status", "Offline") + }, + ), + ) + } + + private fun threadRevision( + text: String, + streaming: Boolean, + ): WearThreadContentRevision = + WearThreadContentRevision( + entryCount = 1, + latestEntryId = "entry-1", + latestText = text, + latestStreaming = streaming, + thinking = false, + ) + + private fun realtimeSnapshot(entryStreaming: Boolean): WearConversationSnapshot = + WearConversationSnapshot( + gatewayState = WearGatewayState.CONNECTED, + realtimeTalk = + WearRealtimeTalkSnapshot( + active = true, + conversation = + listOf( + WearRealtimeTalkEntry( + id = "user-1", + role = WearRealtimeTalkRole.USER, + text = "Hello", + streaming = entryStreaming, + ), + ), + ), + ) +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt b/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt new file mode 100644 index 0000000..7c203e0 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt @@ -0,0 +1,439 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot +import ai.openclaw.wear.shared.WearRpcMethod +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearGatewayRepositoryTest { + private val json = Json + + @Test + fun talkEventsMatchOnlyTheirCurrentAttempt() { + val current = WearRealtimeTalkSnapshot(attemptId = "attempt-current", active = true) + val stale = WearRealtimeTalkSnapshot(attemptId = "attempt-stale") + + assertTrue(shouldAcceptWearTalkSnapshot(current, "attempt-current")) + assertFalse(shouldAcceptWearTalkSnapshot(stale, "attempt-current")) + assertFalse(shouldAcceptWearTalkSnapshot(WearRealtimeTalkSnapshot(), "attempt-current")) + } + + @Test + fun sessionsAndHistoryParseOnlyProjectedContract() = + runTest { + val requester = + RecordingRequester { method, _ -> + when (method) { + WearRpcMethod.SessionsList -> + json.parseToJsonElement( + """{"sessions":[{"key":"agent:main","agentId":"main","displayName":"Main","updatedAt":7,"hasActiveRun":true,"modelRef":"openai/gpt-test"}],"activeAgentId":"main","selectedSessionValid":true}""", + ) + WearRpcMethod.ChatHistory -> + json.parseToJsonElement( + """{"sessionKey":"agent:main","selectedModelRef":"openai/gpt-test","messages":[{"id":"m1","role":"assistant","content":[{"type":"text","text":"hello 😀"}],"timestamp":9}],"inFlightRun":{"runId":"run-1","text":"working"}}""", + ) + else -> error("unexpected $method") + } + } + val repository = WearGatewayRepository(requester) + + val sessions = + repository.sessions( + selectedSessionKey = "agent:main", + capabilities = setOf(WearProxyCapability.SessionSelectionLookup), + ) + val history = repository.history("agent:main", sessions.phoneNodeId) + + assertEquals("Main", sessions.sessions.single().title) + assertTrue(sessions.sessions.single().hasActiveRun) + assertEquals(7L, sessions.eventSequence) + assertEquals("phone", sessions.phoneNodeId) + assertEquals("phone", sessions.sessions.single().phoneNodeId) + assertEquals("main", sessions.sessions.single().agentId) + assertEquals("openai/gpt-test", sessions.sessions.single().modelRef) + assertEquals("main", sessions.activeAgentId) + assertTrue(sessions.selectedSessionValid) + assertEquals("hello 😀", history.messages.single().text) + assertEquals("run-1", history.activeRunId) + assertEquals("working", history.activeText) + assertEquals("openai/gpt-test", history.selectedModelRef) + assertEquals(7L, history.eventSequence) + assertEquals(setOf("limit", "selectedSessionKey"), requester.calls[0].second.keys) + assertEquals(setOf("sessionKey", "limit", "maxChars"), requester.calls[1].second.keys) + } + + @Test + fun agentsAndGatewayControlsRequireThePreferredPhone() = + runTest { + val capabilities = WearProxyCapability.entries.toSet() + val requester = + RecordingRequester { method, _ -> + when (method) { + WearRpcMethod.AgentsList -> + json.parseToJsonElement( + """{"agents":[{"id":"main","name":"Main","emoji":"*","selected":true}]}""", + ) + WearRpcMethod.AgentsSelect -> JsonObject(emptyMap()) + WearRpcMethod.GatewayDisconnect -> + json.parseToJsonElement( + """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""", + ) + else -> error("unexpected $method") + } + } + val repository = WearGatewayRepository(requester) + + val agents = repository.agents("phone-a", capabilities) + repository.selectAgent("main", "phone-a", capabilities) + val status = + repository.setGatewayEnabled( + enabled = false, + phoneNodeId = "phone-a", + capabilities = capabilities, + ) + + assertEquals("Main", agents.agents.single().name) + assertTrue(agents.agents.single().selected) + assertFalse(status.connected) + assertEquals("main", status.activeAgentId) + assertEquals("openai/gpt-test", status.selectedModelRef) + assertEquals(capabilities, status.capabilities) + assertEquals( + listOf(WearRpcMethod.AgentsList, WearRpcMethod.AgentsSelect, WearRpcMethod.GatewayDisconnect), + requester.calls.map(Pair::first), + ) + assertEquals(setOf("agentId"), requester.calls[1].second.keys) + assertTrue(requester.expectedNodeIds.all { it == "phone-a" }) + assertTrue(requester.requirePreferredNodes.all { it }) + } + + @Test + fun oldPhoneStatusBlocksUnsupportedControlsBeforeSendingTheirRpc() = + runTest { + val requester = + RecordingRequester { method, _ -> + assertEquals(WearRpcMethod.ProxyStatus, method) + json.parseToJsonElement( + """{"connected":true,"status":"Connected","activeSessionKey":"agent:main"}""", + ) + } + val repository = WearGatewayRepository(requester) + + val status = repository.status() + val agentsFailure = runCatching { repository.agents(status.phoneNodeId, status.capabilities) }.exceptionOrNull() + val gatewayFailure = + runCatching { + repository.setGatewayEnabled( + enabled = false, + phoneNodeId = status.phoneNodeId, + capabilities = status.capabilities, + ) + }.exceptionOrNull() + val modelsFailure = runCatching { repository.models(status.phoneNodeId, status.capabilities) }.exceptionOrNull() + + assertTrue(status.capabilities.isEmpty()) + assertEquals("unsupported_peer", (agentsFailure as? WearProxyException)?.code) + assertEquals("unsupported_peer", (gatewayFailure as? WearProxyException)?.code) + assertEquals("unsupported_peer", (modelsFailure as? WearProxyException)?.code) + assertEquals(listOf(WearRpcMethod.ProxyStatus), requester.calls.map(Pair::first)) + } + + @Test + fun newPhoneStatusNegotiatesKnownCapabilitiesAndIgnoresFutureOnes() = + runTest { + val requester = + RecordingRequester { _, _ -> + json.parseToJsonElement( + """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""", + ) + } + + val status = WearGatewayRepository(requester).status() + + assertEquals(WearProxyCapability.entries.toSet(), status.capabilities) + } + + @Test + fun modelSelectionKeepsTheSelectedSessionAndUsesThePreferredPhone() = + runTest { + val capabilities = setOf(WearProxyCapability.ModelControls) + val requester = + RecordingRequester { method, params -> + when (method) { + WearRpcMethod.ModelsList -> { + assertEquals("openai/gpt-a", params.getValue("selectedModelRef").jsonPrimitive.content) + json.parseToJsonElement( + """{"models":[{"ref":"openai/gpt-a","name":"GPT A"},{"ref":"openai/gpt-b","name":"GPT B"}]}""", + ) + } + WearRpcMethod.ModelsSelect -> { + assertEquals("agent:main:thread-7", params.getValue("sessionKey").jsonPrimitive.content) + assertEquals("openai/gpt-b", params.getValue("modelRef").jsonPrimitive.content) + json.parseToJsonElement( + """{"sessionKey":"agent:main:thread-7","selectedModelRef":"openai/gpt-b"}""", + ) + } + else -> error("unexpected $method") + } + } + val repository = WearGatewayRepository(requester) + + val models = repository.models("phone-a", capabilities, selectedModelRef = "openai/gpt-a") + val selected = + repository.selectModel( + sessionKey = "agent:main:thread-7", + modelRef = "openai/gpt-b", + phoneNodeId = "phone-a", + capabilities = capabilities, + ) + + assertEquals(listOf("openai/gpt-a", "openai/gpt-b"), models.models.map(WearModel::ref)) + assertEquals("openai/gpt-b", selected.selectedModelRef) + assertEquals(7L, selected.eventSequence) + assertEquals("phone-a", selected.phoneNodeId) + assertEquals(listOf(WearRpcMethod.ModelsList, WearRpcMethod.ModelsSelect), requester.calls.map { it.first }) + assertTrue(requester.expectedNodeIds.all { it == "phone-a" }) + assertTrue(requester.requirePreferredNodes.all { it }) + } + + @Test + fun chatEventPreservesReplaceAndTextOnlyMessage() { + val event = + parseWearChatEvent( + json.parseToJsonElement( + """{"sessionKey":"main","runId":"run-1","state":"delta","deltaText":"new","replace":true,"streamText":"done","streamTextComplete":true,"message":{"role":"assistant","content":"done"}}""", + ), + ) + + assertEquals("main", event?.sessionKey) + assertEquals("new", event?.deltaText) + assertTrue(event?.replace == true) + assertEquals("done", event?.streamText) + assertTrue(event?.streamTextComplete == true) + assertEquals("done", event?.message?.text) + } + + @Test + fun nonTextOrEmptyMessagesAreDropped() { + val binaryOnly = + parseChatMessage( + json.parseToJsonElement( + """{"role":"assistant","content":[{"type":"image"}]}""", + ), + ) + + assertNull(binaryOnly) + } + + @Test + fun ambiguousSendRetryReusesItsIdempotencyKeyUntilSuccess() = + runTest { + val generatedIds = ArrayDeque(listOf("first", "second")) + val tracker = WearSendAttemptTracker(newId = { generatedIds.removeFirst() }) + val first = tracker.begin("session-1", "hello", "phone-1") + tracker.markAmbiguous(first) + val retry = tracker.begin("session-1", "hello", "phone-1") + + assertEquals(first, retry) + + val requester = RecordingRequester { _, _ -> JsonObject(emptyMap()) } + WearGatewayRepository(requester).send(retry) + assertEquals( + "wear-first", + requester.calls + .single() + .second + .getValue("idempotencyKey") + .jsonPrimitive + .content, + ) + + tracker.markSucceeded(retry) + assertEquals("wear-second", tracker.begin("session-1", "hello", "phone-1").idempotencyKey) + } + + @Test + fun differentMessageExpiresAnAbandonedAmbiguousAttempt() { + val generatedIds = ArrayDeque(listOf("first", "second", "third")) + val tracker = WearSendAttemptTracker(newId = { generatedIds.removeFirst() }) + val abandoned = tracker.begin("session-1", "hello", "phone-1") + tracker.markAmbiguous(abandoned) + + val different = tracker.begin("session-1", "different", "phone-1") + tracker.markSucceeded(different) + val laterHello = tracker.begin("session-1", "hello", "phone-1") + + assertEquals("wear-second", different.idempotencyKey) + assertEquals("wear-third", laterHello.idempotencyKey) + } + + @Test + fun realtimeTalkStartCarriesTheSelectedSessionAndPhone() = + runTest { + val requester = + RecordingRequester { method, _ -> + assertEquals(WearRpcMethod.TalkStart, method) + json.parseToJsonElement("""{"active":true}""") + } + + val snapshot = + WearGatewayRepository(requester).startRealtimeTalk( + sessionKey = "agent:main:thread-7", + attemptId = "attempt-7", + language = "de", + phoneNodeId = "phone-a", + attemptScopedAudio = true, + ) + + assertTrue(snapshot.active) + assertEquals( + json + .parseToJsonElement( + """{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7","language":"de","attemptScopedAudio":true}""", + ).jsonObject, + requester.calls.single().second, + ) + assertEquals("phone-a", requester.expectedNodeIds.single()) + assertTrue(requester.requirePreferredNodes.single()) + } + + @Test + fun realtimeTalkStartOmitsAttemptScopedAudioForLegacyPhones() = + runTest { + val requester = + RecordingRequester { _, _ -> + json.parseToJsonElement("""{"active":true}""") + } + + WearGatewayRepository(requester).startRealtimeTalk( + sessionKey = "agent:main:thread-7", + attemptId = "attempt-7", + language = null, + phoneNodeId = "phone-a", + attemptScopedAudio = false, + ) + + assertEquals( + json + .parseToJsonElement( + """{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7"}""", + ).jsonObject, + requester.calls.single().second, + ) + } + + @Test + fun observedFinalMessageSurvivesAnOlderSnapshotWithoutDuplication() { + val older = WearChatMessage(id = "m1", role = "assistant", text = "older", timestamp = 1) + val final = WearChatMessage(id = "m2", role = "assistant", text = "done", timestamp = 2) + + val merged = mergeEventMessage(listOf(older), final) + val deduplicated = mergeEventMessage(merged, final.copy(text = "done!")) + + assertEquals(listOf(older, final.copy(text = "done!")), deduplicated) + } + + @Test + fun eventMergeReplacesIdentifiedRowsInPlaceAndPreservesUnknownDuplicates() { + val identified = WearChatMessage(id = "m1", role = "assistant", text = "old", timestamp = 1) + val newer = WearChatMessage(id = "m2", role = "user", text = "later", timestamp = 2) + val unknown = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null) + + val replaced = mergeEventMessage(listOf(identified, newer), identified.copy(text = "updated")) + val duplicates = mergeEventMessage(listOf(unknown), unknown) + + assertEquals(listOf(identified.copy(text = "updated"), newer), replaced) + assertEquals(listOf(unknown, unknown), duplicates) + } + + @Test + fun canonicalSnapshotDeduplicatesItsIdentityLessObservedFinal() { + val canonical = WearChatMessage(id = "m1", role = "assistant", text = "done", timestamp = 7) + val observed = WearChatMessage(id = null, role = "assistant", text = "done", timestamp = null) + + assertEquals(listOf(canonical), mergeObservedMessageIntoSnapshot(listOf(canonical), observed)) + } + + @Test + fun canonicalSnapshotDeduplicatesObservedFinalThatOnlyHasTimestamp() { + val canonical = WearChatMessage(id = "m1", role = "assistant", text = "done", timestamp = 7) + val observed = WearChatMessage(id = null, role = "assistant", text = "done", timestamp = 7) + val other = observed.copy(timestamp = 8) + + assertEquals(listOf(canonical), mergeObservedMessageIntoSnapshot(listOf(canonical), observed)) + assertEquals(listOf(canonical, other), mergeObservedMessageIntoSnapshot(listOf(canonical), other)) + } + + @Test + fun historyLoadCarriesRacedCanonicalStreamIntoItsSnapshot() { + val tracker = WearHistoryLoadTracker() + val token = tracker.start("session-1") + + tracker.observeDelta("other-session", text = "wrong", complete = true, runId = "other") + tracker.observeDelta("session-1", text = "Hello world", complete = true, runId = "run-1") + + assertTrue(tracker.isCurrent(token)) + assertEquals( + WearLiveStreamSnapshot(text = "Hello world", complete = true, runId = "run-1"), + tracker.finish(token).liveStream, + ) + assertNull(tracker.finish(token).liveStream) + } + + @Test + fun stableHistoryLoadCanApplyItsCanonicalSnapshot() { + val tracker = WearHistoryLoadTracker() + val token = tracker.start("session-1") + + assertNull(tracker.finish(token).liveStream) + } + + @Test + fun racedStreamReconcilesCanonicalPrefixWithoutDuplication() { + assertEquals("Hello world", reconcileWearStreamSnapshot("Hello", "Hello world", liveComplete = true)) + assertEquals("Hello world", reconcileWearStreamSnapshot("Hello world", "Hello", liveComplete = true)) + assertEquals("Hello", reconcileWearStreamSnapshot("Hello", "He", liveComplete = false)) + assertEquals("Hello", reconcileWearStreamSnapshot("Hello", "Hel", liveComplete = false)) + assertEquals("Hello world!", reconcileWearStreamSnapshot("Hello world", " world!", liveComplete = false)) + } + + @Test + fun liveStreamCapPreservesWholeUnicodeCodePoints() { + val oversized = "x".repeat(2_000) + "😀" + + val bounded = updateWearStreamText(current = null, delta = oversized, replace = true) + + assertEquals(2_000, bounded?.codePointCount(0, bounded.length)) + assertTrue(bounded?.endsWith("😀") == true) + } +} + +private class RecordingRequester( + private val handler: suspend (WearRpcMethod, JsonObject) -> JsonElement, +) : WearRpcRequester { + val calls = mutableListOf>() + val expectedNodeIds = mutableListOf() + val requirePreferredNodes = mutableListOf() + + override suspend fun request( + method: WearRpcMethod, + params: JsonObject, + expectedNodeId: String?, + requirePreferredNode: Boolean, + ): WearRpcResult { + calls += method to params + expectedNodeIds += expectedNodeId + requirePreferredNodes += requirePreferredNode + return WearRpcResult(payload = handler(method, params), eventSequence = 7, sourceNodeId = expectedNodeId ?: "phone") + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearLaunchIntentTest.kt b/wear/src/test/java/ai/openclaw/wear/WearLaunchIntentTest.kt new file mode 100644 index 0000000..dd960e0 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearLaunchIntentTest.kt @@ -0,0 +1,219 @@ +package ai.openclaw.wear + +import android.content.Intent +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.wear.protolayout.ActionBuilders +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WearLaunchIntentTest { + @Test + fun normalAndUnknownLaunchesStartOnChat() { + assertEquals(WearLaunchTarget.Chat, parseWearLaunchTarget(Intent(Intent.ACTION_MAIN))) + assertEquals( + WearLaunchTarget.Chat, + parseWearLaunchTarget(Intent().putExtra(extraWearLaunchTarget, "unknown")), + ) + } + + @Test + fun tileTalkLaunchStartsOnVoice() { + val target = + parseWearLaunchTarget( + Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue), + ) + + assertEquals(WearLaunchTarget.Voice, target) + assertEquals(WearHomePage.Voice, target.initialPage) + } + + @Test + fun launchTargetsAreConsumedOnce() { + val intent = Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue) + + val initial = WearLaunchState.initial(intent) + + assertEquals(WearLaunchTarget.Voice, initial.initialTarget) + assertFalse(intent.hasExtra(extraWearLaunchTarget)) + assertEquals(WearLaunchTarget.Chat, WearLaunchState.initial(intent).initialTarget) + } + + @Test + fun warmLaunchesCreateUniquePagerRequestsForTalkOpenAndNotifications() { + val initial = WearLaunchState.initial(Intent(Intent.ACTION_MAIN)) + val voice = + initial.next( + Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue), + ) + val chat = + voice.next( + Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Chat.rawValue), + ) + val notification = chat.next(Intent()) + + assertEquals(WearLaunchTarget.Chat, initial.initialTarget) + assertEquals(WearNavigationRequest(1, WearLaunchTarget.Voice), voice.navigationRequest) + assertEquals(WearNavigationRequest(2, WearLaunchTarget.Chat), chat.navigationRequest) + assertEquals(WearNavigationRequest(3, WearLaunchTarget.Chat), notification.navigationRequest) + assertSame(notification, notification.handled(requestId = 2)) + assertNull(notification.handled(requestId = 3).navigationRequest) + } + + @Test + fun activeRealtimeTalkKeepsWarmRoutesOnVoice() { + assertEquals( + WearHomePage.Voice, + wearLaunchPage(WearLaunchTarget.Chat, realtimeActive = true), + ) + assertEquals( + WearHomePage.Voice, + wearLaunchPage(WearLaunchTarget.Voice, realtimeActive = true), + ) + assertEquals( + WearHomePage.Chat, + wearLaunchPage(WearLaunchTarget.Chat, realtimeActive = false), + ) + } + + @Test + fun warmPagerRequestsPreservePendingReplyAndRealtimeUiState() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + var launchState by mutableStateOf(WearLaunchState.initial(Intent(Intent.ACTION_MAIN))) + var retainedState: WarmLaunchRetentionProbe? = null + + controller.get().setContent { + WearLaunchContent(launchState) { _, _ -> + retainedState = + remember { + WarmLaunchRetentionProbe( + awaitingReply = true, + realtimeStartedAtMillis = 4_200L, + ) + } + } + } + idleMainLooper() + val initialRetainedState = retainedState + + launchState = + launchState.next( + Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue), + ) + idleMainLooper() + + assertSame(initialRetainedState, retainedState) + assertTrue(retainedState?.awaitingReply == true) + assertEquals(4_200L, retainedState?.realtimeStartedAtMillis) + + launchState = + launchState.next( + Intent().putExtra(extraWearLaunchTarget, WearLaunchTarget.Chat.rawValue), + ) + idleMainLooper() + + assertSame(initialRetainedState, retainedState) + assertTrue(retainedState?.awaitingReply == true) + assertEquals(4_200L, retainedState?.realtimeStartedAtMillis) + + controller.pause().stop().destroy() + } + + @Test + fun warmDebugLaunchesRecreateForScreenshotEntrySwitchAndExit() { + val voiceScreenshotIntent = + Intent(Intent.ACTION_MAIN) + .putExtra(extraWearScreenshotMode, true) + .putExtra(extraWearScreenshotScene, WearScreenshotScene.Voice.rawValue) + val controlsScreenshotIntent = + Intent(Intent.ACTION_MAIN) + .putExtra(extraWearScreenshotMode, true) + .putExtra(extraWearScreenshotScene, WearScreenshotScene.Controls.rawValue) + val normalIntent = Intent(Intent.ACTION_MAIN) + + assertEquals( + true, + shouldRecreateForScreenshotMode(null, voiceScreenshotIntent, screenshotModeEnabled = true), + ) + assertEquals( + true, + shouldRecreateForScreenshotMode( + WearScreenshotScene.Voice, + controlsScreenshotIntent, + screenshotModeEnabled = true, + ), + ) + assertEquals( + true, + shouldRecreateForScreenshotMode( + WearScreenshotScene.Controls, + normalIntent, + screenshotModeEnabled = true, + ), + ) + assertEquals( + false, + shouldRecreateForScreenshotMode( + null, + Intent(Intent.ACTION_MAIN) + .putExtra(extraWearLaunchTarget, WearLaunchTarget.Voice.rawValue), + screenshotModeEnabled = true, + ), + ) + } + + @Test + fun releaseLaunchDoesNotRecreateForScreenshotExtras() { + val screenshotIntent = + Intent(Intent.ACTION_MAIN) + .putExtra(extraWearScreenshotMode, true) + .putExtra(extraWearScreenshotScene, WearScreenshotScene.Voice.rawValue) + + assertEquals( + false, + shouldRecreateForScreenshotMode(null, screenshotIntent, screenshotModeEnabled = false), + ) + } + + @Test + fun tileActionsTargetMainActivityWithTheirRequestedPage() { + val context = RuntimeEnvironment.getApplication() + + WearLaunchTarget.entries.forEach { target -> + val activity = wearLaunchAction(context, target).androidActivity + val pageExtra = + activity?.keyToExtraMapping?.get(extraWearLaunchTarget) + as? ActionBuilders.AndroidStringExtra + + assertEquals(context.packageName, activity?.packageName) + assertEquals(MainActivity::class.java.name, activity?.className) + assertEquals(target.rawValue, pageExtra?.value) + } + } + + private fun idleMainLooper() { + shadowOf(Looper.getMainLooper()).idle() + } + + private data class WarmLaunchRetentionProbe( + val awaitingReply: Boolean, + val realtimeStartedAtMillis: Long, + ) +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearLayoutTest.kt b/wear/src/test/java/ai/openclaw/wear/WearLayoutTest.kt new file mode 100644 index 0000000..e0fcbee --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearLayoutTest.kt @@ -0,0 +1,39 @@ +package ai.openclaw.wear + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearLayoutTest { + @Test + fun voiceLayoutFitsSmallAndLargeRoundScreens() { + assertEquals( + WearVoiceLayout( + horizontalPadding = 6.dp, + orbSize = 80.dp, + contentHeight = 144.dp, + ), + wearVoiceLayout(maxWidth = 192.dp, fontScale = 1f), + ) + assertEquals( + WearVoiceLayout( + horizontalPadding = 6.dp, + orbSize = 92.dp, + contentHeight = 156.dp, + ), + wearVoiceLayout(maxWidth = 227.dp, fontScale = 1f), + ) + } + + @Test + fun voiceLayoutMakesRoomForLargeTextOnSmallRoundScreens() { + assertEquals( + WearVoiceLayout( + horizontalPadding = 4.dp, + orbSize = 68.dp, + contentHeight = 132.dp, + ), + wearVoiceLayout(maxWidth = 192.dp, fontScale = 1.2f), + ) + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearLocaleTextTest.kt b/wear/src/test/java/ai/openclaw/wear/WearLocaleTextTest.kt new file mode 100644 index 0000000..241d9d3 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearLocaleTextTest.kt @@ -0,0 +1,12 @@ +package ai.openclaw.wear + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class WearLocaleTextTest { + @Test + fun `uppercases labels with the active locale`() { + assertEquals("İLETİŞİM", wearUppercase("iletişim", Locale.forLanguageTag("tr"))) + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearProxyClientTest.kt b/wear/src/test/java/ai/openclaw/wear/WearProxyClientTest.kt new file mode 100644 index 0000000..defd64a --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearProxyClientTest.kt @@ -0,0 +1,937 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearDecodeResult +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearProxyCapability +import ai.openclaw.wear.shared.WearRpcMethod +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WearProxyClientTest { + @Test + fun requestUsesReachablePhoneAndCorrelatesResponse() = + runTest { + lateinit var client: WearProxyClient + var sentNode: String? = null + client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { "phone-nearby" }, + transport = + WearMessageTransport { nodeId, path, data -> + sentNode = nodeId + assertEquals(WearProtocol.REQUEST_PATH, path) + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + val response = + WearProtocolCodec.encode( + WearMessage.Response( + requestId = request.requestId, + ok = true, + result = buildJsonObject { put("connected", JsonPrimitive(true)) }, + eventStreamId = "stream-1", + eventSequence = 12, + ), + ) + client.handleMessage( + sourceNodeId = "phone-nearby", + path = WearProtocol.RESPONSE_PATH, + data = response, + ) + }, + ) + + val response = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null) + + assertEquals("phone-nearby", sentNode) + assertTrue( + response.payload + .jsonObject + .getValue("connected") + .jsonPrimitive + .content + .toBoolean(), + ) + assertEquals(12L, response.eventSequence) + assertEquals("stream-1", response.eventStreamId) + assertEquals("phone-nearby", response.sourceNodeId) + } + + @Test + fun responseWithoutWatermarkKeepsLegacyBaselineUnknown() = + runTest { + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { "phone-nearby" }, + transport = + WearMessageTransport { _, _, data -> + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + val response = + WearProtocolCodec.encode( + WearMessage.Response( + requestId = request.requestId, + ok = true, + result = buildJsonObject { put("connected", JsonPrimitive(true)) }, + ), + ) + client.handleMessage( + sourceNodeId = "phone-nearby", + path = WearProtocol.RESPONSE_PATH, + data = response, + ) + }, + ) + + val response = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null) + + assertEquals(null, response.eventSequence) + + val tracker = WearEventSequenceTracker() + tracker.adoptSnapshot(response.eventStreamId, response.eventSequence) + assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 37)) + assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 38)) + } + + @Test + fun inboundMessagesStayBoundToTheSelectedPhone() = + runTest { + var preferredNode = "phone-1" + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { preferredNode }, + transport = + WearMessageTransport { nodeId, _, data -> + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + val response = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)) + client.handleMessage("wrong-phone", WearProtocol.RESPONSE_PATH, response) + client.handleMessage(nodeId, WearProtocol.RESPONSE_PATH, response) + }, + ) + + client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null) + val event = WearProtocolCodec.encode(WearMessage.Event(sequence = 1, event = WearEventType.Connection)) + + assertEquals(null, client.handleMessage("phone-2", WearProtocol.EVENT_PATH, event)) + assertEquals("phone-1", client.handleMessage("phone-1", WearProtocol.EVENT_PATH, event)?.sourceNodeId) + + preferredNode = "phone-2" + client.updatePreferredPhoneNodeId("phone-2") + assertEquals(null, client.handleMessage("phone-1", WearProtocol.EVENT_PATH, event)) + assertEquals("phone-2", client.handleMessage("phone-2", WearProtocol.EVENT_PATH, event)?.sourceNodeId) + } + + @Test + fun snapshotRequestRejectsOldPhoneAfterPreferredPhoneChanges() = + runTest { + lateinit var client: WearProxyClient + lateinit var request: WearMessage.Request + client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { "phone-1" }, + transport = + WearMessageTransport { _, _, data -> + request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + }, + ) + + client.updatePreferredPhoneNodeId("phone-1") + val pending = + async { + runCatching { + client.request(WearRpcMethod.SessionsList, buildJsonObject {}, expectedNodeId = "phone-1") + } + } + runCurrent() + client.updatePreferredPhoneNodeId("phone-2") + client.handleMessage( + sourceNodeId = "phone-1", + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + + assertEquals("phone_changed", (pending.await().exceptionOrNull() as WearProxyException).code) + } + + @Test + fun statefulRequestStaysOnItsExpectedPhoneWithoutRediscovery() = + runTest { + lateinit var client: WearProxyClient + var discoveries = 0 + var sentNode: String? = null + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "different-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + sentNode = nodeId + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + + val result = client.request(WearRpcMethod.ChatAbort, buildJsonObject {}, expectedNodeId = "state-phone") + + assertEquals(0, discoveries) + assertEquals("state-phone", sentNode) + assertEquals("state-phone", result.sourceNodeId) + } + + @Test + fun statefulRequestDoesNotReplaceResolverSelectedEventSource() = + runTest { + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { "preferred-phone" }, + transport = + WearMessageTransport { nodeId, _, data -> + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + + client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null) + client.request(WearRpcMethod.ChatAbort, buildJsonObject {}, expectedNodeId = "notification-phone") + val event = WearProtocolCodec.encode(WearMessage.Event(sequence = 1, event = WearEventType.Connection)) + + assertEquals(null, client.handleMessage("notification-phone", WearProtocol.EVENT_PATH, event)) + assertEquals("preferred-phone", client.handleMessage("preferred-phone", WearProtocol.EVENT_PATH, event)?.sourceNodeId) + } + + @Test + fun capabilitySelectionRoutesSnapshotsAndRejectsOldStatefulActions() = + runTest { + lateinit var client: WearProxyClient + var discoveries = 0 + val sentNodes = mutableListOf() + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "resolver-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + sentNodes += nodeId + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + val changed = async { client.preferredPhoneChanges.first() } + runCurrent() + + client.updatePreferredPhoneNodeId("phone-2") + val status = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null) + val stale = + runCatching { + client.request( + WearRpcMethod.ChatAbort, + buildJsonObject {}, + expectedNodeId = "phone-1", + requirePreferredNode = true, + ) + }.exceptionOrNull() + + assertEquals("phone-2", changed.await()) + assertEquals("phone-2", status.sourceNodeId) + assertEquals("phone_changed", (stale as WearProxyException).code) + assertEquals(0, discoveries) + assertEquals(listOf("phone-2"), sentNodes) + } + + @Test + fun preferredActionResolvesCapabilityBeforeSendingToStoredPhone() = + runTest { + var sends = 0 + val client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { "phone-current" }, + transport = WearMessageTransport { _, _, _ -> sends += 1 }, + ) + + val stale = + runCatching { + client.request( + WearRpcMethod.ChatSend, + buildJsonObject {}, + expectedNodeId = "phone-old", + requirePreferredNode = true, + ) + }.exceptionOrNull() + + assertEquals("phone_changed", (stale as WearProxyException).code) + assertEquals(0, sends) + } + + @Test + fun missingPhoneFailsWithoutSending() = + runTest { + var sends = 0 + val client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { null }, + transport = WearMessageTransport { _, _, _ -> sends += 1 }, + ) + + var code: String? = null + try { + client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null) + } catch (err: WearProxyException) { + code = err.code + } + + assertEquals("phone_unavailable", code) + assertEquals(0, sends) + } + + @Test + fun discoveryTaskCancellationUsesConnectivityError() = + runTest { + val client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { throw CancellationException("task canceled") }, + transport = WearMessageTransport { _, _, _ -> error("must not send") }, + ) + + val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + + assertEquals("phone_unavailable", (failure as WearProxyException).code) + } + + @Test + fun sendTaskCancellationUsesConnectivityError() = + runTest { + val client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { "phone-nearby" }, + transport = WearMessageTransport { _, _, _ -> throw CancellationException("task canceled") }, + ) + + val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + + assertEquals("phone_unavailable", (failure as WearProxyException).code) + } + + @Test + fun shorterCallerTimeoutPropagatesWithoutInvalidatingPhone() = + runTest { + var discoveries = 0 + var respond = false + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "resolver-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + if (!respond) awaitCancellation() + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + client.updatePreferredPhoneNodeId("phone-current") + + val failure = + runCatching { + withTimeout(1_000L) { + client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) + } + }.exceptionOrNull() + respond = true + + assertTrue(failure is TimeoutCancellationException) + assertEquals("phone-current", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId) + assertEquals(0, discoveries) + } + + @Test + fun sendFailureInvalidatesCachedPhoneAndRediscoversNextRequest() = + runTest { + var resolvedNode = "phone-old" + var discoveries = 0 + var sends = 0 + val sentNodes = mutableListOf() + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + resolvedNode + }, + transport = + WearMessageTransport { nodeId, _, data -> + sends += 1 + sentNodes += nodeId + if (sends == 1) error("stale node") + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + + val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + resolvedNode = "phone-new" + val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) + + assertEquals("phone_unavailable", (first as WearProxyException).code) + assertEquals("phone-new", second.sourceNodeId) + assertEquals(2, discoveries) + assertEquals(listOf("phone-old", "phone-new"), sentNodes) + } + + @Test + fun responseTimeoutInvalidatesCachedPhoneAndRediscoversNextRequest() = + runTest { + var resolvedNode = "phone-old" + var discoveries = 0 + var sends = 0 + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + resolvedNode + }, + transport = + WearMessageTransport { nodeId, _, data -> + sends += 1 + if (sends > 1) { + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + } + }, + ) + + val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + resolvedNode = "phone-new" + val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) + + assertEquals("timeout", (first as WearProxyException).code) + assertEquals("phone-new", second.sourceNodeId) + assertEquals(2, discoveries) + } + + @Test + fun staleSendFailureDoesNotInvalidateNewerSamePhoneGeneration() = + runTest { + var discoveries = 0 + var sends = 0 + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "resolver-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + sends += 1 + if (sends == 1) { + client.updatePreferredPhoneNodeId(nodeId) + error("stale send") + } + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + client.updatePreferredPhoneNodeId("phone-current") + + val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) + + assertEquals("phone_unavailable", (first as WearProxyException).code) + assertEquals("phone-current", second.sourceNodeId) + assertEquals(0, discoveries) + } + + @Test + fun staleResponseTimeoutDoesNotInvalidateNewerSamePhoneGeneration() = + runTest { + var discoveries = 0 + var sends = 0 + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "resolver-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + sends += 1 + if (sends == 1) { + client.updatePreferredPhoneNodeId(nodeId) + } else { + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + } + }, + ) + client.updatePreferredPhoneNodeId("phone-current") + + val first = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + val second = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) + + assertEquals("timeout", (first as WearProxyException).code) + assertEquals("phone-current", second.sourceNodeId) + assertEquals(0, discoveries) + } + + @Test + fun successfulParallelResponseProtectsPhoneFromOlderTimeout() = + runTest { + var discoveries = 0 + var sends = 0 + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "resolver-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + sends += 1 + if (sends > 1) { + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + } + }, + ) + client.updatePreferredPhoneNodeId("phone-current") + + val older = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } } + runCurrent() + val newer = async { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } + runCurrent() + + assertEquals("phone-current", newer.await().sourceNodeId) + assertEquals("timeout", (older.await().exceptionOrNull() as WearProxyException).code) + assertEquals("phone-current", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId) + assertEquals(0, discoveries) + } + + @Test + fun correlatedResponseProtectsPhoneBeforeRequesterResumes() = + runTest { + var discoveries = 0 + var respond = false + val requests = mutableListOf() + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "resolver-phone" + }, + transport = + WearMessageTransport { nodeId, _, data -> + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + requests += request + if (respond) { + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + } + }, + ) + client.updatePreferredPhoneNodeId("phone-current") + + val older = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } } + val newer = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } } + runCurrent() + assertEquals(2, requests.size) + + client.handleMessage( + sourceNodeId = "phone-current", + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = requests[1].requestId, ok = true)), + ) + newer.cancel() + advanceUntilIdle() + + assertTrue(newer.isCancelled) + assertEquals("timeout", (older.await().exceptionOrNull() as WearProxyException).code) + respond = true + assertEquals("phone-current", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId) + assertEquals(0, discoveries) + } + + @Test + fun ambiguousCapabilityCallbackForcesReachableRediscovery() = + runTest { + var discoveries = 0 + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + discoveries += 1 + "phone-reachable" + }, + transport = + WearMessageTransport { nodeId, _, data -> + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + client.updatePreferredPhoneNodeId("phone-stale") + + client.invalidatePreferredPhoneNode() + val result = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) + + assertEquals("phone-reachable", result.sourceNodeId) + assertEquals(1, discoveries) + } + + @Test + fun inFlightDiscoveryCannotRestoreInvalidatedPhone() = + runTest { + val discoveryStarted = CompletableDeferred() + val releaseDiscovery = CompletableDeferred() + var resolvedNode = "phone-old" + var discoveries = 0 + val sentNodes = mutableListOf() + lateinit var client: WearProxyClient + client = + WearProxyClient.createForTests( + nodeResolver = + WearNodeResolver { + val result = resolvedNode + discoveries += 1 + if (discoveries == 1) { + discoveryStarted.complete(Unit) + releaseDiscovery.await() + } + result + }, + transport = + WearMessageTransport { nodeId, _, data -> + sentNodes += nodeId + val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request + client.handleMessage( + sourceNodeId = nodeId, + path = WearProtocol.RESPONSE_PATH, + data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)), + ) + }, + ) + + val first = async { runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) } } + discoveryStarted.await() + client.invalidatePreferredPhoneNode() + resolvedNode = "phone-new" + releaseDiscovery.complete(Unit) + + assertEquals("phone_unavailable", (first.await().exceptionOrNull() as WearProxyException).code) + assertEquals("phone-new", client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null).sourceNodeId) + assertEquals(2, discoveries) + assertEquals(listOf("phone-new"), sentNodes) + } + + @Test + fun reachablePhoneSelectionPrefersOneNearbyNodeAndRejectsAmbiguity() { + val nearby = WearReachablePhoneNode(id = "phone-nearby", isNearby = true) + val nearbyTwo = WearReachablePhoneNode(id = "phone-nearby-2", isNearby = true) + val cloud = WearReachablePhoneNode(id = "phone-cloud", isNearby = false) + val cloudTwo = WearReachablePhoneNode(id = "phone-cloud-2", isNearby = false) + + assertEquals("phone-nearby", selectReachablePhoneNodeId(listOf(cloud, nearby))) + assertEquals("phone-cloud", selectReachablePhoneNodeId(listOf(cloud))) + assertEquals(null, selectReachablePhoneNodeId(listOf(nearby, nearbyTwo, cloud))) + assertEquals(null, selectReachablePhoneNodeId(listOf(cloud, cloudTwo))) + } + + @Test + fun eventGapAndResetRequireCanonicalRefresh() { + val tracker = WearEventSequenceTracker() + + assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 5)) + assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 6)) + assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 9)) + assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(null, 10)) + tracker.adoptSnapshot(null, 9) + assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 8)) + assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(null, 8)) + tracker.adoptSnapshot(null, 8) + assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 1)) + tracker.adoptSnapshot(null, 1) + assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 2)) + } + + @Test + fun phoneProcessEpochForcesRefreshEvenWhenSequenceLooksContiguous() { + val tracker = WearEventSequenceTracker() + + tracker.adoptSnapshot("old-process", 5) + + assertEquals(WearSequenceDecision.GapOrReset, tracker.accept("new-process", 6)) + assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept("new-process", 7)) + tracker.adoptSnapshot("new-process", 7) + assertEquals(WearSequenceDecision.Accepted, tracker.accept("new-process", 8)) + } + + @Test + fun snapshotWatermarkMakesTheFirstMissingEventVisible() { + val tracker = WearEventSequenceTracker() + + tracker.adoptSnapshot("stream", 10) + assertEquals(WearSequenceDecision.GapOrReset, tracker.accept("stream", 12)) + tracker.adoptSnapshot("stream", 12) + assertEquals(WearSequenceDecision.Accepted, tracker.accept("stream", 13)) + } + + @Test + fun rpcResponseMustMatchTheCurrentStreamAndWatermark() { + val tracker = WearEventSequenceTracker() + + tracker.adoptSnapshot("stream", 10) + + assertTrue(tracker.isResponseCurrent(tracker.beginResponseRequest(), "stream", 10)) + assertFalse(tracker.isResponseCurrent(tracker.beginResponseRequest(), "stream", 11)) + assertFalse(tracker.isResponseCurrent(tracker.beginResponseRequest(), "stream", 9)) + assertFalse(tracker.isResponseCurrent(tracker.beginResponseRequest(), "new-stream", 11)) + val pendingSnapshot = tracker.beginResponseRequest() + tracker.requireSnapshot() + assertFalse(tracker.isResponseCurrent(pendingSnapshot, "stream", 11)) + } + + @Test + fun legacyRpcResponseWithoutWatermarkRequiresUnchangedEvents() { + val tracker = WearEventSequenceTracker() + + tracker.adoptSnapshot(null, 10) + + assertTrue(tracker.isResponseCurrent(tracker.beginResponseRequest(), null, null)) + val staleRequest = tracker.beginResponseRequest() + assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 11)) + assertFalse(tracker.isResponseCurrent(staleRequest, null, null)) + } + + @Test + fun newerRpcRequestInvalidatesAnOlderCompletion() { + val tracker = WearEventSequenceTracker() + + tracker.adoptSnapshot("stream", 10) + val olderRequest = tracker.beginResponseRequest() + val newerRequest = tracker.beginResponseRequest() + + assertFalse(tracker.isResponseCurrent(olderRequest, "stream", 12)) + assertTrue(tracker.isResponseCurrent(newerRequest, "stream", 10)) + } + + @Test + fun resyncBufferReplaysEventsNewerThanTheSnapshotWatermark() { + val tracker = WearEventSequenceTracker() + val buffer = WearEventResyncBuffer() + val missed = + WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null) + val raced = + WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null) + + tracker.adoptSnapshot(null, 3) + assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(missed.streamId, missed.sequence)) + buffer.start(missed) + assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(raced.streamId, raced.sequence)) + buffer.append(raced) + + val replay = buffer.drainAfterSnapshot(null, 4) + tracker.adoptSnapshot(null, 4) + + assertEquals(listOf(5L, 6L), replay.map(WearInboundEvent::sequence)) + assertTrue(replay.all { tracker.accept(it.streamId, it.sequence) == WearSequenceDecision.Accepted }) + } + + @Test + fun resyncBufferDiscardsEventsAlreadyCoveredByTheSnapshot() { + val buffer = WearEventResyncBuffer() + buffer.start( + WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null), + ) + buffer.append( + WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null), + ) + + assertTrue(buffer.drainAfterSnapshot(null, 6).isEmpty()) + } + + @Test + fun resyncBufferDoesNotReplayAnOldProcessEpoch() { + val buffer = WearEventResyncBuffer() + buffer.start( + WearInboundEvent(sourceNodeId = "phone", streamId = "old", sequence = 6, event = WearEventType.Chat, payload = null), + ) + buffer.append( + WearInboundEvent(sourceNodeId = "phone", streamId = "new", sequence = 1, event = WearEventType.Chat, payload = null), + ) + + val replay = buffer.drainAfterSnapshot("new", 0) + + assertEquals(listOf(1L), replay.map(WearInboundEvent::sequence)) + } + + @Test + fun legacySnapshotWithoutWatermarkDoesNotReplayAmbiguousEvents() { + val buffer = WearEventResyncBuffer() + buffer.start( + WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null), + ) + buffer.append( + WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null), + ) + + assertTrue(buffer.drainAfterSnapshot(null, null).isEmpty()) + } + + @Test + fun eventSourceTrackerRequiresResyncOnlyWhenThePhoneChanges() { + val tracker = WearEventSourceTracker() + + assertTrue(!tracker.changed("phone-1")) + assertTrue(!tracker.changed("phone-1")) + assertTrue(tracker.changed("phone-2")) + + tracker.adopt("snapshot-phone") + assertTrue(!tracker.changed("snapshot-phone")) + assertTrue(tracker.changed("other-phone")) + } + + @Test + fun phoneChangeClearsPhoneLocalSessionState() { + val selected = WearSession(key = "main", title = "Main", updatedAt = null, hasActiveRun = true, phoneNodeId = "phone-1") + val state = + WearUiState( + loading = false, + connected = true, + proxyCapabilities = WearProxyCapability.entries.toSet(), + sessions = listOf(selected), + selectedSession = selected, + messages = listOf(WearChatMessage(id = "m1", role = "assistant", text = "old", timestamp = 1)), + streamText = "typing", + activeRunId = "run-1", + sending = true, + failure = WearConversationFailure.INTERNAL_ERROR, + ) + + val reset = state.resetForPhoneChange() + + assertTrue(reset.loading) + assertTrue(!reset.connected) + assertTrue(reset.proxyCapabilities.isEmpty()) + assertTrue(reset.sessions.isEmpty()) + assertEquals(null, reset.selectedSession) + assertTrue(reset.messages.isEmpty()) + assertEquals(null, reset.streamText) + assertEquals(null, reset.activeRunId) + assertTrue(!reset.sending) + assertEquals(null, reset.failure) + } + + @Test + fun requestDeadlineIncludesPhoneDiscovery() = + runTest { + val client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { awaitCancellation() }, + transport = WearMessageTransport { _, _, _ -> error("must not send") }, + ) + + val failure = + async { + runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + } + advanceUntilIdle() + + assertEquals("timeout", (failure.await() as WearProxyException).code) + } + + @Test + fun discoveryFailureUsesConnectivityError() = + runTest { + val client = + WearProxyClient.createForTests( + nodeResolver = WearNodeResolver { error("Play services failed") }, + transport = WearMessageTransport { _, _, _ -> error("must not send") }, + ) + + val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull() + + assertEquals("phone_unavailable", (failure as WearProxyException).code) + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearReplyNotifierTest.kt b/wear/src/test/java/ai/openclaw/wear/WearReplyNotifierTest.kt new file mode 100644 index 0000000..52111bc --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearReplyNotifierTest.kt @@ -0,0 +1,90 @@ +package ai.openclaw.wear + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearReplyNotifierTest { + @Test + fun visibilityTracksOverlappingActivityLifecycles() { + val tracker = VisibleActivityTracker() + + tracker.onStarted() + tracker.onStarted() + tracker.onStopped() + assertTrue(tracker.isVisible()) + tracker.onStopped() + assertTrue(!tracker.isVisible()) + } + + @Test + fun pendingIntentIdentityDoesNotUseCollidingStringHashCodes() { + check("Aa".hashCode() == "BB".hashCode()) + + val first = replyPendingIntentAction("Aa", "notification-1") + val second = replyPendingIntentAction("BB", "notification-1") + + assertNotEquals(first, second) + assertTrue(first.startsWith("ai.openclaw.wear.REPLY.")) + } + + @Test + fun distinctFinalMessagesUseDistinctNotificationAndReplyIdentities() { + val firstMessage = WearChatMessage(id = "m1", role = "assistant", text = "first", timestamp = 1) + val secondMessage = WearChatMessage(id = "m2", role = "assistant", text = "second", timestamp = 2) + + val firstTag = replyNotificationTag("session", firstMessage, "run-1") + val secondTag = replyNotificationTag("session", secondMessage, "run-2") + + assertNotEquals(firstTag, secondTag) + assertNotEquals( + replyPendingIntentAction("session", firstTag), + replyPendingIntentAction("session", secondTag), + ) + } + + @Test + fun missingMessageIdentityUsesStableEventFallback() { + val message = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null) + + val first = replyNotificationTag("session", message, "run-1") + val retry = replyNotificationTag("session", message, "run-1") + val distinct = replyNotificationTag("session", message, "run-2") + + assertEquals(first, retry) + assertNotEquals(first, distinct) + } + + @Test + fun fallbackIdentitySeparatesPhoneProcessEpochs() { + val message = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null) + + val first = replyNotificationTag("session", message, "source:phone\u0000stream:epoch-1\u0000sequence:1") + val restarted = replyNotificationTag("session", message, "source:phone\u0000stream:epoch-2\u0000sequence:1") + + assertNotEquals(first, restarted) + } + + @Test + fun notificationRetryIdentityIsStableForTheSameLogicalReply() { + val first = notificationReplyIdempotencyKey("session", "notification", "reply") + val retry = notificationReplyIdempotencyKey("session", "notification", "reply") + val edited = notificationReplyIdempotencyKey("session", "notification", "edited") + + assertEquals(first, retry) + assertNotEquals(first, edited) + } + + @Test + fun preferredPhoneChangeRequiresAppRecoveryInsteadOfAStaleRetry() { + assertEquals( + NotificationReplyFailureAction.OpenApp, + notificationReplyFailureAction(WearProxyException("phone_changed", "preferred phone changed")), + ) + assertEquals( + NotificationReplyFailureAction.RetrySamePhone, + notificationReplyFailureAction(WearProxyException("phone_unavailable", "offline")), + ) + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearScreenshotModeTest.kt b/wear/src/test/java/ai/openclaw/wear/WearScreenshotModeTest.kt new file mode 100644 index 0000000..54c80f3 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearScreenshotModeTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.wear + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WearScreenshotModeTest { + @Test + fun ignoresNormalLaunches() { + assertNull(parseWearScreenshotModeIntent(Intent(Intent.ACTION_MAIN))) + } + + @Test + fun parsesRequestedScene() { + val parsed = + parseWearScreenshotModeIntent( + Intent(Intent.ACTION_MAIN) + .putExtra(extraWearScreenshotMode, true) + .putExtra(extraWearScreenshotScene, "voice"), + ) + + assertEquals(WearScreenshotScene.Voice, parsed) + } + + @Test + fun defaultsUnknownScenesToChat() { + val parsed = + parseWearScreenshotModeIntent( + Intent(Intent.ACTION_MAIN) + .putExtra(extraWearScreenshotMode, true) + .putExtra(extraWearScreenshotScene, "unknown"), + ) + + assertEquals(WearScreenshotScene.Chat, parsed) + } + + @Test + fun mapsScenesToProductionPages() { + assertEquals(WearHomePage.Chat, WearScreenshotScene.Chat.initialPage) + assertEquals(WearHomePage.Voice, WearScreenshotScene.Voice.initialPage) + assertEquals(WearHomePage.Controls, WearScreenshotScene.Controls.initialPage) + } + + @Test + fun fixtureRepresentsAConnectedConversation() { + val snapshot = WearScreenshotFixture.snapshot + + assertEquals(WearGatewayState.CONNECTED, snapshot.gatewayState) + assertEquals("release-planning", snapshot.activeSessionId) + assertTrue(snapshot.messages.any { message -> message.chatRole == WearChatRole.ASSISTANT }) + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt b/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt new file mode 100644 index 0000000..0893af7 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt @@ -0,0 +1,585 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearProxyCapability +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearSessionScopeTest { + @Test + fun discardsStatusSessionWhenLaterListReportsDifferentAgent() { + assertNull( + coherentWearActiveSessionKey( + statusAgentId = "agent-a", + statusSessionKey = "agent:agent-a:main", + sessionListAgentId = "agent-b", + ), + ) + } + + @Test + fun keepsStatusSessionForMatchingAndLegacyPhoneSnapshots() { + val sessionKey = "agent:agent-a:main" + + assertEquals(sessionKey, coherentWearActiveSessionKey("agent-a", sessionKey, "agent-a")) + assertEquals(sessionKey, coherentWearActiveSessionKey("agent-a", sessionKey, null)) + } + + @Test + fun exposesModelOnlyForPhoneActiveSession() { + assertEquals("openai/model", wearSelectedModelRef("agent:main", "agent:main", "openai/model")) + assertNull(wearSelectedModelRef("agent:other", "agent:main", "openai/model")) + assertNull(wearSelectedModelRef(null, "agent:main", "openai/model")) + } + + @Test + fun modelCatalogScopeTracksBothPhoneAndModel() { + val requested = + WearSession( + key = "agent:main", + title = "Main", + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-a", + modelRef = "openai/model-a", + ) + + assertEquals(false, wearModelCatalogScopeChanged(requested, requested.copy())) + assertEquals(true, wearModelCatalogScopeChanged(requested, requested.copy(phoneNodeId = "phone-b"))) + assertEquals(true, wearModelCatalogScopeChanged(requested, requested.copy(modelRef = "openai/model-b"))) + } + + @Test + fun modelCatalogResultRequiresTheFullRequestedScope() { + val requested = + WearSession( + key = "agent:main", + title = "Main", + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-a", + modelRef = "openai/model-a", + ) + + assertEquals(true, wearSessionRequestIsCurrent(requested, requested.copy(), "phone-a")) + assertEquals( + false, + wearSessionRequestIsCurrent(requested, requested.copy(key = "agent:other"), "phone-a"), + ) + assertEquals( + false, + wearSessionRequestIsCurrent(requested, requested.copy(modelRef = "openai/model-b"), "phone-a"), + ) + assertEquals(false, wearSessionRequestIsCurrent(requested, requested.copy(), "phone-b")) + assertEquals( + false, + wearSessionRequestIsCurrent(requested, requested.copy(phoneNodeId = "phone-b"), "phone-b"), + ) + } + + @Test + fun transcriptResultPreservesNewerModelWithinTheSamePhoneSession() { + val requested = + WearSession( + key = "agent:main", + title = "Main", + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-a", + modelRef = "openai/model-a", + ) + + assertEquals( + true, + wearTranscriptRequestIsCurrent(requested, requested.copy(modelRef = "openai/model-b"), "phone-a"), + ) + assertEquals(false, wearTranscriptRequestIsCurrent(requested, requested.copy(), "phone-b")) + assertEquals( + false, + wearTranscriptRequestIsCurrent(requested, requested.copy(phoneNodeId = "phone-b"), "phone-b"), + ) + } + + @Test + fun delayedSessionActionsRequireTheOriginalPhoneAndSession() { + val requested = + WearSession( + key = "agent:main", + title = "Main", + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-a", + modelRef = "openai/model-a", + ) + + assertTrue( + wearSessionActionIsCurrent( + requested, + WearUiState(phoneNodeId = "phone-a", selectedSession = requested), + requestedRouteGeneration = 3, + currentRouteGeneration = 3, + ), + ) + assertFalse( + wearSessionActionIsCurrent( + requested, + WearUiState( + phoneNodeId = "phone-b", + selectedSession = requested.copy(phoneNodeId = "phone-b"), + ), + requestedRouteGeneration = 3, + currentRouteGeneration = 4, + ), + ) + assertFalse( + wearSessionActionIsCurrent( + requested, + WearUiState( + phoneNodeId = "phone-a", + selectedSession = requested.copy(key = "agent:other"), + ), + requestedRouteGeneration = 3, + currentRouteGeneration = 3, + ), + ) + assertFalse( + wearSessionActionIsCurrent( + requested, + WearUiState(phoneNodeId = "phone-a", selectedSession = requested), + requestedRouteGeneration = 3, + currentRouteGeneration = 5, + ), + ) + } + + @Test + fun delayedControlsRequireTheOriginalPhoneRouteGeneration() { + val phoneA = WearUiState(phoneNodeId = "phone-a", controlBusy = true) + + assertTrue( + wearControlRouteIsCurrent( + requestedPhoneNodeId = "phone-a", + currentState = phoneA, + requestedRouteGeneration = 3, + currentRouteGeneration = 3, + ), + ) + assertFalse( + wearControlRouteIsCurrent( + requestedPhoneNodeId = "phone-a", + currentState = WearUiState(phoneNodeId = "phone-b", controlBusy = true), + requestedRouteGeneration = 3, + currentRouteGeneration = 4, + ), + ) + assertFalse( + wearControlRouteIsCurrent( + requestedPhoneNodeId = "phone-a", + currentState = phoneA, + requestedRouteGeneration = 3, + currentRouteGeneration = 5, + ), + ) + } + + @Test + fun staleControlCompletionCannotClearReplacementBusyOwner() { + val owners = WearControlBusyOwner() + val staleOwner = checkNotNull(owners.claim()) + + owners.reset() + val replacementOwner = checkNotNull(owners.claim()) + + assertFalse(owners.release(staleOwner)) + assertTrue(owners.release(replacementOwner)) + } + + @Test + fun abandonedControlActionReleasesItsOwnBusyOwner() { + val owners = WearControlBusyOwner() + val owner = checkNotNull(owners.claim()) + + assertTrue(owners.release(owner)) + assertTrue(owners.claim() != null) + } + + @Test + fun gatewayControlResponseKeepsBusyUntilItsOwnerFinalizes() { + val updated = + applyWearGatewayControlStatus( + state = + WearUiState( + phoneNodeId = "phone-a", + controlBusy = true, + activeAgentId = "agent-a", + ), + status = + WearProxyStatus( + connected = true, + activeAgentId = "agent-b", + activeSessionKey = null, + selectedModelRef = null, + capabilities = setOf(WearProxyCapability.GatewayControls), + eventStreamId = null, + eventSequence = null, + phoneNodeId = "phone-b", + ), + enabled = true, + ) + + assertTrue(updated.controlBusy) + assertEquals("phone-b", updated.phoneNodeId) + assertEquals("agent-b", updated.activeAgentId) + } + + @Test + fun snapshotResponsesRequireTheSamePhoneAndEventStream() { + assertEquals(true, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-a", "stream-a")) + assertEquals(false, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-b", "stream-a")) + assertEquals(false, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-a", "stream-b")) + assertEquals(true, wearSnapshotSourcesMatch("phone-a", null, "phone-a", null)) + } + + @Test + fun agentSwitchDropsThePreviousSessionModelAndStreamTogether() { + val previousSession = + WearSession( + key = "agent:old:thread-1", + title = "Old", + updatedAt = null, + hasActiveRun = true, + phoneNodeId = "phone-a", + modelRef = "openai/old", + ) + val state = + WearUiState( + activeAgentId = "old", + sessions = listOf(previousSession), + selectedSession = previousSession, + selectedModelRef = "openai/old", + models = listOf(WearModel("openai/old", "Old")), + messages = listOf(WearChatMessage("m1", "assistant", "old reply", 1)), + streamText = "old stream", + activeRunId = "run-old", + ) + + val switched = state.switchAgentContext("new") + + assertEquals("new", switched.activeAgentId) + assertNull(switched.selectedSession) + assertNull(switched.selectedModelRef) + assertNull(switched.streamText) + assertNull(switched.activeRunId) + assertEquals(emptyList(), switched.sessions) + assertEquals(emptyList(), switched.models) + assertEquals(emptyList(), switched.messages) + } + + @Test + fun sessionSwitchMovesModelAndClearsThePreviousCatalogAndTranscript() { + val nextSession = + WearSession( + key = "agent:main:thread-2", + title = "Next", + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-a", + modelRef = "openai/new", + ) + val state = + WearUiState( + activeAgentId = "main", + selectedModelRef = "openai/old", + models = listOf(WearModel("openai/new", "New")), + messages = listOf(WearChatMessage("m1", "assistant", "old reply", 1)), + streamText = "old stream", + activeRunId = "run-old", + ) + + val switched = state.switchSessionContext(nextSession) + + assertEquals(nextSession, switched.selectedSession) + assertEquals("openai/new", switched.selectedModelRef) + assertEquals("main", switched.activeAgentId) + assertEquals(emptyList(), switched.models) + assertEquals(emptyList(), switched.messages) + assertNull(switched.streamText) + assertNull(switched.activeRunId) + } + + @Test + fun modelSwitchClearsTheSelectedModelScopedCatalog() { + val selectedSession = + WearSession( + key = "agent:main:thread-2", + title = "Selected", + updatedAt = null, + hasActiveRun = false, + phoneNodeId = "phone-a", + modelRef = "openai/model-59", + ) + val state = + WearUiState( + sessions = listOf(selectedSession), + selectedSession = selectedSession, + selectedModelRef = "openai/model-59", + models = + listOf( + WearModel("openai/model-0", "Model 0"), + WearModel("openai/model-59", "Model 59"), + ), + ) + + val switched = state.switchModelContext("openai/model-0") + + assertEquals("openai/model-0", switched.selectedModelRef) + assertEquals("openai/model-0", switched.selectedSession?.modelRef) + assertEquals("openai/model-0", switched.sessions.single().modelRef) + assertEquals(emptyList(), switched.models) + } + + @Test + fun olderRunFinalMergesItsMessageWithoutEndingTheActiveReply() { + val previous = WearChatMessage("previous", "assistant", "Earlier", 1) + val completed = WearChatMessage("older", "assistant", "Finished older reply", 2) + val current = activeTerminalState(messages = listOf(previous)) + + val transition = + reduceWearTerminalChatEvent( + current, + terminalEvent(state = "final", runId = "older-run", message = completed), + ) + + assertEquals(listOf(previous, completed), transition.state.messages) + assertEquals("active-run", transition.state.activeRunId) + assertEquals("Hello", transition.state.streamText) + assertFalse(transition.reloadHistory) + assertNull(transition.observedMessage) + } + + @Test + fun olderRunAbortCannotEndTheActiveReply() { + assertForeignTerminalPreservesLiveReply("aborted") + } + + @Test + fun olderRunErrorCannotEndTheActiveReply() { + assertForeignTerminalPreservesLiveReply("error") + } + + @Test + fun identifiedOlderFinalCannotEndAnAnonymousReply() { + assertForeignTerminalPreservesLiveReply("final", activeRunId = null) + } + + @Test + fun identifiedOlderAbortCannotEndAnAnonymousReply() { + assertForeignTerminalPreservesLiveReply("aborted", activeRunId = null) + } + + @Test + fun identifiedOlderErrorCannotEndAnAnonymousReply() { + assertForeignTerminalPreservesLiveReply("error", activeRunId = null) + } + + @Test + fun anonymousReplyFinalReconcilesWhenTheRunIsFirstIdentified() { + val completed = WearChatMessage("completed", "assistant", "Finished reply", 2) + val current = activeTerminalState(activeRunId = null) + val transition = + reduceWearTerminalChatEvent( + current, + terminalEvent(state = "final", runId = "revealed-run", message = completed), + ) + + assertEquals(listOf(completed), transition.state.messages) + assertEquals("Hello", transition.state.streamText) + assertNull(transition.state.activeRunId) + assertTrue(transition.reloadHistory) + assertEquals(completed, transition.observedMessage) + } + + @Test + fun anonymousReplyAbortReconcilesWhenTheRunIsFirstIdentified() { + assertUncertainTerminalPreservesReplyAndReloadsHistory( + state = "aborted", + activeRunId = null, + eventRunId = "revealed-run", + ) + } + + @Test + fun anonymousReplyErrorReconcilesWhenTheRunIsFirstIdentified() { + assertUncertainTerminalPreservesReplyAndReloadsHistory( + state = "error", + activeRunId = null, + eventRunId = "revealed-run", + ) + } + + @Test + fun matchingRunFinalEndsTheReplyAndReloadsItsFinalMessage() { + val completed = WearChatMessage("completed", "assistant", "Finished reply", 2) + val transition = + reduceWearTerminalChatEvent( + activeTerminalState(), + terminalEvent(state = "final", runId = "active-run", message = completed), + ) + + assertEquals(listOf(completed), transition.state.messages) + assertNull(transition.state.activeRunId) + assertNull(transition.state.streamText) + assertTrue(transition.reloadHistory) + assertEquals(completed, transition.observedMessage) + } + + @Test + fun matchingRunAbortEndsTheReplyAndReloadsHistory() { + assertOwnTerminalEndsLiveReply("aborted", runId = "active-run") + } + + @Test + fun matchingRunErrorEndsTheReplyAndReloadsHistory() { + assertOwnTerminalEndsLiveReply("error", runId = "active-run") + } + + @Test + fun unidentifiedAbortReconcilesWithoutClearingAnIdentifiedReply() { + assertUncertainTerminalPreservesReplyAndReloadsHistory( + state = "aborted", + activeRunId = "active-run", + eventRunId = null, + ) + } + + @Test + fun unidentifiedErrorReconcilesWithoutClearingAnIdentifiedReply() { + assertUncertainTerminalPreservesReplyAndReloadsHistory( + state = "error", + activeRunId = "active-run", + eventRunId = null, + ) + } + + @Test + fun unidentifiedFinalMergesWithoutClearingAnIdentifiedReply() { + val completed = WearChatMessage("completed", "assistant", "Finished reply", 2) + val transition = + reduceWearTerminalChatEvent( + activeTerminalState(), + terminalEvent(state = "final", runId = null, message = completed), + ) + + assertEquals(listOf(completed), transition.state.messages) + assertEquals("active-run", transition.state.activeRunId) + assertEquals("Hello", transition.state.streamText) + assertTrue(transition.reloadHistory) + assertEquals(completed, transition.observedMessage) + } + + @Test + fun otherSessionTerminalNeverChangesTheSelectedReply() { + val current = activeTerminalState() + val transition = + reduceWearTerminalChatEvent( + current, + terminalEvent(state = "final", runId = "active-run", sessionKey = "agent:other"), + ) + + assertEquals(current, transition.state) + assertFalse(transition.reloadHistory) + assertNull(transition.observedMessage) + } + + private fun assertUncertainTerminalPreservesReplyAndReloadsHistory( + state: String, + activeRunId: String?, + eventRunId: String?, + ) { + val current = activeTerminalState(activeRunId = activeRunId) + val transition = + reduceWearTerminalChatEvent( + current, + terminalEvent(state = state, runId = eventRunId), + ) + + assertEquals(current, transition.state) + assertTrue(transition.reloadHistory) + assertNull(transition.observedMessage) + } + + private fun assertForeignTerminalPreservesLiveReply( + state: String, + activeRunId: String? = "active-run", + ) { + val current = activeTerminalState(activeRunId = activeRunId) + val transition = + reduceWearTerminalChatEvent( + current, + terminalEvent(state = state, runId = "older-run"), + ) + + assertEquals(current, transition.state) + if (activeRunId == null) { + assertTrue(transition.reloadHistory) + } else { + assertFalse(transition.reloadHistory) + } + assertNull(transition.observedMessage) + } + + private fun assertOwnTerminalEndsLiveReply( + state: String, + runId: String?, + ) { + val transition = + reduceWearTerminalChatEvent( + activeTerminalState(), + terminalEvent(state = state, runId = runId), + ) + + assertNull(transition.state.activeRunId) + assertNull(transition.state.streamText) + assertTrue(transition.reloadHistory) + assertNull(transition.observedMessage) + } + + private fun activeTerminalState( + activeRunId: String? = "active-run", + messages: List = emptyList(), + ): WearUiState { + val selected = + WearSession( + key = "agent:main", + title = "Main", + updatedAt = null, + hasActiveRun = true, + phoneNodeId = "phone-a", + ) + return WearUiState( + selectedSession = selected, + messages = messages, + streamText = "Hello", + activeRunId = activeRunId, + ) + } + + private fun terminalEvent( + state: String, + runId: String?, + sessionKey: String = "agent:main", + message: WearChatMessage? = null, + ): WearChatEvent = + WearChatEvent( + sessionKey = sessionKey, + runId = runId, + state = state, + deltaText = null, + replace = false, + streamText = null, + streamTextComplete = false, + message = message, + ) +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearSettingsStoreTest.kt b/wear/src/test/java/ai/openclaw/wear/WearSettingsStoreTest.kt new file mode 100644 index 0000000..cd162aa --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearSettingsStoreTest.kt @@ -0,0 +1,60 @@ +package ai.openclaw.wear + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class WearSettingsStoreTest { + @Test + fun defaultsAreStableWithoutWritingPreferenceRows() { + val preferences = freshPreferences() + + val settings = WearSettingsStore(preferences).read() + + assertEquals(WearThemeMode.Dark, settings.themeMode) + assertFalse(settings.autoSpeak) + assertTrue(preferences.all.isEmpty()) + } + + @Test + fun oneStoreOwnsThemeAndAutoSpeakAcrossProcessRestart() { + val preferences = freshPreferences() + WearSettingsStore(preferences).apply { + writeThemeMode(WearThemeMode.Light) + writeAutoSpeak(true) + } + + val restored = WearSettingsStore(preferences).read() + + assertEquals(WearThemeMode.Light, restored.themeMode) + assertTrue(restored.autoSpeak) + assertEquals(setOf("appearance.themeMode", "conversation.autoSpeak"), preferences.all.keys) + } + + @Test + fun unknownThemeFallsBackWithoutResettingOtherSettings() { + val preferences = freshPreferences() + preferences + .edit() + .putString("appearance.themeMode", "future-theme") + .putBoolean("conversation.autoSpeak", true) + .commit() + + val restored = WearSettingsStore(preferences).read() + + assertEquals(WearThemeMode.Dark, restored.themeMode) + assertTrue(restored.autoSpeak) + } + + private fun freshPreferences() = + RuntimeEnvironment + .getApplication() + .getSharedPreferences("wear-settings-${UUID.randomUUID()}", Context.MODE_PRIVATE) +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt b/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt new file mode 100644 index 0000000..a5d8ff8 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt @@ -0,0 +1,556 @@ +package ai.openclaw.wear + +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearRpcMethod +import android.animation.ValueAnimator +import android.content.Intent +import android.os.Looper +import android.os.Parcel +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.android.gms.wearable.ChannelClient +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock +import org.robolectric.shadows.ShadowValueAnimator +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WearTalkAvatarTest { + @Test + fun silenceKeepsTheAvatarMouthClosed() { + val pcm = ByteArray(samplesForFrames(2) * 2) + + assertEquals(listOf(0f, 0f), pcm16LeMouthLevels(pcm)) + } + + @Test + fun outputPcmProducesOneBoundedMouthLevelPerPlaybackFrame() { + val pcm = pcm16Le(samplesForFrames(2), sample = 24_000) + + val levels = pcm16LeMouthLevels(pcm) + + assertEquals(2, levels.size) + assertTrue(levels.all { level -> level in 0f..1f }) + assertTrue(levels.all { level -> level > 0.9f }) + } + + @Test + fun finalPartialPlaybackFrameStillMovesTheMouth() { + val pcm = pcm16Le(samplesForFrames(1) + 12, sample = 12_000) + + val levels = pcm16LeMouthLevels(pcm) + + assertEquals(2, levels.size) + assertTrue(levels.last() > 0f) + } + + @Test + fun consecutiveMaximumSizeChunksPreserveCumulativeWindowsAndFlushTheFinalPartial() { + val chunks = + listOf( + pcm16Le(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES / 2, sample = 4_000), + pcm16Le(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES / 2, sample = 20_000), + pcm16Le(100, sample = 12_000), + ) + val client = realtimeTalkClient() + val queuedLevels = Channel(Channel.UNLIMITED) + val attempt = realtimeAttempt(generation = 1L) + client.setPrivateField("activeAttempt", attempt) + client.setPrivateField("mouthFrames", queuedLevels) + + try { + val writeOutput = + WearRealtimeTalkClient::class.java.getDeclaredMethod( + "writeOutput", + WearRealtimeTalkClient.ActiveAttempt::class.java, + ByteArray::class.java, + ) + writeOutput.isAccessible = true + chunks.forEach { chunk -> writeOutput.invoke(client, attempt, chunk) } + awaitPlaybackTeardown(client) + + val actualLevels = + buildList { + while (true) add(queuedLevels.tryReceive().getOrNull() ?: break) + } + assertEquals(pcm16LeMouthLevels(chunks.reduce(ByteArray::plus)), actualLevels) + } finally { + client.shutdown() + } + } + + @Test + fun staleAttemptCallbacksCannotMutateReplacement() { + val client = realtimeTalkClient() + val stale = realtimeAttempt(generation = 1L) + val replacement = realtimeAttempt(generation = 2L) + + try { + client.invokePrivate("activate", stale) + client.invokePrivate("handleChannelFailure", stale) + assertTrue(client.channelFailed.value) + assertEquals(null, client.privateField("activeAttempt")) + + client.invokePrivate("activate", replacement) + val replacementReader = Job() + client.setPrivateField("readJob", replacementReader) + assertFalse(client.channelFailed.value) + client.invokePrivate("writeOutput", stale, pcm16Le(samplesForFrames(1), sample = 20_000)) + client.invokePrivate("handleChannelFailure", stale) + client.invokePrivate("closeLocal", stale, false) + + assertFalse(client.isPlaying.value) + assertFalse(client.channelFailed.value) + assertTrue(replacementReader.isActive) + assertSame(replacement, client.privateField("activeAttempt")) + } finally { + client.shutdown() + } + } + + @Test + fun realtimeAudioPathUsesNegotiatedAttemptScope() { + assertEquals( + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + wearRealtimeAudioChannelPath("attempt-7", attemptScopedAudio = false), + ) + assertEquals( + WearProtocol.realtimeAudioChannelPath("attempt-7"), + wearRealtimeAudioChannelPath("attempt-7", attemptScopedAudio = true), + ) + } + + @Test + fun mouthEnvelopeUsesFastAttackAndSoftReleaseWithoutOvershoot() { + val attack = smoothAvatarMouth(current = 0f, target = 1f, deltaSeconds = 0.02f) + val release = smoothAvatarMouth(current = 1f, target = 0f, deltaSeconds = 0.02f) + + assertTrue(attack in 0f..1f) + assertTrue(release in 0f..1f) + assertTrue(attack > 0f) + assertTrue(release > attack) + } + + @Test + fun mouthEnvelopeConvergesAcrossDisplayFrames() { + var level = 0f + repeat(30) { level = smoothAvatarMouth(level, target = 1f, deltaSeconds = 1f / 60f) } + + assertTrue(level > 0.99f) + + repeat(60) { level = smoothAvatarMouth(level, target = 0f, deltaSeconds = 1f / 60f) } + + assertTrue(level < 0.001f) + } + + @Test + fun avatarFrameDeltaHonorsAnimatorDurationScale() { + val frameDelta = 1f / 60f + + assertEquals(1f / 30f, scaledAvatarDeltaSeconds(frameDelta, durationScale = 0.5f), 0.000_001f) + assertEquals(frameDelta, scaledAvatarDeltaSeconds(frameDelta, durationScale = 1f), 0.000_001f) + assertEquals(1f / 120f, scaledAvatarDeltaSeconds(frameDelta, durationScale = 2f), 0.000_001f) + } + + @Test + fun zeroAnimatorDurationScaleStopsAvatarTime() { + assertEquals(0f, scaledAvatarDeltaSeconds(deltaSeconds = 1f / 60f, durationScale = 0f), 0f) + } + + @Test + fun effectiveScaleTransitionsStopAndRestartTheClockWhileComposed() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val scaleSource = FakeWearAnimatorScaleSource(initialScale = 1f) + val motionDurationScale = FakeMotionDurationScale(initialScale = 1f) + val frameClock = FakeWearAvatarFrameClock() + val observedStates = mutableListOf() + + controller.get().setContent { + WearTalkAvatar( + state = RealtimeVoiceButtonState.SPEAKING, + mouthLevel = 1f, + syntheticSpeech = false, + accent = Color.Cyan, + danger = Color.Red, + animatorScaleSource = scaleSource, + motionDurationScale = motionDurationScale, + frameClock = frameClock, + onAnimationStateChanged = observedStates::add, + ) + } + idleMainLooper() + + assertEquals(1, scaleSource.subscriptionCount) + assertEquals(1f, observedStates.last().durationScale, 0f) + frameClock.sendFrame(1_000_000_000L) + idleMainLooper() + frameClock.sendFrame(1_016_666_667L) + idleMainLooper() + assertTrue(observedStates.last().animationSeconds > 0f) + + scaleSource.emit(0f) + idleMainLooper() + assertEquals(0f, observedStates.last().durationScale, 0f) + assertEquals(0f, observedStates.last().animationSeconds, 0f) + assertEquals(0f, observedStates.last().mouthLevel, 0f) + val frameRequestsAtZero = frameClock.awaitCount + idleMainLooper(Duration.ofMillis(100)) + assertEquals(frameRequestsAtZero, frameClock.awaitCount) + + scaleSource.emit(1f) + idleMainLooper() + assertEquals(1f, observedStates.last().durationScale, 0f) + assertTrue(frameClock.awaitCount > frameRequestsAtZero) + + motionDurationScale.scaleFactor = 2f + idleMainLooper() + assertEquals(2f, observedStates.last().durationScale, 0f) + + controller.pause().stop().destroy() + idleMainLooper() + assertEquals(1, scaleSource.disposeCount) + assertEquals(0, scaleSource.activeSubscriptionCount) + } + + @Test + @Config(sdk = [32]) + fun api31And32CanonicalScaleRestartsClockWithoutEffectiveScaleCallback() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val lifecycleOwner = TestLifecycleOwner() + val scaleSource = AndroidWearAnimatorScaleSource(RuntimeEnvironment.getApplication(), lifecycleOwner) + val motionDurationScale = FakeMotionDurationScale(initialScale = 0f) + val frameClock = FakeWearAvatarFrameClock() + val observedStates = mutableListOf() + setRobolectricAnimatorDurationScale(0f) + + try { + assertEquals(false, ValueAnimator.areAnimatorsEnabled()) + controller.get().setContent { + WearTalkAvatar( + state = RealtimeVoiceButtonState.IDLE, + mouthLevel = 0f, + syntheticSpeech = false, + accent = Color.Cyan, + danger = Color.Red, + animatorScaleSource = scaleSource, + motionDurationScale = motionDurationScale, + frameClock = frameClock, + onAnimationStateChanged = observedStates::add, + ) + } + idleMainLooper() + + assertEquals(0f, observedStates.last().durationScale, 0f) + assertEquals(0, frameClock.awaitCount) + + motionDurationScale.scaleFactor = 1f + idleMainLooper() + + assertEquals(1f, observedStates.last().durationScale, 0f) + assertTrue(frameClock.awaitCount > 0) + } finally { + controller.pause().stop().destroy() + idleMainLooper() + setRobolectricAnimatorDurationScale(1f) + } + } + + @Test + @Config(sdk = [33]) + fun zeroSystemScaleColdStartUsesTheComposeMotionScaleWithoutCrashing() { + val context = RuntimeEnvironment.getApplication() + val originalScale = + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + val controller = Robolectric.buildActivity(ComponentActivity::class.java) + val observedStates = mutableListOf() + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + setRobolectricAnimatorDurationScale(0f) + + try { + controller.setup() + controller.get().setContent { + WearTalkAvatar( + state = RealtimeVoiceButtonState.LISTENING, + mouthLevel = 0f, + syntheticSpeech = false, + accent = Color.Cyan, + danger = Color.Red, + animatorScaleSource = FakeWearAnimatorScaleSource(initialScale = 1f), + onAnimationStateChanged = observedStates::add, + ) + } + idleMainLooper() + + assertEquals(0f, observedStates.last().durationScale, 0f) + assertEquals(0f, observedStates.last().animationSeconds, 0f) + } finally { + controller.pause().stop().destroy() + idleMainLooper() + Settings.Global.putFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + originalScale, + ) + setRobolectricAnimatorDurationScale(originalScale) + } + } + + @Test + @Config(sdk = [32]) + fun api31And32RefreshEffectiveScaleOnLifecycleAndPowerChangesAndCleanUp() { + val context = RuntimeEnvironment.getApplication() + val lifecycleOwner = TestLifecycleOwner() + val source = AndroidWearAnimatorScaleSource(context, lifecycleOwner) + val observedScales = mutableListOf() + val subscription = source.subscribe(observedScales::add) + val countAfterSubscribe = observedScales.size + + lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + assertTrue(observedScales.size > countAfterSubscribe) + val countAfterStart = observedScales.size + + context.sendBroadcast(Intent(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)) + idleMainLooper() + assertTrue(observedScales.size > countAfterStart) + + subscription.dispose() + val countAfterDispose = observedScales.size + lifecycleOwner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + context.sendBroadcast(Intent(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)) + idleMainLooper() + assertEquals(countAfterDispose, observedScales.size) + } + + @Test + fun zeroMotionKeepsEveryVoiceStateStaticAndVisuallyDistinct() { + val poses = + RealtimeVoiceButtonState.entries.map { state -> + avatarPoseAt( + state = state, + animationSeconds = 0f, + mouthLevel = 0f, + ) + } + + assertEquals(RealtimeVoiceButtonState.entries.size, poses.distinct().size) + assertEquals(0f, poses.first().floatOffset, 0f) + assertTrue(poses.last().antennaDroop > 0f) + } + + @Test + fun disabledAnimationsSuppressClockAndAudioMotionInputs() { + val inputs = + avatarMotionInputs( + animationsEnabled = false, + animationSeconds = 12.5f, + mouthLevel = 1f, + ) + + assertEquals(WearAvatarMotionInputs(animationSeconds = 0f, mouthLevel = 0f), inputs) + } + + @Test + fun enabledAnimationsPreserveClockAndBoundAudioMotionInput() { + val inputs = + avatarMotionInputs( + animationsEnabled = true, + animationSeconds = 12.5f, + mouthLevel = 1.5f, + ) + + assertEquals(WearAvatarMotionInputs(animationSeconds = 12.5f, mouthLevel = 1f), inputs) + } + + private fun samplesForFrames(frameCount: Int): Int = WEAR_REALTIME_SAMPLE_RATE_HZ * MOUTH_FRAME_MILLIS / 1_000 * frameCount + + private fun pcm16Le( + sampleCount: Int, + sample: Int, + ): ByteArray = + ByteArray(sampleCount * 2).also { bytes -> + repeat(sampleCount) { index -> + bytes[index * 2] = (sample and 0xff).toByte() + bytes[(index * 2) + 1] = ((sample shr 8) and 0xff).toByte() + } + } + + private fun realtimeTalkClient(): WearRealtimeTalkClient { + val requester = + object : WearRpcRequester { + override suspend fun request( + method: WearRpcMethod, + params: JsonObject, + expectedNodeId: String?, + requirePreferredNode: Boolean, + ): WearRpcResult = error("Unexpected request: $method $params $expectedNodeId $requirePreferredNode") + } + return WearRealtimeTalkClient(RuntimeEnvironment.getApplication(), WearGatewayRepository(requester)) + } + + private fun realtimeAttempt(generation: Long): WearRealtimeTalkClient.ActiveAttempt = + WearRealtimeTalkClient.ActiveAttempt( + nodeId = "watch-a", + attemptId = "attempt-$generation", + generation = generation, + resources = + WearRealtimeTalkClient.ChannelResources( + channel = FakeRealtimeChannel("watch-a", "channel-$generation"), + input = ByteArrayInputStream(byteArrayOf()), + output = ByteArrayOutputStream(), + ), + ) + + private fun awaitPlaybackTeardown(client: WearRealtimeTalkClient) { + ShadowSystemClock.advanceBy(Duration.ofSeconds(1L)) + val deadlineNanos = System.nanoTime() + 2_000_000_000L + while (client.isPlaying.value && System.nanoTime() < deadlineNanos) Thread.sleep(10L) + assertEquals(false, client.isPlaying.value) + } + + private fun idleMainLooper(duration: Duration = Duration.ZERO) { + shadowOf(Looper.getMainLooper()).idleFor(duration) + } + + private fun setRobolectricAnimatorDurationScale(scale: Float) { + ShadowValueAnimator::class.java + .getDeclaredMethod("setDurationScale", java.lang.Float.TYPE) + .apply { isAccessible = true } + .invoke(null, scale) + } + + private fun Any.setPrivateField( + name: String, + value: Any, + ) { + javaClass.getDeclaredField(name).apply { + isAccessible = true + set(this@setPrivateField, value) + } + } + + private fun Any.privateField(name: String): Any? = + javaClass.getDeclaredField(name).run { + isAccessible = true + get(this@privateField) + } + + private fun WearRealtimeTalkClient.invokePrivate( + name: String, + vararg args: Any, + ) { + javaClass.declaredMethods + .single { method -> + method.name == name && + method.parameterTypes.size == args.size && + method.parameterTypes.zip(args).all { (type, arg) -> + type.isAssignableFrom(arg.javaClass) || + (type == Boolean::class.javaPrimitiveType && arg is Boolean) + } + }.apply { isAccessible = true } + .invoke(this, *args) + } + + private companion object { + const val WEAR_REALTIME_SAMPLE_RATE_HZ = 24_000 + } + + private class FakeMotionDurationScale( + initialScale: Float, + ) : MotionDurationScale { + override var scaleFactor by mutableFloatStateOf(initialScale) + } + + private class FakeWearAnimatorScaleSource( + initialScale: Float, + ) : WearAnimatorScaleSource { + private var scale = initialScale + private var listener: ((Float) -> Unit)? = null + var subscriptionCount = 0 + private set + var disposeCount = 0 + private set + val activeSubscriptionCount: Int + get() = if (listener == null) 0 else 1 + + override fun currentScale(): Float = scale + + override fun subscribe(onScaleChanged: (Float) -> Unit): WearAnimatorScaleSubscription { + subscriptionCount += 1 + listener = onScaleChanged + return WearAnimatorScaleSubscription { + if (listener === onScaleChanged) listener = null + disposeCount += 1 + } + } + + fun emit(newScale: Float) { + scale = newScale + listener?.invoke(newScale) + } + } + + private class FakeWearAvatarFrameClock : WearAvatarFrameClock { + private val frames = Channel(Channel.UNLIMITED) + var awaitCount = 0 + private set + + override suspend fun awaitFrame(onFrame: (Long) -> Unit) { + awaitCount += 1 + onFrame(frames.receive()) + } + + fun sendFrame(frameNanos: Long) { + assertTrue(frames.trySend(frameNanos).isSuccess) + } + } + + private class TestLifecycleOwner : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle = registry + } +} + +private data class FakeRealtimeChannel( + private val nodeId: String, + private val label: String, +) : ChannelClient.Channel { + override fun getNodeId(): String = nodeId + + override fun getPath(): String = WearProtocol.realtimeAudioChannelPath("attempt-$label") + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + dest: Parcel, + flags: Int, + ) { + dest.writeString(label) + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearThemeTest.kt b/wear/src/test/java/ai/openclaw/wear/WearThemeTest.kt new file mode 100644 index 0000000..04fd3a6 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearThemeTest.kt @@ -0,0 +1,115 @@ +package ai.openclaw.wear + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +class WearThemeTest { + @Test + fun `wear palettes mirror the canonical phone surfaces and voice accents`() { + val dark = wearColorsFor(WearThemeMode.Dark) + assertEquals(Color(0xFF030303), dark.canvas) + assertEquals(Color(0xFF0A0A0A), dark.surface) + assertEquals(Color(0xFF111111), dark.surfaceRaised) + assertEquals(Color(0xFF1A1A1A), dark.surfacePressed) + assertEquals(Color(0xFF242424), dark.border) + assertEquals(Color(0xFF3A3A3A), dark.borderStrong) + assertEquals(Color(0xFFF8F8F8), dark.text) + assertEquals(Color(0xFFA8A8A8), dark.textMuted) + assertEquals(Color(0xFFFFFFFF), dark.primary) + assertEquals(Color(0xFF050505), dark.primaryText) + assertEquals(Color(0xFF6EA8FF), dark.voiceAccent) + assertEquals(Color(0xFF1A2A44), dark.voiceAccentSoft) + + val light = wearColorsFor(WearThemeMode.Light) + assertEquals(Color(0xFFFAFBFC), light.canvas) + assertEquals(Color(0xFFFFFEFB), light.surface) + assertEquals(Color(0xFFFFFFFF), light.surfaceRaised) + assertEquals(Color(0xFFE9EDF3), light.surfacePressed) + assertEquals(Color(0xFFDDE3EC), light.border) + assertEquals(Color(0xFFC7D0DC), light.borderStrong) + assertEquals(Color(0xFF111318), light.text) + assertEquals(Color(0xFF505865), light.textMuted) + assertEquals(Color(0xFF111827), light.primary) + assertEquals(Color(0xFFFFFFFF), light.primaryText) + assertEquals(Color(0xFF1B5ACB), light.voiceAccent) + assertEquals(Color(0xFFEAF2FF), light.voiceAccentSoft) + } + + @Test + fun `dark and light palettes keep panels distinct from the canvas`() { + WearThemeMode.entries.forEach { mode -> + val colors = wearColorsFor(mode) + + assertNotEquals("$mode canvas and panel must differ", colors.canvas, colors.surfaceRaised) + assertTrue( + "$mode panel outline must remain visible", + contrastRatio(colors.borderStrong, colors.surfaceRaised) >= MIN_OUTLINE_CONTRAST, + ) + } + } + + @Test + fun `dark and light palettes keep text readable on panels`() { + WearThemeMode.entries.forEach { mode -> + val colors = wearColorsFor(mode) + + assertTrue( + "$mode text must remain readable", + contrastRatio(colors.text, colors.surfaceRaised) >= MIN_TEXT_CONTRAST, + ) + assertTrue( + "$mode muted text must remain readable", + contrastRatio(colors.textMuted, colors.surfaceRaised) >= MIN_TEXT_CONTRAST, + ) + } + } + + @Test + fun `dark and light primary and voice accents keep their content readable`() { + WearThemeMode.entries.forEach { mode -> + val colors = wearColorsFor(mode) + + assertTrue( + "$mode primary content must remain readable", + contrastRatio(colors.primaryText, colors.primary) >= MIN_TEXT_CONTRAST, + ) + assertTrue( + "$mode voice accent content must remain readable", + contrastRatio(colors.onVoiceAccent, colors.voiceAccent) >= MIN_TEXT_CONTRAST, + ) + } + } + + private fun contrastRatio( + foreground: Color, + background: Color, + ): Double { + val foregroundLuminance = relativeLuminance(foreground) + val backgroundLuminance = relativeLuminance(background) + return (max(foregroundLuminance, backgroundLuminance) + 0.05) / + (min(foregroundLuminance, backgroundLuminance) + 0.05) + } + + private fun relativeLuminance(color: Color): Double = + 0.2126 * linearize(color.red.toDouble()) + + 0.7152 * linearize(color.green.toDouble()) + + 0.0722 * linearize(color.blue.toDouble()) + + private fun linearize(channel: Double): Double = + if (channel <= 0.03928) { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).pow(2.4) + } + + private companion object { + const val MIN_OUTLINE_CONTRAST = 1.5 + const val MIN_TEXT_CONTRAST = 4.5 + } +} diff --git a/wear/src/test/java/ai/openclaw/wear/WearViewModelLifecycleTest.kt b/wear/src/test/java/ai/openclaw/wear/WearViewModelLifecycleTest.kt new file mode 100644 index 0000000..c50ae71 --- /dev/null +++ b/wear/src/test/java/ai/openclaw/wear/WearViewModelLifecycleTest.kt @@ -0,0 +1,57 @@ +package ai.openclaw.wear + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = WearApplication::class, sdk = [35]) +class WearViewModelLifecycleTest { + @Test + fun recreatedViewModelGetsALiveTalkClientAfterThePreviousOneClears() { + val app = RuntimeEnvironment.getApplication() as WearApplication + val factory = ViewModelProvider.AndroidViewModelFactory.getInstance(app) + val firstOwner = TestViewModelStoreOwner() + val firstViewModel = ViewModelProvider(firstOwner, factory)[WearViewModel::class.java] + val firstClient = firstViewModel.realtimeTalkClientForTest() + + firstOwner.viewModelStore.clear() + + val reopenedOwner = TestViewModelStoreOwner() + val reopenedViewModel = ViewModelProvider(reopenedOwner, factory)[WearViewModel::class.java] + val reopenedClient = reopenedViewModel.realtimeTalkClientForTest() + try { + assertFalse(firstClient.scopeForTest().coroutineContext[Job]?.isActive == true) + assertNotSame(firstClient, reopenedClient) + assertTrue(reopenedClient.scopeForTest().coroutineContext[Job]?.isActive == true) + } finally { + reopenedOwner.viewModelStore.clear() + } + } + + private class TestViewModelStoreOwner : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + + private fun WearViewModel.realtimeTalkClientForTest(): WearRealtimeTalkClient = + javaClass.getDeclaredField("realtimeTalkClient").run { + isAccessible = true + get(this@realtimeTalkClientForTest) as WearRealtimeTalkClient + } + + private fun WearRealtimeTalkClient.scopeForTest(): CoroutineScope = + javaClass.getDeclaredField("scope").run { + isAccessible = true + get(this@scopeForTest) as CoroutineScope + } +}