Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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__
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source "https://rubygems.org"

gem "fastlane"
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
46 changes: 46 additions & 0 deletions docker/screengrab/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 7 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
128 changes: 128 additions & 0 deletions docs/SCREENSHOTS.md
Original file line number Diff line number Diff line change
@@ -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/<locale>/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`.
35 changes: 35 additions & 0 deletions fastlane/Screengrabfile
Original file line number Diff line number Diff line change
@@ -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/<locale>/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 <applicationId>.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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand Down
Loading
Loading