diff --git a/.github/workflows/demo-smoke-test.yml b/.github/workflows/demo-smoke-test.yml
new file mode 100644
index 000000000..83d6d616d
--- /dev/null
+++ b/.github/workflows/demo-smoke-test.yml
@@ -0,0 +1,120 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Opens every ApiDemos demo on an emulator and checks that it starts, its map or panorama loads,
+# and it survives a zoom and a configuration change. The tests live in the `smoke` package of each
+# app's androidTest sources and are excluded from the instrumentation workflow.
+#
+# Like the instrumentation workflow, this needs the ACTIONS_API_KEY secret and is skipped
+# without it.
+name: Demo smoke test
+
+on:
+ pull_request:
+ branches-ignore: ['gh-pages']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ # secrets cannot be referenced from a job-level `if`, so the presence of the
+ # key is resolved here and published as an output the emulator job gates on.
+ check-api-key:
+ runs-on: ubuntu-latest
+ outputs:
+ has-key: ${{ steps.check.outputs.has-key }}
+ steps:
+ - name: Check for Maps API key
+ id: check
+ env:
+ MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }}
+ run: |
+ if [ -n "$MAPS_API_KEY" ]; then
+ echo "has-key=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has-key=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::ACTIONS_API_KEY is not set; skipping the demo smoke test."
+ fi
+
+ demo-smoke-test:
+ needs: check-api-key
+ if: needs.check-api-key.outputs.has-key == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - module: ':ApiDemos:java-app'
+ path: ApiDemos/project/java-app
+ smoke-package: com.example.mapdemo.smoke
+ - module: ':ApiDemos:kotlin-app'
+ path: ApiDemos/project/kotlin-app
+ smoke-package: com.example.kotlindemos.smoke
+
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
+ | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Inject Maps API key
+ env:
+ MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }}
+ run: echo "MAPS_API_KEY=$MAPS_API_KEY" >> ./secrets.properties
+
+ - name: Build demo and test APKs
+ run: ./gradlew ${{ matrix.module }}:assembleDebug ${{ matrix.module }}:assembleDebugAndroidTest --stacktrace
+
+ - name: Run demo smoke test
+ uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
+ with:
+ # Emulators never update Google Play services, so the image's bundled version is what
+ # the maps run on. Older images bundle a version where adding advanced markers crashes
+ # inside Play services. API 36 images bundle 25.26, which works.
+ api-level: 36
+ target: google_apis
+ arch: x86_64
+ disable-animations: true
+ script: >-
+ ./gradlew ${{ matrix.module }}:connectedDebugAndroidTest --stacktrace
+ -Pandroid.testInstrumentationRunnerArguments.package=${{ matrix.smoke-package }}
+ -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true
+
+ - name: Upload test reports
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: demo-smoke-test-reports-${{ strategy.job-index }}
+ path: |
+ ${{ matrix.path }}/build/reports/androidTests
+ ${{ matrix.path }}/build/outputs/androidTest-results
+ if-no-files-found: warn
diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml
index 386228cdb..fdcf1cd45 100644
--- a/.github/workflows/instrumentation-test.yml
+++ b/.github/workflows/instrumentation-test.yml
@@ -60,9 +60,11 @@ jobs:
strategy:
fail-fast: false
matrix:
- module:
- - ':ApiDemos:java-app'
- - ':ApiDemos:kotlin-app'
+ include:
+ - module: ':ApiDemos:java-app'
+ smoke-package: com.example.mapdemo.smoke
+ - module: ':ApiDemos:kotlin-app'
+ smoke-package: com.example.kotlindemos.smoke
steps:
- name: Checkout Repo
@@ -100,7 +102,10 @@ jobs:
target: google_apis
arch: x86
disable-animations: true
- script: ./gradlew ${{ matrix.module }}:connectedDebugAndroidTest --stacktrace
+ # The demo smoke tests run in their own workflow on a newer emulator image.
+ script: >-
+ ./gradlew ${{ matrix.module }}:connectedDebugAndroidTest --stacktrace
+ -Pandroid.testInstrumentationRunnerArguments.notPackage=${{ matrix.smoke-package }}
- name: Upload test reports
if: always()
diff --git a/AGENTS.md b/AGENTS.md
index 54b338c5f..f5b07ce14 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,6 +56,17 @@ demonstrate the same features and must stay in sync.
python3 scripts/update_docs_versions.py --check # verify doc snippet versions match version catalog
```
+- **ApiDemos smoke test** (emulator, needs a real API key): opens every demo activity and checks
+ that its map loads and that it survives a zoom and a configuration change. New demos are picked
+ up from the manifest automatically.
+ ```bash
+ ./gradlew :ApiDemos:kotlin-app:connectedDebugAndroidTest \
+ -Pandroid.testInstrumentationRunnerArguments.package=com.example.kotlindemos.smoke \
+ -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true
+ ```
+ For the Java app use `:ApiDemos:java-app` and `com.example.mapdemo.smoke`. Demos that need a
+ map ID are skipped unless `MAP_ID` is set.
+
Snippet modules do not contain unit tests; they are verified through successful compilation (`assembleDebug`), Android lint (`lintDebug`), and doc version synchronization.
Running the apps requires a Maps API key: copy the keys named in
diff --git a/ApiDemos/project/java-app/build.gradle.kts b/ApiDemos/project/java-app/build.gradle.kts
index 096a2764f..292a13fd8 100644
--- a/ApiDemos/project/java-app/build.gradle.kts
+++ b/ApiDemos/project/java-app/build.gradle.kts
@@ -31,6 +31,8 @@ android {
versionName = libs.versions.versionName.get()
multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ // Start every instrumented test from a clean app state.
+ testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
buildFeatures {
@@ -55,6 +57,11 @@ android {
namespace = "com.example.mapdemo"
+ testOptions {
+ // Each test runs in its own process, so a crashing demo does not abort the rest of the run.
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ }
+
lint {
disable += setOf("MissingInflatedId")
sarifOutput = layout.buildDirectory.file("reports/lint-results-debug.sarif").get().asFile
@@ -79,6 +86,8 @@ dependencies {
androidTestImplementation(libs.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.truth)
+ androidTestImplementation(libs.androidx.test.rules)
+ androidTestUtil(libs.androidx.test.orchestrator)
androidTestImplementation(libs.ext.junit)
}
diff --git a/ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java b/ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java
new file mode 100644
index 000000000..9437deb19
--- /dev/null
+++ b/ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java
@@ -0,0 +1,451 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.example.mapdemo.smoke;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assume.assumeTrue;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.graphics.Rect;
+import android.os.Looper;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.Lifecycle;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.GrantPermissionRule;
+import androidx.viewpager.widget.ViewPager;
+import com.example.common_ui.R;
+import com.example.mapdemo.BuildConfig;
+import com.example.mapdemo.MainActivity;
+import com.google.android.gms.maps.CameraUpdateFactory;
+import com.google.android.gms.maps.GoogleMap;
+import com.google.android.gms.maps.MapView;
+import com.google.android.gms.maps.StreetViewPanoramaView;
+import com.google.android.gms.maps.SupportMapFragment;
+import com.google.android.gms.maps.SupportStreetViewPanoramaFragment;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+/**
+ * End-to-end smoke test covering every demo in the app.
+ *
+ *
The parameter list is every activity the manifest declares in the app's package except
+ * {@link MainActivity}, so a demo added to the app is covered here with no change to this file.
+ *
+ *
Each demo is checked for these things:
+ *
+ *
it reaches {@link Lifecycle.State#RESUMED} without throwing,
+ *
a map or Street View panorama is attached, laid out and ready,
+ *
its map survives a zoom out and back in,
+ *
it survives a configuration change,
+ *
nothing crashes on a background thread while it is open.
+ *
+ *
+ *
This deliberately does not assert on map content. Checking a particular marker or overlay
+ * belongs in the focused tests next to this one; the value here is breadth.
+ */
+@RunWith(Parameterized.class)
+public class DemoSmokeTest {
+
+ /**
+ * How long to wait for a demo's map to be laid out and ready. Generous because the first demo
+ * to run on a cold emulator pays for Maps SDK initialization.
+ */
+ private static final long MAP_READY_TIMEOUT_MS = 20_000L;
+
+ private static final long POLL_INTERVAL_MS = 250L;
+
+ /** How long to wait for tiles to render when {@code requireMapLoaded} is set. */
+ private static final long MAP_LOADED_TIMEOUT_MS = 30_000L;
+
+ /** Time given to a demo to react to a camera change before the next check. */
+ private static final long SETTLE_MS = 1_500L;
+
+ /**
+ * Demos that legitimately show no map of their own. Keep this empty unless a demo really is
+ * map-free; an entry here is a hole in the coverage, not a fix.
+ */
+ private static final Set DEMOS_WITHOUT_MAP = Collections.emptySet();
+
+ /** Demos that close themselves when no map ID is configured, see the README. */
+ private static final Set DEMOS_REQUIRING_MAP_ID = new HashSet<>(Arrays.asList(
+ "AdvancedMarkersDemoActivity",
+ "DataDrivenBoundariesActivity",
+ "DataDrivenDatasetStylingActivity"));
+
+ @Parameterized.Parameters(name = "{0}")
+ public static List