From 77014e2c494624a188e3f8641965290f13219d9e Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 30 Oct 2026 14:25:26 -0600
Subject: [PATCH] test: add android-maps-robolectric unit test suites for
maps-compose and navigation-app
---
gradle/libs.versions.toml | 2 +
maps-compose/build.gradle.kts | 13 +-
maps-compose/src/debug/AndroidManifest.xml | 23 ++
.../compose/CameraPositionStateTest.kt | 148 +++++--
.../maps/android/compose/GoogleMapTest.kt | 260 ++++++++++++
.../android/compose/MapApplierCleanupTest.kt | 41 ++
.../maps/android/compose/MapApplierTest.kt | 198 ++++++++++
navigation-app/build.gradle.kts | 11 +
.../compose/navigation/ExampleUnitTest.kt | 72 ++++
.../compose/navigation/MainActivityTest.kt | 115 ++++++
.../navigation/NavigationScreenTest.kt | 281 +++++++++++++
.../navigation/NavigationViewModelTest.kt | 365 +++++++++++++++++
.../navigation/shadows/ShadowNavigationApi.kt | 46 +++
.../shadows/ShadowNavigationView.kt | 370 ++++++++++++++++++
14 files changed, 1902 insertions(+), 43 deletions(-)
create mode 100644 maps-compose/src/debug/AndroidManifest.xml
create mode 100644 maps-compose/src/test/java/com/google/maps/android/compose/GoogleMapTest.kt
create mode 100644 maps-compose/src/test/java/com/google/maps/android/compose/MapApplierTest.kt
create mode 100644 navigation-app/src/test/java/com/google/maps/android/compose/navigation/ExampleUnitTest.kt
create mode 100644 navigation-app/src/test/java/com/google/maps/android/compose/navigation/MainActivityTest.kt
create mode 100644 navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationScreenTest.kt
create mode 100644 navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationViewModelTest.kt
create mode 100644 navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationApi.kt
create mode 100644 navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationView.kt
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a671940e3..6b87a8ed8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -34,6 +34,7 @@ materialIconsExtendedAndroid = "1.7.8"
# Google Maps Platform
mapsecrets = "2.0.1"
+mapsRobolectric = "1.2.0-rc01"
mapsutils = "7.0.0"
navigation = "8.0.0"
places = "6.0.2"
@@ -88,6 +89,7 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3",
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Google Maps Platform
+maps-robolectric-shadows = { module = "com.google.android.maps.robolectric:shadows", version.ref = "mapsRobolectric" }
maps-utils-core = { module = "com.google.maps.android:android-maps-utils-core", version.ref = "mapsutils" }
maps-utils = { module = "com.google.maps.android:android-maps-utils", version.ref = "mapsutils" }
maps-secrets-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "mapsecrets" }
diff --git a/maps-compose/build.gradle.kts b/maps-compose/build.gradle.kts
index 872e54dd4..53dd3535f 100644
--- a/maps-compose/build.gradle.kts
+++ b/maps-compose/build.gradle.kts
@@ -56,6 +56,12 @@ android {
enableAndroidTestCoverage = true
}
}
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
kotlin {
@@ -92,9 +98,14 @@ dependencies {
api(libs.maps.utils.core)
testImplementation(libs.test.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.truth)
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.maps.robolectric.shadows)
testImplementation(libs.mockk)
- testImplementation(libs.truth)
+ testImplementation(libs.androidx.test.compose.ui)
+ testImplementation(libs.androidx.activity.compose)
+ testImplementation(libs.androidx.test.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.test.espresso)
diff --git a/maps-compose/src/debug/AndroidManifest.xml b/maps-compose/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..b106adacb
--- /dev/null
+++ b/maps-compose/src/debug/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/maps-compose/src/test/java/com/google/maps/android/compose/CameraPositionStateTest.kt b/maps-compose/src/test/java/com/google/maps/android/compose/CameraPositionStateTest.kt
index 1b96ce6ba..ec042ad1f 100644
--- a/maps-compose/src/test/java/com/google/maps/android/compose/CameraPositionStateTest.kt
+++ b/maps-compose/src/test/java/com/google/maps/android/compose/CameraPositionStateTest.kt
@@ -16,71 +16,135 @@
package com.google.maps.android.compose
+import android.app.Activity
+import android.os.Bundle
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.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
+import com.google.android.maps.robolectric.annotation.EnableMapsShadows
+import com.google.android.maps.robolectric.shadows.ShadowGoogleMap
+import com.google.android.maps.robolectric.truth.LatLngSubject
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowLooper
+import java.util.concurrent.atomic.AtomicReference
-public class CameraPositionStateTest {
+// [START maps_compose_camera_position_state_test]
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@EnableMapsShadows
+internal class CameraPositionStateTest {
- private val explicitPosition = CameraPosition.fromLatLngZoom(LatLng(48.1, 11.5), 15f)
+ private lateinit var googleMap: GoogleMap
+ private lateinit var shadowMap: ShadowGoogleMap
+
+ private val sydney: LatLng = LatLng(-33.852, 151.211)
+ private val tokyo: LatLng = LatLng(35.6762, 139.6503)
@Before
- public fun setUp() {
- initFakeCameraUpdateFactory()
+ internal fun setUp() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val mapView = MapView(activity)
+ mapView.onCreate(Bundle())
+
+ val mapRef = AtomicReference()
+ mapView.getMapAsync { mapRef.set(it) }
+ ShadowLooper.idleMainLooper()
+
+ googleMap = mapRef.get()
+ shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap
}
@Test
- public fun testSettingPositionWhileUnboundOverridesPendingMove() {
- val state = CameraPositionState()
- state.move(CameraUpdateFactory.zoomTo(3f))
- state.position = explicitPosition
+ internal fun testInitialPosition_withoutMapBound() {
+ val state = CameraPositionState(position = CameraPosition.fromLatLngZoom(sydney, 10f))
+ assertThat(state.position.target).isEqualTo(sydney)
+ assertThat(state.position.zoom).isEqualTo(10f)
+ assertThat(state.isMoving).isFalse()
+ assertThat(state.projection).isNull()
+ }
- val calls = mutableListOf()
- state.setMap(fakeGoogleMap(calls))
+ @Test
+ internal fun testSetMap_syncsPositionFromStateToMap() {
+ val initialPosition = CameraPosition.fromLatLngZoom(sydney, 12f)
+ val state = CameraPositionState(position = initialPosition)
- assertEquals(listOf(moveCameraTo(explicitPosition)), calls.filter { it.method == "moveCamera" })
+ state.setMap(googleMap)
+
+ assertThat(googleMap.cameraPosition.target).isEqualTo(sydney)
+ assertThat(googleMap.cameraPosition.zoom).isEqualTo(12f)
+ assertThat(state.projection).isNotNull()
}
- @Test(timeout = 5_000)
- public fun testSettingPositionWhileUnboundCancelsPendingAnimate(): Unit = runBlocking {
- val state = CameraPositionState()
- val animation = launch(start = CoroutineStart.UNDISPATCHED) {
- state.animate(CameraUpdateFactory.zoomTo(3f))
+ private fun bindState(state: CameraPositionState) {
+ state.setMap(googleMap)
+ googleMap.setOnCameraMoveStartedListener { reason ->
+ state.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(reason)
+ state.isMoving = true
+ }
+ googleMap.setOnCameraMoveListener {
+ state.rawPosition = googleMap.cameraPosition
}
- state.position = explicitPosition
- animation.join()
- assertTrue(animation.isCancelled)
+ googleMap.setOnCameraIdleListener {
+ state.isMoving = false
+ state.rawPosition = googleMap.cameraPosition
+ }
+ }
- val calls = mutableListOf()
- state.setMap(fakeGoogleMap(calls))
+ @Test
+ internal fun testMove_instantaneouslyUpdatesCameraPosition() {
+ val state = CameraPositionState()
+ bindState(state)
+
+ state.move(CameraUpdateFactory.newLatLngZoom(tokyo, 14f))
- assertEquals(listOf(moveCameraTo(explicitPosition)), calls.filter { it.method == "moveCamera" })
+ assertThat(googleMap.cameraPosition.target).isEqualTo(tokyo)
+ LatLngSubject.assertThat(state.position.target)
+ .isWithin(1.0)
+ .of(tokyo)
+ assertThat(state.position.zoom).isEqualTo(14f)
+ assertThat(state.isMoving).isFalse()
}
@Test
- public fun testPendingMoveIsAppliedWhenMapIsBound() {
+ internal fun testAnimate_updatesCameraPosition(): Unit = runTest {
val state = CameraPositionState()
- state.move(CameraUpdateFactory.zoomTo(3f))
-
- val calls = mutableListOf()
- state.setMap(fakeGoogleMap(calls))
-
- assertEquals(
- listOf(
- moveCameraTo(state.position),
- FakeCall("moveCamera", listOf(FakeCall("zoomTo", listOf(3f)))),
- ),
- calls.filter { it.method == "moveCamera" },
- )
+ bindState(state)
+
+ state.animate(CameraUpdateFactory.newLatLngZoom(tokyo, 15f), durationMs = 1000)
+
+ assertThat(googleMap.cameraPosition.target).isEqualTo(tokyo)
+ LatLngSubject.assertThat(state.position.target)
+ .isWithin(1.0)
+ .of(tokyo)
+ assertThat(state.position.zoom).isEqualTo(15f)
+ assertThat(state.isMoving).isFalse()
}
- private fun moveCameraTo(position: CameraPosition) =
- FakeCall("moveCamera", listOf(FakeCall("newCameraPosition", listOf(position))))
+ @Test
+ internal fun testProjection_convertsCoordinates() {
+ val state = CameraPositionState()
+ state.setMap(googleMap)
+
+ state.move(CameraUpdateFactory.newLatLngZoom(sydney, 10f))
+
+ val projection = state.projection
+ assertThat(projection).isNotNull()
+ val screenPoint = projection?.toScreenLocation(sydney)
+ assertThat(screenPoint).isNotNull()
+ val roundTripLatLng = projection?.fromScreenLocation(screenPoint!!)
+ LatLngSubject.assertThat(roundTripLatLng)
+ .isWithin(1.0)
+ .of(sydney)
+ }
}
+// [END maps_compose_camera_position_state_test]
diff --git a/maps-compose/src/test/java/com/google/maps/android/compose/GoogleMapTest.kt b/maps-compose/src/test/java/com/google/maps/android/compose/GoogleMapTest.kt
new file mode 100644
index 000000000..ebe9b523f
--- /dev/null
+++ b/maps-compose/src/test/java/com/google/maps/android/compose/GoogleMapTest.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.google.maps.android.compose
+
+import android.os.Bundle
+import android.widget.FrameLayout
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.maps.robolectric.annotation.EnableMapsShadows
+import com.google.android.maps.robolectric.shadows.ShadowGoogleMap
+import com.google.android.maps.robolectric.shadows.ShadowMapView
+import com.google.android.maps.robolectric.truth.LatLngSubject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+
+// [START maps_compose_google_map_composable_test]
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@EnableMapsShadows
+@OptIn(MapsComposeExperimentalApi::class)
+internal class GoogleMapTest {
+
+ @get:Rule
+ internal val composeTestRule: AndroidComposeTestRule, ComponentActivity> =
+ createAndroidComposeRule()
+
+ private val sydney: LatLng = LatLng(-33.852, 151.211)
+
+ @Test
+ internal fun testGoogleMap_rendersWithMarkersAndCircles() {
+ lateinit var cameraState: CameraPositionState
+ var underlyingMap: GoogleMap? = null
+
+ composeTestRule.setContent {
+ cameraState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(sydney, 11f)
+ }
+ GoogleMap(
+ cameraPositionState = cameraState,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ MapEffect(Unit) { map ->
+ underlyingMap = map
+ }
+ Marker(
+ state = rememberUpdatedMarkerState(position = sydney),
+ title = "Sydney Flagship",
+ )
+ Circle(
+ center = sydney,
+ radius = 1500.0,
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ assertThat(underlyingMap).isNotNull()
+ val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap
+
+ // Verify camera position
+ LatLngSubject.assertThat(cameraState.position.target)
+ .isWithin(1.0)
+ .of(sydney)
+ assertThat(cameraState.position.zoom).isEqualTo(11f)
+
+ // Verify Marker rendered via MapApplier
+ assertThat(shadowMap.markers).hasSize(1)
+ val marker = shadowMap.markers.first()
+ assertThat(marker.title).isEqualTo("Sydney Flagship")
+ LatLngSubject.assertThat(marker.position)
+ .isWithin(1.0)
+ .of(sydney)
+
+ // Verify Circle rendered via MapApplier
+ assertThat(shadowMap.circles).hasSize(1)
+ val circle = shadowMap.circles.first()
+ assertThat(circle.radius).isEqualTo(1500.0)
+ LatLngSubject.assertThat(circle.center)
+ .isWithin(1.0)
+ .of(sydney)
+ }
+
+ @Test
+ internal fun testGoogleMap_markerClickUpdatesComposeState() {
+ var underlyingMap: GoogleMap? = null
+ var markerClicked by mutableStateOf(false)
+
+ composeTestRule.setContent {
+ val cameraState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(sydney, 10f)
+ }
+ GoogleMap(
+ cameraPositionState = cameraState,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ MapEffect(Unit) { map ->
+ underlyingMap = map
+ }
+ Marker(
+ state = rememberUpdatedMarkerState(position = sydney),
+ title = "Clickable Marker",
+ onClick = {
+ markerClicked = true
+ true
+ },
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ assertThat(underlyingMap).isNotNull()
+ val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap
+ val marker = shadowMap.markers.first()
+
+ // Simulate user clicking on marker
+ shadowMap.clickMarker(marker)
+ composeTestRule.waitForIdle()
+
+ assertThat(markerClicked).isTrue()
+ }
+
+ @Test
+ internal fun testGoogleMap_dynamicRecompositionAddsAndRemovesMarker() {
+ var underlyingMap: GoogleMap? = null
+ var showMarker by mutableStateOf(true)
+
+ composeTestRule.setContent {
+ val cameraState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(sydney, 10f)
+ }
+ GoogleMap(
+ cameraPositionState = cameraState,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ MapEffect(Unit) { map ->
+ underlyingMap = map
+ }
+ if (showMarker) {
+ Marker(
+ state = rememberUpdatedMarkerState(position = sydney),
+ title = "Conditional Marker",
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ assertThat(underlyingMap).isNotNull()
+ val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap
+ assertThat(shadowMap.markers).hasSize(1)
+
+ // Mutate Compose state to remove marker
+ showMarker = false
+ composeTestRule.waitForIdle()
+
+ // Assert that the marker was dynamically removed from the map
+ assertThat(shadowMap.markers).isEmpty()
+ }
+
+ @Test
+ internal fun testGoogleMap_customMapViewCreatorDelegate_lifecycleAndMarkerComposable() {
+ val lifecycleEvents = mutableListOf()
+ val fakeGoogleMap = ShadowMapView.createFakeGoogleMap()
+ var customDelegate: AbstractMapViewDelegate? = null
+
+ composeTestRule.setContent {
+ val cameraState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(sydney, 13f)
+ }
+ GoogleMap(
+ cameraPositionState = cameraState,
+ modifier = Modifier.fillMaxSize(),
+ mapViewCreator = { context, _ ->
+ object : AbstractMapViewDelegate {
+ override val mapView: FrameLayout = FrameLayout(context)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ lifecycleEvents.add("onCreate")
+ }
+ override fun onStart() {
+ lifecycleEvents.add("onStart")
+ }
+ override fun onResume() {
+ lifecycleEvents.add("onResume")
+ }
+ override fun onPause() {
+ lifecycleEvents.add("onPause")
+ }
+ override fun onStop() {
+ lifecycleEvents.add("onStop")
+ }
+ override fun onLowMemory() {
+ lifecycleEvents.add("onLowMemory")
+ }
+ override fun onDestroy() {
+ lifecycleEvents.add("onDestroy")
+ }
+ override suspend fun awaitMap(): GoogleMap = fakeGoogleMap
+ }.also { customDelegate = it }
+ },
+ ) {
+ Marker(
+ state = rememberUpdatedMarkerState(position = sydney),
+ title = "Delegated Marker",
+ )
+ MarkerComposable(
+ state = rememberUpdatedMarkerState(position = sydney),
+ title = "Composable Marker",
+ ) {
+ Box(modifier = Modifier.size(32.dp).background(Color.Blue))
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ assertThat(customDelegate).isNotNull()
+ assertThat(lifecycleEvents).containsExactly("onCreate", "onStart", "onResume").inOrder()
+
+ val shadowMap = Shadow.extract(fakeGoogleMap) as ShadowGoogleMap
+ assertThat(shadowMap.markers).hasSize(2)
+ assertThat(shadowMap.markers.map { it.title }).containsExactly("Delegated Marker", "Composable Marker")
+ }
+}
+// [END maps_compose_google_map_composable_test]
diff --git a/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierCleanupTest.kt b/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierCleanupTest.kt
index 9d0849e5b..b96b5c45a 100644
--- a/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierCleanupTest.kt
+++ b/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierCleanupTest.kt
@@ -437,4 +437,45 @@ public class MapApplierCleanupTest {
callbacks.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND)
verify(exactly = 2) { streetView.onLowMemory() }
}
+
+ @Test
+ public fun `MapLifecycleEventObserver forwards all lifecycle events to custom AbstractMapViewDelegate`() {
+ val customDelegate = mockk>(relaxed = true)
+ val observer = MapLifecycleEventObserver(customDelegate)
+ val owner = object : LifecycleOwner {
+ val registry = LifecycleRegistry.createUnsafe(this)
+ override val lifecycle: Lifecycle get() = registry
+ }
+
+ observer.onStateChanged(owner, Lifecycle.Event.ON_CREATE)
+ observer.onStateChanged(owner, Lifecycle.Event.ON_START)
+ observer.onStateChanged(owner, Lifecycle.Event.ON_RESUME)
+ observer.moveToBaseState()
+ observer.moveToDestroyedState()
+
+ verifyOrder {
+ customDelegate.onCreate(any())
+ customDelegate.onStart()
+ customDelegate.onResume()
+ customDelegate.onPause()
+ customDelegate.onStop()
+ customDelegate.onDestroy()
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ public fun `AbstractMapViewDelegate componentCallbacks2 forwards onLowMemory and UI_HIDDEN trimMemory`() {
+ val customDelegate = mockk>(relaxed = true)
+ val callbacks = customDelegate.componentCallbacks2()
+
+ callbacks.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW)
+ verify(exactly = 0) { customDelegate.onLowMemory() }
+
+ callbacks.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN)
+ verify(exactly = 1) { customDelegate.onLowMemory() }
+
+ callbacks.onLowMemory()
+ verify(exactly = 2) { customDelegate.onLowMemory() }
+ }
}
diff --git a/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierTest.kt b/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierTest.kt
new file mode 100644
index 000000000..bbe4babf0
--- /dev/null
+++ b/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierTest.kt
@@ -0,0 +1,198 @@
+/*
+ * 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.google.maps.android.compose
+
+import android.app.Activity
+import android.os.Bundle
+import androidx.compose.runtime.CompositionContext
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.MapView
+import com.google.android.gms.maps.model.CircleOptions
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.Marker
+import com.google.android.gms.maps.model.MarkerOptions
+import com.google.android.maps.robolectric.annotation.EnableMapsShadows
+import com.google.android.maps.robolectric.shadows.ShadowGoogleMap
+import com.google.android.maps.robolectric.truth.LatLngSubject
+import com.google.common.truth.Truth.assertThat
+import io.mockk.mockk
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowLooper
+import java.util.concurrent.atomic.AtomicReference
+
+// [START maps_compose_map_applier_test]
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@EnableMapsShadows
+internal class MapApplierTest {
+
+ private lateinit var mapView: MapView
+ private lateinit var googleMap: GoogleMap
+ private lateinit var shadowMap: ShadowGoogleMap
+ private lateinit var applier: MapApplier
+
+ private val sydney: LatLng = LatLng(-33.852, 151.211)
+ private val perth: LatLng = LatLng(-31.952, 115.857)
+
+ @Before
+ internal fun setUp() {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ mapView = MapView(activity)
+ mapView.onCreate(Bundle())
+
+ val mapRef = AtomicReference()
+ mapView.getMapAsync { mapRef.set(it) }
+ ShadowLooper.idleMainLooper()
+
+ googleMap = mapRef.get()
+ shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap
+ applier = MapApplier(googleMap, MapViewDelegate(mapView), MapClickListeners(), isLiteMode = false)
+ }
+
+ private fun createMarkerNode(
+ markerState: MarkerState,
+ onMarkerClick: (Marker) -> Boolean = { false },
+ position: LatLng = sydney,
+ ): MarkerNode {
+ val marker = googleMap.addMarker(MarkerOptions().position(position).title("Test Marker"))!!
+ val dummyContext: CompositionContext = mockk(relaxed = true)
+ return MarkerNode(
+ compositionContext = dummyContext,
+ marker = marker,
+ markerState = markerState,
+ onMarkerClick = onMarkerClick,
+ onInfoWindowClick = {},
+ onInfoWindowClose = {},
+ onInfoWindowLongClick = {},
+ infoWindow = null,
+ infoContent = null,
+ )
+ }
+
+ @Test
+ internal fun testInsertMarkerNode_attachesAndSyncsMarkerState() {
+ val markerState = MarkerState(sydney)
+ val node = createMarkerNode(markerState)
+
+ applier.insertBottomUp(0, node)
+
+ assertThat(markerState.marker).isSameInstanceAs(node.marker)
+ assertThat(shadowMap.markers).contains(node.marker)
+ }
+
+ @Test
+ internal fun testRemoveMarkerNode_clearsMarkerStateAndRemovesFromMap() {
+ val markerState = MarkerState(sydney)
+ val node = createMarkerNode(markerState)
+
+ applier.insertBottomUp(0, node)
+ assertThat(shadowMap.markers).hasSize(1)
+
+ applier.remove(0, 1)
+
+ assertThat(markerState.marker).isNull()
+ assertThat(shadowMap.markers).isEmpty()
+ }
+
+ @Test
+ internal fun testMarkerClick_dispatchesToNodeCallback() {
+ val markerState = MarkerState(sydney)
+ var clickCount = 0
+ val node = createMarkerNode(
+ markerState = markerState,
+ onMarkerClick = {
+ clickCount++
+ true
+ },
+ )
+
+ applier.insertBottomUp(0, node)
+
+ // Simulate marker click via shadow
+ shadowMap.clickMarker(node.marker)
+
+ assertThat(clickCount).isEqualTo(1)
+ }
+
+ @Test
+ internal fun testMarkerDrag_updatesMarkerStatePosition() {
+ val markerState = MarkerState(sydney)
+ val node = createMarkerNode(markerState)
+ node.marker.isDraggable = true
+
+ applier.insertBottomUp(0, node)
+
+ // Drag marker to Perth via shadow
+ shadowMap.dragMarkerStart(node.marker)
+ assertThat(markerState.isDragging).isTrue()
+
+ shadowMap.dragMarker(node.marker, perth)
+ LatLngSubject.assertThat(markerState.position).isWithin(1.0).of(perth)
+
+ shadowMap.dragMarkerEnd(node.marker, perth)
+ assertThat(markerState.isDragging).isFalse()
+ LatLngSubject.assertThat(markerState.position).isWithin(1.0).of(perth)
+ }
+
+ @Test
+ internal fun testCircleNode_clickAndRemoval() {
+ val circle = googleMap.addCircle(CircleOptions().center(sydney).radius(500.0).clickable(true))
+ var circleClicked = false
+ val circleNode = CircleNode(
+ circle = circle,
+ onCircleClick = { circleClicked = true },
+ )
+
+ applier.insertBottomUp(0, circleNode)
+ assertThat(shadowMap.circles).contains(circle)
+
+ // Simulate click via shadow
+ shadowMap.clickCircle(circle)
+ assertThat(circleClicked).isTrue()
+
+ // Remove node
+ applier.remove(0, 1)
+ assertThat(shadowMap.circles).isEmpty()
+ }
+
+ @Test
+ internal fun testOnClear_removesAllDecorations() {
+ val markerState = MarkerState(sydney)
+ val markerNode = createMarkerNode(markerState)
+ val circle = googleMap.addCircle(CircleOptions().center(sydney).radius(500.0))
+ val circleNode = CircleNode(circle, onCircleClick = {})
+
+ applier.insertBottomUp(0, markerNode)
+ applier.insertBottomUp(1, circleNode)
+
+ assertThat(shadowMap.markers).hasSize(1)
+ assertThat(shadowMap.circles).hasSize(1)
+
+ applier.clear()
+
+ assertThat(shadowMap.markers).isEmpty()
+ assertThat(shadowMap.circles).isEmpty()
+ assertThat(markerState.marker).isNull()
+ }
+}
+// [END maps_compose_map_applier_test]
diff --git a/navigation-app/build.gradle.kts b/navigation-app/build.gradle.kts
index 333afe8f3..ceda0f358 100644
--- a/navigation-app/build.gradle.kts
+++ b/navigation-app/build.gradle.kts
@@ -113,6 +113,17 @@ dependencies {
implementation(libs.play.services.location)
+ testImplementation(libs.androidx.core)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.test.junit.ktx)
+ testImplementation(platform(libs.androidx.compose.bom))
+ testImplementation(libs.androidx.test.compose.ui)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.maps.robolectric.shadows)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.truth)
+
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
diff --git a/navigation-app/src/test/java/com/google/maps/android/compose/navigation/ExampleUnitTest.kt b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/ExampleUnitTest.kt
new file mode 100644
index 000000000..ed1f4ccbd
--- /dev/null
+++ b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/ExampleUnitTest.kt
@@ -0,0 +1,72 @@
+package com.google.maps.android.compose.navigation
+
+import android.app.Activity
+import android.content.ComponentCallbacks2
+import android.os.Bundle
+import com.google.android.gms.maps.GoogleMapOptions
+import com.google.android.libraries.navigation.NavigationView
+import com.google.android.maps.robolectric.annotation.EnableMapsShadows
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.compose.navigation.shadows.ShadowNavBitmapDescriptorFactory
+import com.google.maps.android.compose.navigation.shadows.ShadowNavCameraUpdateFactory
+import com.google.maps.android.compose.navigation.shadows.ShadowNavGoogleMap
+import com.google.maps.android.compose.navigation.shadows.ShadowNavigationView
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+
+@RunWith(RobolectricTestRunner::class)
+@Config(
+ sdk = [34],
+ shadows = [
+ ShadowNavigationView::class,
+ ShadowNavGoogleMap::class,
+ ShadowNavCameraUpdateFactory::class,
+ ShadowNavBitmapDescriptorFactory::class,
+ ],
+)
+@EnableMapsShadows
+class ExampleUnitTest {
+
+ @Test
+ fun navigationViewDelegate_forwardsLifecycleAndAwaitsMap() = runTest {
+ val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+ val options = GoogleMapOptions().compassEnabled(true)
+ val navigationView = NavigationView(activity, options)
+ val shadowNavView = Shadow.extract(navigationView) as ShadowNavigationView
+ val delegate = NavigationViewDelegate(navigationView)
+ val savedInstanceState = Bundle()
+
+ assertThat(delegate.mapView).isSameInstanceAs(navigationView)
+ assertThat(shadowNavView.googleMapOptions).isSameInstanceAs(options)
+
+ delegate.onCreate(savedInstanceState)
+ assertThat(shadowNavView.isOnCreateCalled).isTrue()
+
+ delegate.onStart()
+ assertThat(shadowNavView.isOnStartCalled).isTrue()
+
+ delegate.onResume()
+ assertThat(shadowNavView.isOnResumeCalled).isTrue()
+
+ val googleMap = delegate.awaitMap()
+ assertThat(googleMap).isNotNull()
+ assertThat(shadowNavView.googleMap).isSameInstanceAs(googleMap)
+
+ delegate.onLowMemory()
+ assertThat(shadowNavView.lastTrimMemoryLevel).isEqualTo(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN)
+
+ delegate.onPause()
+ assertThat(shadowNavView.isOnPauseCalled).isTrue()
+
+ delegate.onStop()
+ assertThat(shadowNavView.isOnStopCalled).isTrue()
+
+ delegate.onDestroy()
+ assertThat(shadowNavView.isOnDestroyCalled).isTrue()
+ }
+}
diff --git a/navigation-app/src/test/java/com/google/maps/android/compose/navigation/MainActivityTest.kt b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/MainActivityTest.kt
new file mode 100644
index 000000000..fef22f8b0
--- /dev/null
+++ b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/MainActivityTest.kt
@@ -0,0 +1,115 @@
+package com.google.maps.android.compose.navigation
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.libraries.navigation.ListenableResultFuture
+import com.google.android.libraries.navigation.NavigationView
+import com.google.android.libraries.navigation.Navigator
+import com.google.android.libraries.navigation.RoutingOptions
+import com.google.android.libraries.navigation.Simulator
+import com.google.android.libraries.navigation.Waypoint
+import com.google.android.maps.robolectric.annotation.EnableMapsShadows
+import com.google.android.maps.robolectric.shadows.ShadowGoogleMap
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.compose.navigation.shadows.ShadowNavBitmapDescriptorFactory
+import com.google.maps.android.compose.navigation.shadows.ShadowNavCameraUpdateFactory
+import com.google.maps.android.compose.navigation.shadows.ShadowNavGoogleMap
+import com.google.maps.android.compose.navigation.shadows.ShadowNavigationApi
+import com.google.maps.android.compose.navigation.shadows.ShadowNavigationView
+import com.google.maps.android.compose.navigation.shadows.findNavigationView
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+
+@RunWith(RobolectricTestRunner::class)
+@Config(
+ sdk = [34],
+ shadows = [
+ ShadowNavigationView::class,
+ ShadowNavigationApi::class,
+ ShadowNavGoogleMap::class,
+ ShadowNavCameraUpdateFactory::class,
+ ShadowNavBitmapDescriptorFactory::class,
+ ],
+)
+@EnableMapsShadows
+class MainActivityTest {
+
+ @get:Rule
+ val composeTestRule: ComposeTestRule = createEmptyComposeRule()
+
+ @Before
+ fun injectMockPlacesClient() {
+ val app = ApplicationProvider.getApplicationContext()
+ app.customPlacesClient = mockk(relaxed = true)
+ }
+
+ @Test
+ fun mainActivity_registersNavigator_mountsNavigationView_andSupportsStopAndSpeedControls() {
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ composeTestRule.waitForIdle()
+
+ // Verify MainActivity registered its NavigationViewModel with NavigationApi.getNavigator
+ val listener = ShadowNavigationApi.lastListener
+ assertThat(listener).isNotNull()
+
+ // Verify NavigationView is mounted inside MainActivity's Compose hierarchy
+ var navigationView: NavigationView? = null
+ scenario.onActivity { activity ->
+ navigationView = activity.window.decorView.findNavigationView()
+ }
+ assertThat(navigationView).isNotNull()
+
+ val shadowNavView = Shadow.extract(navigationView) as ShadowNavigationView
+ val googleMap = shadowNavView.googleMap
+ assertThat(googleMap).isNotNull()
+ val shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap
+ assertThat(shadowMap.markers.map { it.title }).contains("Bigfoot")
+
+ // Simulate NavigationApi delivering the Navigator and firing RouteChangedListener
+ val routeChangedSlot = slot()
+ val simulator = mockk(relaxed = true)
+ val navigator = mockk(relaxed = true) {
+ every { this@mockk.simulator } returns simulator
+ every { addRouteChangedListener(capture(routeChangedSlot)) } answers {}
+ every {
+ setDestination(any(), any())
+ } returns mockk>(relaxed = true)
+ }
+
+ scenario.onActivity {
+ listener!!.onNavigatorReady(navigator)
+ }
+ composeTestRule.waitForIdle()
+
+ // Verify the "navigator ready" Snackbar is displayed in MainActivity
+ composeTestRule.onNodeWithText("navigator ready").assertIsDisplayed()
+
+ // Trigger route changed -> Stop button appears in the collapsible controls
+ scenario.onActivity {
+ routeChangedSlot.captured.onRouteChanged()
+ }
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Stop").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+
+ verify { simulator.unsetUserLocation() }
+ verify { navigator.stopGuidance() }
+ verify { navigator.clearDestinations() }
+ }
+ }
+}
diff --git a/navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationScreenTest.kt b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationScreenTest.kt
new file mode 100644
index 000000000..b145958c5
--- /dev/null
+++ b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationScreenTest.kt
@@ -0,0 +1,281 @@
+package com.google.maps.android.compose.navigation
+
+import android.app.Application
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.libraries.places.api.model.Place
+import com.google.android.maps.robolectric.annotation.EnableMapsShadows
+import com.google.android.maps.robolectric.shadows.ShadowGoogleMap
+import com.google.android.maps.robolectric.shadows.ShadowMarker
+import com.google.android.maps.robolectric.truth.LatLngSubject
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.compose.navigation.shadows.ShadowNavBitmapDescriptorFactory
+import com.google.maps.android.compose.navigation.shadows.ShadowNavCameraUpdateFactory
+import com.google.maps.android.compose.navigation.shadows.ShadowNavGoogleMap
+import com.google.maps.android.compose.navigation.shadows.ShadowNavigationView
+import com.google.maps.android.compose.navigation.shadows.findNavigationView
+import com.google.maps.android.compose.navigation.ui.theme.AndroidmapscomposeTheme
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+
+@RunWith(RobolectricTestRunner::class)
+@Config(
+ sdk = [34],
+ application = Application::class,
+ shadows = [
+ ShadowNavigationView::class,
+ ShadowNavGoogleMap::class,
+ ShadowNavCameraUpdateFactory::class,
+ ShadowNavBitmapDescriptorFactory::class,
+ ],
+)
+@EnableMapsShadows
+class NavigationScreenTest {
+
+ @get:Rule
+ val composeTestRule: AndroidComposeTestRule, ComponentActivity> =
+ createAndroidComposeRule()
+
+ private val bigfootCoordinates = LatLng(39.99932703674056, -105.28152457787887)
+ private val boulderCoordinates = LatLng(40.014984, -105.270546)
+
+ @Test
+ fun navigationScreen_mountsNavigationView_andRendersBigfootComposableMarker() {
+ composeTestRule.setContent {
+ AndroidmapscomposeTheme {
+ NavigationScreen(
+ deviceLocation = null,
+ placesAlongRoute = emptyList(),
+ onClearSearchResults = {},
+ onSearchClicked = {},
+ routeReady = false,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ val rootView = composeTestRule.activity.window.decorView
+ val navigationView = rootView.findNavigationView()
+ assertThat(navigationView).isNotNull()
+
+ val shadowNavView = Shadow.extract(navigationView) as ShadowNavigationView
+ assertThat(shadowNavView.isOnCreateCalled).isTrue()
+ assertThat(shadowNavView.isOnStartCalled).isTrue()
+ assertThat(shadowNavView.isOnResumeCalled).isTrue()
+
+ val googleMap = shadowNavView.googleMap
+ assertThat(googleMap).isNotNull()
+ val shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap
+
+ assertThat(shadowMap.markers).hasSize(1)
+ val bigfootMarker = shadowMap.markers.first()
+ assertThat(bigfootMarker.title).isEqualTo("Bigfoot")
+ LatLngSubject.assertThat(bigfootMarker.position)
+ .isWithin(1.0)
+ .of(bigfootCoordinates)
+
+ val shadowMarker = Shadow.extract(bigfootMarker) as ShadowMarker
+ assertThat(shadowMarker.iconBitmap).isNotNull()
+ assertThat(shadowMarker.iconBitmap.width).isGreaterThan(0)
+ assertThat(shadowMarker.iconBitmap.height).isGreaterThan(0)
+ }
+
+ @Test
+ fun navigationScreen_whenDeviceLocationProvidedAndUpdated_movesUserMarkerAndCamera() {
+ var currentDeviceLocation by mutableStateOf(null)
+
+ composeTestRule.setContent {
+ AndroidmapscomposeTheme {
+ NavigationScreen(
+ deviceLocation = currentDeviceLocation,
+ placesAlongRoute = emptyList(),
+ onClearSearchResults = {},
+ onSearchClicked = {},
+ routeReady = false,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ val shadowNavView = Shadow.extract(ShadowNavigationView.instances.single()) as ShadowNavigationView
+ val googleMap = checkNotNull(shadowNavView.googleMap)
+ val shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap
+
+ assertThat(shadowMap.markers.map { it.title }).containsExactly("Bigfoot")
+
+ currentDeviceLocation = boulderCoordinates
+ composeTestRule.waitForIdle()
+
+ assertThat(shadowMap.markers.map { it.title }).containsExactly("User location", "Bigfoot")
+ val userMarker = shadowMap.markers.first { it.title == "User location" }
+ LatLngSubject.assertThat(userMarker.position)
+ .isWithin(1.0)
+ .of(boulderCoordinates)
+ LatLngSubject.assertThat(googleMap.cameraPosition.target)
+ .isWithin(1.0)
+ .of(boulderCoordinates)
+ assertThat(googleMap.cameraPosition.zoom).isEqualTo(15f)
+ }
+
+ @Test
+ fun navigationScreen_expandablePlaceSearch_alongRouteToggle_andPlaceResultsWithMapMarkers() {
+ var routeReady by mutableStateOf(false)
+ var placesAlongRoute by mutableStateOf>(emptyList())
+ val alongRouteQueries = mutableListOf()
+ val destinationQueries = mutableListOf()
+ val selectedPlaces = mutableListOf()
+ var clearClickedCount = 0
+
+ composeTestRule.setContent {
+ AndroidmapscomposeTheme {
+ NavigationScreen(
+ deviceLocation = boulderCoordinates,
+ placesAlongRoute = placesAlongRoute,
+ onClearSearchResults = {
+ clearClickedCount++
+ placesAlongRoute = emptyList()
+ },
+ onSearchClicked = { query ->
+ alongRouteQueries.add(query)
+ },
+ onDestinationSearchClicked = { query ->
+ destinationQueries.add(query)
+ },
+ onPlaceSelected = { place ->
+ selectedPlaces.add(place)
+ },
+ routeReady = routeReady,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ // Tap "Search" in the floating controls -> opens the search card to search for any destination
+ composeTestRule.onNodeWithText("Search").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Search any destination...").performTextInput("Red Rocks")
+ composeTestRule.onNodeWithText("Find").performClick()
+ assertThat(destinationQueries).containsExactly("Red Rocks")
+
+ // When routeReady becomes true -> "Along Route" chip appears in the search card
+ routeReady = true
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Along Route").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Find").performClick()
+ assertThat(alongRouteQueries).containsExactly("Red Rocks")
+
+ // Populate placesAlongRoute with locations -> renders list AND map markers
+ val leafCoords = LatLng(40.0165, -105.2760)
+ val place1 = Place.builder()
+ .setId("place-1")
+ .setDisplayName("Leaf Vegetarian Restaurant")
+ .setLocation(leafCoords)
+ .build()
+ placesAlongRoute = listOf(place1)
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithText("Leaf Vegetarian Restaurant").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Navigate").assertIsDisplayed().performClick()
+ assertThat(selectedPlaces).containsExactly(place1)
+
+ // Verify marker for Leaf Vegetarian Restaurant is plotted on GoogleMap
+ val shadowNavView = Shadow.extract(ShadowNavigationView.instances.single()) as ShadowNavigationView
+ val shadowMap = Shadow.extract(checkNotNull(shadowNavView.googleMap)) as ShadowGoogleMap
+ assertThat(shadowMap.markers.map { it.title }).contains("Leaf Vegetarian Restaurant")
+
+ // Clicking Close clears the search results and collapses the search overlay
+ composeTestRule.onNodeWithContentDescription("Close").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+
+ assertThat(clearClickedCount).isEqualTo(1)
+ }
+
+ @Test
+ fun navigationScreen_collapsibleControls_autoCollapseOnIdle_andExpandOnClick() {
+ var simulationState by mutableStateOf(SimulationState.RUNNING)
+ var simulationSpeed by mutableFloatStateOf(5f)
+ var stopCount = 0
+
+ composeTestRule.setContent {
+ AndroidmapscomposeTheme {
+ NavigationScreen(
+ deviceLocation = boulderCoordinates,
+ placesAlongRoute = emptyList(),
+ onClearSearchResults = {},
+ onSearchClicked = {},
+ routeReady = true,
+ simulationState = simulationState,
+ simulationSpeed = simulationSpeed,
+ onSimulationSpeedChanged = { simulationSpeed = it },
+ onPauseSimulation = { simulationState = SimulationState.PAUSED },
+ onResumeSimulation = { simulationState = SimulationState.RUNNING },
+ onStopNavigation = {
+ stopCount++
+ simulationState = SimulationState.STOPPED
+ },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ // Initially expanded -> "Search", "5x", "Pause", "Stop" are visible
+ composeTestRule.onNodeWithText("5x").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("10x").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ assertThat(simulationSpeed).isEqualTo(10f)
+
+ // Verify Pause -> Resume flow
+ composeTestRule.onNodeWithText("Pause").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ assertThat(simulationState).isEqualTo(SimulationState.PAUSED)
+
+ composeTestRule.onNodeWithText("Resume").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ assertThat(simulationState).isEqualTo(SimulationState.RUNNING)
+
+ // Verify Stop button stops navigation
+ composeTestRule.onNodeWithText("Stop").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ assertThat(stopCount).isEqualTo(1)
+ assertThat(simulationState).isEqualTo(SimulationState.STOPPED)
+
+ // Advance clock past CONTROLS_AUTO_COLLAPSE_DELAY_MS -> controls collapse into single FAB
+ composeTestRule.mainClock.advanceTimeBy(CONTROLS_AUTO_COLLAPSE_DELAY_MS + 500L)
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithContentDescription("Expand controls").assertIsDisplayed().performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Search").assertIsDisplayed()
+ }
+}
diff --git a/navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationViewModelTest.kt b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationViewModelTest.kt
new file mode 100644
index 000000000..3a6a1af61
--- /dev/null
+++ b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/NavigationViewModelTest.kt
@@ -0,0 +1,365 @@
+package com.google.maps.android.compose.navigation
+
+import android.Manifest
+import android.app.Application
+import android.location.Location
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.tasks.Tasks
+import com.google.android.libraries.navigation.ListenableResultFuture
+import com.google.android.libraries.navigation.NavigationApi
+import com.google.android.libraries.navigation.Navigator
+import com.google.android.libraries.navigation.RouteSegment
+import com.google.android.libraries.navigation.RoutingOptions
+import com.google.android.libraries.navigation.Simulator
+import com.google.android.libraries.navigation.Waypoint
+import com.google.android.libraries.places.api.model.EncodedPolyline
+import com.google.android.libraries.places.api.model.Place
+import com.google.android.libraries.places.api.net.PlacesClient
+import com.google.android.libraries.places.api.net.SearchByTextRequest
+import com.google.android.libraries.places.api.net.SearchByTextResponse
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.compose.navigation.repositories.LocationProvider
+import com.google.maps.android.compose.navigation.repositories.PermissionChecker
+import com.google.maps.android.latLngListEncode
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import io.mockk.verifyOrder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLooper
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class NavigationViewModelTest {
+
+ private lateinit var placesClient: PlacesClient
+ private lateinit var locationProvider: LocationProvider
+ private lateinit var permissionChecker: PermissionChecker
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ placesClient = mockk(relaxed = true)
+ locationProvider = mockk(relaxed = true)
+ permissionChecker = mockk(relaxed = true)
+ }
+
+ @After
+ fun tearDown() {
+ ShadowLooper.idleMainLooper()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun requestLocation_whenPermissionMissing_emitsRequestLocationPermissionEvent() = runTest {
+ every { permissionChecker.isGranted(any()) } returns false
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+
+ val events = mutableListOf()
+ val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.uiEvent.collect { events.add(it) }
+ }
+
+ viewModel.requestLocation()
+ advanceUntilIdle()
+
+ assertThat(events).contains(UiEvent.RequestLocationPermission)
+ collectJob.cancel()
+ }
+
+ @Test
+ fun checkLocationPermission_whenGranted_fetchesLocationAndUpdatesStateFlow() = runTest {
+ every { permissionChecker.isGranted(Manifest.permission.ACCESS_FINE_LOCATION) } returns true
+ val androidLocation = Location("fused").apply {
+ latitude = 40.0150
+ longitude = -105.2705
+ }
+ coEvery { locationProvider.getLastLocation() } returns androidLocation
+
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.location.collect {}
+ }
+
+ viewModel.checkLocationPermission()
+ advanceUntilIdle()
+
+ val current = viewModel.location.first { it != null }
+ assertThat(current).isEqualTo(LatLng(40.0150, -105.2705))
+ collectJob.cancel()
+ }
+
+ @Test
+ fun onNavigatorReady_clearsStaleSimulatedLocation_andNavigateToPlaceStartsGuidance() = runTest {
+ val navigator = mockk(relaxed = true)
+ val simulator = mockk(relaxed = true)
+ every { navigator.simulator } returns simulator
+
+ val routeChangedListenerSlot = slot()
+ every { navigator.addRouteChangedListener(capture(routeChangedListenerSlot)) } answers {}
+
+ val resultListenerSlot = slot>()
+ val routeStatusFuture = mockk>(relaxed = true)
+ every { routeStatusFuture.setOnResultListener(capture(resultListenerSlot)) } answers {
+ resultListenerSlot.captured.onResult(Navigator.RouteStatus.OK)
+ }
+ every { navigator.setDestination(any(), any()) } returns routeStatusFuture
+
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ val events = mutableListOf()
+ val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.uiEvent.collect { events.add(it) }
+ }
+
+ viewModel.onNavigatorReady(navigator)
+ advanceUntilIdle()
+
+ assertThat(events).contains(UiEvent.ShowSnackbar("navigator ready"))
+ verify { navigator.setTaskRemovedBehavior(Navigator.TaskRemovedBehavior.QUIT_SERVICE) }
+ verify { simulator.unsetUserLocation() }
+ verify(exactly = 0) { navigator.setDestination(any(), any()) }
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.IDLE)
+
+ // Now navigate to a chosen place
+ viewModel.navigateToPlace(DEFAULT_DESTINATION_PLACE_ID, label = "Chautauqua")
+ for (i in 1..20) {
+ if (resultListenerSlot.isCaptured) break
+ Thread.sleep(20)
+ ShadowLooper.idleMainLooper()
+ }
+ advanceUntilIdle()
+
+ verify { navigator.setDestination(any(), any()) }
+ verify { simulator.simulateLocationsAlongExistingRoute(any()) }
+ verify { navigator.startGuidance() }
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.RUNNING)
+
+ assertThat(viewModel.routeReady.value).isFalse()
+ routeChangedListenerSlot.captured.onRouteChanged()
+ assertThat(viewModel.routeReady.value).isTrue()
+
+ collectJob.cancel()
+ }
+
+ @Test
+ fun simulationControls_pause_resume_changeSpeed_andStopNavigationClearsSimulatedLocation() = runTest {
+ every { permissionChecker.isGranted(Manifest.permission.ACCESS_FINE_LOCATION) } returns true
+ val realGpsLocation = Location("gps").apply {
+ latitude = 37.4220
+ longitude = -122.0841
+ }
+ coEvery { locationProvider.getLastLocation() } returns realGpsLocation
+
+ val navigator = mockk(relaxed = true)
+ val simulator = mockk(relaxed = true)
+ every { navigator.simulator } returns simulator
+
+ val resultListenerSlot = slot>()
+ val routeStatusFuture = mockk>(relaxed = true)
+ every { routeStatusFuture.setOnResultListener(capture(resultListenerSlot)) } answers {
+ resultListenerSlot.captured.onResult(Navigator.RouteStatus.OK)
+ }
+ every { navigator.setDestination(any(), any()) } returns routeStatusFuture
+
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.location.collect {}
+ }
+
+ viewModel.onNavigatorReady(navigator)
+ viewModel.navigateToPlace(DEFAULT_DESTINATION_PLACE_ID, label = "Chautauqua")
+ for (i in 1..20) {
+ if (resultListenerSlot.isCaptured) break
+ Thread.sleep(20)
+ ShadowLooper.idleMainLooper()
+ }
+ advanceUntilIdle()
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.RUNNING)
+
+ // Change speed while running -> updates simulationSpeed and re-applies to simulator
+ viewModel.setSimulationSpeed(10f)
+ assertThat(viewModel.simulationSpeed.value).isEqualTo(10f)
+ verify(exactly = 2) { simulator.simulateLocationsAlongExistingRoute(any()) }
+
+ // Pause simulation
+ viewModel.pauseSimulation()
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.PAUSED)
+ verify { simulator.pause() }
+
+ // Resume simulation
+ viewModel.resumeSimulation()
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.RUNNING)
+ verify { simulator.resume() }
+
+ // Stop navigation -> stops guidance and clears destinations BEFORE unsetting simulated user location
+ viewModel.stopNavigation()
+ advanceUntilIdle()
+
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.STOPPED)
+ assertThat(viewModel.routeReady.value).isFalse()
+ assertThat(viewModel.placesAlongRoute.value).isEmpty()
+ verifyOrder {
+ navigator.stopGuidance()
+ navigator.clearDestinations()
+ simulator.unsetUserLocation()
+ }
+ assertThat(viewModel.location.value).isEqualTo(LatLng(37.4220, -122.0841))
+ collectJob.cancel()
+ }
+
+ @Test
+ fun navigateToLatLng_updatesDestinationAndRoutes() = runTest {
+ val navigator = mockk(relaxed = true)
+ val simulator = mockk(relaxed = true)
+ every { navigator.simulator } returns simulator
+
+ val waypointSlot = slot()
+ val resultListenerSlot = slot>()
+ val routeStatusFuture = mockk>(relaxed = true)
+ every { routeStatusFuture.setOnResultListener(capture(resultListenerSlot)) } answers {
+ resultListenerSlot.captured.onResult(Navigator.RouteStatus.OK)
+ }
+ every { navigator.setDestination(capture(waypointSlot), any()) } returns routeStatusFuture
+
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ viewModel.onNavigatorReady(navigator)
+
+ val customPin = LatLng(40.0183, -105.2788)
+ viewModel.navigateToLatLng(customPin, label = "Pearl Street Mall")
+ for (i in 1..20) {
+ if (waypointSlot.isCaptured && waypointSlot.captured.title == "Pearl Street Mall") break
+ Thread.sleep(20)
+ ShadowLooper.idleMainLooper()
+ }
+ advanceUntilIdle()
+
+ assertThat(viewModel.destinationLabel.value).isEqualTo("Pearl Street Mall")
+ assertThat(waypointSlot.captured.title).isEqualTo("Pearl Street Mall")
+ assertThat(waypointSlot.captured.position).isEqualTo(customPin)
+ }
+
+ @Test
+ fun navigateToPlace_whenRouteStatusFails_emitsSnackbarWithoutCrashing() = runTest {
+ val navigator = mockk(relaxed = true)
+ val resultListenerSlot = slot>()
+ val routeStatusFuture = mockk>(relaxed = true)
+ every { routeStatusFuture.setOnResultListener(capture(resultListenerSlot)) } answers {
+ resultListenerSlot.captured.onResult(Navigator.RouteStatus.NO_ROUTE_FOUND)
+ }
+ every { navigator.setDestination(any(), any()) } returns routeStatusFuture
+
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ val events = mutableListOf()
+ val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.uiEvent.collect { events.add(it) }
+ }
+
+ viewModel.onNavigatorReady(navigator)
+ viewModel.navigateToPlace(DEFAULT_DESTINATION_PLACE_ID, label = "Chautauqua")
+ for (i in 1..20) {
+ if (resultListenerSlot.isCaptured) break
+ Thread.sleep(20)
+ ShadowLooper.idleMainLooper()
+ }
+ advanceUntilIdle()
+
+ assertThat(events).contains(
+ UiEvent.ShowSnackbar("Error starting navigation: No route found"),
+ )
+ assertThat(viewModel.simulationState.value).isEqualTo(SimulationState.STOPPED)
+ collectJob.cancel()
+ }
+
+ @Test
+ fun searchAlongRoute_encodesRoutePolyline_queriesPlacesClient_andClearsResults() = runTest {
+ val navigator = mockk(relaxed = true)
+ val routePoints = listOf(
+ LatLng(39.98285, -105.71835),
+ LatLng(39.99932, -105.28152),
+ )
+ val routeSegment = mockk {
+ every { latLngs } returns routePoints
+ }
+ every { navigator.currentRouteSegment } returns routeSegment
+
+ val routeStatusFuture = mockk>(relaxed = true)
+ every { navigator.setDestination(any(), any()) } returns routeStatusFuture
+
+ val place1 = Place.builder()
+ .setId("p1")
+ .setDisplayName("Dushanbe Tea House")
+ .setLocation(LatLng(40.0155, -105.2770))
+ .build()
+ val requestSlot = slot()
+ val searchResponseTask = Tasks.forResult(
+ SearchByTextResponse.newInstance(listOf(place1)),
+ )
+ every { placesClient.searchByText(capture(requestSlot)) } returns searchResponseTask
+ every { placesClient.zzf(capture(requestSlot), any()) } returns searchResponseTask
+
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ viewModel.onNavigatorReady(navigator)
+
+ viewModel.searchAlongRoute("Spicy Vegetarian Food")
+ for (i in 1..20) {
+ ShadowLooper.idleMainLooper()
+ if (viewModel.placesAlongRoute.value.isNotEmpty()) break
+ Thread.sleep(20)
+ }
+ advanceUntilIdle()
+
+ assertThat(viewModel.placesAlongRoute.value).containsExactly(place1)
+
+ val capturedRequest = requestSlot.captured
+ assertThat(capturedRequest.textQuery).isEqualTo("Spicy Vegetarian Food")
+ assertThat(capturedRequest.maxResultCount).isEqualTo(10)
+ val expectedPolyline = routePoints.latLngListEncode()
+ val actualPolyline =
+ (capturedRequest.searchAlongRouteParameters?.polyline as? EncodedPolyline)?.encodedPolyline
+ assertThat(actualPolyline).isEqualTo(expectedPolyline)
+
+ viewModel.clearSearchResults()
+ assertThat(viewModel.placesAlongRoute.value).isEmpty()
+ }
+
+ @Test
+ fun onError_emitsCorrespondingSnackbarMessage() = runTest {
+ val viewModel = NavigationViewModel(placesClient, locationProvider, permissionChecker)
+ val events = mutableListOf()
+ val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.uiEvent.collect { events.add(it) }
+ }
+
+ viewModel.onError(NavigationApi.ErrorCode.NOT_AUTHORIZED)
+ viewModel.onError(NavigationApi.ErrorCode.TERMS_NOT_ACCEPTED)
+ viewModel.onError(NavigationApi.ErrorCode.NETWORK_ERROR)
+ viewModel.onError(NavigationApi.ErrorCode.LOCATION_PERMISSION_MISSING)
+ advanceUntilIdle()
+
+ val messages = events.filterIsInstance().map { it.message }
+ assertThat(messages).containsExactly(
+ "Error loading Navigation SDK: Your API key is invalid or not authorized to use the Navigation SDK.",
+ "Error loading Navigation SDK: User did not accept the Navigation Terms of Use.",
+ "Error loading Navigation SDK: Network error.",
+ "Error loading Navigation SDK: Location permission is missing.",
+ ).inOrder()
+ collectJob.cancel()
+ }
+}
diff --git a/navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationApi.kt b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationApi.kt
new file mode 100644
index 000000000..f5f2ac811
--- /dev/null
+++ b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationApi.kt
@@ -0,0 +1,46 @@
+package com.google.maps.android.compose.navigation.shadows
+
+import android.app.Activity
+import android.app.Application
+import com.google.android.libraries.navigation.NavigationApi
+import com.google.android.libraries.navigation.NavigationApi.NavigatorListener
+import com.google.android.libraries.navigation.Navigator
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+import org.robolectric.annotation.Resetter
+
+@Implements(NavigationApi::class)
+object ShadowNavigationApi {
+
+ var lastListener: NavigatorListener? = null
+ private set
+
+ var navigatorToDeliver: Navigator? = null
+ var errorCodeToDeliver: Int? = null
+
+ @Implementation
+ @JvmStatic
+ fun getNavigator(activity: Activity, listener: NavigatorListener) {
+ handleGetNavigator(listener)
+ }
+
+ @Implementation
+ @JvmStatic
+ fun getNavigator(application: Application, listener: NavigatorListener) {
+ handleGetNavigator(listener)
+ }
+
+ private fun handleGetNavigator(listener: NavigatorListener) {
+ lastListener = listener
+ navigatorToDeliver?.let { listener.onNavigatorReady(it) }
+ errorCodeToDeliver?.let { listener.onError(it) }
+ }
+
+ @Resetter
+ @JvmStatic
+ fun reset() {
+ lastListener = null
+ navigatorToDeliver = null
+ errorCodeToDeliver = null
+ }
+}
diff --git a/navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationView.kt b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationView.kt
new file mode 100644
index 000000000..7afcbbd59
--- /dev/null
+++ b/navigation-app/src/test/java/com/google/maps/android/compose/navigation/shadows/ShadowNavigationView.kt
@@ -0,0 +1,370 @@
+package com.google.maps.android.compose.navigation.shadows
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Point
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+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.GoogleMapOptions
+import com.google.android.gms.maps.OnMapReadyCallback
+import com.google.android.gms.maps.Projection
+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.NavigationView
+import com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptor
+import com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptorFactory
+import com.google.android.maps.robolectric.shadows.ShadowCameraUpdate
+import com.google.android.maps.robolectric.shadows.ShadowCameraUpdateFactory
+import com.google.android.maps.robolectric.shadows.ShadowGoogleMap
+import com.google.android.maps.robolectric.shadows.ShadowProjection
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+import org.robolectric.annotation.RealObject
+import org.robolectric.annotation.Resetter
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowViewGroup
+import java.lang.reflect.Proxy
+
+internal fun newInstanceWithDelegate(targetClass: Class): T {
+ try {
+ val constructors = targetClass.declaredConstructors
+ val ctor = constructors.firstOrNull {
+ it.parameterTypes.size == 1 && it.parameterTypes[0].isInterface
+ } ?: constructors.firstOrNull { it.parameterTypes.size == 1 }
+ if (ctor != null) {
+ ctor.isAccessible = true
+ val delegateClass = ctor.parameterTypes[0]
+ val dummyDelegate = if (delegateClass.isInterface) {
+ Proxy.newProxyInstance(
+ delegateClass.classLoader,
+ arrayOf(delegateClass),
+ ) { _, method, _ ->
+ when (method.returnType) {
+ Boolean::class.javaPrimitiveType -> false
+ Float::class.javaPrimitiveType -> 0f
+ Double::class.javaPrimitiveType -> 0.0
+ Long::class.javaPrimitiveType -> 0L
+ else -> if (method.returnType.isPrimitive) 0 else null
+ }
+ }
+ } else {
+ Shadow.newInstanceOf(delegateClass)
+ }
+ return checkNotNull(targetClass.cast(ctor.newInstance(dummyDelegate)))
+ }
+ } catch (_: Throwable) {
+ }
+ return Shadow.newInstanceOf(targetClass)
+}
+
+@Suppress("FunctionName", "ktlint:standard:function-naming")
+@Implements(NavigationView::class)
+class ShadowNavigationView : ShadowViewGroup() {
+
+ @RealObject
+ private lateinit var realNavigationView: NavigationView
+
+ var googleMapOptions: GoogleMapOptions? = null
+ private set
+ var googleMap: GoogleMap? = null
+ private set
+
+ var isOnCreateCalled: Boolean = false
+ private set
+ var isOnStartCalled: Boolean = false
+ private set
+ var isOnResumeCalled: Boolean = false
+ private set
+ var isOnPauseCalled: Boolean = false
+ private set
+ var isOnStopCalled: Boolean = false
+ private set
+ var isOnDestroyCalled: Boolean = false
+ private set
+ var lastTrimMemoryLevel: Int? = null
+ private set
+
+ @Implementation
+ protected fun __constructor__(context: Context) {
+ instances.add(realNavigationView)
+ }
+
+ @Implementation
+ protected fun __constructor__(context: Context, options: GoogleMapOptions?) {
+ this.googleMapOptions = options
+ instances.add(realNavigationView)
+ }
+
+ @Implementation
+ protected fun __constructor__(context: Context, attrs: AttributeSet?) {
+ instances.add(realNavigationView)
+ }
+
+ @Implementation
+ protected fun __constructor__(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
+ instances.add(realNavigationView)
+ }
+
+ @Implementation
+ protected fun onCreate(savedInstanceState: Bundle?) {
+ isOnCreateCalled = true
+ }
+
+ @Implementation
+ protected fun onStart() {
+ isOnStartCalled = true
+ }
+
+ @Implementation
+ protected fun onResume() {
+ isOnResumeCalled = true
+ }
+
+ @Implementation
+ protected fun onPause() {
+ isOnPauseCalled = true
+ }
+
+ @Implementation
+ protected fun onStop() {
+ isOnStopCalled = true
+ }
+
+ @Implementation
+ protected fun onDestroy() {
+ isOnDestroyCalled = true
+ }
+
+ @Implementation
+ protected fun onTrimMemory(level: Int) {
+ lastTrimMemoryLevel = level
+ }
+
+ @Implementation
+ protected fun onSaveInstanceState(outState: Bundle) {
+ // No-op for headless unit tests
+ }
+
+ @Implementation
+ protected fun getMapAsync(callback: OnMapReadyCallback) {
+ val map = googleMap ?: newInstanceWithDelegate(GoogleMap::class.java).also {
+ googleMap = it
+ }
+ Handler(Looper.getMainLooper()).post {
+ callback.onMapReady(map)
+ }
+ }
+
+ companion object {
+ val instances: MutableList = mutableListOf()
+
+ @Resetter
+ @JvmStatic
+ fun resetInstances() {
+ instances.clear()
+ }
+ }
+}
+
+@Implements(GoogleMap::class)
+class ShadowNavGoogleMap : ShadowGoogleMap() {
+ private var navUiSettings: UiSettings? = null
+
+ @Implementation
+ override fun getProjection(): Projection {
+ val projection = newInstanceWithDelegate(Projection::class.java)
+ val shadow = Shadow.extract(projection)
+ shadow.setMapState(cameraPosition, viewportWidth, viewportHeight)
+ return projection
+ }
+
+ @Implementation
+ override fun getUiSettings(): UiSettings = navUiSettings ?: newInstanceWithDelegate(UiSettings::class.java).also {
+ navUiSettings = it
+ }
+}
+
+@Implements(CameraUpdateFactory::class)
+class ShadowNavCameraUpdateFactory {
+ companion object {
+ private fun createUpdate(): CameraUpdate = newInstanceWithDelegate(CameraUpdate::class.java)
+
+ @Implementation
+ @JvmStatic
+ fun newLatLng(latLng: LatLng): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.latLng = latLng
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newLatLngZoom(latLng: LatLng, zoom: Float): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.latLng = latLng
+ shadow.zoom = zoom
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newCameraPosition(cameraPosition: CameraPosition): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.cameraPosition = cameraPosition
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun zoomIn(): CameraUpdate = zoomBy(1.0f)
+
+ @Implementation
+ @JvmStatic
+ fun zoomOut(): CameraUpdate = zoomBy(-1.0f)
+
+ @Implementation
+ @JvmStatic
+ fun zoomTo(zoom: Float): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.zoom = zoom
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun zoomBy(amount: Float): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.zoomBy = amount
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun zoomBy(amount: Float, focus: Point): CameraUpdate = zoomBy(amount)
+
+ @Implementation
+ @JvmStatic
+ fun newLatLngBounds(bounds: LatLngBounds, padding: Int): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.setLatLngBounds(bounds, padding)
+ return update
+ }
+
+ @Implementation
+ @JvmStatic
+ fun newLatLngBounds(bounds: LatLngBounds, width: Int, height: Int, padding: Int): CameraUpdate =
+ newLatLngBounds(bounds, padding)
+
+ @Implementation
+ @JvmStatic
+ fun scrollBy(xPixel: Float, yPixel: Float): CameraUpdate {
+ val update = createUpdate()
+ val shadow = Shadow.extract(update)
+ shadow.setScroll(xPixel, yPixel)
+ return update
+ }
+ }
+}
+
+@Implements(BitmapDescriptorFactory::class)
+class ShadowNavBitmapDescriptorFactory {
+ companion object {
+ private fun createDescriptor(bitmap: Bitmap?, identifier: String): BitmapDescriptor {
+ val descriptor = newInstanceWithDelegate(BitmapDescriptor::class.java)
+ val shadow = Shadow.extract(descriptor)
+ shadow.bitmap = bitmap
+ shadow.identifier = identifier
+ return descriptor
+ }
+
+ @Implementation
+ @JvmStatic
+ fun defaultMarker(): BitmapDescriptor = defaultMarker(ShadowBitmapDescriptorFactory.HUE_RED)
+
+ @Implementation
+ @JvmStatic
+ fun defaultMarker(hue: Float): BitmapDescriptor = createDescriptor(null, "defaultMarker:hue=$hue")
+
+ @Implementation
+ @JvmStatic
+ fun fromBitmap(image: Bitmap?): BitmapDescriptor {
+ val id = if (image != null) "bitmap:${System.identityHashCode(image)}" else "bitmap:null"
+ return createDescriptor(image, id)
+ }
+
+ @Implementation
+ @JvmStatic
+ fun fromResource(resourceId: Int): BitmapDescriptor {
+ var bitmap: Bitmap? = null
+ try {
+ val app = RuntimeEnvironment.getApplication()
+ if (app != null) {
+ bitmap = BitmapFactory.decodeResource(app.resources, resourceId)
+ }
+ } catch (_: Exception) {
+ }
+ return createDescriptor(bitmap, "resource:$resourceId")
+ }
+
+ @Implementation
+ @JvmStatic
+ fun fromAsset(assetName: String): BitmapDescriptor {
+ var bitmap: Bitmap? = null
+ try {
+ val app = RuntimeEnvironment.getApplication()
+ if (app != null) {
+ app.assets.open(assetName).use { stream ->
+ bitmap = BitmapFactory.decodeStream(stream)
+ }
+ }
+ } catch (_: Exception) {
+ }
+ return createDescriptor(bitmap, "asset:$assetName")
+ }
+
+ @Implementation
+ @JvmStatic
+ fun fromFile(fileName: String): BitmapDescriptor = fromPath(fileName)
+
+ @Implementation
+ @JvmStatic
+ fun fromPath(absolutePath: String): BitmapDescriptor {
+ var bitmap: Bitmap? = null
+ try {
+ bitmap = BitmapFactory.decodeFile(absolutePath)
+ } catch (_: Exception) {
+ }
+ return createDescriptor(bitmap, "path:$absolutePath")
+ }
+ }
+}
+
+fun View.findNavigationView(): NavigationView? {
+ if (this is NavigationView) return this
+ if (this is ViewGroup) {
+ for (i in 0 until childCount) {
+ val found = getChildAt(i).findNavigationView()
+ if (found != null) return found
+ }
+ }
+ return null
+}