From f163f32f850c9df40a7ec8d06fce4f2d81e2c56a Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:30:50 -0500 Subject: [PATCH] fix(android): make release screenshots deterministic --- apps/android/README.md | 17 +- apps/android/VERSIONING.md | 2 +- .../openclaw/app/AndroidScreenshotFixture.kt | 189 ++++++++ .../ai/openclaw/app/AndroidScreenshotMode.kt | 12 +- .../main/java/ai/openclaw/app/MainActivity.kt | 20 +- .../java/ai/openclaw/app/MainViewModel.kt | 13 + .../src/main/java/ai/openclaw/app/NodeApp.kt | 10 + .../main/java/ai/openclaw/app/NodeRuntime.kt | 145 +++++-- .../app/ui/AndroidScreenshotModeScreen.kt | 402 ------------------ .../java/ai/openclaw/app/ui/VoiceScreen.kt | 32 +- .../app/AndroidScreenshotFixtureTest.kt | 52 +++ .../openclaw/app/AndroidScreenshotModeTest.kt | 12 +- apps/android/fastlane/Fastfile | 4 +- apps/android/fastlane/SETUP.md | 11 +- scripts/android-screenshots.sh | 305 ++++++++++++- .../android-release-fastlane-gates.test.ts | 26 ++ test/scripts/android-screenshots.test.ts | 55 ++- 17 files changed, 793 insertions(+), 514 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt delete mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt diff --git a/apps/android/README.md b/apps/android/README.md index 34ab6f93d40..993129f8314 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -69,16 +69,13 @@ Generate raw Google Play screenshots: pnpm android:screenshots ``` -To make screenshot capture own emulator startup, pass a named AVD: - -```bash -ANDROID_SCREENSHOT_AVD=OpenClaw_QA_API35 pnpm android:screenshots -``` - -The screenshot script uses one connected ADB device when available. If none is -connected and `ANDROID_SCREENSHOT_AVD` is set, it boots that emulator -headlessly, waits for Android to finish booting, disables animations, captures -the screenshots, then shuts down the emulator it started. +The screenshot script defaults to a retained `OpenClaw_Screenshots_API36` AVD +created from Android's no-cutout Pixel 2 profile. It creates the AVD when +missing, boots it headlessly, waits for Android to finish booting, disables +animations, captures the screenshots, then shuts down the emulator it started. +The API 36 Google APIs system image must be installed in the local Android SDK. +Use `ANDROID_SCREENSHOT_AVD` or `--avd` to select another AVD, or `--device` to +explicitly use a connected emulator. `pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files: diff --git a/apps/android/VERSIONING.md b/apps/android/VERSIONING.md index 4e1a351a290..e745b432257 100644 --- a/apps/android/VERSIONING.md +++ b/apps/android/VERSIONING.md @@ -49,7 +49,7 @@ Recommended workflow: 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 `ANDROID_SCREENSHOT_AVD= pnpm android:screenshots` to refresh raw Google Play screenshots with a script-managed emulator, or run `pnpm android:screenshots` when exactly one ADB device is already connected. +6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots with the script-managed no-cutout emulator. 7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK. 8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to the configured Google Play track. 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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt new file mode 100644 index 00000000000..0f2815d8e92 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt @@ -0,0 +1,189 @@ +package ai.openclaw.app + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +internal object AndroidScreenshotFixture { + const val mainSessionKey = "agent:main:node-screenshot" + const val primarySessionTitle = "Android release planning" + const val chatReadyText = "Ready when you are" + + 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, + 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() + "chat.metadata" -> chatMetadata() + else -> error("Screenshot fixture does not implement gateway method $method with params $paramsJson") + } + + private fun chatHistory(): String = + buildJsonObject { + put("sessionId", JsonPrimitive("screenshot-session")) + put("thinkingLevel", JsonPrimitive("low")) + put("messages", buildJsonArray {}) + put( + "sessionInfo", + buildJsonObject { + put("key", JsonPrimitive(mainSessionKey)) + put("displayName", JsonPrimitive("New chat")) + put("updatedAt", JsonPrimitive(1_783_555_200_000)) + put("unread", JsonPrimitive(false)) + put("modelProvider", JsonPrimitive("openai")) + put("model", JsonPrimitive("gpt-5.2")) + put("contextTokens", JsonPrimitive(200_000)) + }, + ) + }.toString() + + private fun sessionList(): String = + 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 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( + "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/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt index 929aa2e1a18..9ab8a5a9854 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt @@ -7,16 +7,16 @@ const val extraAndroidScreenshotScene = "openclaw.screenshotScene" enum class AndroidScreenshotScene( val rawValue: String, + val homeDestination: HomeDestination, ) { - Connect("connect"), - Chat("chat"), - Voice("voice"), - Screen("screen"), - Settings("settings"), + Home("home", HomeDestination.Connect), + Chat("chat", HomeDestination.Chat), + Voice("voice", HomeDestination.Voice), + Settings("settings", HomeDestination.Settings), ; companion object { - fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Connect + fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Home } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index 0c6719603f5..1ec008c8fa2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -1,6 +1,5 @@ package ai.openclaw.app -import ai.openclaw.app.ui.AndroidScreenshotModeScreen import ai.openclaw.app.ui.OpenClawTheme import ai.openclaw.app.ui.RootScreen import android.content.Intent @@ -48,6 +47,7 @@ class MainActivity : ComponentActivity() { private var didStartViewModelCollectors = false private var foreground = false private var pendingIntent: Intent? = null + private var screenshotScene: AndroidScreenshotScene? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -55,10 +55,8 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) if (BuildConfig.DEBUG) { - parseAndroidScreenshotModeIntent(intent)?.let { scene -> - enterScreenshotMode(scene) - return - } + screenshotScene = parseAndroidScreenshotModeIntent(intent) + if (screenshotScene != null) hideScreenshotModeStatusBar() } setContent { @@ -70,6 +68,7 @@ class MainActivity : ComponentActivity() { (application as NodeApp).prefs } val readyViewModel = viewModel + screenshotScene?.let(readyViewModel::enterScreenshotFixtureMode) activateViewModel(readyViewModel) activeViewModel = readyViewModel } @@ -88,13 +87,6 @@ class MainActivity : ComponentActivity() { } } - private fun enterScreenshotMode(scene: AndroidScreenshotScene) { - hideScreenshotModeStatusBar() - setContent { - AndroidScreenshotModeScreen(scene = scene) - } - } - private fun hideScreenshotModeStatusBar() { WindowCompat .getInsetsController(window, window.decorView) @@ -160,6 +152,10 @@ class MainActivity : ComponentActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { readyViewModel.runtimeInitialized.collect { ready -> if (!ready || didAttachRuntimeUi) return@collect + if (screenshotScene != null) { + didAttachRuntimeUi = true + return@collect + } // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) didAttachRuntimeUi = true diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index f5f1885bbcb..ba38a004f3e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -87,6 +87,19 @@ class MainViewModel( return runtime } + internal fun enterScreenshotFixtureMode(scene: AndroidScreenshotScene) { + check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" } + check(runtimeRef.value == null) { "Screenshot fixture mode must be selected before runtime startup" } + prefs.setOnboardingCompleted(true) + prefs.setAppearanceThemeMode(AppearanceThemeMode.Dark) + prefs.setDisplayName("Pixel") + prefs.setSpeakerEnabled(true) + val runtime = nodeApp.ensureScreenshotFixtureRuntime() + runtime.setForeground(foreground) + runtimeRef.value = runtime + _requestedHomeDestination.value = scene.homeDestination + } + /** * 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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index 69c36e2e1d8..36d6fc68716 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -25,6 +25,16 @@ class NodeApp : Application() { runtimeInstance ?: NodeRuntime(this, prefs).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. */ diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index d2325534c31..9249abe7567 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -321,6 +321,11 @@ private data class AndroidChatStores( val commandOutbox: ChatCommandOutbox, ) +internal enum class NodeRuntimeMode { + Live, + ScreenshotFixture, +} + private fun openAndroidChatStores(context: Context): AndroidChatStores { val database = ChatCacheDatabase.open(context.applicationContext) return AndroidChatStores( @@ -334,6 +339,7 @@ class NodeRuntime private constructor( val prefs: SecurePrefs, private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult, chatStores: AndroidChatStores, + internal val mode: NodeRuntimeMode, ) { private val chatTranscriptCache = chatStores.transcriptCache private val chatCommandOutbox = chatStores.commandOutbox @@ -363,6 +369,19 @@ class NodeRuntime private constructor( prefs = prefs, tlsFingerprintProbe = tlsFingerprintProbe, chatStores = openAndroidChatStores(context), + mode = NodeRuntimeMode.Live, + ) + + internal constructor( + context: Context, + prefs: SecurePrefs, + mode: NodeRuntimeMode, + ) : this( + context = context, + prefs = prefs, + tlsFingerprintProbe = ::probeGatewayTlsFingerprint, + chatStores = openAndroidChatStores(context), + mode = mode, ) internal constructor( @@ -378,6 +397,7 @@ class NodeRuntime private constructor( transcriptCache = chatTranscriptCache, commandOutbox = RoomChatCommandOutbox(ChatCacheDatabase.open(context.applicationContext)), ), + mode = NodeRuntimeMode.Live, ) /** @@ -984,28 +1004,39 @@ class NodeRuntime private constructor( } init { - scope.launch { notificationOutbox.deliver() } - DeviceNotificationListenerService.setNodeEventSink { event, payloadJson -> - notificationOutbox.enqueue( - PendingNotificationNodeEvent( - event = event, - payloadJson = payloadJson, - gatewayId = prefs.gatewayRegistry.activeStableId.value, - ), - ) + 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 chat: ChatController = - ChatController( - scope = scope, - session = operatorSession, - json = json, - transcriptCache = chatTranscriptCache, - cacheScope = ::chatCacheScope, - commandOutbox = chatCommandOutbox, - recordModelRecent = prefs::recordModelRecent, - ).also { + when (mode) { + NodeRuntimeMode.Live -> + ChatController( + scope = scope, + session = operatorSession, + json = json, + transcriptCache = chatTranscriptCache, + cacheScope = ::chatCacheScope, + commandOutbox = chatCommandOutbox, + recordModelRecent = prefs::recordModelRecent, + ) + NodeRuntimeMode.ScreenshotFixture -> + ChatController( + scope = scope, + json = json, + requestGateway = AndroidScreenshotFixture::request, + ) + }.also { it.applyMainSessionKey(_mainSessionKey.value) } @@ -1272,6 +1303,7 @@ class NodeRuntime private constructor( } fun refreshHomeCanvasOverviewIfConnected() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return if (!operatorConnected) { updateHomeCanvasState() return @@ -1292,22 +1324,26 @@ class NodeRuntime private constructor( } fun refreshModelCatalog() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshModelCatalogFromGateway() } } fun refreshTalkSetupReadiness() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshTalkSetupReadinessFromGateway() } } fun refreshAgents() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshAgentsFromGateway() } } fun refreshCronJobs() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshCronFromGateway() } @@ -1328,24 +1364,28 @@ class NodeRuntime private constructor( } fun refreshUsage() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshUsageFromGateway() } } fun refreshSkills() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshSkillsFromGateway() } } fun refreshNodesDevices() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshNodesDevicesFromGateway() } } fun refreshExecApprovals() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshExecApprovalsFromGateway() } @@ -1364,18 +1404,21 @@ class NodeRuntime private constructor( } fun refreshChannels() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshChannelsFromGateway() } } fun refreshDreaming() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshDreamingFromGateway() } } fun refreshHealthLogs() { + if (mode == NodeRuntimeMode.ScreenshotFixture) return scope.launch { refreshHealthLogsFromGateway() } @@ -1543,20 +1586,60 @@ class NodeRuntime private constructor( fun deleteChatOutboxCommand(id: String) = chat.deleteOutboxCommand(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 + _gatewayDefaultAgentId.value = "main" + _gatewayAgents.value = AndroidScreenshotFixture.agents + _modelCatalog.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 = 2, + nextWakeAtMs = 1_783_641_600_000, + ) + _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 + } + chat.refreshSessions(limit = 20) + } + init { - if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) { - prefs.setVoiceWakeMode(VoiceWakeMode.Off) - } - - if (prefs.voiceMicEnabled.value) { - setVoiceCaptureMode(VoiceCaptureMode.ManualMic, persistManualMic = false) - } - - scope.launch(Dispatchers.Default) { - gateways.collect { list -> - seedLastDiscoveredGateway(list) - autoConnectIfNeeded() + if (mode == NodeRuntimeMode.Live) { + if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) { + prefs.setVoiceWakeMode(VoiceWakeMode.Off) } + + if (prefs.voiceMicEnabled.value) { + setVoiceCaptureMode(VoiceCaptureMode.ManualMic, persistManualMic = false) + } + + scope.launch(Dispatchers.Default) { + gateways.collect { list -> + seedLastDiscoveredGateway(list) + autoConnectIfNeeded() + } + } + } else { + applyScreenshotFixture() } scope.launch { @@ -1581,6 +1664,7 @@ class NodeRuntime private constructor( /** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */ fun setForeground(value: Boolean) { _isForeground.value = value + if (mode == NodeRuntimeMode.ScreenshotFixture) return if (!value) { voiceLifecycleEpoch.incrementAndGet() } @@ -1863,6 +1947,7 @@ class NodeRuntime private constructor( } fun setVoiceScreenActive(active: Boolean) { + if (mode == NodeRuntimeMode.ScreenshotFixture) return if (!active) { stopManualVoiceSession() } else { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt deleted file mode 100644 index f18c4c53e98..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/AndroidScreenshotModeScreen.kt +++ /dev/null @@ -1,402 +0,0 @@ -package ai.openclaw.app.ui - -import ai.openclaw.app.AndroidScreenshotScene -import ai.openclaw.app.ui.design.ClawDesignTheme -import ai.openclaw.app.ui.design.ClawTheme -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.WifiTethering -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp - -private val ScreenshotIconBoxSize = 44.dp - -@Composable -fun AndroidScreenshotModeScreen(scene: AndroidScreenshotScene) { - ClawDesignTheme(dark = true) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(ClawTheme.colors.canvas) - .padding( - horizontal = ClawTheme.spacing.md, - vertical = 26.dp, - ), - verticalArrangement = Arrangement.SpaceBetween, - ) { - ScreenshotHeader(scene) - ScreenshotSceneBody(scene = scene, modifier = Modifier.weight(1f)) - ScreenshotTabBar(activeScene = scene) - } - } -} - -@Composable -private fun ScreenshotHeader(scene: AndroidScreenshotScene) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text(text = "OpenClaw", style = ClawTheme.type.title, color = ClawTheme.colors.text) - Text( - text = sceneTitle(scene), - style = ClawTheme.type.caption, - color = ClawTheme.colors.textMuted, - ) - } - StatusPill(label = "Connected", color = ClawTheme.colors.success) - } -} - -@Composable -private fun ScreenshotSceneBody( - scene: AndroidScreenshotScene, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxWidth().padding(vertical = ClawTheme.spacing.md), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - when (scene) { - AndroidScreenshotScene.Connect -> ConnectScene() - AndroidScreenshotScene.Chat -> ChatScene() - AndroidScreenshotScene.Voice -> VoiceScene() - AndroidScreenshotScene.Screen -> ScreenScene() - AndroidScreenshotScene.Settings -> SettingsScene() - } - } -} - -@Composable -private fun ConnectScene() { - FeaturePanel(icon = Icons.Default.WifiTethering, title = "Gateway paired", subtitle = "Mac Studio - Tailnet") { - MetricRow(label = "Node", value = "Android Pixel 9") - MetricRow(label = "Transport", value = "Secure WebSocket") - MetricRow(label = "Capabilities", value = "Chat, Talk, Camera, Screen") - } - CompactList( - title = "Ready", - rows = - listOf( - "Push wakes active", - "Approvals synced", - "Device tools available", - ), - ) -} - -@Composable -private fun ChatScene() { - ChatBubble(label = "You", text = "Hi Molty, are you there?") - ChatBubble( - label = "Molty", - text = "Always. Lurking in the shadows, exfoliating.", - raised = true, - ) -} - -@Composable -private fun VoiceScene() { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = ClawTheme.spacing.md), - contentAlignment = Alignment.Center, - ) { - // Scale the proof orb with compact capture widths while preserving its 196dp design cap. - Surface( - modifier = Modifier.widthIn(max = 196.dp).fillMaxWidth().aspectRatio(1f), - shape = CircleShape, - color = ClawTheme.colors.surfaceRaised, - border = BorderStroke(1.dp, ClawTheme.colors.borderStrong), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = null, - tint = ClawTheme.colors.primary, - modifier = Modifier.fillMaxSize(0.37f), - ) - } - } - } - FeaturePanel(icon = Icons.Default.Mic, title = "Talk mode", subtitle = "Listening on device") { - MetricRow(label = "Wake phrase", value = "OpenClaw") - MetricRow(label = "Latency", value = "Realtime") - } -} - -@Composable -private fun ScreenScene() { - FeaturePanel(icon = Icons.AutoMirrored.Filled.ScreenShare, title = "Screen tools", subtitle = "Shared with your gateway") { - MetricRow(label = "Canvas", value = "Available") - MetricRow(label = "Camera", value = "Permission granted") - MetricRow(label = "Location", value = "On request") - } - Surface( - modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp), - shape = RoundedCornerShape(ClawTheme.radii.button), - color = ClawTheme.colors.surfaceRaised, - border = BorderStroke(1.dp, ClawTheme.colors.border), - ) { - Column(modifier = Modifier.padding(ClawTheme.spacing.sm), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(text = "Live context", style = ClawTheme.type.section, color = ClawTheme.colors.text) - ContextBar(label = "Camera", fraction = 0.74f) - ContextBar(label = "Screen", fraction = 0.58f) - ContextBar(label = "Location", fraction = 0.38f) - } - } -} - -@Composable -private fun SettingsScene() { - CompactList( - title = "Security", - rows = listOf("Biometric lock enabled", "Gateway token encrypted", "Tool approvals required"), - ) - CompactList( - title = "Notifications", - rows = listOf("Gateway status", "Approval requests", "Background presence"), - ) -} - -@Composable -private fun FeaturePanel( - icon: ImageVector, - title: String, - subtitle: String, - content: @Composable () -> Unit, -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(ClawTheme.radii.button), - color = ClawTheme.colors.surface, - border = BorderStroke(1.dp, ClawTheme.colors.border), - ) { - Column(modifier = Modifier.padding(ClawTheme.spacing.sm), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - IconBox(icon = icon) - Column { - Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) - } - } - content() - } - } -} - -@Composable -private fun CompactList( - title: String, - rows: List, -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(ClawTheme.radii.button), - color = ClawTheme.colors.surfaceRaised, - border = BorderStroke(1.dp, ClawTheme.colors.border), - ) { - Column(modifier = Modifier.padding(ClawTheme.spacing.sm), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text) - rows.forEach { row -> - Row(verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(ClawTheme.colors.success)) - Spacer(modifier = Modifier.width(10.dp)) - Text(text = row, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) - } - } - } - } -} - -@Composable -private fun ChatBubble( - label: String, - text: String, - raised: Boolean = false, -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(ClawTheme.radii.button), - color = if (raised) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface, - border = BorderStroke(1.dp, if (raised) ClawTheme.colors.borderStrong else ClawTheme.colors.border), - ) { - Column(modifier = Modifier.padding(ClawTheme.spacing.sm), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle) - Text(text = text, style = ClawTheme.type.body, color = ClawTheme.colors.text) - } - } -} - -@Composable -private fun MetricRow( - label: String, - value: String, -) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(text = label, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) - Text( - text = value, - style = ClawTheme.type.label, - color = ClawTheme.colors.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun ContextBar( - label: String, - fraction: Float, -) { - Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(7.dp) - .clip(RoundedCornerShape(ClawTheme.radii.row)) - .background(ClawTheme.colors.surfacePressed), - ) { - Box( - modifier = - Modifier - .fillMaxWidth(fraction) - .height(7.dp) - .background(ClawTheme.colors.primary), - ) - } - } -} - -@Composable -private fun ScreenshotTabBar(activeScene: AndroidScreenshotScene) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(ClawTheme.radii.button), - color = ClawTheme.colors.surface, - border = BorderStroke(1.dp, ClawTheme.colors.border), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TabIcon(icon = Icons.Default.CheckCircle, active = activeScene == AndroidScreenshotScene.Connect) - TabIcon(icon = Icons.Default.ChatBubble, active = activeScene == AndroidScreenshotScene.Chat) - TabIcon(icon = Icons.Default.Mic, active = activeScene == AndroidScreenshotScene.Voice) - TabIcon(icon = Icons.AutoMirrored.Filled.ScreenShare, active = activeScene == AndroidScreenshotScene.Screen) - TabIcon(icon = Icons.Default.Settings, active = activeScene == AndroidScreenshotScene.Settings) - } - } -} - -@Composable -private fun TabIcon( - icon: ImageVector, - active: Boolean, -) { - Box( - modifier = - Modifier - .size(ScreenshotIconBoxSize) - .clip(RoundedCornerShape(ClawTheme.radii.control)) - .background(if (active) ClawTheme.colors.surfacePressed else Color.Transparent), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle, - modifier = Modifier.size(20.dp), - ) - } -} - -@Composable -private fun IconBox(icon: ImageVector) { - Box( - modifier = - Modifier - .size(ScreenshotIconBoxSize) - .clip(RoundedCornerShape(ClawTheme.radii.button)) - .background(ClawTheme.colors.surfacePressed), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = ClawTheme.colors.primary, - modifier = Modifier.size(22.dp), - ) - } -} - -@Composable -private fun StatusPill( - label: String, - color: Color, -) { - Surface( - shape = RoundedCornerShape(ClawTheme.radii.button), - color = ClawTheme.colors.surfaceRaised, - border = BorderStroke(1.dp, ClawTheme.colors.border), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(color)) - Spacer(modifier = Modifier.width(7.dp)) - Text( - text = label, - style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold), - color = color, - ) - } - } -} - -private fun sceneTitle(scene: AndroidScreenshotScene): String = - when (scene) { - AndroidScreenshotScene.Connect -> "Connect" - AndroidScreenshotScene.Chat -> "Chat" - AndroidScreenshotScene.Voice -> "Talk" - AndroidScreenshotScene.Screen -> "Device tools" - AndroidScreenshotScene.Settings -> "Settings" - } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt index 47acfc3f7f2..947f3adef68 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt @@ -136,6 +136,8 @@ fun VoiceScreen( // Talk mode and dictation use different managers, so choose the transcript // from the mode the user is actually seeing. val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation + val showTranscriptThinking = + micIsSending && activeConversation.none { it.role == VoiceConversationRole.Assistant && it.isStreaming } val voiceActive = micEnabled || micIsSending || talkModeEnabled val gatewayReady = gatewayStatus.isVoiceGatewayReady() val voiceAttentionStatus = @@ -252,11 +254,13 @@ fun VoiceScreen( ) } - VoiceTranscript( - entries = activeConversation, - showThinking = micIsSending && activeConversation.none { it.role == VoiceConversationRole.Assistant && it.isStreaming }, - modifier = Modifier.weight(1f), - ) + if (activeConversation.isNotEmpty() || showTranscriptThinking) { + VoiceTranscript( + entries = activeConversation, + showThinking = showTranscriptThinking, + modifier = Modifier.weight(1f), + ) + } } } @@ -1031,24 +1035,6 @@ private fun VoiceTranscript( items(entries.asReversed(), key = { it.id }) { entry -> VoiceTurnCard(entry = entry) } - - if (entries.isEmpty() && !showThinking) { - item { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle) - ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 9.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = "No transcript yet", style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text( - text = "Your words and OpenClaw replies will appear here.", - style = ClawTheme.type.body, - color = ClawTheme.colors.textMuted, - ) - } - } - } - } - } } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt new file mode 100644 index 00000000000..10d2507441e --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt @@ -0,0 +1,52 @@ +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 + + 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) + } + + @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/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt index db1fb737605..1f573b18c07 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotModeTest.kt @@ -29,7 +29,7 @@ class AndroidScreenshotModeTest { } @Test - fun defaultsUnknownScenesToConnect() { + fun defaultsUnknownScenesToHome() { val parsed = parseAndroidScreenshotModeIntent( Intent(Intent.ACTION_MAIN) @@ -37,6 +37,14 @@ class AndroidScreenshotModeTest { .putExtra(extraAndroidScreenshotScene, "unknown"), ) - assertEquals(AndroidScreenshotScene.Connect, parsed) + assertEquals(AndroidScreenshotScene.Home, parsed) + } + + @Test + fun mapsScenesToProductionShellDestinations() { + assertEquals(HomeDestination.Connect, AndroidScreenshotScene.Home.homeDestination) + assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Chat.homeDestination) + assertEquals(HomeDestination.Voice, AndroidScreenshotScene.Voice.homeDestination) + assertEquals(HomeDestination.Settings, AndroidScreenshotScene.Settings.homeDestination) } } diff --git a/apps/android/fastlane/Fastfile b/apps/android/fastlane/Fastfile index 9cba352893c..70e5be9c5b1 100644 --- a/apps/android/fastlane/Fastfile +++ b/apps/android/fastlane/Fastfile @@ -175,14 +175,14 @@ def play_metadata_path end def play_screenshot_paths - Dir[File.join(play_metadata_path, "**", "images", "**", "*.png")] + Dir[File.join(play_metadata_path, "**", "images", "**", "*.{png,jpg,jpeg}")] end def validate_android_screenshots! return unless play_screenshot_upload_requested? if play_screenshot_paths.empty? - UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no PNG screenshots were found under apps/android/fastlane/metadata/android/*/images.") + UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no screenshots were found under apps/android/fastlane/metadata/android/*/images.") end end diff --git a/apps/android/fastlane/SETUP.md b/apps/android/fastlane/SETUP.md index 77e9fe532a2..849f2e7a40d 100644 --- a/apps/android/fastlane/SETUP.md +++ b/apps/android/fastlane/SETUP.md @@ -68,13 +68,14 @@ path after `pnpm android:release:upload` fails. Generate deterministic Google Play screenshots: ```bash -ANDROID_SCREENSHOT_AVD=OpenClaw_QA_API35 pnpm android:screenshots +pnpm android:screenshots ``` -If exactly one ADB device is already connected, `pnpm android:screenshots` -uses it. With `ANDROID_SCREENSHOT_AVD` or `--avd `, the script can boot a -headless emulator, wait for boot completion, stabilize animation settings, -capture screenshots, and shut down only the emulator it started. +The script creates and boots a retained `OpenClaw_Screenshots_API36` AVD from +Android's no-cutout Pixel 2 profile when needed. The API 36 Google APIs system +image must be installed. Use `ANDROID_SCREENSHOT_AVD` or `--avd ` to +select another AVD, or `--device ` to explicitly capture from a +connected emulator. Upload metadata, release notes, and the Play AAB to the configured Google Play track: diff --git a/scripts/android-screenshots.sh b/scripts/android-screenshots.sh index e3bd5f4308c..d22e0f28639 100644 --- a/scripts/android-screenshots.sh +++ b/scripts/android-screenshots.sh @@ -6,28 +6,47 @@ usage() { Usage: scripts/android-screenshots.sh [--device ] [--avd ] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run] -Builds and installs the Play debug app, launches deterministic screenshot scenes, -and writes raw Google Play screenshots under: +Builds and installs the Play debug app on an emulator, launches production screens +with deterministic local fixture state, and writes Google Play screenshots under: apps/android/fastlane/metadata/android//images/phoneScreenshots/ -If no ADB device is connected, pass --avd or set ANDROID_SCREENSHOT_AVD to boot -an emulator non-interactively for the screenshot run. +Capture evidence is saved under: + .artifacts/android-screenshots/latest/ + +By default, the script creates and boots a retained Pixel 2 AVD with no display +cutout. Use --avd or ANDROID_SCREENSHOT_AVD to select another AVD, or --device +to explicitly use a connected emulator. EOF } ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ANDROID_DIR="${ROOT_DIR}/apps/android" +DEFAULT_SCREENSHOT_AVD="OpenClaw_Screenshots_API36" +DEFAULT_SCREENSHOT_DEVICE_PROFILE="pixel_2" +case "$(uname -m)" in + arm64|aarch64) DEFAULT_SCREENSHOT_ABI="arm64-v8a" ;; + *) DEFAULT_SCREENSHOT_ABI="x86_64" ;; +esac +DEFAULT_SCREENSHOT_SYSTEM_IMAGE="system-images;android-36;google_apis;${DEFAULT_SCREENSHOT_ABI}" LOCALE="en-US" DEVICE="${ANDROID_SCREENSHOT_DEVICE:-}" -AVD="${ANDROID_SCREENSHOT_AVD:-}" +AVD="${ANDROID_SCREENSHOT_AVD:-$DEFAULT_SCREENSHOT_AVD}" +SCREENSHOT_DEVICE_PROFILE="${ANDROID_SCREENSHOT_DEVICE_PROFILE:-$DEFAULT_SCREENSHOT_DEVICE_PROFILE}" +SCREENSHOT_SYSTEM_IMAGE="${ANDROID_SCREENSHOT_SYSTEM_IMAGE:-$DEFAULT_SCREENSHOT_SYSTEM_IMAGE}" KEEP_EMULATOR="${ANDROID_SCREENSHOT_KEEP_EMULATOR:-0}" SKIP_BUILD=0 SKIP_INSTALL=0 DRY_RUN=0 -SCENES=(connect chat voice screen settings) +SCENES=(home chat voice settings) EMULATOR_PID="" EMULATOR_LOG="" STARTED_EMULATOR=0 +ARTIFACT_DIR="${ROOT_DIR}/.artifacts/android-screenshots/latest" +SCREENSHOT_SIZE="${ANDROID_SCREENSHOT_SIZE:-1440x2560}" +DISPLAY_OVERRIDDEN=0 +ORIGINAL_WM_SIZE="" +ORIGINAL_WM_DENSITY="" +SCREENSHOT_DENSITY="" while [[ $# -gt 0 ]]; do case "$1" in @@ -86,6 +105,20 @@ validate_locale() { validate_locale "$LOCALE" +if [[ ! "$SCREENSHOT_SIZE" =~ ^[0-9]+x[0-9]+$ ]]; then + echo "Invalid Android screenshot size: ${SCREENSHOT_SIZE}" >&2 + echo "Use a pixel size like 1080x1920." >&2 + exit 1 +fi +SCREENSHOT_WIDTH=$((10#${SCREENSHOT_SIZE%x*})) +SCREENSHOT_HEIGHT=$((10#${SCREENSHOT_SIZE#*x})) +SCREENSHOT_MIN_DIMENSION=$((SCREENSHOT_WIDTH < SCREENSHOT_HEIGHT ? SCREENSHOT_WIDTH : SCREENSHOT_HEIGHT)) +SCREENSHOT_MAX_DIMENSION=$((SCREENSHOT_WIDTH > SCREENSHOT_HEIGHT ? SCREENSHOT_WIDTH : SCREENSHOT_HEIGHT)) +if (( SCREENSHOT_MIN_DIMENSION < 320 || SCREENSHOT_MAX_DIMENSION > 3840 || SCREENSHOT_MAX_DIMENSION > SCREENSHOT_MIN_DIMENSION * 2 )); then + echo "Android screenshot size ${SCREENSHOT_SIZE} does not meet Google Play dimension and aspect-ratio limits." >&2 + exit 1 +fi + cleanup_started_emulator() { local stopped=0 @@ -102,6 +135,22 @@ cleanup_started_emulator() { fi } +restore_device_display() { + if [[ "$DISPLAY_OVERRIDDEN" != "1" || -z "${ADB_BIN:-}" || -z "${ADB_SERIAL:-}" ]]; then + return + fi + if [[ -n "$ORIGINAL_WM_SIZE" ]]; then + "$ADB_BIN" -s "$ADB_SERIAL" shell wm size "$ORIGINAL_WM_SIZE" >/dev/null 2>&1 || true + else + "$ADB_BIN" -s "$ADB_SERIAL" shell wm size reset >/dev/null 2>&1 || true + fi + if [[ -n "$ORIGINAL_WM_DENSITY" ]]; then + "$ADB_BIN" -s "$ADB_SERIAL" shell wm density "$ORIGINAL_WM_DENSITY" >/dev/null 2>&1 || true + else + "$ADB_BIN" -s "$ADB_SERIAL" shell wm density reset >/dev/null 2>&1 || true + fi +} + cleanup_emulator_log() { if [[ -n "$EMULATOR_LOG" && -f "$EMULATOR_LOG" ]]; then rm -f "$EMULATOR_LOG" @@ -109,6 +158,7 @@ cleanup_emulator_log() { } cleanup() { + restore_device_display cleanup_started_emulator cleanup_emulator_log } @@ -153,6 +203,50 @@ emulator_bin() { return 127 } +avdmanager_bin() { + if [[ -n "${AVDMANAGER:-}" ]]; then + printf '%s\n' "$AVDMANAGER" + return + fi + if command -v avdmanager >/dev/null 2>&1; then + command -v avdmanager + return + fi + for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do + for relative_path in cmdline-tools/latest/bin/avdmanager cmdline-tools/bin/avdmanager tools/bin/avdmanager; do + if [[ -n "$sdk_root" && -x "$sdk_root/$relative_path" ]]; then + printf '%s\n' "$sdk_root/$relative_path" + return + fi + done + done + echo "avdmanager not found. Install Android SDK command-line tools or set AVDMANAGER." >&2 + return 127 +} + +ensure_screenshot_avd() { + local avd="$1" + local emulator + local avdmanager + + emulator="$(emulator_bin)" + if "$emulator" -list-avds | grep -Fxq "$avd"; then + return + fi + + avdmanager="$(avdmanager_bin)" + echo "Creating no-cutout screenshot AVD '${avd}' from device profile '${SCREENSHOT_DEVICE_PROFILE}'." >&2 + if ! printf 'no\n' | "$avdmanager" create avd \ + --force \ + --name "$avd" \ + --package "$SCREENSHOT_SYSTEM_IMAGE" \ + --device "$SCREENSHOT_DEVICE_PROFILE"; then + echo "Could not create Android screenshot AVD '${avd}'." >&2 + echo "Install SDK package '${SCREENSHOT_SYSTEM_IMAGE}' or set ANDROID_SCREENSHOT_SYSTEM_IMAGE." >&2 + return 1 + fi +} + connected_devices() { local adb="$1" "$adb" devices | awk 'NR > 1 && $2 == "device" { print $1 }' @@ -163,6 +257,12 @@ device_count() { printf '%s\n' "$devices" | sed '/^$/d' | wc -l | tr -d ' ' } +running_avd_name() { + local adb="$1" + local serial="$2" + "$adb" -s "$serial" emu avd name 2>/dev/null | tr -d '\r' | sed -n '1p' +} + wait_for_single_device() { local adb="$1" local timeout_seconds="${ANDROID_SCREENSHOT_EMULATOR_TIMEOUT_SECONDS:-180}" @@ -236,6 +336,49 @@ stabilize_device_for_screenshots() { "$adb" -s "$serial" shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true "$adb" -s "$serial" shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true "$adb" -s "$serial" shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true + "$adb" -s "$serial" shell settings put system font_scale 1.0 >/dev/null 2>&1 || true +} + +require_emulator_device() { + local adb="$1" + local serial="$2" + local qemu + + qemu="$("$adb" -s "$serial" shell getprop ro.kernel.qemu 2>/dev/null | tr -d '\r')" + if [[ "$qemu" == "1" ]]; then + return + fi + echo "Android screenshot capture requires an emulator; '${serial}' is not an emulator." >&2 + echo "Pass --avd or --device ." >&2 + return 1 +} + +configure_screenshot_display() { + local adb="$1" + local serial="$2" + local current_size + local current_density + local effective_size + local effective_density + local effective_width + + current_size="$("$adb" -s "$serial" shell wm size 2>/dev/null | tr -d '\r')" + current_density="$("$adb" -s "$serial" shell wm density 2>/dev/null | tr -d '\r')" + ORIGINAL_WM_SIZE="$(printf '%s\n' "$current_size" | sed -n 's/^Override size: //p')" + ORIGINAL_WM_DENSITY="$(printf '%s\n' "$current_density" | sed -n 's/^Override density: //p')" + effective_size="${ORIGINAL_WM_SIZE:-$(printf '%s\n' "$current_size" | sed -n 's/^Physical size: //p')}" + effective_density="${ORIGINAL_WM_DENSITY:-$(printf '%s\n' "$current_density" | sed -n 's/^Physical density: //p')}" + if [[ ! "$effective_size" =~ ^[0-9]+x[0-9]+$ || ! "$effective_density" =~ ^[0-9]+$ ]]; then + echo "Could not determine emulator display size and density." >&2 + return 1 + fi + + effective_width=$((10#${effective_size%x*})) + SCREENSHOT_DENSITY=$(((10#$effective_density * SCREENSHOT_WIDTH + effective_width / 2) / effective_width)) + DISPLAY_OVERRIDDEN=1 + "$adb" -s "$serial" shell wm size "$SCREENSHOT_SIZE" >/dev/null + "$adb" -s "$serial" shell wm density "$SCREENSHOT_DENSITY" >/dev/null + echo "Android screenshot density: ${SCREENSHOT_DENSITY} dpi" } boot_emulator() { @@ -246,6 +389,7 @@ boot_emulator() { local extra_args local serial + ensure_screenshot_avd "$avd" emulator="$(emulator_bin)" EMULATOR_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-android-screenshot-emulator.XXXXXX.log")" echo "No connected Android device found. Booting AVD '${avd}'." >&2 @@ -268,6 +412,7 @@ resolve_device() { local adb="$1" local devices local count + local connected_avd if [[ -n "$DEVICE" ]]; then wait_for_explicit_device "$adb" "$DEVICE" @@ -278,17 +423,19 @@ resolve_device() { devices="$(connected_devices "$adb")" count="$(device_count "$devices")" if [[ "$count" == "1" ]]; then + connected_avd="$(running_avd_name "$adb" "$devices")" + if [[ "$connected_avd" != "$AVD" ]]; then + echo "Connected emulator '${connected_avd:-unknown}' is not the screenshot AVD '${AVD}'." >&2 + echo "Stop it so the script can boot '${AVD}', or pass --device '${devices}' to override the no-cutout profile." >&2 + return 1 + fi stabilize_device_for_screenshots "$adb" "$devices" ADB_SERIAL="$devices" return fi if [[ "$count" == "0" ]]; then - if [[ -n "$AVD" ]]; then - boot_emulator "$adb" "$AVD" - return - fi - echo "No Android device or emulator is connected." >&2 - echo "Start one manually, pass --device , or pass --avd to boot an emulator." >&2 + boot_emulator "$adb" "$AVD" + return else echo "Multiple Android devices are connected. Pass --device ." >&2 fi @@ -302,16 +449,112 @@ latest_play_debug_apk() { find "${ANDROID_DIR}/app/build/outputs/apk/play/debug" -maxdepth 1 -name '*-play-debug.apk' -print 2>/dev/null | sort | tail -n 1 } +scene_ready_text() { + case "$1" in + home) printf '%s\n' "Overview" ;; + chat) printf '%s\n' "Ready when you are" ;; + voice) printf '%s\n' "Ready to talk" ;; + settings) printf '%s\n' "OpenClaw mobile" ;; + *) + echo "Unknown Android screenshot scene: $1" >&2 + return 1 + ;; + esac +} + +wait_for_scene_ready() { + local adb="$1" + local serial="$2" + local scene="$3" + local dump_path="$4" + local marker + local timeout_seconds="${ANDROID_SCREENSHOT_SCENE_TIMEOUT_SECONDS:-45}" + local deadline=$((SECONDS + timeout_seconds)) + + marker="$(scene_ready_text "$scene")" + while (( SECONDS < deadline )); do + if "$adb" -s "$serial" exec-out uiautomator dump /dev/tty >"$dump_path" 2>/dev/null; then + if grep -Fq "$marker" "$dump_path"; then + return + fi + fi + sleep 1 + done + + echo "Timed out waiting for scene '${scene}' to expose '${marker}' in the Android UI tree." >&2 + return 1 +} + +sips_bin() { + if [[ -n "${SIPS:-}" ]]; then + printf '%s\n' "$SIPS" + return + fi + if command -v sips >/dev/null 2>&1; then + command -v sips + return + fi + echo "sips not found. Android release screenshots require macOS or an explicit SIPS executable." >&2 + return 127 +} + +normalize_capture_for_play() { + local input_path="$1" + local output_path="$2" + local sips + local description + + sips="$(sips_bin)" + "$sips" -s format jpeg -s formatOptions best "$input_path" --out "$output_path" >/dev/null + rm -f "$input_path" + + description="$(file "$output_path")" + if [[ "$description" != *"${SCREENSHOT_SIZE/x/x}"* || "$description" != *"JPEG image data"* ]]; then + echo "Invalid Google Play screenshot output: ${description}" >&2 + return 1 + fi +} + +write_artifact_manifest() { + local serial="$1" + local avd_name + local git_sha + local checksum_command + + avd_name="$(running_avd_name "$ADB_BIN" "$serial")" + git_sha="$(git -C "$ROOT_DIR" rev-parse HEAD)" + if command -v shasum >/dev/null 2>&1; then + checksum_command=(shasum -a 256) + else + checksum_command=(sha256sum) + fi + + { + printf 'git_sha=%s\n' "$git_sha" + printf 'device=%s\n' "$serial" + printf 'avd=%s\n' "${avd_name:-unknown}" + printf 'locale=%s\n' "$LOCALE" + printf 'size=%s\n' "$SCREENSHOT_SIZE" + printf 'density=%s\n' "$SCREENSHOT_DENSITY" + printf 'format=high-quality JPEG\n' + printf 'scenes=%s\n' "${SCENES[*]}" + printf 'screenshots:\n' + "${checksum_command[@]}" "$ARTIFACT_DIR"/screenshots/*.jpg + } >"$ARTIFACT_DIR/manifest.txt" +} + OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/phoneScreenshots" ADB_SERIAL="" ADB_DISPLAY="${DEVICE:-}" echo "Android screenshot output: ${OUTPUT_DIR}" +echo "Android screenshot artifacts: ${ARTIFACT_DIR}" +echo "Android screenshot size: ${SCREENSHOT_SIZE}" echo "Scenes: ${SCENES[*]}" echo "ADB device: ${ADB_DISPLAY}" -if [[ -n "$AVD" ]]; then - echo "Fallback AVD: ${AVD}" -fi +echo "Screenshot AVD: ${AVD}" +echo "Screenshot device profile: ${SCREENSHOT_DEVICE_PROFILE}" +echo "Screenshot system image: ${SCREENSHOT_SYSTEM_IMAGE}" if [[ "$DRY_RUN" == "1" ]]; then echo "Dry run complete. No build, install, or capture commands were executed." @@ -320,8 +563,12 @@ fi ADB_BIN="$(adb_bin)" resolve_device "$ADB_BIN" +require_emulator_device "$ADB_BIN" "$ADB_SERIAL" +configure_screenshot_display "$ADB_BIN" "$ADB_SERIAL" mkdir -p "$OUTPUT_DIR" -rm -f "$OUTPUT_DIR"/*.png +rm -f "$OUTPUT_DIR"/*.png "$OUTPUT_DIR"/*.jpg "$OUTPUT_DIR"/*.jpeg +rm -rf "$ARTIFACT_DIR" +mkdir -p "$ARTIFACT_DIR/screenshots" "$ARTIFACT_DIR/ui-dumps" "$ARTIFACT_DIR/activity-start" if [[ "$SKIP_INSTALL" != "1" ]]; then if [[ "$SKIP_BUILD" != "1" ]]; then @@ -343,16 +590,34 @@ elif [[ "$SKIP_BUILD" != "1" ]]; then ) fi +"$ADB_BIN" -s "$ADB_SERIAL" shell pm clear ai.openclaw.app >/dev/null +"$ADB_BIN" -s "$ADB_SERIAL" shell pm grant ai.openclaw.app android.permission.RECORD_AUDIO >/dev/null +"$ADB_BIN" -s "$ADB_SERIAL" logcat -c >/dev/null 2>&1 || true + for scene in "${SCENES[@]}"; do - output_path="${OUTPUT_DIR}/openclaw-${scene}.png" + output_path="${OUTPUT_DIR}/openclaw-${scene}.jpg" + raw_path="${OUTPUT_DIR}/openclaw-${scene}.raw.png" + artifact_path="${ARTIFACT_DIR}/screenshots/openclaw-${scene}.jpg" + ui_dump_path="${ARTIFACT_DIR}/ui-dumps/openclaw-${scene}.xml" + activity_start_path="${ARTIFACT_DIR}/activity-start/openclaw-${scene}.txt" "$ADB_BIN" -s "$ADB_SERIAL" shell am force-stop ai.openclaw.app >/dev/null "$ADB_BIN" -s "$ADB_SERIAL" shell am start -W \ -n ai.openclaw.app/.MainActivity \ --ez openclaw.screenshotMode true \ - --es openclaw.screenshotScene "$scene" >/dev/null - sleep "${ANDROID_SCREENSHOT_SETTLE_SECONDS:-1.5}" - "$ADB_BIN" -s "$ADB_SERIAL" exec-out screencap -p >"$output_path" + --es openclaw.screenshotScene "$scene" >"$activity_start_path" + wait_for_scene_ready "$ADB_BIN" "$ADB_SERIAL" "$scene" "$ui_dump_path" + sleep "${ANDROID_SCREENSHOT_SETTLE_SECONDS:-0.5}" + "$ADB_BIN" -s "$ADB_SERIAL" exec-out screencap -p >"$raw_path" + normalize_capture_for_play "$raw_path" "$output_path" + cp "$output_path" "$artifact_path" echo "Captured ${output_path}" done +"$ADB_BIN" -s "$ADB_SERIAL" logcat -d >"$ARTIFACT_DIR/logcat.txt" 2>&1 || true +if [[ -n "$EMULATOR_LOG" && -f "$EMULATOR_LOG" ]]; then + cp "$EMULATOR_LOG" "$ARTIFACT_DIR/emulator.log" +fi +write_artifact_manifest "$ADB_SERIAL" + echo "Android screenshots written to ${OUTPUT_DIR}" +echo "Android screenshot artifacts written to ${ARTIFACT_DIR}" diff --git a/test/scripts/android-release-fastlane-gates.test.ts b/test/scripts/android-release-fastlane-gates.test.ts index cc12b4b0ea4..5afc43e8791 100644 --- a/test/scripts/android-release-fastlane-gates.test.ts +++ b/test/scripts/android-release-fastlane-gates.test.ts @@ -21,6 +21,18 @@ function functionBody(source: string, name: string): string { return nextDef < 0 ? rest : rest.slice(0, nextDef); } +function laneBody(source: string, name: string): string { + const startMarker = `lane :${name} do`; + const start = source.indexOf(startMarker); + if (start < 0) { + throw new Error(`missing Fastlane lane ${name}`); + } + + const rest = source.slice(start + startMarker.length); + const nextLane = rest.search(/\n\s*(?:desc |lane :|end\nend)/); + return nextLane < 0 ? rest : rest.slice(0, nextLane); +} + describe("Android Fastlane release upload gates", () => { it("preflights and records mobile release refs around Play build upload", () => { const fastfile = readFastfile(); @@ -43,4 +55,18 @@ describe("Android Fastlane release upload gates", () => { ); expect(uploadBuild).toContain("unless play_validate_only?"); }); + + it("generates fresh screenshots before building and uploading a release", () => { + const releaseUpload = laneBody(readFastfile(), "release_upload"); + + expect(releaseUpload).toContain("screenshots"); + expect(releaseUpload.indexOf("screenshots")).toBeLessThan( + releaseUpload.indexOf("build_release_artifacts!"), + ); + expect(releaseUpload.indexOf("screenshots")).toBeLessThan( + releaseUpload.indexOf("upload_play_store_build!"), + ); + expect(releaseUpload).toContain('ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"'); + expect(readFastfile()).toContain("*.{png,jpg,jpeg}"); + }); }); diff --git a/test/scripts/android-screenshots.test.ts b/test/scripts/android-screenshots.test.ts index c7293d7210a..b6975b015e8 100644 --- a/test/scripts/android-screenshots.test.ts +++ b/test/scripts/android-screenshots.test.ts @@ -1,11 +1,13 @@ import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const SCRIPT = "scripts/android-screenshots.sh"; -function runAndroidScreenshots(args: string[]) { +function runAndroidScreenshots(args: string[], env: NodeJS.ProcessEnv = {}) { return spawnSync("bash", [SCRIPT, ...args], { encoding: "utf8", + env: { ...process.env, ...env }, }); } @@ -17,9 +19,51 @@ describe("android screenshots script", () => { expect(result.stdout).toContain( "apps/android/fastlane/metadata/android/pt-BR/images/phoneScreenshots", ); + expect(result.stdout).toContain(".artifacts/android-screenshots/latest"); + expect(result.stdout).toContain("Android screenshot size: 1440x2560"); + expect(result.stdout).toContain("Screenshot AVD: OpenClaw_Screenshots_API36"); + expect(result.stdout).toContain("Screenshot device profile: pixel_2"); + expect(result.stdout).toContain("Scenes: home chat voice settings"); + expect(result.stdout).not.toContain("connect chat voice screen settings"); expect(result.stdout).toContain("Dry run complete."); }); + it("keeps artifact cleanup inside the repository-owned evidence directory", () => { + const result = runAndroidScreenshots(["--dry-run"], { + ANDROID_SCREENSHOT_ARTIFACT_DIR: process.env.HOME, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(".artifacts/android-screenshots/latest"); + expect(result.stdout).not.toContain(`Android screenshot artifacts: ${process.env.HOME}\n`); + }); + + it("waits for content unique to the settings screen", () => { + const script = readFileSync(SCRIPT, "utf8"); + + expect(script).toContain("settings) printf '%s\\n' \"OpenClaw mobile\""); + expect(script).not.toContain("settings) printf '%s\\n' \"Settings\""); + }); + + it("scales and restores display density with the screenshot size", () => { + const script = readFileSync(SCRIPT, "utf8"); + + expect(script).toContain('SCREENSHOT_SIZE="${ANDROID_SCREENSHOT_SIZE:-1440x2560}"'); + expect(script).toContain('shell wm density "$SCREENSHOT_DENSITY"'); + expect(script).toContain('shell wm density "$ORIGINAL_WM_DENSITY"'); + expect(script).toContain("shell wm density reset"); + }); + + it("provisions a retained no-cutout screenshot emulator by default", () => { + const script = readFileSync(SCRIPT, "utf8"); + + expect(script).toContain('DEFAULT_SCREENSHOT_AVD="OpenClaw_Screenshots_API36"'); + expect(script).toContain('DEFAULT_SCREENSHOT_DEVICE_PROFILE="pixel_2"'); + expect(script).toContain('ensure_screenshot_avd "$avd"'); + expect(script).toContain('--device "$SCREENSHOT_DEVICE_PROFILE"'); + expect(script).toContain("is not the screenshot AVD"); + }); + it.each(["../escape", "en/US", ".hidden", "en..US", ""])( "rejects locale path escapes before dry-run output: %j", (locale) => { @@ -31,4 +75,13 @@ describe("android screenshots script", () => { expect(result.stdout).not.toContain("Android screenshot output:"); }, ); + + it("rejects screenshot dimensions outside Google Play's aspect-ratio limit", () => { + const result = runAndroidScreenshots(["--dry-run"], { + ANDROID_SCREENSHOT_SIZE: "1080x2424", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("does not meet Google Play dimension and aspect-ratio limits"); + }); });