fix(android): make release screenshots deterministic

This commit is contained in:
joshavant 2026-07-09 01:30:50 -05:00
parent 4adfc30b90
commit f163f32f85
No known key found for this signature in database
GPG key ID: 4463B60B0DD49BC4
17 changed files with 793 additions and 514 deletions

View file

@ -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:

View file

@ -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=<signing repo 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=<avd-name> 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.

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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.

View file

@ -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.
*/

View file

@ -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 {

View file

@ -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<String>,
) {
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"
}

View file

@ -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,
)
}
}
}
}
}
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 <name>`, 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 <name>` to
select another AVD, or `--device <adb-serial>` to explicitly capture from a
connected emulator.
Upload metadata, release notes, and the Play AAB to the configured Google Play track:

View file

@ -6,28 +6,47 @@ usage() {
Usage:
scripts/android-screenshots.sh [--device <adb-serial>] [--avd <name>] [--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/<locale>/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 <name> or --device <emulator-serial>." >&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 <adb-serial>, or pass --avd <name> to boot an emulator." >&2
boot_emulator "$adb" "$AVD"
return
else
echo "Multiple Android devices are connected. Pass --device <adb-serial>." >&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:-<auto>}"
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}"

View file

@ -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}");
});
});

View file

@ -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");
});
});