{@link OnMapAndViewReadyListener} synchronization between layout completion and {@code
+ * getMapAsync} for both {@link SupportMapFragment} and {@link SupportNavigationFragment}.
+ *
+ */
+@RunWith(RobolectricTestRunner.class)
+public class MapUtilitiesJavaTest extends NavMapsRobolectricTest {
+
+ /** Test double for {@link SupportMapFragment} with a configurable view and deferred map ready. */
+ private static class FakeSupportMapFragment extends SupportMapFragment {
+ private final View fragmentView;
+
+ FakeSupportMapFragment(View fragmentView) {
+ this.fragmentView = fragmentView;
+ }
+
+ @Override
+ public View getView() {
+ return fragmentView;
+ }
+
+ @Override
+ public void getMapAsync(OnMapReadyCallback callback) {
+ // Deferred until test explicitly invokes listener.onMapReady(map)
+ }
+ }
+
+ /**
+ * Test double for {@link SupportNavigationFragment} with a configurable view and deferred map
+ * ready.
+ */
+ private static class FakeSupportNavigationFragment extends SupportNavigationFragment {
+ private final View fragmentView;
+
+ FakeSupportNavigationFragment(View fragmentView) {
+ this.fragmentView = fragmentView;
+ }
+
+ @Override
+ public View getView() {
+ return fragmentView;
+ }
+
+ @Override
+ public void getMapAsync(OnMapReadyCallback callback) {
+ // Deferred until test explicitly invokes listener.onMapReady(map)
+ }
+ }
+
+ @Test
+ public void edgeToEdgeMarginConfig_defaultsAllMarginsToTrueAndAppliesSelectedInsets() {
+ Context context = ApplicationProvider.getApplicationContext();
+ View targetView = new View(context);
+ MarginLayoutParams initialParams = new MarginLayoutParams(200, 200);
+ targetView.setLayoutParams(initialParams);
+
+ EdgeToEdgeMarginConfig defaultConfig =
+ EdgeToEdgeMarginConfig.builder().setView(targetView).build();
+ assertThat(defaultConfig.view()).isSameInstanceAs(targetView);
+ assertThat(defaultConfig.shouldSetTopMargin()).isTrue();
+ assertThat(defaultConfig.shouldSetBottomMargin()).isTrue();
+ assertThat(defaultConfig.shouldSetLeftMargin()).isTrue();
+ assertThat(defaultConfig.shouldSetRightMargin()).isTrue();
+
+ EdgeToEdgeMarginConfig customConfig =
+ EdgeToEdgeMarginConfig.builder()
+ .setView(targetView)
+ .setShouldSetTopMargin(true)
+ .setShouldSetBottomMargin(false)
+ .setShouldSetLeftMargin(true)
+ .setShouldSetRightMargin(false)
+ .build();
+ EdgeToEdgeUtil.setMarginForEdgeToEdgeSupport(customConfig);
+
+ WindowInsetsCompat windowInsets =
+ new WindowInsetsCompat.Builder()
+ .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.of(12, 24, 36, 48))
+ .build();
+ ViewCompat.onApplyWindowInsets(targetView, windowInsets);
+
+ MarginLayoutParams updatedParams = (MarginLayoutParams) targetView.getLayoutParams();
+ assertThat(updatedParams.leftMargin).isEqualTo(12);
+ assertThat(updatedParams.topMargin).isEqualTo(24);
+ assertThat(updatedParams.rightMargin).isEqualTo(0);
+ assertThat(updatedParams.bottomMargin).isEqualTo(0);
+ }
+
+ @Test
+ public void featureView_inflatesLayoutAndUpdatesTitleAndDescriptionText() {
+ Context context = ApplicationProvider.getApplicationContext();
+ FeatureView featureView = new FeatureView(context);
+
+ featureView.setTitleId(R.string.basic_map_demo_label);
+ featureView.setDescriptionId(R.string.basic_map_demo_description);
+
+ TextView titleView = featureView.findViewById(R.id.title);
+ TextView descriptionView = featureView.findViewById(R.id.description);
+
+ assertThat(titleView).isNotNull();
+ assertThat(descriptionView).isNotNull();
+ assertThat(titleView.getText().toString())
+ .isEqualTo(context.getString(R.string.basic_map_demo_label));
+ assertThat(descriptionView.getText().toString())
+ .isEqualTo(context.getString(R.string.basic_map_demo_description));
+ }
+
+ @Test
+ public void animationManager_schedulesFramesOnMainLooperAndStopsCleanly() {
+ AtomicInteger frameCount = new AtomicInteger(0);
+ AnimationManager manager = new AnimationManager(frameCount::incrementAndGet);
+
+ assertThat(manager.getFrameRateFps()).isEqualTo(60.0);
+ manager.setFrameRateFps(20.0);
+ assertThat(manager.getFrameRateFps()).isEqualTo(20.0);
+
+ manager.startAnimation();
+ // Starting an already running animation is a no-op.
+ manager.startAnimation();
+
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(160));
+ int countAfterRun = frameCount.get();
+ assertThat(countAfterRun).isAtLeast(2);
+
+ manager.stopAnimation();
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
+ assertThat(frameCount.get()).isEqualTo(countAfterRun);
+ }
+
+ @Test
+ public void onMapAndViewReadyListener_waitsForBothLayoutAndMapReadyBeforeFiringCallback() {
+ Context context = ApplicationProvider.getApplicationContext();
+ // An un-laid-out view has width=0 and height=0
+ FrameLayout unlaidOutView = new FrameLayout(context);
+ FakeSupportMapFragment mapFragment = new FakeSupportMapFragment(unlaidOutView);
+
+ AtomicReference receivedMap = new AtomicReference<>();
+ OnMapAndViewReadyListener listener =
+ new OnMapAndViewReadyListener(
+ mapFragment,
+ (OnMapAndViewReadyListener.OnGlobalLayoutAndMapReadyListener) receivedMap::set);
+
+ GoogleMap expectedMap = NavDelegateReflection.createGoogleMapInstance();
+ // Map arrives first while view dimensions are still 0x0 -> callback should not fire yet.
+ listener.onMapReady(expectedMap);
+ assertThat(receivedMap.get()).isNull();
+
+ // Global layout completes -> callback fires with the ready GoogleMap.
+ listener.onGlobalLayout();
+ assertThat(receivedMap.get()).isSameInstanceAs(expectedMap);
+ }
+
+ @Test
+ public void onMapAndViewReadyListener_firesImmediatelyWhenNavigationFragmentViewAlreadyLaidOut() {
+ Context context = ApplicationProvider.getApplicationContext();
+ FrameLayout laidOutView = new FrameLayout(context);
+ laidOutView.layout(0, 0, 400, 400);
+ FakeSupportNavigationFragment navFragment = new FakeSupportNavigationFragment(laidOutView);
+
+ AtomicReference receivedMap = new AtomicReference<>();
+ OnMapAndViewReadyListener listener =
+ new OnMapAndViewReadyListener(navFragment, (OnMapReadyCallback) receivedMap::set);
+
+ GoogleMap expectedMap = NavDelegateReflection.createGoogleMapInstance();
+ listener.onMapReady(expectedMap);
+ assertThat(receivedMap.get()).isSameInstanceAs(expectedMap);
+ }
+
+ @Test
+ public void permissionUtils_evaluatesGrantResultsAndCreatesDialogs() {
+ String fineLocation = Manifest.permission.ACCESS_FINE_LOCATION;
+ String coarseLocation = Manifest.permission.ACCESS_COARSE_LOCATION;
+
+ assertThat(
+ PermissionUtils.isPermissionGranted(
+ new String[] {coarseLocation, fineLocation},
+ new int[] {PackageManager.PERMISSION_DENIED, PackageManager.PERMISSION_GRANTED},
+ fineLocation))
+ .isTrue();
+
+ assertThat(
+ PermissionUtils.isPermissionGranted(
+ new String[] {fineLocation},
+ new int[] {PackageManager.PERMISSION_DENIED},
+ fineLocation))
+ .isFalse();
+
+ assertThat(
+ PermissionUtils.isPermissionGranted(
+ new String[] {coarseLocation},
+ new int[] {PackageManager.PERMISSION_GRANTED},
+ fineLocation))
+ .isFalse();
+
+ PermissionUtils.PermissionDeniedDialog deniedDialog =
+ PermissionUtils.PermissionDeniedDialog.newInstance(true);
+ assertThat(deniedDialog.getArguments().getBoolean("finish")).isTrue();
+
+ PermissionUtils.RationaleDialog rationaleDialog =
+ PermissionUtils.RationaleDialog.newInstance(42, true);
+ assertThat(rationaleDialog.getArguments().getInt("requestCode")).isEqualTo(42);
+ assertThat(rationaleDialog.getArguments().getBoolean("finish")).isTrue();
+ }
+
+ @Test
+ public void polygonAndPolylinePagerAdapters_instantiateExpectedControlFragmentsAndTitles() {
+ AppCompatActivity activity = Robolectric.buildActivity(AppCompatActivity.class).setup().get();
+
+ PolylineControlFragmentPagerAdapter polylineAdapter =
+ new PolylineControlFragmentPagerAdapter(activity.getSupportFragmentManager());
+ assertThat(polylineAdapter.getCount()).isEqualTo(8);
+ assertThat(polylineAdapter.getPageTitle(0).toString()).isEqualTo("Color");
+ assertThat(polylineAdapter.getPageTitle(1).toString()).isEqualTo("Width");
+ assertThat(polylineAdapter.getPageTitle(2).toString()).isEqualTo("Cap");
+ assertThat(polylineAdapter.getPageTitle(3).toString()).isEqualTo("Joint");
+ assertThat(polylineAdapter.getPageTitle(4).toString()).isEqualTo("Pattern");
+ assertThat(polylineAdapter.getPageTitle(5).toString()).isEqualTo("Points");
+ assertThat(polylineAdapter.getPageTitle(6).toString()).isEqualTo("Spans");
+ assertThat(polylineAdapter.getPageTitle(7).toString()).isEqualTo("Other Options");
+ assertThat(polylineAdapter.getItem(0)).isInstanceOf(PolylineColorControlFragment.class);
+ assertThat(polylineAdapter.getItem(7)).isInstanceOf(PolylineOtherOptionsControlFragment.class);
+
+ PolygonControlFragmentPagerAdapter polygonAdapter =
+ new PolygonControlFragmentPagerAdapter(activity.getSupportFragmentManager());
+ assertThat(polygonAdapter.getCount()).isEqualTo(6);
+ assertThat(polygonAdapter.getPageTitle(0).toString()).isEqualTo("Stroke/fill color");
+ assertThat(polygonAdapter.getPageTitle(1).toString()).isEqualTo("Stroke width");
+ assertThat(polygonAdapter.getPageTitle(2).toString()).isEqualTo("Stroke joint");
+ assertThat(polygonAdapter.getPageTitle(3).toString()).isEqualTo("Stroke pattern");
+ assertThat(polygonAdapter.getPageTitle(4).toString()).isEqualTo("Points");
+ assertThat(polygonAdapter.getPageTitle(5).toString()).isEqualTo("Other Options");
+ assertThat(polygonAdapter.getItem(0)).isInstanceOf(PolygonColorControlFragment.class);
+ assertThat(polygonAdapter.getItem(5)).isInstanceOf(PolygonOtherOptionsControlFragment.class);
+ }
+}
diff --git a/map-sample/app/src/test/java/com/example/mapdemo/testing/NavMapsTestShadows.kt b/map-sample/app/src/test/java/com/example/mapdemo/testing/NavMapsTestShadows.kt
new file mode 100644
index 0000000..91ee7ff
--- /dev/null
+++ b/map-sample/app/src/test/java/com/example/mapdemo/testing/NavMapsTestShadows.kt
@@ -0,0 +1,574 @@
+/*
+ * 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.testing
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Point
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.RelativeLayout
+import androidx.fragment.app.FragmentActivity
+import com.google.android.gms.maps.CameraUpdate
+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.Projection
+import com.google.android.gms.maps.SupportMapFragment
+import com.google.android.gms.maps.UiSettings
+import com.google.android.gms.maps.model.BitmapDescriptor
+import com.google.android.gms.maps.model.BitmapDescriptorFactory
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.LatLngBounds
+import com.google.android.libraries.navigation.NavigationApi
+import com.google.android.libraries.navigation.NavigationApi.NavigatorListener
+import com.google.android.libraries.navigation.NavigationView
+import com.google.android.libraries.navigation.Navigator
+import com.google.android.libraries.navigation.SupportNavigationFragment
+import com.google.maps.android.robolectric.shadows.ShadowBitmapDescriptor
+import com.google.maps.android.robolectric.shadows.ShadowCameraUpdate
+import com.google.maps.android.robolectric.shadows.ShadowCircle
+import com.google.maps.android.robolectric.shadows.ShadowGoogleMap
+import com.google.maps.android.robolectric.shadows.ShadowGroundOverlay
+import com.google.maps.android.robolectric.shadows.ShadowMapView
+import com.google.maps.android.robolectric.shadows.ShadowMarker
+import com.google.maps.android.robolectric.shadows.ShadowPolygon
+import com.google.maps.android.robolectric.shadows.ShadowPolyline
+import com.google.maps.android.robolectric.shadows.ShadowProjection
+import com.google.maps.android.robolectric.shadows.ShadowSupportMapFragment
+import com.google.maps.android.robolectric.shadows.ShadowTileOverlay
+import com.google.maps.android.robolectric.shadows.ShadowUiSettings
+import java.lang.reflect.Proxy
+import org.mockito.Mockito
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+import org.robolectric.annotation.RealObject
+import org.robolectric.Shadows
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowViewGroup
+import org.robolectric.util.ReflectionHelpers.ClassParameter
+
+/**
+ * Base class for Robolectric tests in `map-sample` that configures SDK 34, native graphics, and
+ * Robolectric shadows for both the Navigation SDK's bundled Maps classes and the Navigation SDK UI
+ * containers (`SupportNavigationFragment`, `NavigationView`, `NavigationApi`).
+ *
+ * Because Robolectric's `ConfigMerger` inspects `@Config` on the test class and its superclass
+ * hierarchy (rather than meta-annotations), extending [NavMapsRobolectricTest] guarantees that all
+ * custom shadows are active for every test method.
+ */
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(
+ sdk = [34],
+ shadows =
+ [
+ NavShadowGoogleMap::class,
+ NavShadowMapView::class,
+ NavShadowSupportMapFragment::class,
+ ShadowMarker::class,
+ ShadowPolyline::class,
+ ShadowPolygon::class,
+ ShadowCircle::class,
+ ShadowGroundOverlay::class,
+ ShadowTileOverlay::class,
+ ShadowCameraUpdate::class,
+ NavShadowCameraUpdateFactory::class,
+ ShadowBitmapDescriptor::class,
+ NavShadowBitmapDescriptorFactory::class,
+ NavShadowUiSettings::class,
+ ShadowProjection::class,
+ ShadowSupportNavigationFragment::class,
+ ShadowNavigationView::class,
+ ShadowNavigationApi::class,
+ ],
+)
+open class NavMapsRobolectricTest
+
+/**
+ * Reflective constructor helper that creates instances of Navigation SDK's bundled Maps classes by
+ * supplying a dynamic proxy for their single-argument obfuscated internal delegate interface.
+ */
+object NavDelegateReflection {
+
+ /**
+ * Constructs an instance of [clazz] using its single-argument constructor and a no-op dynamic
+ * proxy implementing that parameter's interface.
+ */
+ @JvmStatic
+ fun constructWithDummyDelegate(clazz: Class): T {
+ val constructor =
+ clazz.declaredConstructors.firstOrNull { it.parameterCount == 1 }
+ ?: throw IllegalStateException("No single-arg constructor found on ${clazz.name}")
+ constructor.isAccessible = true
+ val paramType = constructor.parameterTypes[0]
+ val dummyDelegate =
+ if (paramType.isInterface) {
+ Proxy.newProxyInstance(paramType.classLoader, arrayOf(paramType)) { _, method, _ ->
+ when (method.returnType) {
+ Boolean::class.javaPrimitiveType -> false
+ Int::class.javaPrimitiveType -> 0
+ Long::class.javaPrimitiveType -> 0L
+ Float::class.javaPrimitiveType -> 0f
+ Double::class.javaPrimitiveType -> 0.0
+ else -> null
+ }
+ }
+ } else {
+ Mockito.mock(paramType)
+ }
+ @Suppress("UNCHECKED_CAST")
+ return constructor.newInstance(dummyDelegate) as T
+ }
+
+ /** Creates a [GoogleMap] wired to a [ShadowGoogleMap]. */
+ @JvmStatic
+ fun createGoogleMapInstance(): GoogleMap {
+ return constructWithDummyDelegate(GoogleMap::class.java)
+ }
+
+ /** Extracts the [GoogleMap] held by either a [SupportMapFragment] or [SupportNavigationFragment]. */
+ @JvmStatic
+ fun extractActivityMap(fragment: Any?): GoogleMap {
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+ return when (fragment) {
+ is SupportNavigationFragment -> {
+ val shadow = Shadow.extract(fragment)
+ shadow.googleMap
+ ?: throw IllegalStateException("GoogleMap was not initialized on SupportNavigationFragment")
+ }
+ is SupportMapFragment -> {
+ val shadow = Shadow.extract(fragment)
+ shadow.resolveGoogleMap()
+ ?: throw IllegalStateException("GoogleMap was not initialized on SupportMapFragment")
+ }
+ else -> throw IllegalArgumentException("Unexpected fragment type: $fragment")
+ }
+ }
+}
+
+/**
+ * Subclass of [ShadowGoogleMap] that handles Navigation SDK's obfuscated `UiSettings` and
+ * `Projection` constructors.
+ */
+@Implements(GoogleMap::class)
+class NavShadowGoogleMap : ShadowGoogleMap() {
+ private var navUiSettings: UiSettings? = null
+
+ @Implementation
+ override fun getUiSettings(): UiSettings {
+ if (navUiSettings == null) {
+ navUiSettings = NavDelegateReflection.constructWithDummyDelegate(UiSettings::class.java)
+ }
+ return navUiSettings!!
+ }
+
+ @Implementation
+ override fun getProjection(): Projection {
+ val projection = NavDelegateReflection.constructWithDummyDelegate(Projection::class.java)
+ val shadow = Shadow.extract(projection)
+ val width = if (mapView != null && mapView.width > 0) mapView.width else viewportWidth
+ val height = if (mapView != null && mapView.height > 0) mapView.height else viewportHeight
+ shadow.setMapState(cameraPosition, width, height)
+ return projection
+ }
+}
+
+/**
+ * Subclass of [ShadowUiSettings] adding full state tracking for
+ * `isScrollGesturesEnabledDuringRotateOrZoom`, which `UiSettingsDemoActivity` toggles.
+ */
+@Implements(UiSettings::class)
+class NavShadowUiSettings : ShadowUiSettings() {
+ private var scrollGesturesEnabledDuringRotateOrZoom: Boolean = true
+
+ @Implementation
+ fun setScrollGesturesEnabledDuringRotateOrZoom(enabled: Boolean) {
+ scrollGesturesEnabledDuringRotateOrZoom = enabled
+ }
+
+ @Implementation
+ fun isScrollGesturesEnabledDuringRotateOrZoom(): Boolean =
+ scrollGesturesEnabledDuringRotateOrZoom
+}
+
+/** Subclass of [ShadowMapView] that creates a [GoogleMap] compatible with Navigation SDK's classes. */
+@Implements(MapView::class)
+class NavShadowMapView : ShadowMapView() {
+ @RealObject private lateinit var realMapViewInstance: MapView
+
+ @Implementation
+ override fun getMapAsync(callback: OnMapReadyCallback) {
+ if (getGoogleMap() == null) {
+ val map = NavDelegateReflection.createGoogleMapInstance()
+ val shadowMap = Shadow.extract(map)
+ shadowMap.setMapView(realMapViewInstance)
+ val field = ShadowMapView::class.java.getDeclaredField("googleMap")
+ field.isAccessible = true
+ field.set(this, map)
+ }
+ val map = getGoogleMap()
+ Handler(Looper.getMainLooper()).post { callback.onMapReady(map) }
+ }
+}
+
+/**
+ * Subclass of [ShadowSupportMapFragment] that creates a [GoogleMap] compatible with Navigation
+ * SDK's bundled Maps classes and lays out its container view with default 400x400 bounds so
+ * `OnMapAndViewReadyListener` and screen-projection calculations work out of the box.
+ */
+@Implements(SupportMapFragment::class)
+class NavShadowSupportMapFragment : ShadowSupportMapFragment() {
+ @Implementation
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val view = super.onCreateView(inflater, container, savedInstanceState)
+ view.layout(0, 0, 400, 400)
+ return view
+ }
+
+ @Implementation
+ override fun getMapAsync(callback: OnMapReadyCallback) {
+ val view = getMapView()
+ if (view != null) {
+ view.getMapAsync(callback)
+ return
+ }
+ val field = ShadowSupportMapFragment::class.java.getDeclaredField("googleMap")
+ field.isAccessible = true
+ var currentMap = field.get(this) as? GoogleMap
+ if (currentMap == null) {
+ currentMap = NavDelegateReflection.createGoogleMapInstance()
+ field.set(this, currentMap)
+ }
+ callback.onMapReady(currentMap)
+ }
+
+ fun resolveGoogleMap(): GoogleMap? {
+ val view = getMapView()
+ if (view != null) {
+ val shadowView = Shadow.extract(view)
+ if (shadowView.getGoogleMap() != null) {
+ return shadowView.getGoogleMap()
+ }
+ }
+ val field = ShadowSupportMapFragment::class.java.getDeclaredField("googleMap")
+ field.isAccessible = true
+ return field.get(this) as? GoogleMap
+ }
+}
+
+/**
+ * Shadow for [CameraUpdateFactory] that creates [CameraUpdate] instances via Navigation SDK's
+ * single-argument delegate constructor and configures their [ShadowCameraUpdate] state.
+ */
+@Implements(CameraUpdateFactory::class)
+class NavShadowCameraUpdateFactory {
+ companion object {
+ private fun createUpdate(configure: (ShadowCameraUpdate) -> Unit): CameraUpdate {
+ val update = NavDelegateReflection.constructWithDummyDelegate(CameraUpdate::class.java)
+ val shadow = Shadow.extract(update)
+ configure(shadow)
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newCameraPosition(cameraPosition: CameraPosition): CameraUpdate = createUpdate { shadow ->
+ shadow.cameraPosition = cameraPosition
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newLatLng(latLng: LatLng): CameraUpdate = createUpdate { shadow ->
+ shadow.latLng = latLng
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newLatLngZoom(latLng: LatLng, zoom: Float): CameraUpdate = createUpdate { shadow ->
+ shadow.latLng = latLng
+ shadow.zoom = zoom
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newLatLngBounds(bounds: LatLngBounds, padding: Int): CameraUpdate = createUpdate { shadow ->
+ shadow.setLatLngBounds(bounds, padding)
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newLatLngBounds(
+ bounds: LatLngBounds,
+ width: Int,
+ height: Int,
+ padding: Int,
+ ): CameraUpdate = createUpdate { shadow ->
+ shadow.setLatLngBounds(bounds, width, height, padding)
+ }
+
+ @Implementation
+ @JvmStatic
+ fun zoomIn(): CameraUpdate = zoomBy(1.0f)
+
+ @Implementation
+ @JvmStatic
+ fun zoomOut(): CameraUpdate = zoomBy(-1.0f)
+
+ @Implementation
+ @JvmStatic
+ fun zoomTo(zoom: Float): CameraUpdate = createUpdate { shadow ->
+ shadow.zoom = zoom
+ }
+
+ @Implementation
+ @JvmStatic
+ fun zoomBy(amount: Float): CameraUpdate = createUpdate { shadow ->
+ shadow.zoomBy = amount
+ }
+
+ @Implementation
+ @JvmStatic
+ fun zoomBy(amount: Float, focus: Point): CameraUpdate = zoomBy(amount)
+
+ @Implementation
+ @JvmStatic
+ fun scrollBy(xPixel: Float, yPixel: Float): CameraUpdate = createUpdate { shadow ->
+ shadow.setScroll(xPixel, yPixel)
+ }
+ }
+}
+
+/**
+ * Shadow for [BitmapDescriptorFactory] that constructs [BitmapDescriptor] instances via Navigation
+ * SDK's single-argument delegate constructor and configures their [ShadowBitmapDescriptor].
+ */
+@Implements(BitmapDescriptorFactory::class)
+class NavShadowBitmapDescriptorFactory {
+ companion object {
+ const val HUE_RED = 0.0f
+
+ private fun createDescriptor(bitmap: Bitmap?, identifier: String): BitmapDescriptor {
+ val descriptor =
+ NavDelegateReflection.constructWithDummyDelegate(BitmapDescriptor::class.java)
+ val shadow = Shadow.extract(descriptor)
+ shadow.bitmap = bitmap
+ shadow.identifier = identifier
+ return descriptor
+ }
+
+ @Implementation
+ @JvmStatic
+ fun defaultMarker(): BitmapDescriptor = defaultMarker(HUE_RED)
+
+ @Implementation
+ @JvmStatic
+ fun defaultMarker(hue: Float): BitmapDescriptor =
+ createDescriptor(null, "defaultMarker:hue=$hue")
+
+ @Implementation
+ @JvmStatic
+ fun fromResource(resourceId: Int): BitmapDescriptor =
+ createDescriptor(null, "resource:$resourceId")
+
+ @Implementation
+ @JvmStatic
+ fun fromBitmap(image: Bitmap?): BitmapDescriptor =
+ createDescriptor(image, "bitmap:${System.identityHashCode(image)}")
+
+ @Implementation
+ @JvmStatic
+ fun fromAsset(assetName: String): BitmapDescriptor =
+ createDescriptor(null, "asset:$assetName")
+
+ @Implementation
+ @JvmStatic
+ fun fromFile(fileName: String): BitmapDescriptor =
+ createDescriptor(null, "file:$fileName")
+
+ @Implementation
+ @JvmStatic
+ fun fromPath(absolutePath: String): BitmapDescriptor =
+ createDescriptor(null, "path:$absolutePath")
+ }
+}
+
+/**
+ * Robolectric shadow for [SupportNavigationFragment] so activities using the Navigation SDK flavor
+ * (`SupportNavigationFragment.getMapAsync(...)`) can be tested headlessly on the JVM alongside
+ * `SupportMapFragment`.
+ */
+@Implements(SupportNavigationFragment::class)
+class ShadowSupportNavigationFragment {
+ @RealObject private lateinit var realFragment: SupportNavigationFragment
+
+ var googleMap: GoogleMap? = null
+ private set
+
+ private var pendingCallback: OnMapReadyCallback? = null
+ var isHeaderEnabledState: Boolean = true
+ private set
+ var isEtaCardEnabledState: Boolean = true
+ private set
+
+ @Implementation
+ protected fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ val view = FrameLayout(realFragment.requireContext())
+ view.layout(0, 0, 400, 400)
+ pendingCallback?.let { cb ->
+ triggerOnMapReady(cb)
+ pendingCallback = null
+ }
+ return view
+ }
+
+ @Implementation
+ fun getMapAsync(callback: OnMapReadyCallback) {
+ if (googleMap == null) {
+ googleMap = NavDelegateReflection.createGoogleMapInstance()
+ }
+ if (realFragment.view != null || realFragment.activity != null) {
+ triggerOnMapReady(callback)
+ } else {
+ pendingCallback = callback
+ }
+ }
+
+ @Implementation
+ fun setHeaderEnabled(isEnabled: Boolean) {
+ isHeaderEnabledState = isEnabled
+ }
+
+ @Implementation
+ fun setEtaCardEnabled(isEnabled: Boolean) {
+ isEtaCardEnabledState = isEnabled
+ }
+
+ private fun triggerOnMapReady(callback: OnMapReadyCallback) {
+ if (googleMap == null) {
+ googleMap = NavDelegateReflection.createGoogleMapInstance()
+ }
+ val map = googleMap!!
+ Handler(Looper.getMainLooper()).post { callback.onMapReady(map) }
+ }
+}
+
+/** Robolectric shadow for [NavigationView]. */
+@Implements(NavigationView::class)
+class ShadowNavigationView : ShadowViewGroup() {
+ @RealObject private lateinit var realNavigationView: NavigationView
+
+ var googleMap: GoogleMap? = null
+ private set
+
+ @Implementation
+ protected fun __constructor__(context: Context) {
+ Shadow.invokeConstructor(
+ RelativeLayout::class.java,
+ realNavigationView,
+ ClassParameter.from(Context::class.java, context),
+ )
+ }
+
+ @Implementation
+ protected fun __constructor__(context: Context, attrs: AttributeSet?) {
+ Shadow.invokeConstructor(
+ RelativeLayout::class.java,
+ realNavigationView,
+ ClassParameter.from(Context::class.java, context),
+ ClassParameter.from(AttributeSet::class.java, attrs),
+ )
+ }
+
+ @Implementation
+ protected fun __constructor__(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
+ Shadow.invokeConstructor(
+ RelativeLayout::class.java,
+ realNavigationView,
+ ClassParameter.from(Context::class.java, context),
+ ClassParameter.from(AttributeSet::class.java, attrs),
+ ClassParameter.from(Int::class.javaPrimitiveType, defStyleAttr),
+ )
+ }
+
+ @Implementation fun onCreate(savedInstanceState: Bundle?) {}
+
+ @Implementation fun onStart() {}
+
+ @Implementation fun onResume() {}
+
+ @Implementation fun onPause() {}
+
+ @Implementation fun onStop() {}
+
+ @Implementation fun onDestroy() {}
+
+ @Implementation fun onSaveInstanceState(bundle: Bundle?) {}
+
+ @Implementation
+ fun getMapAsync(callback: OnMapReadyCallback) {
+ if (googleMap == null) {
+ googleMap = NavDelegateReflection.createGoogleMapInstance()
+ }
+ callback.onMapReady(googleMap!!)
+ }
+}
+
+/** Robolectric shadow for [NavigationApi]. */
+@Implements(NavigationApi::class)
+class ShadowNavigationApi {
+ companion object {
+ @JvmStatic var mockNavigator: Navigator = Mockito.mock(Navigator::class.java)
+
+ @Implementation
+ @JvmStatic
+ fun getNavigator(application: Application, listener: NavigatorListener) {
+ listener.onNavigatorReady(mockNavigator)
+ }
+
+ @Implementation
+ @JvmStatic
+ fun getNavigator(activity: Activity, listener: NavigatorListener) {
+ listener.onNavigatorReady(mockNavigator)
+ }
+
+ @Implementation
+ @JvmStatic
+ fun getNavigator(activity: FragmentActivity, listener: NavigatorListener) {
+ listener.onNavigatorReady(mockNavigator)
+ }
+ }
+}
diff --git a/map-sample/app/src/test/resources/goldens/circle_demo_sydney.png b/map-sample/app/src/test/resources/goldens/circle_demo_sydney.png
new file mode 100644
index 0000000..e1579e4
Binary files /dev/null and b/map-sample/app/src/test/resources/goldens/circle_demo_sydney.png differ
diff --git a/map-sample/app/src/test/resources/goldens/polygon_demo_australia.png b/map-sample/app/src/test/resources/goldens/polygon_demo_australia.png
new file mode 100644
index 0000000..0bac98a
Binary files /dev/null and b/map-sample/app/src/test/resources/goldens/polygon_demo_australia.png differ
diff --git a/map-sample/app/src/test/resources/goldens/tile_coordinate_demo.png b/map-sample/app/src/test/resources/goldens/tile_coordinate_demo.png
new file mode 100644
index 0000000..70802fd
Binary files /dev/null and b/map-sample/app/src/test/resources/goldens/tile_coordinate_demo.png differ
diff --git a/map-sample/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/map-sample/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..d71325d
--- /dev/null
+++ b/map-sample/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-proxy
diff --git a/map-sample/build.gradle.kts b/map-sample/build.gradle.kts
index d9f89f7..acc657a 100644
--- a/map-sample/build.gradle.kts
+++ b/map-sample/build.gradle.kts
@@ -18,6 +18,8 @@
plugins {
alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.power.assert) apply false
alias(libs.plugins.maps.secrets) apply false
}
diff --git a/map-sample/gradle/libs.versions.toml b/map-sample/gradle/libs.versions.toml
index 74516c8..67c0831 100644
--- a/map-sample/gradle/libs.versions.toml
+++ b/map-sample/gradle/libs.versions.toml
@@ -1,11 +1,17 @@
[versions]
agp = "9.2.0"
androidxLifecycle = "2.10.0"
+androidxTestCore = "1.7.0"
+androidxTestEspresso = "3.7.0"
+androidxTestExt = "1.3.0"
+androidxTestRules = "1.7.0"
+androidxTestRunner = "1.7.0"
annotation = "1.10.0"
appcompat = "1.8.0"
autoValue = "1.11.1"
cardview = "1.0.0"
constraintlayout = "2.2.2"
+coroutines = "1.11.0"
cronetFallback = "143.7445.0"
customview = "1.2.0"
datatransport = "4.1.1"
@@ -14,16 +20,23 @@ errorProneAnnotations = "2.51.0"
glide = "5.0.9"
guava = "33.5.0-android"
jodaTime = "2.15.0"
-kotlin = "2.3.0"
+junit = "4.13.2"
+# Aligned with android-maps-testing (1.0.0-rc01) and org.jetbrains.kotlin.plugin.power-assert.
+kotlin = "2.4.21"
leakcanary = "2.14"
legacySupportV4 = "1.0.0"
+mapsTesting = "1.0.0-rc01"
material = "1.14.0"
mediarouter = "1.8.1"
+mockito = "5.23.0"
navSdk = "8.0.0"
preference = "1.2.1"
recyclerview = "1.4.0"
+robolectric = "4.17"
secretsPlugin = "2.0.1"
transition = "1.7.2"
+truth = "1.4.5"
+uiautomator = "2.4.0"
viewpager2 = "1.1.0"
window = "1.5.1"
@@ -39,7 +52,13 @@ androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-p
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "mediarouter" }
androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
+androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
+androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxTestEspresso" }
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
+androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
+androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-transition = { group = "androidx.transition", name = "transition", version.ref = "transition" }
+androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
auto-value-annotations = { group = "com.google.auto.value", name = "auto-value-annotations", version.ref = "autoValue" }
@@ -56,9 +75,19 @@ google-maps-navigation = { group = "com.google.android.libraries.navigation", na
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
guava = { group = "com.google.guava", name = "guava", version.ref = "guava" }
joda-time = { group = "joda-time", name = "joda-time", version.ref = "jodaTime" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" }
+maps-testing-golden = { group = "com.google.maps.android", name = "maps-testing-golden", version.ref = "mapsTesting" }
+maps-testing-robolectric = { group = "com.google.maps.android", name = "maps-testing-robolectric", version.ref = "mapsTesting" }
+maps-testing-visual = { group = "com.google.maps.android", name = "maps-testing-visual", version.ref = "mapsTesting" }
+mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
+robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
+truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-power-assert = { id = "org.jetbrains.kotlin.plugin.power-assert", version.ref = "kotlin" }
maps-secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
diff --git a/navigation-sample/app/build.gradle.kts b/navigation-sample/app/build.gradle.kts
index 8a47608..81d306e 100644
--- a/navigation-sample/app/build.gradle.kts
+++ b/navigation-sample/app/build.gradle.kts
@@ -14,13 +14,29 @@
* limitations under the License.
*/
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.powerassert.gradle.PowerAssertCompilationFilter
plugins {
alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.power.assert)
alias(libs.plugins.maps.secrets)
}
+@OptIn(ExperimentalKotlinGradlePluginApi::class)
+powerAssert {
+ functions = listOf(
+ "kotlin.assert",
+ "kotlin.require",
+ "kotlin.check",
+ "com.google.maps.android.robolectric.powerassert.assertGeo",
+ "com.google.maps.android.robolectric.powerassert.assertMap",
+ "com.google.maps.android.robolectric.powerassert.assertMarker",
+ )
+ compilationFilter = PowerAssertCompilationFilter.ALL
+}
+
android {
namespace = "com.example.navigationapidemo"
compileSdk = 37
@@ -36,6 +52,21 @@ android {
targetSdk = 37
versionCode = 1
versionName = "1.0"
+ manifestPlaceholders["MAPS_API_KEY"] = "DEFAULT_API_KEY"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ isReturnDefaultValues = true
+ all {
+ it.systemProperty(
+ "maps.golden.record",
+ System.getProperty("maps.golden.record") ?: "false",
+ )
+ }
+ }
}
androidResources {
@@ -124,6 +155,29 @@ dependencies {
implementation(libs.guava)
coreLibraryDesugaring(libs.desugar.jdk.libs)
+
+ // Unit tests: JUnit, Kotlin Power-Assert, Google Truth, Robolectric, and android-maps-testing
+ testImplementation(libs.junit)
+ testImplementation(libs.truth)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.maps.testing.robolectric)
+ testImplementation(libs.maps.testing.golden)
+
+ // Instrumented & visual tests
+ androidTestImplementation(libs.junit)
+ androidTestImplementation(libs.truth)
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.test.rules)
+ androidTestImplementation(libs.androidx.test.espresso.core)
+ androidTestImplementation(libs.androidx.uiautomator)
+ androidTestImplementation(libs.maps.testing.golden)
+ androidTestImplementation(libs.maps.testing.visual)
}
secrets {
diff --git a/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationSampleSmokeTest.kt b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationSampleSmokeTest.kt
new file mode 100644
index 0000000..bdb083d
--- /dev/null
+++ b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationSampleSmokeTest.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.navigationapidemo
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.view.View
+import android.widget.ListView
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Connected-device instrumentation smoke tests for the `navigation-sample` application.
+ *
+ * Sitting near the top of the testing pyramid, this compact suite verifies that:
+ * 1. All 5 navigation sample activities are properly declared in `AndroidManifest.xml` and
+ * resolvable by the Android `PackageManager` on a real device or emulator.
+ * 2. The launcher (`MainActivity`) inflates its layout and populates the chooser list with all 3
+ * navigation demo entries cleanly without crashing.
+ */
+@RunWith(AndroidJUnit4::class)
+class NavigationSampleSmokeTest {
+
+ @Test
+ fun manifest_declaresAllNavigationSampleActivities() {
+ val context = ApplicationProvider.getApplicationContext()
+ val packageInfo =
+ context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES)
+ val declaredClassNames =
+ packageInfo.activities?.map { activityInfo -> activityInfo.name }.orEmpty()
+
+ assertThat(declaredClassNames)
+ .containsAtLeast(
+ MainActivity::class.java.name,
+ SplashScreenActivity::class.java.name,
+ NavViewActivity::class.java.name,
+ NavFragmentActivity::class.java.name,
+ SwappingMapAndNavActivity::class.java.name,
+ )
+ }
+
+ @Test
+ fun mainActivity_launchesAndDisplaysChooserList() {
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ val listView = activity.findViewById(R.id.list_view)
+ assertThat(listView).isNotNull()
+ assertThat(listView.visibility).isEqualTo(View.VISIBLE)
+ assertThat(listView.adapter.count).isEqualTo(3)
+ assertThat(listView.adapter.getItem(0)).isEqualTo("NavViewActivity")
+ assertThat(listView.adapter.getItem(1)).isEqualTo("NavFragmentActivity")
+ assertThat(listView.adapter.getItem(2)).isEqualTo("SwappingMapAndNavActivity")
+ }
+ }
+ }
+}
diff --git a/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationVisualTest.kt b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationVisualTest.kt
new file mode 100644
index 0000000..17f6bfd
--- /dev/null
+++ b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationVisualTest.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.navigationapidemo
+
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.maps.android.testing.visual.BaseVisualTest
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Top-of-pyramid multimodal AI visual and hybrid golden test for `navigation-sample`.
+ *
+ * Extends [BaseVisualTest] from `com.google.maps.android:maps-testing-visual` to verify the visual
+ * presentation of the launcher chooser screen using `verifyScreenshotWithGoldenFallback`:
+ * - When a committed golden exists under `src/androidTest/assets/goldens/`, compares against it
+ * offline with zero API quota usage.
+ * - When visual drift exceeds tolerance or when a golden is not yet recorded and `GEMINI_API_KEY`
+ * is configured, falls back to Gemini Vision semantic verification.
+ */
+@RunWith(AndroidJUnit4::class)
+class NavigationVisualTest : BaseVisualTest() {
+
+ @Test
+ fun mainActivity_rendersNavigationSampleChooserScreen() = runBlocking {
+ ActivityScenario.launch(MainActivity::class.java).use {
+ val screenshotBitmap = captureScreenshot("navigation_sample_main_chooser.png")
+ verifyScreenshotWithGoldenFallback(
+ testName = "navigation_sample_main_chooser",
+ screenshotBitmap = screenshotBitmap,
+ prompt =
+ "Verify that the Google Maps Navigation API Demo launcher screen is visible with " +
+ "entries for NavViewActivity, NavFragmentActivity, and SwappingMapAndNavActivity, " +
+ "and reply PASSED if all criteria are met.",
+ tolerancePercent = 2.0,
+ )
+ }
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt b/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt
index 544a3e1..6e3e419 100644
--- a/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt
@@ -128,7 +128,7 @@ internal object CustomizationPanelsDelegate {
nightModeSpinner.adapter = nightModeAdapter
nightModeSpinner.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
when (position) {
0 -> {}
1 -> onNightModeOptionSelected(ForceNightMode.AUTO)
@@ -172,7 +172,7 @@ internal object CustomizationPanelsDelegate {
override var lastSetNonZeroPosition = 1
private set
- override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
when (position) {
0 -> {}
1 -> {
diff --git a/navigation-sample/app/src/test/java/com/example/navigationapidemo/CustomizationPanelsDelegateTest.kt b/navigation-sample/app/src/test/java/com/example/navigationapidemo/CustomizationPanelsDelegateTest.kt
new file mode 100644
index 0000000..01bedd6
--- /dev/null
+++ b/navigation-sample/app/src/test/java/com/example/navigationapidemo/CustomizationPanelsDelegateTest.kt
@@ -0,0 +1,306 @@
+/*
+ * 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.navigationapidemo
+
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.Spinner
+import android.widget.ToggleButton
+import androidx.appcompat.app.AppCompatActivity
+import com.example.navigationapidemo.testing.NavMapsRobolectricTest
+import com.example.navigationapidemo.testing.NavShadowGoogleMap
+import com.example.navigationapidemo.testing.NavShadowNavigationApi
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.libraries.navigation.ForceNightMode
+import com.google.android.libraries.navigation.Navigator
+import com.google.android.libraries.navigation.OnNavigationUiChangedListener
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.robolectric.ktx.cameraBearing
+import com.google.maps.android.robolectric.ktx.cameraTarget
+import com.google.maps.android.robolectric.ktx.cameraZoom
+import com.google.maps.android.robolectric.ktx.createTestMap
+import com.google.maps.android.robolectric.powerassert.assertGeo
+import com.google.maps.android.robolectric.powerassert.assertMap
+import com.google.maps.android.robolectric.powerassert.distanceTo
+import com.google.maps.android.robolectric.powerassert.isCenteredOn
+import com.google.maps.android.robolectric.powerassert.meters
+import com.google.maps.android.robolectric.truth.assertThat as assertThatMap
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.shadow.api.Shadow
+
+/**
+ * Headless JVM Robolectric unit tests for [CustomizationPanelsDelegate].
+ *
+ * Verifies mutual exclusion of control panels, spinner callbacks for night mode and camera
+ * perspectives, bidirectional synchronization with [GoogleMap] location-following state, and
+ * camera movement to Melbourne using both **Kotlin Power-Assert** (`assertMap`, `assertGeo`) and
+ * **Google Truth** (`assertThatMap`).
+ */
+@RunWith(RobolectricTestRunner::class)
+class CustomizationPanelsDelegateTest : NavMapsRobolectricTest() {
+
+ private lateinit var activity: AppCompatActivity
+ private lateinit var googleMap: GoogleMap
+
+ @Before
+ fun setUp() {
+ NavShadowNavigationApi.reset()
+ NavShadowNavigationApi.autoDeliverResult = false
+ activity = Robolectric.buildActivity(NavViewActivity::class.java).create().get()
+ googleMap = createTestMap(width = 400, height = 400)
+ }
+
+ @After
+ fun tearDown() {
+ NavShadowNavigationApi.reset()
+ }
+
+ @Test
+ fun initializeCustomizationPanels_enforcesMutualExclusionAcrossThreePanels() {
+ val navToggle = activity.findViewById