blob: ecc66472e7e8297a48e7c2f7b8bb26f3d47a1a97 [file] [view]
# Android Hardware Smoke Test
An integration and compatibility smoke test suite
designed to verify visual rendering correctness
on Android hardware.
## Prerequisites & Initial Setup
Because this integration test project
follows a minimal-boilerplate pattern,
standard binary Gradle wrappers and properties
are not committed to the repository.
Before compiling or running
any connected JUnit/instrumented tests locally for the first time,
simply run the standard Flutter project regeneration command
in this directory to restore the missing wrappers:
```sh
flutter create --platform=android --no-overwrite .
```
This will cleanly restore the missing wrapper scripts
(`gradlew`, `gradlew.bat`) and wrapper configurations
without modifying any of the customized build definitions,
Java/Kotlin test harnesses, or package sources.
## 1. Overview & Purpose
The primary objective of the `android_hardware_smoke_test`
is to provide a **fully self-contained Android instrumented test suite**.
This allows Android hardware manufacturers (OEMs)
to run visual regression, performance, and GPU compatibility tests
on a precompiled APK directly on their target devices.
**The test execution requires no Flutter SDK,
Dart CLI commands, or host-side orchestration.**
Feedback is reported directly in standard native Android JUnit test reports.
---
## 2. Structural Differences: `android_hardware_smoke_test` vs. `android_engine_test`
While both suites verify rendering correctness, they are architected differently to support different workflows:
| Dimension | `android_engine_test` | `android_hardware_smoke_test` |
| :--- | :--- | :--- |
| **Verification Location** | **Host-Only** (CI PC) | **Dual-Mode**: On-Device (OEM) & On-Host (CI) |
| **Golden Comparison** | Host-side only against Skia Gold. | **OEM Mode**: In-App pixel comparison against bundled assets.<br>**CI Mode**: Host-side comparisons against repository files. |
| **Device Role** | Passive target rendering static views. | Active participant executing on-device JUnit orchestration. |
| **Target Audience** | Core Engine Contributors & CI Shards. | Android Hardware Manufacturers (OEMs) & CI Shards. |
---
## 3. Dual-Mode Architecture
```mermaid
flowchart TD
TestInit(["Test Initiation"]) --> Mode{"Which Mode?"}
Mode -->|"Host-Driven Mode"| HostScript["driver script<br/>(test_driver/driver_test.dart)"]
Mode -->|"Instrumented Mode"| JUnit["native Android JUnit<br/>(FlutterActivityTest)"]
HostScript --> RequestData["Driver script connects and requests<br/>testName via driver.requestData()"]
JUnit --> SendMessage["JUnit test runner sends<br/>testName over Message Channel"]
RequestData --> RenderHost["Dart app renders target state"]
SendMessage --> RenderDevice["Dart app renders target state"]
RenderHost --> HostRender["Dart app returns image bytes over channel"]
RenderDevice --> OnDeviceCompare["Dart app performs local on-device golden<br/>comparison against bundled assets"]
HostRender --> CompareHost["Driver script asserts exact match<br/>against local filesystem"]
OnDeviceCompare --> Report["Dart app returns success/fail<br/>to JUnit test runner"]
```
### Host-Driven Driver Mode (CI / Host-Driven)
* **Orchestration**: Orchestrated by the host PC using `flutter drive`.
* **Execution**: The host script (`test_driver/driver_test.dart`)
commands the app (`lib/main.dart`
through thin wrapper `integration_test/integration_test_wrapper.dart`)
to transition states.
The app captures the repaint boundary,
base64-encodes it, and streams the bytes back to the host
over the message channel.
The host driver decodes the bytes
and asserts visual matches against local repository baselines
on the host filesystem.
### Instrumented On-Device Mode (OEM / Standalone)
* **Orchestration**: Runs purely on the device under Android `AndroidJUnit4` runner.
* **Execution**: Kotlin JUnit code (`FlutterActivityTest.kt`)
launches the main activity and sends the test payload
over a JSON message channel.
The app (`lib/main.dart`) renders the widget,
performs a local pixel-by-pixel on-device comparison
against baseline images bundled within the APK assets,
and replies with the status to the Java runner
to pass or fail the JUnit assertion.
---
## 4. How to Run the Tests
Unlock your connected Android device or emulator
and ensure it is active before executing any of these commands.
### A. Running via Host Driver (CI / Host-Driven)
This mode is used to execute visual assertions
locally on your PC or in CI pipelines,
and to manage the local golden baselines.
* **Command to run the driver test suite**:
```sh
# Execute from the android_hardware_smoke_test root directory
flutter drive -v \
--driver=test_driver/driver_test.dart \
--target=integration_test/integration_test_wrapper.dart \
--no-dds
```
* **Command to capture/update reference golden baselines**:
Running with `UPDATE_GOLDENS=true`
writes or overwrites the local PNG baselines
under `test_driver/goldens/` on the host.
Because the statically compiled `AndroidManifest.xml`
is the single source of truth,
the app will automatically self-report its active backend variant.
To capture or update the baseline for a specific graphics variant,
simply edit `android/app/src/main/AndroidManifest.xml`
to set the desired `io.flutter.embedding.android.ImpellerBackend` value,
then execute:
```sh
UPDATE_GOLDENS=true flutter drive -v \
--driver=test_driver/driver_test.dart \
--target=integration_test/integration_test_wrapper.dart \
--no-dds
```
---
### B. Running Instrumented Tests (OEM / Self-Contained)
> [!IMPORTANT]
> **Asset Bundling Precondition**:
> Because instrumented tests run completely standalone on the device,
> they compare pixels against baseline images
> bundled as read-only assets inside the APK.
> You **must** first generate the local baselines
> under `test_driver/goldens/`
> using the **Host-Driven Driver Mode (with `UPDATE_GOLDENS=true`)**
> before compiling and building the instrumented APK.
* **Command to compile and run the native JUnit suite**:
```sh
# Execute from the 'android' subdirectory
cd android
./gradlew :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.example.android_hardware_smoke_test.FlutterActivityTest \
-s
```
> [!NOTE]
> **Statically Compiled Single Source of Truth**:
> The app's compiled `AndroidManifest.xml` `<meta-data>` tag
> is the single source of truth
> for the graphics backend configuration
> under **both** Instrumented On-Device Mode (OEM)
> and Host-Driven Driver Mode (CI).
>
> * **Instrumented On-Device Mode (OEM)**:
> The native Kotlin JUnit harness (`FlutterActivityTest.kt`)
> reads this value dynamically using the `PackageManager` API
> and routes it to Dart.
> * **Host-Driven Driver Mode (CI / Host)**:
> The Dart app queries the native Android embedder
> via a custom `MethodChannel` to self-discover its compiled backend
> and self-reports it to the host test script
> inside its JSON reply payload,
> completely eliminating the need for environment variables on the host PC.
>
> To switch the active graphics backend manually for local runs,
> open `android/app/src/main/AndroidManifest.xml`
> and update the `io.flutter.embedding.android.ImpellerBackend` value:
>
> ```xml
> <!-- Enable Vulkan: -->
> <meta-data android:name="io.flutter.embedding.android.ImpellerBackend" android:value="vulkan" />
>
> <!-- Enable OpenGLES: -->
> <meta-data android:name="io.flutter.embedding.android.ImpellerBackend" android:value="opengles" />
> ```
> [!NOTE]
> **Automated HTML Screenshot Embedding (`embedTestResultImages`)**:
> When running the Gradle command above,
> a custom Kotlin DSL task named **`embedTestResultImages`**
> executes automatically once the tests finish.
>
> It performs the following actions seamlessly:
> 1. Prevents Gradle from auto-uninstalling the APKs prematurely
> (via a `gradle.properties` injection).
> 2. Queries the device sandbox cache
> to discover all rendered `.png` files dynamically.
> 3. Streams the raw binary images directly onto the host PC
> using zero-copy ADB piping.
> 4. Dynamically parses the generated HTML reports
> and injects Alternative `<img>` elements
> right next to the test outcome table cells.
> 5. Executes a manual `adb uninstall` cleanup
> to leave the target device perfectly clean.
>
> Once finished,
> open `app/build/reports/androidTests/connected/debug/index.html`
> to view the interactive report
> with all rendering result snapshots embedded natively!
---
### C. Manual Command-Line Debugging (Without Gradle Orchestration)
If you prefer to bypass Gradle entirely for custom debugging,
you can manually build, install, and run the instrumentation
using raw `adb` shell calls:
1. **Build and Install the packages manually**:
```sh
# Run from the 'android' subdirectory
cd android
./gradlew installDebug installDebugAndroidTest
```
2. **Manually launch the native Android instrumentation test**:
```sh
adb shell am instrument -w \
-e class com.example.android_hardware_smoke_test.FlutterActivityTest \
com.example.android_hardware_smoke_test.test/androidx.test.runner.AndroidJUnitRunner
```
3. **Manually pull the generated snapshot off the device's sandbox**:
Since the app remains installed during raw `adb` runs,
you can copy the rendering result files manually:
```sh
adb exec-out "run-as com.example.android_hardware_smoke_test cat cache/results/blueRectangleTest.png" \
> test_driver/results/blueRectangleTest.png
```
---
### D. Running the CI Shard Locally
Since the test suite is registered
inside the central repository test orchestrator (`dev/bots/test.dart`),
you can execute the full CI runner pipeline locally
using standard dev-bot scripts:
```sh
# Run the Vulkan graphics backend shard locally
SHARD=android_hardware_smoke_vulkan_tests bin/cache/dart-sdk/bin/dart dev/bots/test.dart
# Run the OpenGLES graphics backend shard locally
SHARD=android_hardware_smoke_opengles_tests bin/cache/dart-sdk/bin/dart dev/bots/test.dart
```
---
## 5. Test Suite Coverage & Technical Rationales
The suite is composed of targeted visual regression test cases
designed to exercise distinct GPU graphics pipelines
and verify compatibility with driver and hardware configurations:
| Test Case | Rendering Pipeline | Impeller/Hardware Mechanism Exercised | Reference/Source |
| :--- | :--- | :--- | :--- |
| **`blueRectangleTest`** | **Solid Vector Fills** | Standard vector rasterization and layout transformation. | Simple `canvas.drawRect` |
| **`trianglePathTest`** | **Complex Paths** | Path triangulation, rasterization, and hardware anti-aliasing (MSAA). | Simple `canvas.drawPath` |
| **`textTest`** | **Font Rendering** | Text layout (`TextPainter`), glyph caching, shaping, and font atlas rendering. | Simple `TextPainter.paint` |
| **`imageTest`** | **Texture Sampling** | Image decoding, GPU texture uploading, and texture sampler rendering. Uses a 32x32 4-color checkerboard PNG to verify RGB color channel correctness. | Simple `canvas.drawImage` |
| **`advancedBlendTest`** | **Advanced Blending** | Fragment shader blending and framebuffer fetch tile-memory optimizations (e.g. Vulkan subpass inputs, `EXT_shader_framebuffer_fetch` in GLES). Uses `BlendMode.difference`. | Mirrors [animated_advanced_blend.dart](/dev/benchmarks/macrobenchmarks/lib/src/animated_advanced_blend.dart). |
| **`backdropFilterBlurTest`** | **Compositing & Blur** | Offscreen texture allocation, layer downscale/upscale passes, and multi-pass Gaussian blur filter execution. Uses `ImageFilter.blur(sigmaX: 5, sigmaY: 5)`. | Mirrors [backdrop_filter.dart](/dev/benchmarks/macrobenchmarks/lib/src/backdrop_filter.dart). |
| **`platformViewTextureLayerTest`** | **Platform Views (Texture Layer)** | Embedded native Android views composition using Texture Layer Hybrid Composition (TLHC) via `PlatformViewsService.initSurfaceAndroidView`. | Mirrors texture layer composition pathways. |
| **`platformViewHybridCompositionTest`** | **Platform Views (Hybrid Composition)** | Embedded native Android views composition using legacy Hybrid Composition (HC) via `PlatformViewsService.initExpensiveAndroidView`. Uses `AndroidViewSurface` to overlay native UI. | Mirrors legacy hybrid composition pathways. |
| **`platformViewHybridCompositionPlusPlusTest`** | **Platform Views (Hybrid Composition++)** | Embedded native Android views composition using Hybrid Composition++ (HCPP) via `PlatformViewsService.initHybridAndroidView`. Skips cleanly if the device lacks HCPP hardware support. | Mirrors modern HCPP composition pathways. |
---
## 6. Platform View Screenshot Strategy
Testing platform views requires capturing a screenshot
of the physical screen layout
that contains both the Flutter-rendered UI and native Android views
(e.g., a native `TextView` wrapped under hybrid composition).
Because these two contexts are rendered on separate hardware surface layers,
standard in-process widget screenshot methods
(like `RenderRepaintBoundary.toImage()`)
cannot see or capture the native platform view pixels.
To address this,
the test suite implements a **No-Compositing System Screenshot Strategy**:
```mermaid
flowchart TD
TestInit(["Test Initiation"]) --> Mode{"Which Mode?"}
Mode -->|"Host-Driven Mode"| HostScript["driver script<br/>(test_driver/driver_test.dart)"]
Mode -->|"Instrumented Mode"| JUnit["native Android JUnit<br/>(FlutterActivityTest)"]
HostScript --> RequestData["Driver script connects and requests<br/>testName via driver.requestData()"]
JUnit --> SendMessage["JUnit test runner sends<br/>testName over Message Channel"]
RequestData --> RenderHost["Dart app renders target state"]
SendMessage --> RenderDevice["Dart app renders target state"]
RenderHost -->|"Unique for PlatformView tests"| RenderHostPlatformView["Native Kotlin UI renders via PlatformView"]:::unique
RenderDevice -->|"Unique for PlatformView tests"| RenderDevicePlatformView["Native Kotlin UI renders via PlatformView"]:::unique
RenderHostPlatformView --> DrawSyncHost["Native view triggers onDraw;<br/>Dart awaits callback + 1 frame"]:::unique
DrawSyncHost --> CoordsHost["Dart app returns widget<br/>crop coordinates"]:::unique
RenderDevicePlatformView --> DrawSyncDevice["Native view triggers onDraw;<br/>Dart awaits callback + 1 frame"]:::unique
DrawSyncDevice --> CoordsDevice["Dart app returns widget<br/>crop coordinates"]:::unique
CoordsHost --> CaptureHost["Driver script takes ADB screencap<br/>and crops locally"]:::unique
CoordsDevice --> CaptureDevice["JUnit test runner takes UiAutomation<br/>screencap and crops natively"]:::unique
CaptureHost -->|"Remaining steps occur normally"| CompareHost["Driver script asserts exact match<br/>against local filesystem"]
CaptureDevice --> SendBytes["JUnit test runner sends cropped<br/>base64 bytes to Dart app over message channel"]:::unique
SendBytes --> ImmediateComparison["App handles cropped bytes immediately without new frame render"]:::unique
ImmediateComparison -->|"Remaining steps occur normally"| OnDeviceCompare["Dart app performs local on-device golden<br/>comparison against bundled assets"]
OnDeviceCompare --> Report["Dart app returns success/fail<br/>to JUnit test runner"]
classDef unique fill:#fff3cd,stroke:#ffc107,color:#856404,stroke-width:2px;
```
### Flow Breakdown
The beginning and end of the test are the same
as for other tests as described in Section 3 above.
* **Native UI rendering and timing**:
For platform view tests (starting with `platformView` prefix),
the app renders an `AndroidViewLink` or `AndroidView` widget.
This causes the Android OS to render the native Kotlin UI (`NativeTextView`)
via the selected composition mode
([`NativeTextView.kt`](android/app/src/main/kotlin/com/example/android_hardware_smoke_test/NativeTextView.kt)).
Because the native UI is rendered by the OS,
`addPostFrameCallback` is no longer sufficient to guarantee
that all platform view pixels have been fully rendered and composited.
To solve this,
the native `ObservableTextView` overrides `onDraw(Canvas)`
to send an `"onDraw"` method channel callback
once the native view actually paints.
The Dart app awaits this callback via `settleFuture`
in [`goldens.dart`](lib/goldens.dart)
and then waits 1 additional frame using
`await WidgetsBinding.instance.endOfFrame`
to ensure the platform view composition is fully submitted.
* **Crop coordinates and test-script-driven screenshot capture**:
* **Both Modes:** The Dart app (`_handlePlatformViewRequest`
in [`goldens.dart`](lib/goldens.dart))
computes the bounding box of the `RepaintBoundary`
in physical device pixels
and returns these coordinates back to the test runner.
* **Host-Driven Mode:** The driver script takes a full-screen screenshot
of the physical device using ADB
(`adb shell screencap` wrapped inside `NativeDriver.screenshot()`
in [`driver_test.dart`](test_driver/driver_test.dart)).
Then crops it locally to the retrieved coordinates
using the Dart `image` package.
* **Instrumented Mode:** The JUnit test runner takes a full-screen screenshot
using `UiAutomation.takeScreenshot()`
in [`FlutterActivityTest.kt: captureAndSendScreenshot`](android/app/src/androidTest/java/com/example/android_hardware_smoke_test/FlutterActivityTest.kt).
Then crops the bitmap natively using `Bitmap.createBitmap`.
* **Extra round trip and encoding-independent pixel comparison for Instrumented Mode**:
* **Instrumented Mode:** The Dart app is where we perform the golden comparison,
so the JUnit test runner sends the cropped bytes back to the Dart app
by base64-encoding them and sending them over the `BasicMessageChannel`.
It sets a field on the JSON message called `command`
with the value `compare_golden`.
When the Dart app parses this request,
it performs the golden comparison immediately
instead of waiting for another frame through `addPostFrameCallback`.
However, the normal `NaiveLocalFileComparator` compares all image bytes.
This is a problem because the different methods of capturing screenshots
may produce images with PNG encoding differences.
To compare pixels regardless of encoding,
we use a [`PixelExactLocalFileComparator`](lib/pixel_exact_local_file_comparator.dart).
An additional benefit of this custom comparator is
that it supports reading golden references directly
from the package's bundled assets
using the `asset://` URI scheme via `rootBundle.load()`,
completely avoiding the need to copy files
to temporary directories on the device.
After comparison, it reports success or failure
in the same way as it would for other tests.
* **Host-Driven Mode:** The driver doesn't need a new round trip
because it performs the comparison directly
in the same way as it does for image bytes returned from the Dart app.
### Why the `PixelCopy` approach was not desirable
An alternative approach using `PixelCopy` was considered:
* **How it worked:** The native Kotlin app targeted the `FlutterSurfaceView`
directly using `PixelCopy.request(surfaceView, ...)`
and then manually traversed the Android sibling view hierarchy,
drawing the visible native platform view boundaries
on top of the captured bitmap using a Kotlin `Canvas`.
While plausible and functioning,
it was **not desirable** for several reasons:
1. **Manual Compositing Replicas:** Drawing views manually onto a canvas
(`child.draw(canvas)`) relies on replicating the composition steps.
If the operating system or graphics drivers apply specific shader effects,
blending, custom overlays, or subpixel anti-aliasing
during hardware composition, the manual Kotlin reconstruction
might not match what the user actually sees.
2. **Missing Real Composition Bugs:** The primary goal of this smoke test
is to catch platform-specific integration
and composition rendering errors in GPU drivers.
If we manually draw the sibling views ourselves,
we bypass the OS hardware compositor (SurfaceFlinger) entirely
for the screenshot,
defeating the purpose of testing the system's actual composition pipeline.
3. **Fragility:** Manually translating coordinate spaces, handling layouts,
view visibility states, and Z-orders in Kotlin
is highly fragile and prone to emulation/rendering bugs.
By capturing the entire screen
using native system compositor APIs (`adb` / `UiAutomation`) and cropping,
the test asserts on the **true, final composited image**
rendered by the device's GPU and system composer.
### Why the Compile-Online / Run-Offline Split was not desirable
An alternative execution model was considered:
* **How it worked**: The build is split into
two sequential phases: compiling the test APK online
(`assembleDebugAndroidTest`), followed by
running the test execution completely offline
(`connectedDebugAndroidTest --offline`) to
insulate the emulator execution from Maven network flakes.
While beneficial for network isolation,
it was **not desirable** for several reasons:
1. **UTP Dynamic Resolution Design**: The Android Gradle Plugin
(AGP) 8.x/9.x Unified Test Platform (UTP) runner resolves and
downloads its host-side runner plugins (like
`com.android.tools.utp:android-test-plugin-host-additional-test-output`)
dynamically via internal detached configurations at
runtime. These dependencies are not resolved during
compilation, causing the offline check to fail on
cold caches.
2. **Maintenance Overhead / Double Execution**: To support
offline runs, we would either have to run a try-catch
online test execution first (which executes the entire
test suite twice, wasting CI resources) or manually
pin and declare all transitive UTP dependencies in the
`build.gradle.kts` file. Hardcoding UTP plugin versions
is highly brittle and breaks automatically whenever the
project upgrades its AGP version.
3. **Redundant Safety**: Because the online compilation phase
(`assembleDebugAndroidTest`) already queries remote Maven
repositories for project dependencies, running the test
execution online does not introduce any new network
vectors that could flake. Gradle's internal dependency
caching guarantees subsequent execution remains
extremely fast.
By executing `connectedDebugAndroidTest` online directly in
a single step, the test suite executes exactly once,
remains low maintenance across AGP upgrades, and
cleanly bubbles up any legitimate compiler or runner
errors without silent try-catch blocks.