From 3235c9e01d4fde4db77e6fe23732943c2ce90381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 21:52:16 +0200 Subject: [PATCH 1/2] fix: crashes in Kotlin ApiDemos found by the smoke test BackgroundColorCustomizationProgrammaticDemoActivity cast the map type switch to CheckBox, which crashed on recreate, and never requested the map on first launch. The spinner demos declared the selected view as non-null, which crashed LayersDemoActivity on recreate. --- ...BackgroundColorCustomizationProgrammaticDemoActivity.kt | 7 +++---- .../java/com/example/kotlindemos/CircleDemoActivity.kt | 2 +- .../java/com/example/kotlindemos/LayersDemoActivity.kt | 2 +- .../java/com/example/kotlindemos/PolylineDemoActivity.kt | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt index a7fff2c83..804c3f045 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt @@ -21,10 +21,10 @@ import android.view.View import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.GoogleMap -import android.widget.CheckBox import com.google.android.gms.maps.model.MarkerOptions import com.google.android.gms.maps.model.LatLng import com.example.common_ui.R +import com.google.android.material.switchmaterial.SwitchMaterial /** * This shows how to to instantiate a SupportMapFragment programmatically with a custom background @@ -55,15 +55,14 @@ class BackgroundColorCustomizationProgrammaticDemoActivity : SamplesBaseActivity val fragmentTransaction = supportFragmentManager.beginTransaction() fragmentTransaction.replace(R.id.map, mapFragment, MAP_FRAGMENT_TAG) fragmentTransaction.commit() - } else { - mapFragment.getMapAsync(this) } + mapFragment.getMapAsync(this) applyInsets(findViewById(R.id.map_container)) } override fun onMapReady(map: GoogleMap) { map.mapType = GoogleMap.MAP_TYPE_NONE - val mapTypeToggleCheckbox = findViewById(R.id.map_type_toggle) + val mapTypeToggleCheckbox = findViewById(R.id.map_type_toggle) mapTypeToggleCheckbox.setOnCheckedChangeListener { _, isChecked -> map.mapType = if (isChecked) GoogleMap.MAP_TYPE_NORMAL else GoogleMap.MAP_TYPE_NONE } diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt index 9d1217d23..7cf39a9a7 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt @@ -251,7 +251,7 @@ class CircleDemoActivity : SamplesBaseActivity(), private fun getSelectedPattern(pos: Int): List? = patterns[pos].second - override fun onItemSelected(parent: AdapterView<*>, view: View, pos: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { if (parent.id == R.id.strokePatternSpinner) { circles.map { it.setStrokePattern(getSelectedPattern(pos)) } } diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt index 35d2c64fe..ba36a582a 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt @@ -194,7 +194,7 @@ class LayersDemoActivity : /** * Called as part of the AdapterView.OnItemSelectedListener */ - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { updateMapType() } diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt index e0c8f7b03..273166a3d 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt @@ -279,7 +279,7 @@ class PolylineDemoActivity : * Listener for changes in a spinner's position. * Can change the polyline's start and end caps, pattern and joint type. */ - override fun onItemSelected(parent: AdapterView<*>, view: View, pos: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when (parent.id) { com.example.common_ui.R.id.startCapSpinner -> mutablePolyline.startCap = getSelectedCap(pos) ?: ButtCap() com.example.common_ui.R.id.endCapSpinner -> mutablePolyline.endCap = getSelectedCap(pos) ?: ButtCap() From 905619fd812b9f4b9999ec55da7b770072222889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 21:52:17 +0200 Subject: [PATCH 2/2] test: add a smoke test that opens every ApiDemos demo Each demo in java-app and kotlin-app is launched, its map or panorama must become ready, the map is zoomed out and in, and the activity is recreated. Demos are read from the manifest, so new ones are covered automatically. Runs in a separate workflow on an API 36 emulator. --- .github/workflows/demo-smoke-test.yml | 120 +++++ .github/workflows/instrumentation-test.yml | 13 +- AGENTS.md | 11 + ApiDemos/project/java-app/build.gradle.kts | 9 + .../example/mapdemo/smoke/DemoSmokeTest.java | 451 ++++++++++++++++++ ApiDemos/project/kotlin-app/build.gradle.kts | 9 + .../kotlindemos/smoke/DemoSmokeTest.kt | 360 ++++++++++++++ gradle/libs.versions.toml | 4 + 8 files changed, 973 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/demo-smoke-test.yml create mode 100644 ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java create mode 100644 ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt 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 demos() throws Exception { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + PackageInfo packageInfo = context.getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.GET_ACTIVITIES); + List names = new ArrayList<>(); + if (packageInfo.activities != null) { + for (ActivityInfo activity : packageInfo.activities) { + if (activity.name.startsWith(context.getPackageName() + ".") + && !activity.name.equals(MainActivity.class.getName())) { + names.add(activity.name); + } + } + } + Collections.sort(names); + + List demos = new ArrayList<>(); + for (String name : names) { + demos.add(new Object[] { + name.substring(name.lastIndexOf('.') + 1), + Class.forName(name).asSubclass(Activity.class), + }); + } + return demos; + } + + /** The location demos ask for this on start; a permission dialog would cover the map. */ + @Rule + public final GrantPermissionRule permissions = GrantPermissionRule.grant( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION); + + private final String demoName; + private final Class demoActivity; + + /** + * Tiles only render with a real key, so waiting for them is opt-in: pass + * {@code -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true}. + */ + private final boolean requireMapLoaded = Boolean.parseBoolean( + InstrumentationRegistry.getArguments().getString("requireMapLoaded")); + + private final List uncaughtExceptions = new CopyOnWriteArrayList<>(); + private Thread.UncaughtExceptionHandler defaultHandler; + + public DemoSmokeTest(String demoName, Class demoActivity) { + this.demoName = demoName; + this.demoActivity = demoActivity; + } + + @Before + public void setUp() { + if (DEMOS_REQUIRING_MAP_ID.contains(demoName)) { + assumeTrue("Map ID not specified", hasMapId()); + } + + Thread.UncaughtExceptionHandler handler = Thread.getDefaultUncaughtExceptionHandler(); + defaultHandler = handler; + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + uncaughtExceptions.add(throwable); + // The main looper cannot recover from a crash, so let it take the process down: the + // orchestrator reports it against this test and carries on with the next one. + if (thread == Looper.getMainLooper().getThread() && handler != null) { + handler.uncaughtException(thread, throwable); + } + }); + } + + /** Also catches crashes while the demo is torn down after the test body. */ + @After + public void tearDown() { + try { + assertNoUncaughtExceptions(); + } finally { + // Only restore what setUp replaced: if the map ID check skipped the test, restoring + // null would disable crash reporting for later tests. + if (defaultHandler != null) { + Thread.setDefaultUncaughtExceptionHandler(defaultHandler); + } + } + } + + /** Mirrors ApiDemoApplication.getMapId(), which cannot be called here as it shows a toast. */ + private static boolean hasMapId() { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + return !BuildConfig.MAP_ID.equals("MAP_ID") + || !context.getString(R.string.map_id).equals("DEMO_MAP_ID"); + } + + @Test + public void demoLaunchesAndShowsMap() throws InterruptedException { + try (ActivityScenario scenario = ActivityScenario.launch(demoActivity)) { + assertResumed(scenario); + if (!DEMOS_WITHOUT_MAP.contains(demoName)) { + GoogleMap map = awaitMapSurfaceReady(scenario); + if (map != null) { + zoomOutAndBackIn(scenario, map); + } + } + assertResumed(scenario); + assertNoUncaughtExceptions(); + } + } + + /** + * Rotation and other configuration changes are where saved state and retained fragments tend + * to regress, and they are easy to miss when clicking through the app by hand. + */ + @Test + public void demoSurvivesConfigurationChange() throws InterruptedException { + try (ActivityScenario scenario = ActivityScenario.launch(demoActivity)) { + assertResumed(scenario); + if (!DEMOS_WITHOUT_MAP.contains(demoName)) { + awaitMapSurfaceReady(scenario); + } + + scenario.recreate(); + + assertResumed(scenario); + if (!DEMOS_WITHOUT_MAP.contains(demoName)) { + awaitMapSurfaceReady(scenario); + } + assertNoUncaughtExceptions(); + } + } + + private static void assertResumed(ActivityScenario scenario) { + assertThat(scenario.getState()).isEqualTo(Lifecycle.State.RESUMED); + } + + /** + * Waits until the demo shows a map or panorama that is laid out on screen, then until the + * Maps SDK reports it ready. Returns the map to zoom, or null when the demo only shows Street + * View. A map fully on screen is preferred: in a scrolling list of maps, one partly off screen + * may never report that it finished loading. + */ + @Nullable + private GoogleMap awaitMapSurfaceReady(ActivityScenario scenario) + throws InterruptedException { + long deadline = System.currentTimeMillis() + MAP_READY_TIMEOUT_MS; + List surfaces = new ArrayList<>(); + List shown = new ArrayList<>(); + + while (shown.isEmpty() && System.currentTimeMillis() < deadline) { + scenario.onActivity(activity -> { + surfaces.clear(); + surfaces.addAll(mapSurfaces(activity)); + shown.clear(); + for (MapSurface surface : surfaces) { + if (surface.view != null && isLaidOutOnScreen(surface.view)) { + shown.add(surface); + } + } + // Demos such as MapInPager open on a page without a map: swipe on to find it. + if (shown.isEmpty()) { + turnPagerPage(activity.getWindow().getDecorView()); + } + }); + if (shown.isEmpty()) { + Thread.sleep(POLL_INTERVAL_MS); + } + } + if (shown.isEmpty()) { + StringBuilder detail = new StringBuilder(); + if (surfaces.isEmpty()) { + detail.append("no map or Street View panorama was found"); + } else { + detail.append("found but not laid out:"); + for (MapSurface surface : surfaces) { + detail.append(' ').append(surface.name); + } + } + throw new AssertionError(demoName + " did not show a map within " + + MAP_READY_TIMEOUT_MS + "ms: " + detail); + } + + MapSurface surface = pickSurface(shown); + AtomicReference map = new AtomicReference<>(); + CountDownLatch ready = new CountDownLatch(1); + scenario.onActivity(activity -> surface.awaitReady.accept(readyMap -> { + map.set(readyMap); + ready.countDown(); + })); + assertWithMessage(demoName + " " + surface.name + " was not ready within " + + MAP_READY_TIMEOUT_MS + "ms") + .that(ready.await(MAP_READY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + return map.get(); + } + + private static MapSurface pickSurface(List shown) { + for (MapSurface surface : shown) { + if (surface.isMap && isFullyOnScreen(surface.view)) { + return surface; + } + } + for (MapSurface surface : shown) { + if (surface.isMap) { + return surface; + } + } + return shown.get(0); + } + + /** + * Zooms the map out and back in. When {@link #requireMapLoaded} is set, also waits for the + * tiles to render each time. This replaces the demo's own map loaded callback, if it had one, + * for the rest of the test. + */ + private void zoomOutAndBackIn(ActivityScenario scenario, GoogleMap map) + throws InterruptedException { + for (float delta : new float[] {-1f, 1f}) { + CountDownLatch loaded = new CountDownLatch(1); + scenario.onActivity(activity -> { + if (requireMapLoaded) { + map.setOnMapLoadedCallback(loaded::countDown); + } + map.moveCamera(CameraUpdateFactory.zoomBy(delta)); + }); + if (requireMapLoaded) { + assertWithMessage(demoName + " map did not finish loading after zooming") + .that(loaded.await(MAP_LOADED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + Thread.sleep(SETTLE_MS); + } + } + + private void assertNoUncaughtExceptions() { + if (!uncaughtExceptions.isEmpty()) { + throw new AssertionError(demoName + " crashed on a background thread", + uncaughtExceptions.get(0)); + } + } + + /** + * A map or panorama shown by a demo, either as a view or as a fragment. {@link #awaitReady} + * calls back with the {@link GoogleMap} for a map, or with null for a panorama once it is + * ready. + */ + private static final class MapSurface { + final String name; + @Nullable final View view; + final boolean isMap; + final Consumer> awaitReady; + + MapSurface(String name, @Nullable View view, boolean isMap, + Consumer> awaitReady) { + this.name = name; + this.view = view; + this.isMap = isMap; + this.awaitReady = awaitReady; + } + } + + /** Every map and panorama in the activity, from its views and its fragments. */ + private static List mapSurfaces(Activity activity) { + List surfaces = new ArrayList<>(); + walk(activity.getWindow().getDecorView(), surfaces); + if (activity instanceof FragmentActivity) { + for (Fragment fragment + : ((FragmentActivity) activity).getSupportFragmentManager().getFragments()) { + walk(fragment, surfaces); + } + } + return surfaces; + } + + private static void walk(View view, List surfaces) { + if (view instanceof MapView) { + MapView mapView = (MapView) view; + surfaces.add(new MapSurface("MapView", view, true, + callback -> mapView.getMapAsync(callback::accept))); + } else if (view instanceof StreetViewPanoramaView) { + StreetViewPanoramaView panoramaView = (StreetViewPanoramaView) view; + surfaces.add(new MapSurface("StreetViewPanoramaView", view, false, + callback -> panoramaView.getStreetViewPanoramaAsync( + panorama -> callback.accept(null)))); + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int index = 0; index < group.getChildCount(); index++) { + walk(group.getChildAt(index), surfaces); + } + } + } + + private static void walk(Fragment fragment, List surfaces) { + if (fragment instanceof SupportMapFragment) { + SupportMapFragment mapFragment = (SupportMapFragment) fragment; + surfaces.add(new MapSurface("SupportMapFragment", fragment.getView(), true, + callback -> mapFragment.getMapAsync(callback::accept))); + } else if (fragment instanceof SupportStreetViewPanoramaFragment) { + SupportStreetViewPanoramaFragment panoramaFragment = + (SupportStreetViewPanoramaFragment) fragment; + surfaces.add(new MapSurface("SupportStreetViewPanoramaFragment", fragment.getView(), + false, callback -> panoramaFragment.getStreetViewPanoramaAsync( + panorama -> callback.accept(null)))); + } + for (Fragment child : fragment.getChildFragmentManager().getFragments()) { + walk(child, surfaces); + } + } + + /** Moves the first pager below this view that has a page left to its next page. */ + private static boolean turnPagerPage(View view) { + if (view instanceof ViewPager) { + ViewPager pager = (ViewPager) view; + int pageCount = pager.getAdapter() == null ? 0 : pager.getAdapter().getCount(); + if (pager.getCurrentItem() < pageCount - 1) { + pager.setCurrentItem(pager.getCurrentItem() + 1); + return true; + } + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int index = 0; index < group.getChildCount(); index++) { + if (turnPagerPage(group.getChildAt(index))) { + return true; + } + } + } + return false; + } + + private static boolean isLaidOutOnScreen(View view) { + return view.isShown() && view.getWidth() > 0 && view.getHeight() > 0; + } + + /** True when the view is shown and its whole area is visible on screen. */ + private static boolean isFullyOnScreen(View view) { + if (!isLaidOutOnScreen(view)) { + return false; + } + Rect visible = new Rect(); + return view.getGlobalVisibleRect(visible) + && visible.width() == view.getWidth() + && visible.height() == view.getHeight(); + } +} diff --git a/ApiDemos/project/kotlin-app/build.gradle.kts b/ApiDemos/project/kotlin-app/build.gradle.kts index 70a9c1dad..3e50885fc 100644 --- a/ApiDemos/project/kotlin-app/build.gradle.kts +++ b/ApiDemos/project/kotlin-app/build.gradle.kts @@ -32,6 +32,8 @@ android { versionCode = 1 versionName = libs.versions.versionName.get() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Start every instrumented test from a clean app state. + testInstrumentationRunnerArguments["clearPackageData"] = "true" multiDexEnabled = true } @@ -51,6 +53,11 @@ android { } } + 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 @@ -98,6 +105,8 @@ dependencies { androidTestImplementation(libs.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.test.rules) + androidTestUtil(libs.androidx.test.orchestrator) implementation(project(":ApiDemos:common-ui")) } diff --git a/ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt b/ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt new file mode 100644 index 000000000..18a58a537 --- /dev/null +++ b/ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt @@ -0,0 +1,360 @@ +// 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.kotlindemos.smoke + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.graphics.Rect +import android.os.Looper +import android.view.View +import android.view.ViewGroup +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.kotlindemos.BuildConfig +import com.example.kotlindemos.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.OnMapReadyCallback +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * 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 + * [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 [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) +class DemoSmokeTest( + private val demoName: String, + private val demoActivity: Class, +) { + + companion object { + /** + * 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 const val MAP_READY_TIMEOUT_MS = 20_000L + + private const val POLL_INTERVAL_MS = 250L + + /** How long to wait for tiles to render when `requireMapLoaded` is set. */ + private const val MAP_LOADED_TIMEOUT_MS = 30_000L + + /** Time given to a demo to react to a camera change before the next check. */ + private const val 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 val DEMOS_WITHOUT_MAP = emptySet() + + /** Demos that close themselves when no map ID is configured, see the README. */ + private val DEMOS_REQUIRING_MAP_ID = setOf( + "AdvancedMarkersDemoActivity", + "DataDrivenBoundariesActivity", + "DataDrivenDatasetStylingActivity", + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun demos(): List> { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_ACTIVITIES, + ) + return packageInfo.activities.orEmpty() + .map { it.name } + .filter { it.startsWith("${context.packageName}.") } + .filter { it != MainActivity::class.java.name } + .sorted() + .map { name -> + arrayOf( + name.substringAfterLast('.'), + Class.forName(name).asSubclass(Activity::class.java), + ) + } + } + } + + /** The location demos ask for this on start; a permission dialog would cover the map. */ + @get:Rule + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + + /** + * Tiles only render with a real key, so waiting for them is opt-in: pass + * `-Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true`. + */ + private val requireMapLoaded = + InstrumentationRegistry.getArguments().getString("requireMapLoaded").toBoolean() + + private val uncaughtExceptions = CopyOnWriteArrayList() + private var defaultHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + if (demoName in DEMOS_REQUIRING_MAP_ID) { + assumeTrue("Map ID not specified", hasMapId()) + } + + val handler = Thread.getDefaultUncaughtExceptionHandler() + defaultHandler = handler + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + uncaughtExceptions.add(throwable) + // The main looper cannot recover from a crash, so let it take the process down: the + // orchestrator reports it against this test and carries on with the next one. + if (thread == Looper.getMainLooper().thread) handler?.uncaughtException(thread, throwable) + } + } + + /** Also catches crashes while the demo is torn down after the test body. */ + @After + fun tearDown() { + try { + assertNoUncaughtExceptions() + } finally { + // Only restore what setUp replaced: if the map ID check skipped the test, restoring + // null would disable crash reporting for later tests. + defaultHandler?.let { Thread.setDefaultUncaughtExceptionHandler(it) } + } + } + + /** Mirrors ApiDemoApplication.mapId, which cannot be read here because it shows a toast. */ + private fun hasMapId(): Boolean { + val context = InstrumentationRegistry.getInstrumentation().targetContext + return BuildConfig.MAP_ID != "MAP_ID" || context.getString(R.string.map_id) != "DEMO_MAP_ID" + } + + @Test + fun demoLaunchesAndShowsMap() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP) { + val map = scenario.awaitMapSurfaceReady() + if (map != null) scenario.zoomOutAndBackIn(map) + } + scenario.assertResumed() + assertNoUncaughtExceptions() + } + } + + /** + * Rotation and other configuration changes are where saved state and retained fragments tend + * to regress, and they are easy to miss when clicking through the app by hand. + */ + @Test + fun demoSurvivesConfigurationChange() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP) scenario.awaitMapSurfaceReady() + + scenario.recreate() + + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP) scenario.awaitMapSurfaceReady() + assertNoUncaughtExceptions() + } + } + + private fun ActivityScenario.assertResumed() { + assertThat(state).isEqualTo(Lifecycle.State.RESUMED) + } + + /** + * Waits until the demo shows a map or panorama that is laid out on screen, then until the + * Maps SDK reports it ready. Returns the map to zoom, or null when the demo only shows Street + * View. A map fully on screen is preferred: in a scrolling list of maps, one partly off + * screen may never report that it finished loading. + */ + private fun ActivityScenario.awaitMapSurfaceReady(): GoogleMap? { + val deadline = System.currentTimeMillis() + MAP_READY_TIMEOUT_MS + var surfaces = emptyList() + var shown = emptyList() + + while (shown.isEmpty() && System.currentTimeMillis() < deadline) { + onActivity { activity -> + surfaces = activity.mapSurfaces() + shown = surfaces.filter { it.view?.isLaidOutOnScreen() == true } + // Demos such as MapInPager open on a page without a map: swipe on to find it. + if (shown.isEmpty()) activity.window.decorView.turnPagerPage() + } + if (shown.isEmpty()) Thread.sleep(POLL_INTERVAL_MS) + } + if (shown.isEmpty()) { + val detail = if (surfaces.isEmpty()) { + "no map or Street View panorama was found" + } else { + surfaces.joinToString(prefix = "found but not laid out: ") { it.name } + } + throw AssertionError( + "$demoName did not show a map within ${MAP_READY_TIMEOUT_MS}ms: $detail", + ) + } + + val surface = shown.firstOrNull { it.isMap && it.view!!.isFullyOnScreen() } + ?: shown.firstOrNull { it.isMap } + ?: shown.first() + var map: GoogleMap? = null + val ready = CountDownLatch(1) + onActivity { + surface.awaitReady { readyMap -> + map = readyMap + ready.countDown() + } + } + assertWithMessage("$demoName ${surface.name} was not ready within ${MAP_READY_TIMEOUT_MS}ms") + .that(ready.await(MAP_READY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + return map + } + + /** + * Zooms the map out and back in. When [requireMapLoaded] is set, also waits for the tiles to + * render each time. This replaces the demo's own map loaded callback, if it had one, for the + * rest of the test. + */ + private fun ActivityScenario.zoomOutAndBackIn(map: GoogleMap) { + for (delta in floatArrayOf(-1f, 1f)) { + val loaded = CountDownLatch(1) + onActivity { + if (requireMapLoaded) map.setOnMapLoadedCallback { loaded.countDown() } + map.moveCamera(CameraUpdateFactory.zoomBy(delta)) + } + if (requireMapLoaded) { + assertWithMessage("$demoName map did not finish loading after zooming") + .that(loaded.await(MAP_LOADED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + } + Thread.sleep(SETTLE_MS) + } + } + + private fun assertNoUncaughtExceptions() { + val failure = uncaughtExceptions.firstOrNull() ?: return + throw AssertionError("$demoName crashed on a background thread", failure) + } + + /** + * A map or panorama shown by a demo, either as a view or as a fragment. [awaitReady] calls + * back with the [GoogleMap] for a map, or with null for a panorama once it is ready. + */ + private class MapSurface( + val name: String, + val view: View?, + val isMap: Boolean, + val awaitReady: ((GoogleMap?) -> Unit) -> Unit, + ) + + private fun mapSurface(name: String, view: View?, getMapAsync: (OnMapReadyCallback) -> Unit) = + MapSurface(name, view, isMap = true) { callback -> getMapAsync { callback(it) } } + + private fun panoramaSurface( + name: String, + view: View?, + getPanoramaAsync: (() -> Unit) -> Unit, + ) = MapSurface(name, view, isMap = false) { callback -> getPanoramaAsync { callback(null) } } + + /** Every map and panorama in the activity, from its views and its fragments. */ + private fun Activity.mapSurfaces(): List = buildList { + fun walk(view: View) { + when (view) { + is MapView -> add(mapSurface("MapView", view, view::getMapAsync)) + is StreetViewPanoramaView -> add( + panoramaSurface("StreetViewPanoramaView", view) { onReady -> + view.getStreetViewPanoramaAsync { onReady() } + }, + ) + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + } + + fun walk(fragment: Fragment) { + when (fragment) { + is SupportMapFragment -> add( + mapSurface("SupportMapFragment", fragment.view, fragment::getMapAsync), + ) + is SupportStreetViewPanoramaFragment -> add( + panoramaSurface("SupportStreetViewPanoramaFragment", fragment.view) { onReady -> + fragment.getStreetViewPanoramaAsync { onReady() } + }, + ) + } + fragment.childFragmentManager.fragments.forEach(::walk) + } + + walk(window.decorView) + if (this@mapSurfaces is FragmentActivity) { + supportFragmentManager.fragments.forEach(::walk) + } + } + + /** Moves the first pager below this view that has a page left to its next page. */ + private fun View.turnPagerPage(): Boolean { + if (this is ViewPager && currentItem < (adapter?.count ?: 0) - 1) { + currentItem += 1 + return true + } + return this is ViewGroup && (0 until childCount).any { getChildAt(it).turnPagerPage() } + } + + private fun View.isLaidOutOnScreen(): Boolean = isShown && width > 0 && height > 0 + + /** True when this view is shown and its whole area is visible on screen. */ + private fun View.isFullyOnScreen(): Boolean { + if (!isLaidOutOnScreen()) return false + val visible = Rect() + return getGlobalVisibleRect(visible) && + visible.width() == width && + visible.height() == height + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c4ccacd..2dfc7bb3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,8 @@ espresso = "3.7.0" junit = "4.13.2" mockito = "6.3.0" robolectric = "4.17" +androidxTestOrchestrator = "1.6.1" +androidxTestRules = "1.7.0" truth = "1.4.5" turbine = "1.2.1" uiautomator = "2.4.0" @@ -132,6 +134,8 @@ hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", ve # Testing espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } espresso-idling-resource = { module = "androidx.test.espresso:espresso-idling-resource", version.ref = "espresso" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidxTestOrchestrator" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }