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 +}