diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt index 57c663e394e..607ffc80d09 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt @@ -191,13 +191,44 @@ internal object AndroidScreenshotFixture { buildJsonObject { put("sessionId", JsonPrimitive("screenshot-session")) put("thinkingLevel", JsonPrimitive("low")) - put("messages", buildJsonArray {}) + put( + "messages", + buildJsonArray { + add(chatMessage("user", "What is blocking the Android release?", 1_783_555_020_000)) + add( + chatMessage( + "assistant", + "Two review threads are still open on the release branch, and the localization sync needs one more pass. " + + "Once those land, the changelog draft is ready for review and the tag can go out.", + 1_783_555_080_000, + ), + ) + add(chatMessage("user", "Summarize the open review feedback for me.", 1_783_555_140_000)) + add( + chatMessage( + "assistant", + "The main thread asks for a regression test around session restore, and the second one wants the new " + + "config key documented before merge. Both are small; I can draft patches for each if you want.", + 1_783_555_200_000, + ), + ) + add(chatMessage("user", "Draft a short status update for the team.", 1_783_555_260_000)) + add( + chatMessage( + "assistant", + "The Android release is close. Two review follow-ups and one localization pass remain; once those land, " + + "the changelog can be reviewed and the tag can go out.", + 1_783_555_320_000, + ), + ) + }, + ) put( "sessionInfo", buildJsonObject { put("key", JsonPrimitive(mainSessionKey)) put("displayName", JsonPrimitive("New chat")) - put("updatedAt", JsonPrimitive(1_783_555_200_000)) + put("updatedAt", JsonPrimitive(1_783_555_320_000)) put("unread", JsonPrimitive(false)) put("modelProvider", JsonPrimitive("openai")) put("model", JsonPrimitive("gpt-5.2")) @@ -206,6 +237,16 @@ internal object AndroidScreenshotFixture { ) }.toString() + private fun chatMessage( + role: String, + content: String, + timestamp: Long, + ) = buildJsonObject { + put("role", JsonPrimitive(role)) + put("content", JsonPrimitive(content)) + put("timestamp", JsonPrimitive(timestamp)) + } + private fun sessionList(): String = buildJsonObject { put( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt index 610f0093b80..8ab73f5dfe0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt @@ -67,6 +67,50 @@ class AndroidScreenshotFixtureTest { assertEquals("Play publish blocked", parsedCronRuns.last().error) } + @Test + fun providesDeterministicChatHistory() { + val messages = + json + .parseToJsonElement(AndroidScreenshotFixture.request("chat.history", null)) + .jsonObject["messages"] + ?.jsonArray + .orEmpty() + + assertEquals( + listOf( + listOf("user", "What is blocking the Android release?", "1783555020000"), + listOf( + "assistant", + "Two review threads are still open on the release branch, and the localization sync needs one more pass. " + + "Once those land, the changelog draft is ready for review and the tag can go out.", + "1783555080000", + ), + listOf("user", "Summarize the open review feedback for me.", "1783555140000"), + listOf( + "assistant", + "The main thread asks for a regression test around session restore, and the second one wants the new " + + "config key documented before merge. Both are small; I can draft patches for each if you want.", + "1783555200000", + ), + listOf("user", "Draft a short status update for the team.", "1783555260000"), + listOf( + "assistant", + "The Android release is close. Two review follow-ups and one localization pass remain; once those land, " + + "the changelog can be reviewed and the tag can go out.", + "1783555320000", + ), + ), + messages.map { message -> + val fields = message.jsonObject + listOf( + fields["role"]?.jsonPrimitive?.content, + fields["content"]?.jsonPrimitive?.content, + fields["timestamp"]?.jsonPrimitive?.content, + ) + }, + ) + } + @Test fun rejectsUnexpectedGatewayCalls() { val error = diff --git a/scripts/android-screenshots.sh b/scripts/android-screenshots.sh index 0de2dd4818a..3afb8410fd1 100644 --- a/scripts/android-screenshots.sh +++ b/scripts/android-screenshots.sh @@ -47,6 +47,9 @@ DISPLAY_OVERRIDDEN=0 ORIGINAL_WM_SIZE="" ORIGINAL_WM_DENSITY="" SCREENSHOT_DENSITY="" +TIMEZONE_OVERRIDDEN=0 +ORIGINAL_AUTO_TIME_ZONE="" +ORIGINAL_TIME_ZONE="" while [[ $# -gt 0 ]]; do case "$1" in @@ -151,6 +154,18 @@ restore_device_display() { fi } +restore_device_timezone() { + if [[ "$TIMEZONE_OVERRIDDEN" != "1" || -z "${ADB_BIN:-}" || -z "${ADB_SERIAL:-}" ]]; then + return + fi + if [[ -n "$ORIGINAL_TIME_ZONE" ]]; then + "$ADB_BIN" -s "$ADB_SERIAL" shell cmd alarm set-timezone "$ORIGINAL_TIME_ZONE" >/dev/null 2>&1 || true + fi + if [[ "$ORIGINAL_AUTO_TIME_ZONE" == "true" || "$ORIGINAL_AUTO_TIME_ZONE" == "false" ]]; then + "$ADB_BIN" -s "$ADB_SERIAL" shell cmd time_zone_detector set_auto_detection_enabled "$ORIGINAL_AUTO_TIME_ZONE" >/dev/null 2>&1 || true + fi +} + cleanup_emulator_log() { if [[ -n "$EMULATOR_LOG" && -f "$EMULATOR_LOG" ]]; then rm -f "$EMULATOR_LOG" @@ -159,6 +174,7 @@ cleanup_emulator_log() { cleanup() { restore_device_display + restore_device_timezone cleanup_started_emulator cleanup_emulator_log } @@ -333,6 +349,22 @@ wait_for_explicit_device() { stabilize_device_for_screenshots() { local adb="$1" local serial="$2" + ORIGINAL_AUTO_TIME_ZONE="$("$adb" -s "$serial" shell cmd time_zone_detector is_auto_detection_enabled 2>/dev/null | tr -d '\r')" + ORIGINAL_TIME_ZONE="$("$adb" -s "$serial" shell getprop persist.sys.timezone 2>/dev/null | tr -d '\r')" + if [[ "$ORIGINAL_AUTO_TIME_ZONE" != "true" && "$ORIGINAL_AUTO_TIME_ZONE" != "false" ]]; then + echo "Could not determine emulator automatic timezone setting." >&2 + return 1 + fi + if [[ -z "$ORIGINAL_TIME_ZONE" ]]; then + echo "Could not determine emulator timezone." >&2 + return 1 + fi + # Arm cleanup before the first mutation; restoring an unchanged snapshot is safe. + TIMEZONE_OVERRIDDEN=1 + # Seeded chat timestamps use the device timezone, so disable detection before + # pinning UTC to avoid inheriting the runner's locale. + "$adb" -s "$serial" shell cmd time_zone_detector set_auto_detection_enabled false >/dev/null + "$adb" -s "$serial" shell cmd alarm set-timezone UTC >/dev/null "$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 @@ -404,7 +436,6 @@ boot_emulator() { serial="$(wait_for_single_device "$adb")" wait_for_boot_completed "$adb" "$serial" - stabilize_device_for_screenshots "$adb" "$serial" ADB_SERIAL="$serial" } @@ -416,7 +447,6 @@ resolve_device() { if [[ -n "$DEVICE" ]]; then wait_for_explicit_device "$adb" "$DEVICE" - stabilize_device_for_screenshots "$adb" "$DEVICE" ADB_SERIAL="$DEVICE" return fi @@ -429,7 +459,6 @@ resolve_device() { 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 @@ -452,7 +481,9 @@ latest_play_debug_apk() { scene_ready_text() { case "$1" in home) printf '%s\n' "Overview" ;; - chat) printf '%s\n' "Ready when you are" ;; + # The screenshot fixture seeds chat history and restores at the latest user + # turn, so wait for that visible anchor instead of empty-chat copy. + chat) printf '%s\n' "Draft a short status update for the team." ;; voice) printf '%s\n' "Ready to talk" ;; settings) printf '%s\n' "OpenClaw mobile" ;; voice-wake) printf '%s\n' "Wake listener" ;; @@ -568,6 +599,7 @@ fi ADB_BIN="$(adb_bin)" resolve_device "$ADB_BIN" require_emulator_device "$ADB_BIN" "$ADB_SERIAL" +stabilize_device_for_screenshots "$ADB_BIN" "$ADB_SERIAL" configure_screenshot_display "$ADB_BIN" "$ADB_SERIAL" mkdir -p "$OUTPUT_DIR" rm -f "$OUTPUT_DIR"/*.png "$OUTPUT_DIR"/*.jpg "$OUTPUT_DIR"/*.jpeg diff --git a/test/scripts/android-screenshots.test.ts b/test/scripts/android-screenshots.test.ts index 2c9122696ff..2c7452f6fc5 100644 --- a/test/scripts/android-screenshots.test.ts +++ b/test/scripts/android-screenshots.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const SCRIPT = "scripts/android-screenshots.sh"; +const SCREENSHOT_FIXTURE = + "apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt"; function runAndroidScreenshots(args: string[], env: NodeJS.ProcessEnv = {}) { return spawnSync("bash", [SCRIPT, ...args], { @@ -38,9 +40,13 @@ describe("android screenshots script", () => { expect(result.stdout).not.toContain(`Android screenshot artifacts: ${process.env.HOME}\n`); }); - it("waits for content unique to the settings and gateway screens", () => { + it("waits for fixture content unique to the chat, settings, and gateway screens", () => { const script = readFileSync(SCRIPT, "utf8"); + const fixture = readFileSync(SCREENSHOT_FIXTURE, "utf8"); + expect(script).toContain("chat) printf '%s\\n' \"Draft a short status update for the team.\""); + expect(script).not.toContain("chat) printf '%s\\n' \"Ready when you are\""); + expect(fixture).toContain('"Draft a short status update for the team."'); expect(script).toContain("settings) printf '%s\\n' \"OpenClaw mobile\""); expect(script).not.toContain("settings) printf '%s\\n' \"Settings\""); expect(script).toContain( @@ -58,6 +64,23 @@ describe("android screenshots script", () => { expect(script).toContain("shell wm density reset"); }); + it("pins the device timezone before rendering seeded timestamps", () => { + const script = readFileSync(SCRIPT, "utf8"); + const requireEmulatorIndex = script.indexOf('require_emulator_device "$ADB_BIN" "$ADB_SERIAL"'); + const stabilizeDeviceIndex = script.indexOf( + 'stabilize_device_for_screenshots "$ADB_BIN" "$ADB_SERIAL"', + ); + + expect(script).toContain("shell cmd time_zone_detector set_auto_detection_enabled false"); + expect(script).toContain("shell cmd alarm set-timezone UTC"); + expect(script).toContain('shell cmd alarm set-timezone "$ORIGINAL_TIME_ZONE"'); + expect(script).toContain( + 'shell cmd time_zone_detector set_auto_detection_enabled "$ORIGINAL_AUTO_TIME_ZONE"', + ); + expect(requireEmulatorIndex).toBeGreaterThan(-1); + expect(stabilizeDeviceIndex).toBeGreaterThan(requireEmulatorIndex); + }); + it("provisions a retained no-cutout screenshot emulator by default", () => { const script = readFileSync(SCRIPT, "utf8");