diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt index 189e1126..d680c70e 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReusableContentHost import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed @@ -26,6 +27,8 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.test.platform.app.InstrumentationRegistry import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions @@ -39,6 +42,7 @@ import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule import org.junit.Test +import java.lang.ref.WeakReference import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.math.abs @@ -501,6 +505,102 @@ class GoogleMapViewTests { .performClick() } + @Test + fun testShowingHidingMapRepeatedlyReleasesMapViews() { + // Regression test for https://github.com/googlemaps/android-maps-compose/issues/581 + val showMapState = mutableStateOf(true) + val mapViews = mutableListOf>() + lateinit var markerState: MarkerState + + composeTestRule.setContent { + markerState = rememberUpdatedMarkerState(position = startingPosition) + if (showMapState.value) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + mapViewFactory = { context, options -> + MapView(context, options).also { + mapViews.add(WeakReference(it)) + } + }, + ) { + MarkerInfoWindowContent( + state = markerState, + title = "Test", + ) { + Text("Test Info") + } + } + } + } + + // Toggle visibility repeatedly (including rapid cycles before and after map initialization) + repeat(15) { + showMapState.value = true + composeTestRule.waitForIdle() + Thread.sleep(50) + showMapState.value = false + composeTestRule.waitForIdle() + composeTestRule.runOnUiThread { + assertThat(cameraPositionState.projection).isNull() + } + } + + assertThat(mapViews.size).isAtLeast(15) + // Deterministically verify every released MapView has cleared its tag, view-tree owners, + // and internal child view hierarchy without relying on non-deterministic GC timing. + for (ref in mapViews) { + val mapView = ref.get() + if (mapView != null) { + assertThat(mapView.tag).isNull() + assertThat(mapView.childCount).isEqualTo(0) + assertThat(mapView.findViewTreeLifecycleOwner()).isNull() + assertThat(mapView.findViewTreeSavedStateRegistryOwner()).isNull() + } + } + } + + @Test + fun testReusableContentHostResetAndReuse() { + val activeState = mutableStateOf(true) + lateinit var markerState: MarkerState + + composeTestRule.setContent { + markerState = rememberUpdatedMarkerState(position = startingPosition) + ReusableContentHost(active = activeState.value) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Marker( + state = markerState, + title = "Reusable Marker", + ) + } + } + } + + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.runOnUiThread { cameraPositionState.projection != null } + } + + // Deactivate and reactivate multiple times to exercise AndroidView.onReset -> update + repeat(3) { + activeState.value = false + composeTestRule.waitForIdle() + composeTestRule.runOnUiThread { + assertThat(cameraPositionState.projection).isNull() + } + + activeState.value = true + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.runOnUiThread { cameraPositionState.projection != null } + } + } + } + @Test fun testRememberUpdatedMarkerStateBeUpdate() { val testPoint0 = LatLng(0.0, 0.0) diff --git a/maps-compose/build.gradle.kts b/maps-compose/build.gradle.kts index d4791004..872e54dd 100644 --- a/maps-compose/build.gradle.kts +++ b/maps-compose/build.gradle.kts @@ -92,6 +92,9 @@ dependencies { api(libs.maps.utils.core) testImplementation(libs.test.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.truth) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.test.espresso) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index 2191813d..1951fa27 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -14,12 +14,12 @@ package com.google.maps.android.compose -import android.content.ComponentCallbacks import android.content.ComponentCallbacks2 import android.content.Context import android.content.res.Configuration import android.location.Location import android.os.Bundle +import android.util.Log import android.view.View import android.view.ViewGroup import androidx.compose.foundation.focusable @@ -60,6 +60,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -168,7 +169,6 @@ public fun GoogleMap( val parentComposition = rememberCompositionContext() val currentContent by rememberUpdatedState(content) - var subcompositionJob by remember { mutableStateOf(null) } val parentCompositionScope = rememberCoroutineScope() AndroidView( @@ -216,48 +216,37 @@ public fun GoogleMap( val lifecycleObserver = MapLifecycleEventObserver(mapView) + // Only register for [lifecycleOwner]'s lifecycle events while MapView is attached + val onAttachStateListener = MapAttachStateChangeListener(lifecycleObserver) + mapView.tag = MapTagData( - componentCallbacks, - lifecycleObserver, + componentCallbacks = componentCallbacks, + lifecycleObserver = lifecycleObserver, + detachLifecycleObserver = onAttachStateListener::cleanup, + onAttachStateChangeListener = onAttachStateListener, isLiteMode = isLiteMode, ) - // Only register for [lifecycleOwner]'s lifecycle events while MapView is attached - val onAttachStateListener = object : View.OnAttachStateChangeListener { - private var lifecycle: Lifecycle? = null - - override fun onViewAttachedToWindow(mapView: View) { - lifecycle = mapView.findViewTreeLifecycleOwner()!!.lifecycle.also { - it.addObserver(lifecycleObserver) - } - } - - override fun onViewDetachedFromWindow(v: View) { - lifecycle?.removeObserver(lifecycleObserver) - lifecycle = null - lifecycleObserver.moveToBaseState() - } - } - mapView.addOnAttachStateChangeListener(onAttachStateListener) } }, - onReset = { /* View is detached. */ }, + onReset = { mapView -> + mapView.onReset() + }, onRelease = { mapView -> - val (componentCallbacks, lifecycleObserver) = mapView.tagData - mapView.context.unregisterComponentCallbacks(componentCallbacks) - lifecycleObserver.moveToDestroyedState() - mapView.tag = null + mapView.onRelease() }, update = { mapView -> mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - if (subcompositionJob == null) { - subcompositionJob = parentCompositionScope.launchSubcomposition( + val tagData = mapView.tagDataOrNull + if (tagData != null && tagData.subcompositionJob == null) { + tagData.subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, parentComposition, mapView, + tagData, mapClickListeners, currentContent, ) @@ -287,10 +276,11 @@ private fun MapView.applyFocusability(focusable: Boolean) { * Create and apply the [content] compositions to the map + * dispose the [Composition] when the parent composable is disposed. * */ -private fun CoroutineScope.launchSubcomposition( +internal fun CoroutineScope.launchSubcomposition( mapUpdaterState: MapUpdaterState, parentComposition: CompositionContext, mapView: MapView, + tagData: MapTagData, mapClickListeners: MapClickListeners, content: @Composable @GoogleMapComposable @@ -298,19 +288,23 @@ private fun CoroutineScope.launchSubcomposition( ): Job { // Use [CoroutineStart.UNDISPATCHED] to kick off GoogleMap loading immediately return launch( - context = Dispatchers.Main, + context = Dispatchers.Main.immediate, start = CoroutineStart.UNDISPATCHED, ) { val map = mapView.awaitMap() + if (!isActive || mapView.tagDataOrNull !== tagData) { + return@launch + } val composition = Composition( applier = MapApplier( map, mapView, mapClickListeners, - isLiteMode = mapView.tagData.isLiteMode, + isLiteMode = tagData.isLiteMode, ), parent = parentComposition, ) + tagData.subcomposition = composition try { composition.setContent { @@ -325,6 +319,9 @@ private fun CoroutineScope.launchSubcomposition( } awaitCancellation() } finally { + if (tagData.subcomposition === composition) { + tagData.subcomposition = null + } composition.dispose() } } @@ -351,15 +348,76 @@ internal class MapUpdaterState( var mapColorScheme by mutableStateOf(mapColorScheme) } +private const val TAG = "GoogleMap" + /** Used to store things in the tag which must be retrievable across recompositions */ -private data class MapTagData( - val componentCallbacks: ComponentCallbacks, +internal class MapTagData( + val componentCallbacks: ComponentCallbacks2, val lifecycleObserver: MapLifecycleEventObserver, + val detachLifecycleObserver: () -> Unit, + val onAttachStateChangeListener: View.OnAttachStateChangeListener, val isLiteMode: Boolean, -) +) { + var subcompositionJob: Job? = null + var subcomposition: Composition? = null + + fun disposeSubcomposition() { + subcompositionJob?.cancel() + subcompositionJob = null + subcomposition?.dispose() + subcomposition = null + } +} + +internal class MapAttachStateChangeListener(private val lifecycleObserver: MapLifecycleEventObserver) : + View.OnAttachStateChangeListener { + private var lifecycle: Lifecycle? = null + + override fun onViewAttachedToWindow(mapView: View) { + val viewTreeLifecycleOwner = mapView.findViewTreeLifecycleOwner() + if (viewTreeLifecycleOwner == null) { + val message = + "ViewTreeLifecycleOwner not found for MapView; cannot observe lifecycle events." + Log.e(TAG, message) + error(message) + } + lifecycle = viewTreeLifecycleOwner.lifecycle.also { + it.addObserver(lifecycleObserver) + } + } + + override fun onViewDetachedFromWindow(v: View) { + cleanup() + } -private val MapView.tagData: MapTagData - get() = tag as MapTagData + fun cleanup() { + lifecycle?.removeObserver(lifecycleObserver) + lifecycle = null + lifecycleObserver.moveToBaseState() + } +} + +internal val MapView.tagDataOrNull: MapTagData? + get() = tag as? MapTagData + +internal fun MapView.onReset() { + tagDataOrNull?.disposeSubcomposition() +} + +internal fun MapView.onRelease() { + val tagData = tagDataOrNull + tagData?.disposeSubcomposition() + if (tagData != null) { + context.unregisterComponentCallbacks(tagData.componentCallbacks) + removeOnAttachStateChangeListener(tagData.onAttachStateChangeListener) + tagData.detachLifecycleObserver() + tagData.lifecycleObserver.moveToDestroyedState() + } + setViewTreeLifecycleOwner(null) + setViewTreeSavedStateRegistryOwner(null) + removeAllViews() + tag = null +} public typealias GoogleMapFactory = @Composable () -> Unit @@ -398,7 +456,7 @@ public fun googleMapFactory( } } -private class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver { +internal class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver { private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt index e9db1788..74fe2a54 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt @@ -14,6 +14,7 @@ package com.google.maps.android.compose +import android.annotation.SuppressLint import androidx.compose.runtime.AbstractApplier import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.MapView @@ -54,6 +55,7 @@ internal class MapApplier( map.clear() decorations.forEach { it.onCleared() } decorations.clear() + detachClickListeners() } override fun insertBottomUp(index: Int, instance: MapNode) { @@ -76,6 +78,7 @@ internal class MapApplier( decorations.remove(index, count) } + @SuppressLint("PotentialBehaviorOverride") internal fun attachClickListeners() { map.setOnCircleClickListener { circle -> decorations.findInputCallback( @@ -221,6 +224,20 @@ internal class MapApplier( ), ) } + + @SuppressLint("PotentialBehaviorOverride") + internal fun detachClickListeners() { + map.setOnCircleClickListener(null) + map.setOnGroundOverlayClickListener(null) + map.setOnPolygonClickListener(null) + map.setOnPolylineClickListener(null) + map.setOnMarkerClickListener(null) + map.setOnInfoWindowClickListener(null) + map.setOnInfoWindowCloseListener(null) + map.setOnInfoWindowLongClickListener(null) + map.setOnMarkerDragListener(null) + map.setInfoWindowAdapter(null) + } } /** diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt index 12bb9d41..febf7d51 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt @@ -79,11 +79,20 @@ internal class MapPropertiesNode( } override fun onRemoved() { - cameraPositionState.setMap(null) + clearMapReferences() } override fun onCleared() { + clearMapReferences() + } + + private fun clearMapReferences() { cameraPositionState.setMap(null) + map.setOnCameraIdleListener(null) + map.setOnCameraMoveCanceledListener(null) + map.setOnCameraMoveStartedListener(null) + map.setOnCameraMoveListener(null) + map.setLocationSource(null) } } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt b/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt index 0d8489ab..de203900 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt @@ -26,7 +26,7 @@ import com.google.android.gms.maps.model.TileOverlay import com.google.android.gms.maps.model.TileProvider import com.google.maps.android.addTileOverlay -private class TileOverlayNode( +internal class TileOverlayNode( var tileOverlay: TileOverlay, var tileOverlayState: TileOverlayState, var onTileOverlayClick: (TileOverlay) -> Unit, @@ -35,6 +35,11 @@ private class TileOverlayNode( tileOverlayState.tileOverlay = tileOverlay } override fun onRemoved() { + tileOverlayState.tileOverlay = null + tileOverlay.remove() + } + override fun onCleared() { + tileOverlayState.tileOverlay = null tileOverlay.remove() } } 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 new file mode 100644 index 00000000..6d0694a9 --- /dev/null +++ b/maps-compose/src/test/java/com/google/maps/android/compose/MapApplierCleanupTest.kt @@ -0,0 +1,402 @@ +/* + * 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.content.ComponentCallbacks2 +import android.content.Context +import android.util.Log +import android.view.View +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionContext +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.findViewTreeLifecycleOwner +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.OnMapReadyCallback +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.TileOverlay +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Test + +public class MapApplierCleanupTest { + + @Test + public fun `MapApplier onClear detaches all GoogleMap listeners and InfoWindowAdapter`() { + val map = mockk(relaxed = true) + val mapView = mockk(relaxed = true) + val mapClickListeners = MapClickListeners() + + val applier = MapApplier( + map = map, + mapView = mapView, + mapClickListeners = mapClickListeners, + isLiteMode = false, + ) + + var nodeCleared = false + val node = object : MapNode { + override fun onCleared() { + nodeCleared = true + } + } + applier.insertBottomUp(0, node) + + applier.clear() + + assertThat(nodeCleared).isTrue() + verify { map.clear() } + verify { map.setOnCircleClickListener(null) } + verify { map.setOnGroundOverlayClickListener(null) } + verify { map.setOnPolygonClickListener(null) } + verify { map.setOnPolylineClickListener(null) } + verify { map.setOnMarkerClickListener(null) } + verify { map.setOnInfoWindowClickListener(null) } + verify { map.setOnInfoWindowCloseListener(null) } + verify { map.setOnInfoWindowLongClickListener(null) } + verify { map.setOnMarkerDragListener(null) } + verify { map.setInfoWindowAdapter(null) } + } + + @Test + public fun `MapPropertiesNode onCleared and onRemoved detach camera listeners and locationSource`() { + mockkStatic(CameraUpdateFactory::class) + try { + every { CameraUpdateFactory.newCameraPosition(any()) } returns mockk(relaxed = true) + val map = mockk(relaxed = true) + val cameraPositionState = CameraPositionState( + position = CameraPosition.fromLatLngZoom(LatLng(1.0, 2.0), 10f), + ) + val node = MapPropertiesNode( + map = map, + cameraPositionState = cameraPositionState, + contentDescription = "Test Map", + density = Density(1f), + layoutDirection = LayoutDirection.Ltr, + contentPadding = PaddingValues(), + ) + + node.onAttached() + node.onCleared() + + verify { map.setOnCameraIdleListener(null) } + verify { map.setOnCameraMoveCanceledListener(null) } + verify { map.setOnCameraMoveStartedListener(null) } + verify { map.setOnCameraMoveListener(null) } + verify { map.setLocationSource(null) } + } finally { + unmockkStatic(CameraUpdateFactory::class) + } + } + + @Test + public fun `TileOverlayNode onCleared and onRemoved clear tileOverlayState reference`() { + val tileOverlay = mockk(relaxed = true) + val tileOverlayState = TileOverlayState() + val node = TileOverlayNode( + tileOverlay = tileOverlay, + tileOverlayState = tileOverlayState, + onTileOverlayClick = {}, + ) + + node.onAttached() + assertThat(tileOverlayState.tileOverlay).isSameInstanceAs(tileOverlay) + + node.onCleared() + assertThat(tileOverlayState.tileOverlay).isNull() + verify { tileOverlay.remove() } + } + + @Test + public fun `MarkerNode onCleared and onRemoved clear markerState reference`() { + val marker = mockk(relaxed = true) + val markerState = MarkerState(position = LatLng(1.0, 2.0)) + val node = MarkerNode( + compositionContext = mockk(relaxed = true), + marker = marker, + markerState = markerState, + onMarkerClick = { false }, + onInfoWindowClick = {}, + onInfoWindowClose = {}, + onInfoWindowLongClick = {}, + infoWindow = null, + infoContent = null, + ) + + node.onAttached() + assertThat(markerState.marker).isSameInstanceAs(marker) + + node.onCleared() + assertThat(markerState.marker).isNull() + verify { marker.remove() } + } + + @Test + public fun `MapTagData disposeSubcomposition cancels job and disposes composition`() { + val mapView = mockk(relaxed = true) + val job = mockk(relaxed = true) + val composition = mockk(relaxed = true) + val tagData = MapTagData( + componentCallbacks = mockk(relaxed = true), + lifecycleObserver = MapLifecycleEventObserver(mapView), + detachLifecycleObserver = {}, + onAttachStateChangeListener = mockk(relaxed = true), + isLiteMode = false, + ) + tagData.subcompositionJob = job + tagData.subcomposition = composition + + tagData.disposeSubcomposition() + + assertThat(tagData.subcompositionJob).isNull() + assertThat(tagData.subcomposition).isNull() + verifyOrder { + job.cancel(null) + composition.dispose() + } + } + + @Test + public fun `MapLifecycleEventObserver transitions MapView through lifecycle states in order`() { + val mapView = mockk(relaxed = true) + val observer = MapLifecycleEventObserver(mapView) + 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) + + // Detaching / ON_DESTROY from parent lifecycle moves down to CREATED (not onDestroy) + observer.moveToBaseState() + + // Explicit release moves to DESTROYED (invoking mapView.onDestroy()) + observer.moveToDestroyedState() + + verifyOrder { + mapView.onCreate(any()) + mapView.onStart() + mapView.onResume() + mapView.onPause() + mapView.onStop() + mapView.onDestroy() + } + } + + @Test + public fun `MapView onRelease disposes subcomposition BEFORE mapView onDestroy and cleans up view state`() { + val context = mockk(relaxed = true) + val mapView = mockk(relaxed = true) + every { mapView.context } returns context + val componentCallbacks = mockk(relaxed = true) + val attachListener = mockk(relaxed = true) + val lifecycleObserver = MapLifecycleEventObserver(mapView) + val owner = object : LifecycleOwner { + val registry = LifecycleRegistry.createUnsafe(this) + override val lifecycle: Lifecycle get() = registry + } + lifecycleObserver.onStateChanged(owner, Lifecycle.Event.ON_CREATE) + lifecycleObserver.onStateChanged(owner, Lifecycle.Event.ON_START) + lifecycleObserver.onStateChanged(owner, Lifecycle.Event.ON_RESUME) + + var attachCleanupCalled = false + val tagData = MapTagData( + componentCallbacks = componentCallbacks, + lifecycleObserver = lifecycleObserver, + detachLifecycleObserver = { attachCleanupCalled = true }, + onAttachStateChangeListener = attachListener, + isLiteMode = false, + ) + val job = mockk(relaxed = true) + val composition = mockk(relaxed = true) + tagData.subcompositionJob = job + tagData.subcomposition = composition + every { mapView.tag } returns tagData + + mapView.onRelease() + + assertThat(attachCleanupCalled).isTrue() + verifyOrder { + // 1. Subcomposition is cancelled and disposed synchronously first + job.cancel(null) + composition.dispose() + // 2. ComponentCallbacks and AttachStateChangeListener are unregistered + context.unregisterComponentCallbacks(componentCallbacks) + mapView.removeOnAttachStateChangeListener(attachListener) + // 3. MapView lifecycle transitions down to DESTROYED (mapView.onDestroy()) + mapView.onPause() + mapView.onStop() + mapView.onDestroy() + // 4. Child views and tag are cleared + mapView.removeAllViews() + mapView.tag = null + } + } + + @Test + public fun `MapView onReset disposes subcomposition without destroying MapView`() { + val mapView = mockk(relaxed = true) + val lifecycleObserver = MapLifecycleEventObserver(mapView) + val owner = object : LifecycleOwner { + val registry = LifecycleRegistry.createUnsafe(this) + override val lifecycle: Lifecycle get() = registry + } + lifecycleObserver.onStateChanged(owner, Lifecycle.Event.ON_CREATE) + + val tagData = MapTagData( + componentCallbacks = mockk(relaxed = true), + lifecycleObserver = lifecycleObserver, + detachLifecycleObserver = {}, + onAttachStateChangeListener = mockk(relaxed = true), + isLiteMode = false, + ) + val job = mockk(relaxed = true) + val composition = mockk(relaxed = true) + tagData.subcompositionJob = job + tagData.subcomposition = composition + every { mapView.tag } returns tagData + + mapView.onReset() + + // Subcomposition is disposed and reset to null so update{} will re-launch on reuse + assertThat(tagData.subcompositionJob).isNull() + assertThat(tagData.subcomposition).isNull() + verifyOrder { + job.cancel(null) + composition.dispose() + } + // MapView itself is NOT destroyed on reset + verify(exactly = 0) { mapView.onDestroy() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + public fun `launchSubcomposition aborts safely when MapView is released before awaitMap completes`(): Unit = + runTest { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + Dispatchers.setMain(testDispatcher) + try { + val context = mockk(relaxed = true) + val mapView = mockk(relaxed = true) + val map = mockk(relaxed = true) + val callbackSlot = slot() + every { mapView.context } returns context + every { mapView.getMapAsync(capture(callbackSlot)) } returns Unit + + val tagData = MapTagData( + componentCallbacks = mockk(relaxed = true), + lifecycleObserver = MapLifecycleEventObserver(mapView), + detachLifecycleObserver = {}, + onAttachStateChangeListener = mockk(relaxed = true), + isLiteMode = false, + ) + var currentTag: Any? = tagData + every { mapView.tag } answers { currentTag } + every { mapView.tag = any() } answers { currentTag = firstArg() } + + val mapUpdaterState = MapUpdaterState( + mergeDescendants = false, + contentDescription = null, + cameraPositionState = CameraPositionState(), + contentPadding = PaddingValues(), + locationSource = null, + mapProperties = DefaultMapProperties, + mapUiSettings = DefaultMapUiSettings, + mapColorScheme = null, + ) + + // 1. Launch subcomposition (suspends at mapView.awaitMap()) + tagData.subcompositionJob = launchSubcomposition( + mapUpdaterState = mapUpdaterState, + parentComposition = mockk(relaxed = true), + mapView = mapView, + tagData = tagData, + mapClickListeners = MapClickListeners(), + content = {}, + ) + assertThat(callbackSlot.isCaptured).isTrue() + + // 2. Simulate rapid hide: MapView is released BEFORE getMapAsync callback fires + mapView.onRelease() + assertThat(currentTag).isNull() + assertThat(tagData.subcompositionJob).isNull() + + // 3. Even if the Maps SDK still invokes the captured OnMapReadyCallback afterwards, + // it must be a safe no-op (no NPE on mapView.tag and no listeners attached to map) + callbackSlot.captured.onMapReady(map) + assertThat(tagData.subcomposition).isNull() + verify(exactly = 0) { map.setInfoWindowAdapter(any()) } + } finally { + Dispatchers.resetMain() + } + } + + @Test + public fun `MapAttachStateChangeListener logs error and throws when ViewTreeLifecycleOwner is null`() { + mockkStatic(Log::class) + mockkStatic("androidx.lifecycle.ViewTreeLifecycleOwner") + try { + every { Log.e(any(), any()) } returns 0 + val mapView = mockk(relaxed = true) + every { mapView.findViewTreeLifecycleOwner() } returns null + + val listener = MapAttachStateChangeListener(MapLifecycleEventObserver(mapView)) + + var thrown: IllegalStateException? = null + try { + listener.onViewAttachedToWindow(mapView) + } catch (e: IllegalStateException) { + thrown = e + } + + assertThat(thrown).isNotNull() + assertThat(thrown!!.message).contains("ViewTreeLifecycleOwner not found for MapView") + verify { + Log.e( + "GoogleMap", + match { it.contains("ViewTreeLifecycleOwner not found for MapView") }, + ) + } + } finally { + unmockkStatic("androidx.lifecycle.ViewTreeLifecycleOwner") + unmockkStatic(Log::class) + } + } +}