mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(android): make release screenshots deterministic
This commit is contained in:
parent
4adfc30b90
commit
f163f32f85
17 changed files with 793 additions and 514 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue