From 130434ae45db771a2b271f0dd943e7b68e34e151 Mon Sep 17 00:00:00 2001 From: Dai1678 Date: Wed, 7 Oct 2026 15:15:49 +0900 Subject: [PATCH 1/3] fix(maps-compose): do not apply mapColorScheme to Lite mode maps Lite mode does not support map color schemes. Recent Play services ignore them, but older ones throw UnsupportedOperationException from MapView.onCreate or GoogleMap.setMapColorScheme. Since the default became FOLLOW_SYSTEM, Lite mode maps crash on those devices without any opt-in from the app. Fixes #1028 --- docs/lite-mode-dark-theme.md | 2 +- .../android/compose/GoogleMapViewTests.kt | 30 +++++++++++++++---- .../google/maps/android/compose/GoogleMap.kt | 9 +++++- .../google/maps/android/compose/MapUpdater.kt | 3 +- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/lite-mode-dark-theme.md b/docs/lite-mode-dark-theme.md index dd664a6aa..5b13a09d3 100644 --- a/docs/lite-mode-dark-theme.md +++ b/docs/lite-mode-dark-theme.md @@ -17,7 +17,7 @@ GoogleMap( However, when Lite Mode is enabled (`GoogleMapOptions().liteMode(true)`), the underlying Google Maps Android SDK (`play-services-maps`) functions differently: 1. **Static Pre-rendered Tiles**: Lite Mode maps fetch static raster tiles rather than rendering vectors on the client GPU. -2. **`MapColorScheme` Bypassed**: The Google Maps Android SDK explicitly ignores dynamic `MapColorScheme` configurations for Lite Mode maps. +2. **`MapColorScheme` Bypassed**: Lite Mode maps do not support `MapColorScheme`, so Maps Compose does not apply `mapColorScheme` to them. Older versions of Google Play services throw `UnsupportedOperationException` when a color scheme is set on a Lite Mode map. 3. **Map Types Remain in Light Mode**: Neither `MapType.NORMAL` nor `MapType.TERRAIN` will render dark tiles through `mapColorScheme`. To support dark mode on a Lite Mode map, your application must listen for system theme changes and dynamically supply a custom dark JSON style using `MapProperties.mapStyleOptions`. 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 9ef1dde58..0dc6d11ae 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 @@ -170,7 +170,7 @@ class GoogleMapViewTests { } @Test - fun testLiteModePreservesColorSchemeAndLiteModeInOptions() { + fun testLiteModeDoesNotApplyColorSchemeToOptions() { var capturedOptions: GoogleMapOptions? = null composeTestRule.setContent { GoogleMap( @@ -183,7 +183,7 @@ class GoogleMapViewTests { ) } assertThat(capturedOptions?.liteMode).isTrue() - assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.DARK) + assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.LIGHT) } @Test @@ -205,11 +205,11 @@ class GoogleMapViewTests { } assertThat(capturedOptions?.liteMode).isTrue() assertThat(capturedOptions?.mapType).isEqualTo(GoogleMap.MAP_TYPE_TERRAIN) - assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.DARK) + assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.LIGHT) } @Test - fun testLiteModeDefaultColorSchemeIsFollowSystem() { + fun testLiteModeDoesNotApplyDefaultColorSchemeToOptions() { var capturedOptions: GoogleMapOptions? = null composeTestRule.setContent { GoogleMap( @@ -221,7 +221,27 @@ class GoogleMapViewTests { ) } assertThat(capturedOptions?.liteMode).isTrue() - assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.FOLLOW_SYSTEM) + assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.LIGHT) + } + + @Test + fun testLiteModePreservesColorSchemeSetInOptions() { + var capturedOptions: GoogleMapOptions? = null + composeTestRule.setContent { + GoogleMap( + googleMapOptionsFactory = { + GoogleMapOptions() + .liteMode(true) + .mapColorScheme(MapColorScheme.DARK) + }, + mapViewFactory = { context, options -> + capturedOptions = options + MapView(context, options) + } + ) + } + assertThat(capturedOptions?.liteMode).isTrue() + assertThat(capturedOptions?.mapColorScheme).isEqualTo(MapColorScheme.DARK) } @Test 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 6edd9d4e7..22263f8fe 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 @@ -90,6 +90,7 @@ import kotlinx.coroutines.launch * @param contentPadding the padding values used to signal that portions of the map around the edges * may be obscured. The map will move the Google logo, etc. to avoid overlapping the padding. * @param mapColorScheme Defines the color scheme for the Map. Defaults to [ComposeMapColorScheme.FOLLOW_SYSTEM]. + * Not applied to Lite mode maps, which do not support color schemes. * @param content the content of the map */ @Composable @@ -179,13 +180,16 @@ public fun GoogleMap( // If mapColorScheme is passed to GoogleMap() and has not been explicitly set // in googleMapOptionsFactory (where 0 / MapColorScheme.LIGHT is the Java int default), // apply it to GoogleMapOptions so MapView is created with it. - if (mapColorScheme != null && opts.mapColorScheme == 0) { + // Lite mode does not support color schemes, and older Play services throw + // UnsupportedOperationException for it, so it is never applied there (#1028). + if (mapColorScheme != null && opts.mapColorScheme == 0 && opts.liteMode != true) { opts.mapColorScheme(mapColorScheme.value) } else { opts } } cameraPositionState.isLiteMode = options.liteMode == true + mapUpdaterState.isLiteMode = options.liteMode == true mapViewFactory(context, options).also { mapView -> mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) @@ -331,6 +335,9 @@ internal class MapUpdaterState( var mapProperties by mutableStateOf(mapProperties) var mapUiSettings by mutableStateOf(mapUiSettings) var mapColorScheme by mutableStateOf(mapColorScheme) + + /** Set once from the [GoogleMapOptions] the [MapView] is created with. */ + var isLiteMode: Boolean = false } /** Used to store things in the tag which must be retrievable across recompositions */ 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 fb44dae86..eead3a520 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 @@ -138,7 +138,8 @@ internal inline fun MapUpdater(mapUpdaterState: MapUpdaterState) = with(mapUpdat set(mapProperties.maxZoomPreference) { map.setMaxZoomPreference(it) } set(mapProperties.minZoomPreference) { map.setMinZoomPreference(it) } set(mapColorScheme) { - if (it != null) { + // Lite mode does not support color schemes; see the GoogleMapOptions setup in GoogleMap. + if (it != null && !isLiteMode) { map.mapColorScheme = it } } From b8f6ef43096dd943105176f772398aeef9bf9000 Mon Sep 17 00:00:00 2001 From: Dai1678 Date: Wed, 7 Oct 2026 15:45:21 +0900 Subject: [PATCH 2/3] fix(maps-compose): keep Lite mode flag when MapView is reused AndroidView reuses the MapView (for example in LazyColumn) without running the factory again, while remembered MapUpdaterState is recreated. The Lite mode flag then reset to false, and MapUpdater set the color scheme on a reused Lite mode map again. --- .../google/maps/android/compose/GoogleMap.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 22263f8fe..8efdbe508 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 @@ -189,7 +189,6 @@ public fun GoogleMap( } } cameraPositionState.isLiteMode = options.liteMode == true - mapUpdaterState.isLiteMode = options.liteMode == true mapViewFactory(context, options).also { mapView -> mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) @@ -214,7 +213,11 @@ public fun GoogleMap( val lifecycleObserver = MapLifecycleEventObserver(mapView) - mapView.tag = MapTagData(componentCallbacks, lifecycleObserver) + mapView.tag = MapTagData( + componentCallbacks, + lifecycleObserver, + isLiteMode = options.liteMode == true, + ) // Only register for [lifecycleOwner]'s lifecycle events while MapView is attached val onAttachStateListener = object : View.OnAttachStateChangeListener { @@ -247,6 +250,7 @@ public fun GoogleMap( mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) + mapUpdaterState.isLiteMode = mapView.tagData.isLiteMode if (subcompositionJob == null) { subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, @@ -336,14 +340,19 @@ internal class MapUpdaterState( var mapUiSettings by mutableStateOf(mapUiSettings) var mapColorScheme by mutableStateOf(mapColorScheme) - /** Set once from the [GoogleMapOptions] the [MapView] is created with. */ + /** + * Whether the [MapView] was created in Lite mode. Copied from [MapTagData] on every + * `AndroidView` update, because a reused [MapView] skips the factory while this state is + * recreated. + */ var isLiteMode: Boolean = false } /** Used to store things in the tag which must be retrievable across recompositions */ private data class MapTagData( val componentCallbacks: ComponentCallbacks, - val lifecycleObserver: MapLifecycleEventObserver + val lifecycleObserver: MapLifecycleEventObserver, + val isLiteMode: Boolean, ) private val MapView.tagData: MapTagData From 96dbe3e9c3bd6dce00b097233fb058da20f2e797 Mon Sep 17 00:00:00 2001 From: Dai1678 Date: Wed, 7 Oct 2026 16:28:59 +0900 Subject: [PATCH 3/3] refactor(maps-compose): keep Lite mode flag on MapApplier MapApplier is created per subcomposition from the MapView, so it has the right lifetime for the flag and needs no copy on every update. This also keeps a plain var out of the @Stable MapUpdaterState, and reads liteMode from the options once. --- .../google/maps/android/compose/GoogleMap.kt | 43 +++++++++---------- .../google/maps/android/compose/MapApplier.kt | 2 + .../google/maps/android/compose/MapUpdater.kt | 1 + 3 files changed, 24 insertions(+), 22 deletions(-) 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 8efdbe508..98b91206f 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 @@ -176,19 +176,21 @@ public fun GoogleMap( // out of focus traversal entirely. modifier = if (focusable) modifier.focusable() else modifier, factory = { context -> - val options = googleMapOptionsFactory().let { opts -> - // If mapColorScheme is passed to GoogleMap() and has not been explicitly set - // in googleMapOptionsFactory (where 0 / MapColorScheme.LIGHT is the Java int default), - // apply it to GoogleMapOptions so MapView is created with it. - // Lite mode does not support color schemes, and older Play services throw - // UnsupportedOperationException for it, so it is never applied there (#1028). - if (mapColorScheme != null && opts.mapColorScheme == 0 && opts.liteMode != true) { - opts.mapColorScheme(mapColorScheme.value) - } else { - opts - } + val baseOptions = googleMapOptionsFactory() + val isLiteMode = baseOptions.liteMode == true + // If mapColorScheme is passed to GoogleMap() and has not been explicitly set + // in googleMapOptionsFactory (where 0 / MapColorScheme.LIGHT is the Java int default), + // apply it to GoogleMapOptions so MapView is created with it. + // Lite mode does not support color schemes, and older Play services throw + // UnsupportedOperationException for it, so it is never applied there (#1028). + val applyColorScheme = + mapColorScheme != null && baseOptions.mapColorScheme == 0 && !isLiteMode + val options = if (applyColorScheme) { + baseOptions.mapColorScheme(mapColorScheme.value) + } else { + baseOptions } - cameraPositionState.isLiteMode = options.liteMode == true + cameraPositionState.isLiteMode = isLiteMode mapViewFactory(context, options).also { mapView -> mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) @@ -216,7 +218,7 @@ public fun GoogleMap( mapView.tag = MapTagData( componentCallbacks, lifecycleObserver, - isLiteMode = options.liteMode == true, + isLiteMode = isLiteMode, ) // Only register for [lifecycleOwner]'s lifecycle events while MapView is attached @@ -250,7 +252,6 @@ public fun GoogleMap( mapView.applyFocusability(focusable) mapView.setViewTreeLifecycleOwner(lifecycleOwner) mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - mapUpdaterState.isLiteMode = mapView.tagData.isLiteMode if (subcompositionJob == null) { subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, @@ -298,7 +299,12 @@ private fun CoroutineScope.launchSubcomposition( ) { val map = mapView.awaitMap() val composition = Composition( - applier = MapApplier(map, mapView, mapClickListeners), + applier = MapApplier( + map, + mapView, + mapClickListeners, + isLiteMode = mapView.tagData.isLiteMode, + ), parent = parentComposition ) @@ -339,13 +345,6 @@ internal class MapUpdaterState( var mapProperties by mutableStateOf(mapProperties) var mapUiSettings by mutableStateOf(mapUiSettings) var mapColorScheme by mutableStateOf(mapColorScheme) - - /** - * Whether the [MapView] was created in Lite mode. Copied from [MapTagData] on every - * `AndroidView` update, because a reused [MapView] skips the factory while this state is - * recreated. - */ - var isLiteMode: Boolean = false } /** Used to store things in the tag which must be retrievable across recompositions */ 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 f1793069f..65132abeb 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 @@ -40,6 +40,8 @@ internal class MapApplier( val map: GoogleMap, internal val mapView: MapView, val mapClickListeners: MapClickListeners, + /** Whether [mapView] was created in Lite mode, which does not support color schemes. */ + internal val isLiteMode: Boolean, ) : AbstractApplier(MapNodeRoot) { private val decorations = mutableListOf() 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 eead3a520..d27aa9a36 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 @@ -101,6 +101,7 @@ public val DefaultMapContentPadding: PaddingValues = PaddingValues() internal inline fun MapUpdater(mapUpdaterState: MapUpdaterState) = with(mapUpdaterState) { val map = (currentComposer.applier as MapApplier).map val mapView = (currentComposer.applier as MapApplier).mapView + val isLiteMode = (currentComposer.applier as MapApplier).isLiteMode if (mergeDescendants) { mapView.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS }