Skip to content

Latest commit

 

History

History
502 lines (360 loc) · 27.4 KB

File metadata and controls

502 lines (360 loc) · 27.4 KB

Android CI/CD

This repository uses one reusable Android validation workflow plus three entry workflows: automatic pull-request checks, automatic push main, and manual release:

  • GitHub Actions is the primary Android CI/CD entrypoint on main
  • .github/workflows/android-ci-reusable.yml contains the actual Android CI implementation
  • .github/workflows/pr-checks.yml contains the required aggregate pull-request gate and conditionally calls the Android validation jobs
  • .github/workflows/android-ci.yml is the automatic push main Android validation workflow
  • .github/workflows/android-release.yml is the manual Android release workflow
  • Firebase Test Lab runs only from the manual release workflow on Google-managed devices
  • automatic Android CI and manual Android release are fully independent from the AWS/Web release workflow
  • the manual Android release workflow uploads a production-track draft release to Google Play; final publication still happens later in Play Console
  • cloudbuild.android.yaml is the Google-native entrypoint for Cloud Build triggers in the Google Cloud console

This setup keeps repository-native checks in GitHub while still allowing Google-managed device testing and avoiding long-lived Google service account keys. We treat the managed-device app instrumentation suite as the closest CI signal to production behavior, while GitHub-hosted jobs keep the fast unit/build/lint checks and the smaller data:local instrumentation gate. For release runs, the workflow resolves one shared ANDROID_VERSION_CODE and one manager-readable Android release identifier once, then reuses them across Android build artifacts, the Play draft release name, and Firebase Test Lab result correlation for that same SHA. The current release identifier format is vc<versionCode>-r<runId>a<attempt>-s<shortSha>.

Required GitHub repository variables

The manual Android release workflow Firebase Test Lab job depends on these repository variables:

  • GCP_PROJECT_ID
  • GCP_WORKLOAD_IDENTITY_PROVIDER
  • GCP_SERVICE_ACCOUNT_EMAIL
  • ANDROID_FTL_DEVICE_MODEL
  • ANDROID_FTL_DEVICE_VERSION
  • ANDROID_FTL_RESULTS_BUCKET
  • ANDROID_FTL_RESULTS_DIR

The Android Google Play release workflow depends on these repository variables:

  • GCP_PROJECT_ID
  • GCP_WORKLOAD_IDENTITY_PROVIDER
  • GCP_PLAY_SERVICE_ACCOUNT_EMAIL
  • ANDROID_PLAY_PACKAGE_NAME
  • ANDROID_SENTRY_DSN
  • ANDROID_SENTRY_TRACES_SAMPLE_RATE (optional, defaults to 0 for release builds)
  • SENTRY_ORG
  • SENTRY_ANDROID_PROJECT

This optional local .env variable documents the operator service account used for Firebase Test Lab diagnostics:

  • GCP_FTL_READER_SERVICE_ACCOUNT

And these repository secrets:

  • ANDROID_UPLOAD_KEYSTORE_BASE64
  • ANDROID_UPLOAD_KEYSTORE_PASSWORD
  • ANDROID_UPLOAD_KEY_ALIAS
  • ANDROID_UPLOAD_KEY_PASSWORD
  • SENTRY_AUTH_TOKEN

Push them to the repository with:

bash scripts/android/setup-github-android.sh

This Android-specific sync is separate from the AWS deploy bootstrap script bash scripts/setup/setup-github.sh.

What runs

Pull-request GitHub Actions workflow: .github/workflows/pr-checks.yml

  • Starts on every pull_request, so the required Repository static checks context is always reported
  • Detects Android-impacting files while excluding apps/android/README.md and apps/android/docs/**
  • Calls .github/workflows/android-ci-reusable.yml for the pull-request merge commit with run_data_local_instrumentation: false when Android-impacting files changed, so unit tests, debug builds, and lint run before merge
  • Calls the reusable workflow separately with run_build: false and run_data_local_instrumentation: true only when files that can change the :data:local suite result change: apps/android/data/**, apps/android/core/observability/**, or shared Android Gradle configuration
  • The narrow filter exists because :data:local depends only on :core:observability, so :app, :core:ui, and :feature:* changes cannot change that suite result
  • Aggregates every conditional and unconditional PR job into Repository static checks; branch protection requires that context with strict up-to-date checks before merge
  • Does not upload a Google Play draft
  • Does not submit Firebase Test Lab

Automatic GitHub Actions workflow: .github/workflows/android-ci.yml

  • Starts on push main when Android-impacting files change
  • Calls .github/workflows/android-ci-reusable.yml with run_build: false, so only the data:local emulator instrumentation job runs
  • Is the post-merge emulator backstop and does not repeat the build, unit tests, or lint already enforced before merge
  • Does not upload a Google Play draft
  • Does not submit Firebase Test Lab

GitHub Actions reusable workflow: .github/workflows/android-ci-reusable.yml

  • Exposes two boolean inputs that both default to true: run_build gates the build, unit test, and lint job, and run_data_local_instrumentation gates the emulator data:local instrumentation job; the two conditional callers in pr-checks.yml each turn one off, android-ci.yml turns off the build job, and android-release.yml leaves both on
  • Runs test for the whole Android Gradle project
  • Builds :app:assembleDebug
  • Builds :app:assembleDebugAndroidTest
  • Builds :data:local:assembleDebugAndroidTest
  • Runs :app:lintDebug
  • Delegates the GitHub-hosted Android Gradle entrypoints to repo-root shell scripts in scripts/android/
  • Uploads the debug APK, Android test APK, unit test reports, and lint report as workflow artifacts
  • Boots a headless Android 17 / API 37 emulator in GitHub Actions with -gpu swiftshader
  • Runs :data:local:connectedDebugAndroidTest on that emulator
  • Uploads data:local instrumentation reports from the emulator run when the Gradle task produced them
  • Intentionally does not make the emulator job depend on the build job, because the emulator job does its own checkout and Gradle build, so a failing build no longer prevents or cancels the emulator run
  • Reuses the caller-provided ANDROID_VERSION_CODE across Android CI/build artifacts
  • Uses the Sentry release name com.flashcardsopensourceapp.app@<versionName>+<versionCode> for Android release artifact correlation; the workflow summary also prints the manager-readable Play release identifier, but runtime Sentry event tags/contexts are controlled by app runtime code

Top-level release workflow Firebase job: .github/workflows/android-release.yml job firebase_test_lab_submission

  • Starts on every manual Android Release run after android_ci succeeds
  • Validates Firebase Test Lab configuration, authenticates to Google Cloud, downloads the debug APK artifacts, and submits the full app instrumentation package com.flashcardsopensourceapp.app, excluding com.flashcardsopensourceapp.app.ManualOnlyAndroidTest
  • Reuses the shared release identifier vc<versionCode>-r<runId>a<attempt>-s<shortSha> in Firebase result naming and traces results under ${ANDROID_FTL_RESULTS_DIR}/<releaseIdentifier>
  • Requires Firebase Test Lab submission before the Play draft upload starts; the submission is asynchronous, so review the Firebase matrix result before publishing from Play Console

Top-level release workflow Play job: .github/workflows/android-release.yml job publish_android

  • Builds and uploads the signed Android App Bundle artifact, then gates R8 optimization coverage before anything reaches Google Play
  • Reads the R8 run summary AGP embeds at BUNDLE-METADATA/com.android.tools/r8.json inside that exact bundle, so the reported numbers describe the artifact being uploaded rather than a separate analysis run
  • Converts noShrinkingPercentage, noOptimizationPercentage, and noObfuscationPercentage into coverage percentages, prints them to the run summary, and fails the run when any category is below Google Play's 25% minimum
  • Fails loudly when the R8 metadata is missing or unreadable, so a silently unoptimized bundle cannot be published
  • Runs after the bundle artifact upload, so a failed gate still leaves the exact bundle attached to the run for debugging

The pull-request Android flow is:

  1. pr-checks.yml starts on every pull request and detects the affected areas
  2. Android-impacting changes validate the pull-request merge commit with unit tests, debug builds, and lint
  3. Android data-layer, :core:observability, and shared Gradle configuration changes also run data:local emulator instrumentation in parallel
  4. The always-present Repository static checks aggregate succeeds only when every in-scope PR job succeeds and every out-of-scope conditional job is skipped
  5. Branch protection requires that aggregate against the latest main, so an outdated or failing pull request cannot merge
  6. The workflow stops there: no Firebase Test Lab submission and no Google Play draft upload

The automatic Android CI flow is:

  1. android-ci.yml starts on push main for Android-impacting changes
  2. data:local Android instrumentation runs on a GitHub-hosted Android 17 emulator
  3. The already-required build, unit tests, and lint do not repeat
  4. The workflow stops there: no Firebase Test Lab submission and no Google Play draft upload

The manual Android release flow is:

  1. android-release.yml starts only through manual workflow_dispatch; optional target_sha pins a specific release commit, otherwise the selected workflow ref SHA is used
  2. The workflow resolves one shared ANDROID_VERSION_CODE and one shared Android release identifier for the run
  3. The reusable Android CI gate runs for the target SHA
  4. Firebase Test Lab app instrumentation is submitted for the debug APKs produced by the CI gate
  5. After Firebase submission succeeds, the signed Android App Bundle is built and uploaded as a workflow artifact
  6. The R8 optimization coverage gate reads BUNDLE-METADATA/com.android.tools/r8.json from that bundle and fails the run when shrinking, optimization, or obfuscation coverage is below Google's 25% minimum
  7. Only then is the bundle uploaded as a Google Play production-track draft
  8. Review the Firebase matrix result and Play Console draft before publishing manually

After pushing to main, watch Android CI separately when Android-impacting files changed.

For Android, a green automatic Android CI run means the post-merge data:local emulator backstop passed for that SHA. The required PR aggregate already enforced the applicable build, unit tests, lint, and pull-request emulator gate. A green automatic run does not mean Firebase Test Lab was submitted, a Google Play draft was uploaded, or a release is ready to publish.

A green manual Android Release run means the GitHub-hosted Android gate passed, Firebase Test Lab submission succeeded, and CI uploaded a Play draft. It still does not mean Firebase Test Lab finished or passed, and it does not mean the release is already live. Translation review, Firebase matrix review, Play-delivered build verification, and final publication still happen later in Play Console. A non-green Android Release run means one of the required release stages failed or was skipped by a failed dependency.

To match a Play draft or Firebase submission to the exact SHA and GitHub run:

  • use the Android release preflight summary to get the target SHA, shared ANDROID_VERSION_CODE, and shared release identifier vc<versionCode>-r<runId>a<attempt>-s<shortSha>
  • use the Android Play draft upload summary to get the Play draft release name main-draft-<releaseIdentifier> and version code
  • use the Run details link in the release summary to open the exact GitHub Actions run
  • use the Firebase summaries to get the Google-assigned matrix ID and the Firebase results path ${ANDROID_FTL_RESULTS_DIR}/<releaseIdentifier> in the configured results bucket
  • correlate everything by the shared release identifier, GitHub run id and attempt, and target SHA; the Firebase matrix ID is useful for lookup after submission but it is not the manager-readable release identifier

Local Firebase Test Lab diagnostics

Use GCP_FTL_READER_SERVICE_ACCOUNT for local read-only Firebase Test Lab inspection. Do not create or store Google service account JSON keys for this project; key creation is blocked by organization policy, and local access should use gcloud service account impersonation.

The current reader service account is:

codex-ftl-reader@flashcards-open-source-app.iam.gserviceaccount.com

It needs these permissions:

  • roles/viewer, roles/cloudtestservice.testViewer, and roles/firebase.viewer on the Google Cloud project
  • roles/storage.objectViewer on the dedicated Firebase Test Lab results bucket
  • roles/iam.serviceAccountTokenCreator for the local operator account that impersonates it

After gcloud auth login, verify diagnostics access with:

source .env

gcloud auth print-access-token \
  --impersonate-service-account "${GCP_FTL_READER_SERVICE_ACCOUNT}" >/dev/null

gcloud --impersonate-service-account "${GCP_FTL_READER_SERVICE_ACCOUNT}" \
  storage ls "${ANDROID_FTL_RESULTS_BUCKET}/${ANDROID_FTL_RESULTS_DIR}/"

Fetch a specific matrix summary with the Testing API:

source .env

access_token="$(gcloud auth print-access-token --impersonate-service-account "${GCP_FTL_READER_SERVICE_ACCOUNT}")"

curl --silent --show-error --fail \
  --header "Authorization: Bearer ${access_token}" \
  "https://testing.googleapis.com/v1/projects/${GCP_PROJECT_ID}/testMatrices/<matrix-id>"

Android translation model

Android app-internal translations are Play-first:

  • Keep the repository authoritative for the base English Android strings and locale plumbing.
  • Do not add or maintain repository-owned Android values-xx translation trees by default, including Spanish.
  • After CI uploads the signed AAB as a draft production release, use Google Play App strings translation and Gemini in Play Console to create or update translated Android UI copy.
  • Review and publish that draft later in Play Console after the Play-managed translations are ready.
  • Treat Google Play listing localization separately from in-app Android strings.

Cross-client live smoke references:

  • Android: apps/android/app/src/androidTest/java/com/flashcardsopensourceapp/app/livesmoke/LiveSmokeTest.kt
  • Android notification tap gate: apps/android/app/src/androidTest/java/com/flashcardsopensourceapp/app/notifications/NotificationTapSmokeTest.kt
  • iOS: apps/ios/Flashcards/FlashcardsUITests/LiveSmoke*Tests.swift
  • Web: apps/web/e2e/live-smoke.spec.ts

Cloud Build config: cloudbuild.android.yaml

  • Builds a dedicated Android CI container from apps/android/ci/Dockerfile
  • Reuses the same fast CI shell script as GitHub Actions and the same Firebase Test Lab package-level targeting
  • Can be attached to a Cloud Build trigger connected to the GitHub repository

Recommended architecture

This is the current recommended shape for this repository:

  • Use GitHub Actions as the default CI orchestrator because the repo already uses GitHub Actions for other services
  • Use Workload Identity Federation for GitHub to Google Cloud authentication
  • Do not store Google service account JSON keys in GitHub secrets
  • Use Firebase Test Lab for instrumentation tests instead of self-hosted emulators
  • Use a separate Google Cloud service account for Google Play uploads, scoped in Play Console to this app only
  • Use Google Play production-track draft uploads so translation review and final release approval stay in Play Console
  • Use a dedicated Cloud Storage bucket for Test Lab results so you do not need broad roles/editor

Google's current documentation supports this direction:

  • Android CI guidance explicitly lists Firebase Test Lab as a reliable device farm option for instrumented tests: Android CI automation
  • Firebase Test Lab IAM guidance says gcloud firebase test android run defaults to requiring roles/editor, and recommends using your own results bucket plus narrower roles instead: Firebase Test Lab IAM permissions
  • The Google GitHub auth action warns that Workload Identity Federation is preferred over long-lived service account JSON keys: google-github-actions/auth

One-time Google Cloud setup

You need a Google Cloud project with Firebase enabled for Test Lab.

1. Create or choose the project

  • Pick the Google Cloud project that should own Android CI
  • Add Firebase to that project if it is not already a Firebase project

2. Create a dedicated Test Lab results bucket

Example:

gcloud storage buckets create "gs://flashcards-open-source-app-test-lab-results" \
  --project "YOUR_GCP_PROJECT_ID" \
  --location "europe-west1" \
  --uniform-bucket-level-access

3. Create a GitHub Actions service account

Example:

gcloud iam service-accounts create "github-android-ci" \
  --project "YOUR_GCP_PROJECT_ID" \
  --display-name "GitHub Android CI"

Grant the minimum project roles recommended by Firebase Test Lab when you use your own results bucket:

gcloud projects add-iam-policy-binding "YOUR_GCP_PROJECT_ID" \
  --member "serviceAccount:github-android-ci@YOUR_GCP_PROJECT_ID.iam.gserviceaccount.com" \
  --role "roles/cloudtestservice.testAdmin"

gcloud projects add-iam-policy-binding "YOUR_GCP_PROJECT_ID" \
  --member "serviceAccount:github-android-ci@YOUR_GCP_PROJECT_ID.iam.gserviceaccount.com" \
  --role "roles/firebase.analyticsViewer"

Grant bucket access scoped to the dedicated results bucket:

gcloud storage buckets add-iam-policy-binding "gs://flashcards-open-source-app-test-lab-results" \
  --member "serviceAccount:github-android-ci@YOUR_GCP_PROJECT_ID.iam.gserviceaccount.com" \
  --role "roles/storage.admin"

4. Create Workload Identity Federation for GitHub Actions

Create the pool:

gcloud iam workload-identity-pools create "github" \
  --project "YOUR_GCP_PROJECT_ID" \
  --location "global" \
  --display-name "GitHub Actions"

Create the provider:

gcloud iam workload-identity-pools providers create-oidc "flashcards-open-source-app" \
  --project "YOUR_GCP_PROJECT_ID" \
  --location "global" \
  --workload-identity-pool "github" \
  --display-name "flashcards-open-source-app GitHub" \
  --issuer-uri "https://token.actions.githubusercontent.com" \
  --attribute-mapping "google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition "assertion.repository == 'kirill-markin/flashcards-open-source-app'"

Allow the repository to impersonate the service account:

gcloud iam service-accounts add-iam-policy-binding \
  "github-android-ci@YOUR_GCP_PROJECT_ID.iam.gserviceaccount.com" \
  --project "YOUR_GCP_PROJECT_ID" \
  --role "roles/iam.workloadIdentityUser" \
  --member "principalSet://iam.googleapis.com/projects/YOUR_PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/kirill-markin/flashcards-open-source-app"

The GitHub variable GCP_WORKLOAD_IDENTITY_PROVIDER must use this format:

projects/YOUR_PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/flashcards-open-source-app

5. Create a dedicated Google Play release service account

Create a second service account for Google Play uploads:

gcloud iam service-accounts create "github-android-play" \
  --project "YOUR_GCP_PROJECT_ID" \
  --display-name "GitHub Android Play Release"

You do not need broad Google Cloud project roles for the Play upload itself. The release workflow authenticates as this service account through Workload Identity Federation, and the actual app release permissions are granted in Play Console.

Allow the repository to impersonate the service account:

gcloud iam service-accounts add-iam-policy-binding \
  "github-android-play@YOUR_GCP_PROJECT_ID.iam.gserviceaccount.com" \
  --project "YOUR_GCP_PROJECT_ID" \
  --role "roles/iam.workloadIdentityUser" \
  --member "principalSet://iam.googleapis.com/projects/YOUR_PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/kirill-markin/flashcards-open-source-app"

6. Enable the Google Play Developer API

Enable the Android Publisher API in the same Google Cloud project:

gcloud services enable androidpublisher.googleapis.com \
  --project "YOUR_GCP_PROJECT_ID"

7. Choose the Firebase Test Lab device

This repository intentionally tests Android 17 / API 37 only.

Before setting the GitHub variables, list supported Test Lab devices for your project and choose a device that supports API 37:

gcloud firebase test android models list --project "YOUR_GCP_PROJECT_ID"

Then set:

  • ANDROID_FTL_DEVICE_MODEL
  • ANDROID_FTL_DEVICE_VERSION

GitHub repository variables

Set the required repository variables listed above before running the manual Android Release workflow.

Set the Google Play release variables and secrets before expecting .github/workflows/android-release.yml to upload a draft release successfully.

ANDROID_PLAY_PACKAGE_NAME should match the Android applicationId. In this repository that value is com.flashcardsopensourceapp.app.

scripts/android/setup-github-android.sh requires the Sentry repository variables in the local environment when updating GitHub configuration. SENTRY_AUTH_TOKEN is only written when present locally, so operators can update variables without replacing an existing GitHub secret.

One-time Play Console setup

Before the release workflow can upload draft releases to Google Play, complete this one-time setup in Play Console:

  1. Create the app with package name com.flashcardsopensourceapp.app.
  2. Complete the required Play Console setup sections for the app shell, including app access, ads declaration, content rating, target audience, privacy policy, and Data safety if Play requires them for release submission.
  3. Enable Play App Signing for the app.
  4. Configure production availability in Play Console, including countries and regions for the production track.
  5. Invite GCP_PLAY_SERVICE_ACCOUNT_EMAIL in Play Console under Users and permissions, then grant the app-specific permissions needed to upload production-track draft releases.
  6. Make the first signed upload manually in Play Console using the same upload keystore that CI will use later.

That first manual upload is the safest bootstrap step because it establishes the app entry, Play App Signing state, and first track release before CI takes over subsequent draft uploads.

After CI uploads a draft release:

  1. Open Play Console and review the new production-track draft release.
  2. Review or generate Android App strings translations there with the Play Console workflow and Gemini.
  3. Publish the release manually from Play Console when translation review is complete.

Cloud Build trigger setup

Cloud Build is optional here, but useful if you want a Google-native trigger in the Google Cloud console in addition to GitHub Actions.

1. Connect the GitHub repository to Cloud Build

  • In Google Cloud console, open Cloud Build
  • Connect the GitHub repository
  • Create a trigger that uses cloudbuild.android.yaml

2. Use a dedicated Cloud Build service account

For Cloud Build triggers, use a dedicated service account instead of the legacy default account and grant it the same permissions as the GitHub Actions service account:

  • roles/cloudtestservice.testAdmin
  • roles/firebase.analyticsViewer
  • roles/storage.admin on the dedicated results bucket

3. Configure trigger substitutions

Set these substitutions on the trigger:

  • _ANDROID_FTL_DEVICE_MODEL
  • _ANDROID_FTL_DEVICE_VERSION
  • _ANDROID_FTL_RESULTS_BUCKET
  • _ANDROID_FTL_RESULTS_DIR

Local Testing Rules

For Android, follow apps/android/README.md for platform targets and testing focus. Tests should be run only against the final supported Android target, not against older API levels. Run Android local tests only sequentially on the local machine. Do not run Android local tests in parallel. Before running Android tests, also check which Android emulators are available locally. If a local emulator is available, start it in the background without a visible emulator window by default and preserve the usual test artifacts, logs, screenshots, and reports. Open a visible Android emulator only when the user explicitly asks for it at that time. For local instrumentation runs, prefer one clean emulator only:

  • stop all running Android emulators before the run
  • verify adb devices shows only one target emulator before starting Gradle
  • when launching a local headless emulator manually, prefer emulator @Medium_Phone_API_37.0 -no-window -no-audio -gpu auto
  • for temporary local startup diagnosis only, keep the same command and add -verbose -debug init,metrics -logcat '*:s ActivityManager:i AndroidTestOrchestrator:i TestRunner:i'
  • prefer a clean rebuild and one clean test run when validating a local fix
  • do not reuse a second emulator or a half-failed prior emulator session for the same verification pass

Local parity commands

Build the same artifacts CI expects:

bash scripts/android/run-android-ci.sh

Run the retained Android FSRS parity test against the shared vectors:

cd apps/android && ./gradlew --no-daemon :data:local:testDebugUnitTest --tests com.flashcardsopensourceapp.data.local.model.scheduling.FsrsSchedulerParityTest

Build the signed release bundle with the same inputs that the release workflow uses:

For local release and upload paths, export or source the Sentry variables before invoking Gradle or the helper script: ANDROID_SENTRY_DSN, SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_ANDROID_PROJECT.

bash scripts/android/run-android-release.sh \
  --version-code "12345" \
  --keystore-path "/absolute/path/to/upload-key.jks" \
  --keystore-password "YOUR_KEYSTORE_PASSWORD" \
  --key-alias "YOUR_KEY_ALIAS" \
  --key-password "YOUR_KEY_PASSWORD"

Run one app instrumentation class on a local emulator for ad hoc debugging (requires a running emulator via adb devices):

adb devices
cd apps/android && ./gradlew clean :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.flashcardsopensourceapp.app.livesmoke.LiveSmokeTest

Note: connectedDebugAndroidTest does not support the --tests flag. Use -Pandroid.testInstrumentationRunnerArguments.class= to filter by test class.

Run another app instrumentation class on a local emulator:

adb devices
cd apps/android && ./gradlew clean :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.flashcardsopensourceapp.app.notifications.NotificationTapSmokeTest

Run the full app instrumentation package in Firebase Test Lab directly after authenticating with gcloud:

bash scripts/android/run-android-firebase-test-lab.sh \
  --project-id "YOUR_GCP_PROJECT_ID" \
  --device-model "YOUR_DEVICE_MODEL" \
  --device-version "36" \
  --app-path "apps/android/app/build/outputs/apk/debug/app-debug.apk" \
  --test-path "apps/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \
  --timeout "30m" \
  --max-matrix-duration "35m" \
  --test-targets "package com.flashcardsopensourceapp.app notAnnotation com.flashcardsopensourceapp.app.ManualOnlyAndroidTest" \
  --results-bucket "gs://flashcards-open-source-app-test-lab-results" \
  --results-dir "manual/local"

Keep --max-matrix-duration slightly above --timeout so Test Lab still has room to finish device startup and final matrix reporting cleanly. This matters more when Orchestrator is enabled because it adds per-test process startup overhead.