diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f47153e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Build context for docker/screengrab/Dockerfile only needs Gemfile. +# Excluding the heavy stuff so `docker build` is fast. +.git +.gradle +build +app/build +**/build +**/.gradle +**/node_modules +*.apk +*.aab +fastlane/metadata/android/*/images +docs/screenshots +mockserver/__pycache__ diff --git a/.gitignore b/.gitignore index 1ee9e37f..3119ac79 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,12 @@ fastlane/screenshots fastlane/test_output fastlane/readme.md fastlane/metadata/android/whatsnew-* +fastlane/metadata/android/screenshots.html + +# Ruby / bundler (fastlane) +.bundle +vendor/bundle +Gemfile.lock # Version control vcs.xml diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..7a118b49 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfaaa653..059f9644 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -174,6 +174,9 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.glance.appwidget.testing) + androidTestImplementation(libs.screengrab) + androidTestImplementation(libs.androidx.test.uiautomator) + androidTestImplementation(libs.androidx.test.core) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/app/src/androidTest/java/com/matedroid/screenshots/ScreenshotsTest.kt b/app/src/androidTest/java/com/matedroid/screenshots/ScreenshotsTest.kt new file mode 100644 index 00000000..d47f7505 --- /dev/null +++ b/app/src/androidTest/java/com/matedroid/screenshots/ScreenshotsTest.kt @@ -0,0 +1,84 @@ +package com.matedroid.screenshots + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.matedroid.MainActivity +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import tools.fastlane.screengrab.Screengrab +import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy +import tools.fastlane.screengrab.locale.LocaleTestRule + +/** + * Drives the app and captures Play Store / F-Droid screenshots via Fastlane screengrab. + * + * Run end-to-end (app must be pointed at the mock server first — see scripts/screengrab.sh): + * ./scripts/screengrab.sh + * + * PoC scope: just the dashboard. Add more @Test methods (or steps inside one) as + * navigation gets wired up for the remaining 12 screens listed in docs/SCREENSHOTS.md. + * + * Uses ActivityScenario + UiAutomator instead of `createAndroidComposeRule` because + * Compose's test rule auto-invokes `Espresso.onIdle()`, which crashes on Android 14+ + * with espresso-core 3.6.1: `InputManagerEventInjectionStrategy` does + * `InputManager.getInstance()` via reflection, and that method was removed in API 34. + * Screengrab's screenshot strategy is UiAutomator anyway, so leaning on that here + * keeps the toolchain consistent and avoids a flaky Espresso bump. + */ +@RunWith(AndroidJUnit4::class) +class ScreenshotsTest { + + @get:Rule + val localeTestRule = LocaleTestRule() + + private val device: UiDevice + get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + init { + Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy()) + } + + /** + * Wake the device and dismiss the keyguard before each test. Without this, + * the activity launches *under* the lock screen — the accessibility tree + * still contains the dashboard's nodes (so a semantic-only wait succeeds) + * but the visible surface is whatever the lock screen / black-screen-saver + * is showing, and the screenshot comes out black with just the nav bar. + */ + @Before + fun wakeAndUnlock() { + device.wakeUp() + // No-op when there's a PIN/pattern; on the screenshot device the lock + // screen is just the swipe-up keyguard so this dismisses it. + device.executeShellCommand("wm dismiss-keyguard") + device.executeShellCommand("svc power stayon true") + device.waitForIdle() + } + + @Test + fun captureMainDashboard() { + ActivityScenario.launch(MainActivity::class.java).use { + // The dashboard's hero card carries this content description + // (R.string.car_image_tap_for_stats). Waiting on the semantic node + // tells us Compose has composed the screen — but composition happens + // ahead of drawing, so the pixels may not be on the GPU yet. + device.wait(Until.hasObject(By.descContains("Car image")), 30_000) + ?: error("Dashboard car image did not appear within 30s — is the mock running and serving data?") + + // Force a layout/draw settle. waitForIdle() blocks until the AccessibilityEvent + // queue drains; the extra short sleep is to let Compose's animations + // (e.g. the loading spinner fade-out, hero card scale-in) finish so the + // screenshot is the steady-state and not mid-transition. + device.waitForIdle() + Thread.sleep(1500) + + Screengrab.screenshot("01-main-dashboard") + } + } +} diff --git a/docker/screengrab/Dockerfile b/docker/screengrab/Dockerfile new file mode 100644 index 00000000..7ad49e14 --- /dev/null +++ b/docker/screengrab/Dockerfile @@ -0,0 +1,46 @@ +# Image for `bundle exec fastlane screengrab` so the host laptop doesn't need +# Ruby/bundler/fastlane installed. +# +# Build context is the repo root so the Gemfile (at the project root) is +# copied in. Build: +# docker build -t matedroid-screengrab -f docker/screengrab/Dockerfile . +# Run (Linux): +# docker run --rm --network host \ +# --user "$(id -u):$(id -g)" \ +# -v "$PWD":/workspace \ +# matedroid-screengrab +# +# `--network host` is what lets the container's adb client reach the host's +# adb-server at 127.0.0.1:5037, so the device the host is already paired with +# (e.g. via ADB-over-WiFi) shows up inside the container too. + +FROM ruby:3-slim + +ENV DEBIAN_FRONTEND=noninteractive + +# - android-tools-adb: the `adb` binary fastlane shells out to +# - build-essential: needed for native gem extensions during `bundle install` +# - ca-certificates: HTTPS for rubygems.org +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + android-tools-adb \ + build-essential \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Cache the gem-install layer separately from the project mount: as long as +# the Gemfile doesn't change, this layer is reused. +COPY Gemfile ./ +RUN bundle install + +# The image installs gems as root, but we run the container with --user +# matching the host UID so written files have the right ownership. Make the +# bundle path readable for any uid, and give bundler/fastlane a writable HOME. +RUN chmod -R a+rx /usr/local/bundle && \ + mkdir -p /tmp/home && chmod 777 /tmp/home +ENV HOME=/tmp/home + +ENTRYPOINT ["bundle", "exec", "fastlane"] +CMD ["screengrab"] diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5cf58404..e411d561 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -265,6 +265,13 @@ The `-n` flag (explicit component) is required on Android 14+ since implicit bro ./gradlew connectedAndroidTest ``` +### Screenshots + +Store-listing and README screenshots are generated automatically via Fastlane +[screengrab](https://docs.fastlane.tools/actions/screengrab/). See +[`SCREENSHOTS.md`](SCREENSHOTS.md) for the workflow and how to add a new +screen. The driver is `./scripts/take-screenshots.sh`. + ### Releasing Releases are automated via GitHub Actions. When a release is published, the workflow builds the APK and attaches it to the release, and deploys to Google Play. diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md new file mode 100644 index 00000000..f0dc5429 --- /dev/null +++ b/docs/SCREENSHOTS.md @@ -0,0 +1,128 @@ +# Screenshots + +The app's Play Store / F-Droid / README screenshots are generated automatically +via Fastlane [screengrab](https://docs.fastlane.tools/actions/screengrab/) and +written to `fastlane/metadata/android//images/phoneScreenshots/` — the +same paths F-Droid and Play Store both already read from. + +## Status — PoC + +The infrastructure is in place but only **one screen is wired up so far**: the +main dashboard. The remaining 12 are stubs and need their navigation logic +added in `app/src/androidTest/java/com/matedroid/screenshots/ScreenshotsTest.kt`. + +Target set: + +| # | name (PNG basename) | notes | +|---|-------------------------|--------------------------------------------------| +| 1 | `01-main-dashboard` | ✅ wired up | +| 2 | `02-current-charge` | only renders while mock has `--charging` active | +| 3 | `03-battery-health` | reachable from car stats / dashboard | +| 4 | `04-mileage` | reachable from dashboard | +| 5 | `05-software-versions` | reachable from dashboard / settings | +| 6 | `06-drives` | reachable from dashboard | +| 7 | `07-drive-details` | tap into the first row of drives | +| 8 | `08-charges` | reachable from dashboard | +| 9 | `09-charge-details` | tap into the first row of charges | +|10 | `10-trips` | reachable from dashboard | +|11 | `11-trip-detail` | tap into the first row of trips | +|12 | `12-stats-for-nerds` | reachable from dashboard | +|13 | `13-visited-countries` | reachable from stats | + +## Running it + +Prereqs (one-off): + +- At least one device or emulator visible to `adb devices`. + Emulator works but is slow on older laptops; a physical phone is much + faster. +- Docker installed and runnable without `sudo` + (`sudo usermod -aG docker $USER` + re-login if not). +- Pin the screenshot device by setting `ANDROID_SERIAL` (e.g. in `.env`). + The script honors it, and passes it through to the container so both the + host adb and the in-container fastlane target the same device. With one + device connected and `ANDROID_SERIAL` unset, that device is used; with + multiple devices and no `ANDROID_SERIAL`, the script bails so it doesn't + grab the wrong one. + +Then: + +``` +./scripts/screengrab.sh [car_profile] +``` + +The script: + +1. Builds `app-debug.apk` + `app-debug-androidTest.apk` on the host. +2. Builds (or reuses cached) the `matedroid-screengrab` Docker image — Ruby + slim base + `android-tools-adb` + `bundle install` of the project's + `Gemfile`. +3. Starts the Teslamate mock (`mockserver/server.py`) with the chosen car profile + plus an active DC session, so the live-charge / charges / drives screens have + real-shaped data to render. +4. Points the running app at the mock via the `DebugEndpointReceiver` ADB broadcast. +5. Runs `fastlane screengrab` inside the container. The container shares the + host's network (`--network host`), so the device the host is already paired + with via ADB is visible inside too — no port-forwarding gymnastics. The + project directory is bind-mounted at `/workspace`, and `--user` matches the + host UID so the resulting PNGs land with the right ownership in + `fastlane/metadata/android/en-US/images/phoneScreenshots/`. +6. Tears the mock down and points the app back at the real Teslamate API + (read from `.env`). + +`car_profile` defaults to `modely_juniper_perf_white`. Use any name from +`mockserver/cars.json` — see `--list-cars` in the `/mock` skill. + +### Legacy `take-screenshots.sh` + +The older `scripts/take-screenshots.sh` (adb screencap + imagemagick crops, +driven by intent extras like `EXTRA_NAVIGATE_TO`) is the script that produced +the current `docs/screenshots/*.jpg` set referenced from the README. It still +works and is left in place until the Fastlane flow above covers all 13 target +screens. + +## Adding a new screen + +Edit `ScreenshotsTest.kt`. Either add a new `@Test` method (one screen each — more +robust, slower) or extend `captureMainDashboard` into a single test that walks +the app and captures along the way (faster, all-or-nothing). + +Pattern for a new screen: + +```kotlin +// 1. Drive Compose to the target screen. +composeTestRule.onNodeWithText("Drives").performClick() + +// 2. Wait for a stable anchor on the destination screen. +composeTestRule.waitUntil(timeoutMillis = 15_000) { + composeTestRule.onAllNodesWithText("Total drives", substring = true) + .fetchSemanticsNodes().isNotEmpty() +} + +// 3. Capture. +Screengrab.screenshot("06-drives") +``` + +Use `contentDescription` over hard-coded text where possible — it survives +copy edits and locale changes better. The `LocaleTestRule` on the test class +handles the locale switching for multi-locale runs (currently only `en-US`). + +## Locales + +`fastlane/Screengrabfile` currently lists only `en-US`. To add the other +locales the app supports, expand it to: + +```ruby +locales(['en-US', 'it-IT', 'es-ES', 'ca-ES', 'zh-CN']) +``` + +Screengrab will run the test once per locale, switching the device language +between runs. Expect ~5× the runtime. + +## Pixelating / redacting + +Mock data is fake at the source (`mockserver/cars.json` + replayed upstream +data) so there is normally nothing to redact. If a future screenshot needs +masking — e.g., a debug session capturing real data — write a small +post-processor with Pillow / ImageMagick that blurs fixed rectangles per +screen and run it after `screengrab`. diff --git a/fastlane/Screengrabfile b/fastlane/Screengrabfile new file mode 100644 index 00000000..463b76d1 --- /dev/null +++ b/fastlane/Screengrabfile @@ -0,0 +1,35 @@ +# Configuration for `bundle exec fastlane screengrab`. +# See https://docs.fastlane.tools/actions/screengrab/ + +# Locales to capture. Start with en-US for the PoC; expand later by adding +# 'it-IT', 'es-ES', 'ca-ES', 'zh-CN' once the en-US flow is solid. +locales(['en-US']) + +# Wipe previous PNGs so stale ones don't linger when a screen is renamed. +clear_previous_screenshots(true) + +# Output to fastlane/metadata/android//images/phoneScreenshots/ — +# the path F-Droid + Play Store both already read from. +output_directory('fastlane/metadata/android') + +# Match the test runner declared in app/build.gradle.kts. +test_instrumentation_runner('androidx.test.runner.AndroidJUnitRunner') + +# Limit to the screenshot test class — the project also has widget tests in +# androidTest/ that would otherwise be picked up and fail (they expect specific +# device state). +use_tests_in_classes(['com.matedroid.screenshots.ScreenshotsTest']) + +# AGP names the test APK package .test by default. +app_package_name('com.matedroid') +tests_package_name('com.matedroid.test') + +# Pin the APKs explicitly. Without this screengrab scans for any app-*.apk +# under app/build/outputs/apk/ and prompts interactively when it finds more +# than one (e.g. release + debug); inside the container there is no TTY, so +# the prompt crashes the run. +app_apk_path('app/build/outputs/apk/debug/app-debug.apk') +tests_apk_path('app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk') + +# Skip the build step — scripts/screengrab.sh assembles the APKs first. +skip_open_summary(true) diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01-main-dashboard.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01-main-dashboard.png new file mode 100644 index 00000000..08884d4c Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01-main-dashboard.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2d5f8ab..e94351b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ workManager = "2.10.5" hiltWork = "1.3.0" glance = "1.1.1" detekt = "1.23.8" +screengrab = "2.1.1" +uiautomator = "2.3.0" [libraries] # Core Android @@ -88,6 +90,9 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +screengrab = { group = "tools.fastlane", name = "screengrab", version.ref = "screengrab" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-test-core = { group = "androidx.test", name = "core-ktx", version = "1.6.1" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/scripts/screengrab.sh b/scripts/screengrab.sh new file mode 100755 index 00000000..d7544bc3 --- /dev/null +++ b/scripts/screengrab.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# +# End-to-end Fastlane screengrab driver. Runs fastlane inside a throw-away +# Docker container so the host laptop doesn't need ruby/bundler/fastlane. +# +# Flow: +# 1. Build the debug app + androidTest APK on the host (uses the existing +# Gradle + Android SDK setup). +# 2. Build (or reuse cached) the matedroid-screengrab Docker image. +# 3. Start the Teslamate API mock with a sensible profile + an active DC charge +# so the live-charge / charges / drives / trips screens have real-shaped data. +# 4. Point the connected device at the mock via the debug ADB receiver. +# 5. Run `fastlane screengrab` inside the container — the host's adb-server +# is reused via --network host, so the device the host is paired with +# shows up inside the container automatically. +# 6. Tear the mock down and point the app back at the real Teslamate API. +# +# Prereqs: +# - Docker installed (no ruby/bundler needed on the host). +# - At least one device visible to `adb devices`. +# - .env contains TESLAMATE_API_URL for the real upstream. +# +# Targeting a specific device: +# - Honors the standard `ANDROID_SERIAL` environment variable. Set it (e.g. +# in .env) when you have multiple devices connected and want to pin the +# screenshot run to one of them. With ANDROID_SERIAL set, every adb call +# in this script and the adb client inside the container both target it. +# - With no ANDROID_SERIAL and exactly one device connected, that device +# is used. With multiple devices and no ANDROID_SERIAL, the script bails +# to avoid grabbing the wrong one. +# +# Usage: +# ./scripts/screengrab.sh [car_profile] +# +# car_profile defaults to "modely_juniper_perf_white" — see mockserver/cars.json. +# +# Note: this is the new Fastlane-based pipeline. The older +# scripts/take-screenshots.sh (adb screencap + imagemagick) still works and +# remains the source of the existing docs/screenshots/*.jpg files until this +# one fully covers all 13 target screens. + +set -euo pipefail +# Propagate failures through pipelines (we tee output) so the script's exit +# code reflects the underlying tool, not tee's status. + +cd "$(dirname "$0")/.." + +car_profile="${1:-modely_juniper_perf_white}" +mock_port=4002 +image_tag="matedroid-screengrab" + +# --- Sanity checks --------------------------------------------------------- + +if ! command -v docker >/dev/null 2>&1; then + echo "✗ docker is required on PATH (this script runs fastlane inside a container)." >&2 + exit 1 +fi + +if [ ! -f .env ]; then + echo "✗ .env not found — TESLAMATE_API_URL is needed to seed the mock with real upstream history." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +source .env + +# Pick the device. ANDROID_SERIAL (from env, or .env above) wins; otherwise +# require exactly 1 connected device so we don't grab the wrong one when the +# user has multiple paired (e.g. a phone over USB and another over ADB-WiFi). +if [ -n "${ANDROID_SERIAL:-}" ]; then + if ! adb devices | awk 'NR>1 && $2=="device" {print $1}' | grep -qx "$ANDROID_SERIAL"; then + echo "✗ ANDROID_SERIAL=$ANDROID_SERIAL is set but that device is not online." >&2 + adb devices >&2 + exit 1 + fi + echo "→ Targeting device $ANDROID_SERIAL" +else + device_count=$(adb devices | awk 'NR>1 && $2=="device"' | wc -l) + if [ "$device_count" -ne 1 ]; then + echo "✗ Found $device_count connected ADB devices — set ANDROID_SERIAL to pick one." >&2 + adb devices >&2 + exit 1 + fi + ANDROID_SERIAL=$(adb devices | awk 'NR>1 && $2=="device" {print $1; exit}') + export ANDROID_SERIAL +fi + +local_ip=$(hostname -I | awk '{print $1}') +mock_url="http://${local_ip}:${mock_port}" + +# --- Build APKs ------------------------------------------------------------ + +echo "→ Assembling debug APK + androidTest APK" +./gradlew :app:assembleDebug :app:assembleDebugAndroidTest + +# --- Build (cached) screengrab Docker image -------------------------------- + +echo "→ Building screengrab Docker image (${image_tag})" +docker build -q -t "$image_tag" -f docker/screengrab/Dockerfile . >/dev/null + +# --- Start mock + redirect app -------------------------------------------- + +echo "→ Starting Teslamate mock with profile '${car_profile}' + active DC charge on :${mock_port}" +mockserver/server.py \ + -u "$TESLAMATE_API_URL" \ + -c "$car_profile" \ + -p "$mock_port" \ + --charging --charging-dc --charging-start-soc 32 --charging-limit-soc 80 \ + > /tmp/matedroid-mock.log 2>&1 & +mock_pid=$! + +cleanup() { + echo "→ Stopping mock (pid $mock_pid)" + kill "$mock_pid" 2>/dev/null || true + wait "$mock_pid" 2>/dev/null || true + echo "→ Pointing app back at real Teslamate API" + adb -s "$ANDROID_SERIAL" shell am broadcast -n com.matedroid/.receiver.DebugEndpointReceiver \ + -a com.matedroid.SET_ENDPOINT --es url "$TESLAMATE_API_URL" >/dev/null || true + if [ -n "${original_locale:-}" ]; then + echo "→ Restoring device locale to $original_locale" + adb -s "$ANDROID_SERIAL" shell "setprop persist.sys.locale $original_locale; setprop ctl.restart zygote" >/dev/null || true + fi +} +trap cleanup EXIT + +# Give the mock a moment to bind. +sleep 2 +if ! curl -fsS "${mock_url}/cars" >/dev/null 2>&1; then + echo "✗ Mock did not come up — see /tmp/matedroid-mock.log" >&2 + exit 1 +fi + +echo "→ Pointing app at mock: $mock_url" +adb -s "$ANDROID_SERIAL" shell am broadcast -n com.matedroid/.receiver.DebugEndpointReceiver \ + -a com.matedroid.SET_ENDPOINT --es url "$mock_url" >/dev/null + +# Wake the screen + dismiss the keyguard. The test's @Before does this too, +# but doing it from the script as well means the device's display is on for +# the entire run — without this the screen tends to time out between APK +# install and test start, and screengrab grabs a black frame. +echo "→ Waking device $ANDROID_SERIAL" +adb -s "$ANDROID_SERIAL" shell input keyevent KEYCODE_WAKEUP >/dev/null +adb -s "$ANDROID_SERIAL" shell wm dismiss-keyguard >/dev/null +adb -s "$ANDROID_SERIAL" shell svc power stayon true >/dev/null + +# Force the device's system locale to en-US for the run. screengrab's +# LocaleTestRule retargets only the *test* APK's resources — the app under +# test runs in its own process (under instrumentation) and reads the system +# locale at process-start, so without this the captured screenshots come +# out in whatever language the device was set to (Italian, Spanish, …). +# Force-stopping com.matedroid below ensures the next launch picks up the +# new locale instead of reusing a process started under the old one. +# cleanup() restores the original locale. +original_locale=$(adb -s "$ANDROID_SERIAL" shell getprop persist.sys.locale | tr -d '\r') +if [ "$original_locale" != "en-US" ]; then + echo "→ Switching device locale: $original_locale → en-US" + adb -s "$ANDROID_SERIAL" shell setprop persist.sys.locale en-US >/dev/null + adb -s "$ANDROID_SERIAL" shell am force-stop com.matedroid >/dev/null +fi + +# --- Run fastlane screengrab inside the container ------------------------- + +echo "→ Running fastlane screengrab in Docker (target: $ANDROID_SERIAL)" +docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + -e "ANDROID_SERIAL=$ANDROID_SERIAL" \ + -v "$PWD":/workspace \ + "$image_tag" + +# Screengrab appends a millisecond timestamp to each PNG so re-runs don't +# collide. Strip it so the committed filenames stay stable +# (01-main-dashboard.png, not 01-main-dashboard_1777548982770.png). +echo "→ Normalizing screenshot filenames" +shopt -s nullglob +for f in fastlane/metadata/android/*/images/phoneScreenshots/*_[0-9]*.png; do + mv -f "$f" "${f%_[0-9]*.png}.png" +done +shopt -u nullglob + +echo "✓ Screenshots written to fastlane/metadata/android//images/phoneScreenshots/"