From c0d81bc79bcbb7417a5bf0eb6ae43b9754cb5764 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:52:08 -0600 Subject: [PATCH 1/2] feat(utils): add Compose wrappers for HeatmapTileOverlay, DataLayer, GeoJsonLayer, and KmlLayer - Add HeatmapTileOverlay composable wrapping HeatmapTileProvider and MapApplier tile overlays with reactive updates for data, weightedData, gradient, radius, opacity, and maxIntensity. - Add DataScene and DataLayer composables wrapping the platform-agnostic DataLayer, DataScene, and MapViewRenderer introduced in android-maps-utils 6.x. - Add GeoJsonLayer and KmlLayer composables supporting raw resources, JSONObject, InputStream, and KMZ archive security limits (maxKmzEntryCount, maxKmzUncompressedTotalSize). - Add unit tests for HeatmapTileOverlay and DataLayer/GeoJsonLayer/KmlLayer. --- .../maps/android/compose/data/DataLayer.kt | 277 ++++++++++++++++++ .../compose/data/geojson/GeoJsonLayer.kt | 160 ++++++++++ .../maps/android/compose/data/kml/KmlLayer.kt | 241 +++++++++++++++ .../compose/heatmaps/HeatmapTileOverlay.kt | 223 ++++++++++++++ .../android/compose/data/DataLayerTest.kt | 152 ++++++++++ .../heatmaps/HeatmapTileOverlayTest.kt | 85 ++++++ 6 files changed, 1138 insertions(+) create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt create mode 100644 maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt create mode 100644 maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt new file mode 100644 index 00000000..68c877ca --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt @@ -0,0 +1,277 @@ +/* + * 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.data + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import androidx.annotation.RawRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.MapEffect +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.rememberReattachClickListenersHandle +import com.google.maps.android.data.DataLayerLoader +import com.google.maps.android.data.renderer.IconProvider +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import com.google.maps.android.data.renderer.model.DataLayer +import com.google.maps.android.data.renderer.model.DataScene +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Renders a platform-agnostic [DataScene] onto the map using [MapViewRenderer]. + * + * When [scene] changes or the composable leaves composition, previously rendered features are + * removed from the map. + * + * @param scene the [DataScene] to render. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataScene( + scene: DataScene, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, +) { + val reattach = rememberReattachClickListenersHandle() + var renderer by remember { mutableStateOf(null) } + + MapEffect( + iconProvider, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + ) { map -> + val newRenderer = MapViewRenderer( + map = map, + iconProvider = iconProvider, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + renderer = newRenderer + if ( + markerManager != null || + polygonManager != null || + polylineManager != null || + groundOverlayManager != null + ) { + Handler(Looper.getMainLooper()).post { + reattach() + } + } + } + + val currentRenderer = renderer ?: return + + DisposableEffect(currentRenderer, scene, useAdvancedMarkers) { + currentRenderer.useAdvancedMarkers = useAdvancedMarkers + renderSceneWithCachedImages(currentRenderer, scene) + onDispose { + currentRenderer.clear() + } + } +} + +/** + * Renders a platform-agnostic [DataLayer] (parsed from GeoJSON, KML, KMZ, or GPX) onto the map + * using [MapViewRenderer]. + * + * @param layer the [DataLayer] to render. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataLayer( + layer: DataLayer, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, +) { + val scene = remember(layer) { DataScene(listOf(layer)) } + DataScene( + scene = scene, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) +} + +/** + * Loads a [DataLayer] from an application asset file (supporting `.geojson`, `.json`, `.kml`, + * `.kmz`, and `.gpx`) using [DataLayerLoader] and renders it onto the map. + * + * @param assetName the path of the asset file in the application's `assets/` directory. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used for reading and parsing the asset file. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataLayer( + assetName: String, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + var loadedLayer by remember(assetName) { mutableStateOf(null) } + + LaunchedEffect(context, assetName, ioDispatcher) { + val layer = DataLayerLoader(context, ioDispatcher).loadAsset(assetName) + loadedLayer = layer + onLayerLoadedState(layer) + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +/** + * Loads a [DataLayer] from a raw Android resource (supporting GeoJSON, KML, KMZ, and GPX) using + * [DataLayerLoader] and renders it onto the map. + * + * @param rawResourceId the `@RawRes` resource identifier to load. + * @param fileName an optional file name hint (e.g., `"boroughs.geojson"` or `"campus.kmz"`) used by + * [DataLayerLoader] for extension matching before falling back to content sniffing. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used for reading and parsing the raw resource. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataLayer( + @RawRes rawResourceId: Int, + fileName: String? = null, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + var loadedLayer by remember(rawResourceId, fileName) { mutableStateOf(null) } + + LaunchedEffect(context, rawResourceId, fileName, ioDispatcher) { + val layer = withContext(ioDispatcher) { + runCatching { + context.resources.openRawResource(rawResourceId).use { stream -> + DataLayerLoader(context, ioDispatcher).loadInputStream(stream, fileName) + } + }.getOrNull() + } + loadedLayer = layer + onLayerLoadedState(layer) + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +internal fun renderSceneWithCachedImages(renderer: MapViewRenderer, scene: DataScene) { + scene.layers.forEach { layer -> + val images = layer.properties["images"] as? Map<*, *> + images?.forEach { (key, value) -> + if (key is String && value is Bitmap) { + renderer.cacheImageData(key, value) + } + } + } + renderer.render(scene) +} diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt new file mode 100644 index 00000000..a195935e --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt @@ -0,0 +1,160 @@ +/* + * 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.data.geojson + +import androidx.annotation.RawRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.data.DataLayer +import com.google.maps.android.data.parser.geojson.GeoJsonParser +import com.google.maps.android.data.renderer.IconProvider +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapper.toLayer +import com.google.maps.android.data.renderer.model.DataLayer +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** + * Parses a [JSONObject] containing GeoJSON data and renders it onto the map using the + * platform-agnostic [DataLayer] renderer. + * + * @param geoJsonData the [JSONObject] containing GeoJSON features or geometry. + * @param iconProvider the [IconProvider] used to fetch remote marker icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used to parse the GeoJSON payload. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun GeoJsonLayer( + geoJsonData: JSONObject, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + var loadedLayer by remember(geoJsonData) { mutableStateOf(null) } + + LaunchedEffect(geoJsonData, ioDispatcher) { + val layer = withContext(ioDispatcher) { + parseGeoJsonLayer(geoJsonData.toString()) + } + loadedLayer = layer + onLayerLoadedState(layer) + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +/** + * Loads and parses GeoJSON data from a raw Android resource and renders it onto the map using the + * platform-agnostic [DataLayer] renderer. + * + * @param rawResourceId the `@RawRes` resource identifier containing GeoJSON data. + * @param iconProvider the [IconProvider] used to fetch remote marker icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used to read and parse the GeoJSON resource. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun GeoJsonLayer( + @RawRes rawResourceId: Int, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + var loadedLayer by remember(rawResourceId) { mutableStateOf(null) } + + LaunchedEffect(context, rawResourceId, ioDispatcher) { + val layer = withContext(ioDispatcher) { + runCatching { + context.resources.openRawResource(rawResourceId).use { stream -> + GeoJsonParser().parse(stream)?.toLayer() + } + }.getOrNull() + } + loadedLayer = layer + onLayerLoadedState(layer) + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +internal fun parseGeoJsonLayer(jsonString: String): DataLayer? = runCatching { + jsonString.byteInputStream().use { stream -> + GeoJsonParser().parse(stream)?.toLayer() + } +}.getOrNull() diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt new file mode 100644 index 00000000..b5bc4682 --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt @@ -0,0 +1,241 @@ +/* + * 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.data.kml + +import androidx.annotation.RawRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.data.DataLayer +import com.google.maps.android.data.parser.kml.AndroidImageDecoder +import com.google.maps.android.data.parser.kml.ImageDecoder +import com.google.maps.android.data.parser.kml.KmlParser +import com.google.maps.android.data.parser.kml.KmzParser +import com.google.maps.android.data.renderer.IconProvider +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapper.toLayer +import com.google.maps.android.data.renderer.model.DataLayer +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.InputStream +import java.nio.charset.StandardCharsets + +private const val DEFAULT_MAX_KMZ_ENTRY_COUNT = 200 +private const val DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE = 50L * 1024L * 1024L + +/** + * Loads and parses KML or KMZ data from a raw Android resource and renders it onto the map using + * the platform-agnostic [DataLayer] renderer. + * + * Automatically detects whether the stream is a plain KML document or a compressed KMZ archive and + * enforces [maxKmzEntryCount] and [maxKmzUncompressedTotalSize] limits against zip-bomb payloads. + * + * @param rawResourceId the `@RawRes` resource identifier containing KML or KMZ data. + * @param maxKmzEntryCount maximum number of ZIP entries permitted when parsing a KMZ archive. + * @param maxKmzUncompressedTotalSize maximum total uncompressed byte size permitted when parsing a + * KMZ archive. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used to read and parse the KML/KMZ resource. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun KmlLayer( + @RawRes rawResourceId: Int, + maxKmzEntryCount: Int = DEFAULT_MAX_KMZ_ENTRY_COUNT, + maxKmzUncompressedTotalSize: Long = DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + var loadedLayer by remember( + rawResourceId, + maxKmzEntryCount, + maxKmzUncompressedTotalSize, + ) { + mutableStateOf(null) + } + + LaunchedEffect( + context, + rawResourceId, + maxKmzEntryCount, + maxKmzUncompressedTotalSize, + ioDispatcher, + ) { + val layer = withContext(ioDispatcher) { + runCatching { + context.resources.openRawResource(rawResourceId).use { stream -> + parseKmlOrKmzStream( + inputStream = stream, + maxKmzEntryCount = maxKmzEntryCount, + maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize, + ) + } + }.getOrNull() + } + loadedLayer = layer + onLayerLoadedState(layer) + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +/** + * Loads and parses KML or KMZ data from an application asset file and renders it onto the map using + * the platform-agnostic [DataLayer] renderer. + * + * Automatically detects whether the asset is a plain KML document or a compressed KMZ archive and + * enforces [maxKmzEntryCount] and [maxKmzUncompressedTotalSize] limits against zip-bomb payloads. + * + * @param assetName the path of the KML or KMZ file in the application's `assets/` directory. + * @param maxKmzEntryCount maximum number of ZIP entries permitted when parsing a KMZ archive. + * @param maxKmzUncompressedTotalSize maximum total uncompressed byte size permitted when parsing a + * KMZ archive. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used to read and parse the KML/KMZ asset. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun KmlLayer( + assetName: String, + maxKmzEntryCount: Int = DEFAULT_MAX_KMZ_ENTRY_COUNT, + maxKmzUncompressedTotalSize: Long = DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + var loadedLayer by remember( + assetName, + maxKmzEntryCount, + maxKmzUncompressedTotalSize, + ) { + mutableStateOf(null) + } + + LaunchedEffect( + context, + assetName, + maxKmzEntryCount, + maxKmzUncompressedTotalSize, + ioDispatcher, + ) { + val layer = withContext(ioDispatcher) { + runCatching { + context.assets.open(assetName).use { stream -> + parseKmlOrKmzStream( + inputStream = stream, + maxKmzEntryCount = maxKmzEntryCount, + maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize, + ) + } + }.getOrNull() + } + loadedLayer = layer + onLayerLoadedState(layer) + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +internal fun parseKmlOrKmzStream( + inputStream: InputStream, + maxKmzEntryCount: Int = DEFAULT_MAX_KMZ_ENTRY_COUNT, + maxKmzUncompressedTotalSize: Long = DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE, + imageDecoder: ImageDecoder = AndroidImageDecoder(), +): DataLayer { + val buffered = BufferedInputStream(inputStream) + buffered.mark(1024) + val headerBytes = ByteArray(1024) + val read = buffered.read(headerBytes) + buffered.reset() + + val isKmz = + read > 0 && KmzParser.canParse(String(headerBytes, 0, read, StandardCharsets.UTF_8)) + val kml = if (isKmz) { + KmzParser( + imageDecoder = imageDecoder, + maxKmzEntryCount = maxKmzEntryCount, + maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize, + ).parse(buffered) + } else { + KmlParser().parse(buffered) + } + return kml.toLayer() +} diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt new file mode 100644 index 00000000..bb605bc0 --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt @@ -0,0 +1,223 @@ +/* + * 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.heatmaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.TileOverlay +import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.TileOverlay +import com.google.maps.android.compose.TileOverlayState +import com.google.maps.android.compose.rememberTileOverlayState +import com.google.maps.android.heatmaps.Gradient +import com.google.maps.android.heatmaps.HeatmapTileProvider +import com.google.maps.android.heatmaps.WeightedLatLng + +/** + * A Composable that displays a heatmap tile overlay on the map from a collection of unweighted + * [LatLng] coordinates. + * + * If [data] is empty, no tile overlay is rendered until points are provided. + * + * @param data the collection of [LatLng] points to display in the heatmap. + * @param state the [TileOverlayState] used to control the tile overlay. + * @param radius the radius of the Gaussian blur in pixels, between [HeatmapTileProvider.MIN_RADIUS] + * and [HeatmapTileProvider.MAX_RADIUS]. + * @param gradient the color [Gradient] of the heatmap. + * @param opacity the opacity of the heatmap layer in the range `[0.0, 1.0]`. + * @param maxIntensity the custom maximum intensity value for the heatmap (`0.0` uses the default + * auto-computed maximum). + * @param fadeIn boolean indicating whether the tiles should fade in. + * @param transparency the transparency of the tile overlay. + * @param visible the visibility of the tile overlay. + * @param zIndex the z-index of the tile overlay. + * @param onClick a lambda invoked when the tile overlay is clicked. + */ +@Composable +@GoogleMapComposable +public fun HeatmapTileOverlay( + data: Collection, + state: TileOverlayState = rememberTileOverlayState(), + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0, + fadeIn: Boolean = true, + transparency: Float = 0f, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: (TileOverlay) -> Unit = {}, +) { + val weightedData = remember(data) { + data.map { WeightedLatLng(it) } + } + HeatmapTileOverlay( + weightedData = weightedData, + state = state, + radius = radius, + gradient = gradient, + opacity = opacity, + maxIntensity = maxIntensity, + fadeIn = fadeIn, + transparency = transparency, + visible = visible, + zIndex = zIndex, + onClick = onClick, + ) +} + +/** + * A Composable that displays a heatmap tile overlay on the map from a collection of + * [WeightedLatLng] points. + * + * If [weightedData] is empty, no tile overlay is rendered until points are provided. + * + * @param weightedData the collection of [WeightedLatLng] points to display in the heatmap. + * @param state the [TileOverlayState] used to control the tile overlay. + * @param radius the radius of the Gaussian blur in pixels, between [HeatmapTileProvider.MIN_RADIUS] + * and [HeatmapTileProvider.MAX_RADIUS]. + * @param gradient the color [Gradient] of the heatmap. + * @param opacity the opacity of the heatmap layer in the range `[0.0, 1.0]`. + * @param maxIntensity the custom maximum intensity value for the heatmap (`0.0` uses the default + * auto-computed maximum). + * @param fadeIn boolean indicating whether the tiles should fade in. + * @param transparency the transparency of the tile overlay. + * @param visible the visibility of the tile overlay. + * @param zIndex the z-index of the tile overlay. + * @param onClick a lambda invoked when the tile overlay is clicked. + */ +@Composable +@GoogleMapComposable +@JvmName("WeightedHeatmapTileOverlay") +public fun HeatmapTileOverlay( + weightedData: Collection, + state: TileOverlayState = rememberTileOverlayState(), + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0, + fadeIn: Boolean = true, + transparency: Float = 0f, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: (TileOverlay) -> Unit = {}, +) { + if (weightedData.isEmpty()) return + + var holder by remember { mutableStateOf(null) } + val currentHolder = updateHeatmapProviderHolder( + existing = holder, + weightedData = weightedData, + radius = radius, + gradient = gradient, + opacity = opacity, + maxIntensity = maxIntensity, + ) + if (holder !== currentHolder) { + holder = currentHolder + } + + TileOverlay( + tileProvider = currentHolder.provider, + state = state, + fadeIn = fadeIn, + transparency = transparency, + visible = visible, + zIndex = zIndex, + onClick = onClick, + ) + + LaunchedEffect(currentHolder.version) { + if (currentHolder.version > 0) { + runCatching { state.clearTileCache() } + } + } +} + +internal class HeatmapProviderHolder( + val provider: HeatmapTileProvider, + var weightedData: Collection, + var radius: Int, + var gradient: Gradient, + var opacity: Double, + var maxIntensity: Double, + var version: Int = 0, +) + +internal fun updateHeatmapProviderHolder( + existing: HeatmapProviderHolder?, + weightedData: Collection, + radius: Int, + gradient: Gradient, + opacity: Double, + maxIntensity: Double, +): HeatmapProviderHolder { + if (existing == null) { + val provider = HeatmapTileProvider.Builder() + .weightedData(weightedData) + .radius(radius) + .gradient(gradient) + .opacity(opacity) + .maxIntensity(maxIntensity) + .build() + return HeatmapProviderHolder( + provider = provider, + weightedData = weightedData, + radius = radius, + gradient = gradient, + opacity = opacity, + maxIntensity = maxIntensity, + version = 0, + ) + } + + var changed = false + if (existing.weightedData != weightedData) { + existing.provider.updateData(weightedData) + existing.weightedData = weightedData + changed = true + } + if (existing.radius != radius) { + existing.provider.setRadius(radius) + existing.radius = radius + changed = true + } + if (existing.gradient != gradient) { + existing.provider.setGradient(gradient) + existing.gradient = gradient + changed = true + } + if (existing.opacity != opacity) { + existing.provider.setOpacity(opacity) + existing.opacity = opacity + changed = true + } + if (existing.maxIntensity != maxIntensity) { + existing.provider.setMaxIntensity(maxIntensity) + existing.maxIntensity = maxIntensity + changed = true + } + if (changed) { + existing.version++ + } + return existing +} diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt new file mode 100644 index 00000000..c919b11e --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt @@ -0,0 +1,152 @@ +/* + * 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.data + +import android.graphics.Bitmap +import com.google.maps.android.compose.data.geojson.parseGeoJsonLayer +import com.google.maps.android.compose.data.kml.parseKmlOrKmzStream +import com.google.maps.android.data.parser.kml.ImageDecoder +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import com.google.maps.android.data.renderer.model.DataLayer +import com.google.maps.android.data.renderer.model.DataScene +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +public class DataLayerTest { + + @Test + public fun testRenderSceneWithCachedImagesCachesBitmapsBeforeRender() { + val renderer = mockk(relaxed = true) + val bitmap = mockk() + every { renderer.cacheImageData(any(), any()) } just runs + every { renderer.render(any()) } just runs + + val layer = DataLayer( + features = emptyList(), + properties = mapOf("images" to mapOf("icons/pin.png" to bitmap)), + ) + val scene = DataScene(listOf(layer)) + + renderSceneWithCachedImages(renderer, scene) + + verify { renderer.cacheImageData("icons/pin.png", bitmap) } + verify { renderer.render(scene) } + } + + @Test + public fun testParseGeoJsonLayerParsesValidGeoJsonAndReturnsNullOnMalformedInput() { + val validGeoJson = """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "feature-1", + "geometry": { + "type": "Point", + "coordinates": [-122.084, 37.422] + }, + "properties": { + "title": "Googleplex" + } + } + ] + } + """.trimIndent() + + val layer = parseGeoJsonLayer(validGeoJson) + assertNotNull(layer) + assertEquals(1, layer!!.features.size) + assertEquals("Googleplex", layer.features.first().properties["title"]) + + val invalidLayer = parseGeoJsonLayer("not valid json {{{") + assertNull(invalidLayer) + } + + @Test + public fun testParseKmlOrKmzStreamParsesKmlAndEnforcesKmzLimits() { + val kmlContent = """ + + + + + Mountain View + + -122.084,37.422,0 + + + + + """.trimIndent() + + val kmlLayer = parseKmlOrKmzStream( + inputStream = ByteArrayInputStream(kmlContent.toByteArray()), + imageDecoder = object : ImageDecoder { + override fun decode(bytes: ByteArray): Bitmap? = null + }, + ) + assertEquals(1, kmlLayer.features.size) + assertEquals("Mountain View", kmlLayer.features.first().properties["name"]) + + // Create a KMZ archive in-memory with 2 entries + val kmzBytes = ByteArrayOutputStream().apply { + ZipOutputStream(this).use { zos -> + zos.putNextEntry(ZipEntry("doc.kml")) + zos.write(kmlContent.toByteArray()) + zos.closeEntry() + zos.putNextEntry(ZipEntry("images/icon.png")) + zos.write(byteArrayOf(1, 2, 3, 4)) + zos.closeEntry() + } + }.toByteArray() + + val fakeBitmap = mockk() + val fakeDecoder = object : ImageDecoder { + override fun decode(bytes: ByteArray): Bitmap = fakeBitmap + } + val kmzLayer = parseKmlOrKmzStream( + inputStream = ByteArrayInputStream(kmzBytes), + imageDecoder = fakeDecoder, + ) + assertEquals(1, kmzLayer.features.size) + val images = kmzLayer.properties["images"] as? Map<*, *> + assertNotNull(images) + assertEquals(fakeBitmap, images!!["images/icon.png"]) + + // Enforcing maxKmzEntryCount = 1 should throw IOException + assertThrows(IOException::class.java) { + parseKmlOrKmzStream( + inputStream = ByteArrayInputStream(kmzBytes), + maxKmzEntryCount = 1, + imageDecoder = fakeDecoder, + ) + } + } +} diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt new file mode 100644 index 00000000..304e6784 --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt @@ -0,0 +1,85 @@ +/* + * 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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.heatmaps.Gradient +import com.google.maps.android.heatmaps.HeatmapTileProvider +import com.google.maps.android.heatmaps.WeightedLatLng +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Test + +public class HeatmapTileOverlayTest { + + @Test + public fun testUpdateHeatmapProviderHolderCreatesAndUpdatesProviderInPlace() { + val initialPoints = listOf( + WeightedLatLng(LatLng(37.7749, -122.4194), 1.0), + WeightedLatLng(LatLng(37.7849, -122.4094), 2.0), + ) + + val holder = updateHeatmapProviderHolder( + existing = null, + weightedData = initialPoints, + radius = HeatmapTileProvider.DEFAULT_RADIUS, + gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity = 0.0, + ) + + assertNotNull(holder.provider) + assertEquals(0, holder.version) + + // Calling with identical values should not increment version + val unchangedHolder = updateHeatmapProviderHolder( + existing = holder, + weightedData = initialPoints, + radius = HeatmapTileProvider.DEFAULT_RADIUS, + gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity = 0.0, + ) + assertSame(holder, unchangedHolder) + assertEquals(0, unchangedHolder.version) + + // Updating radius, opacity, maxIntensity, gradient, and data should reuse provider and bump version + val updatedPoints = listOf( + WeightedLatLng(LatLng(34.0522, -118.2437), 3.0), + ) + val customGradient = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.1f, 0.9f), + ) + val updatedHolder = updateHeatmapProviderHolder( + existing = holder, + weightedData = updatedPoints, + radius = 30, + gradient = customGradient, + opacity = 0.5, + maxIntensity = 10.0, + ) + assertSame(holder, updatedHolder) + assertEquals(1, updatedHolder.version) + assertEquals(30, updatedHolder.radius) + assertEquals(0.5, updatedHolder.opacity, 0.0001) + assertEquals(10.0, updatedHolder.maxIntensity, 0.0001) + assertSame(customGradient, updatedHolder.gradient) + assertSame(updatedPoints, updatedHolder.weightedData) + } +} From 148905135ea44159df92a03bf123958bfa846073 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:27:01 -0600 Subject: [PATCH 2/2] fix(utils): address review feedback on DataLayer and HeatmapTileOverlay --- .../maps/android/compose/data/DataLayer.kt | 80 +++--- .../compose/data/geojson/GeoJsonLayer.kt | 160 ------------ .../maps/android/compose/data/kml/KmlLayer.kt | 241 ------------------ .../compose/heatmaps/HeatmapTileOverlay.kt | 148 +++++------ .../android/compose/data/DataLayerTest.kt | 146 +++++------ .../heatmaps/HeatmapTileOverlayTest.kt | 85 +++--- 6 files changed, 205 insertions(+), 655 deletions(-) delete mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt delete mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt index 68c877ca..17ba8390 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt @@ -17,11 +17,8 @@ package com.google.maps.android.compose.data import android.graphics.Bitmap -import android.os.Handler -import android.os.Looper import androidx.annotation.RawRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import com.google.maps.android.collections.GroundOverlayManager import com.google.maps.android.collections.MarkerManager import com.google.maps.android.collections.PolygonManager @@ -45,6 +43,7 @@ import com.google.maps.android.data.renderer.model.DataLayer import com.google.maps.android.data.renderer.model.DataScene import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.withContext /** @@ -74,7 +73,6 @@ public fun DataScene( groundOverlayManager: GroundOverlayManager? = null, ) { val reattach = rememberReattachClickListenersHandle() - var renderer by remember { mutableStateOf(null) } MapEffect( iconProvider, @@ -82,35 +80,32 @@ public fun DataScene( polygonManager, polylineManager, groundOverlayManager, + scene, + useAdvancedMarkers, ) { map -> - val newRenderer = MapViewRenderer( + val renderer = MapViewRenderer( map = map, iconProvider = iconProvider, markerManager = markerManager, polygonManager = polygonManager, polylineManager = polylineManager, groundOverlayManager = groundOverlayManager, - ) - renderer = newRenderer + ).apply { + this.useAdvancedMarkers = useAdvancedMarkers + } + renderSceneWithCachedImages(renderer, scene) if ( markerManager != null || polygonManager != null || polylineManager != null || groundOverlayManager != null ) { - Handler(Looper.getMainLooper()).post { - reattach() - } + reattach() } - } - - val currentRenderer = renderer ?: return - - DisposableEffect(currentRenderer, scene, useAdvancedMarkers) { - currentRenderer.useAdvancedMarkers = useAdvancedMarkers - renderSceneWithCachedImages(currentRenderer, scene) - onDispose { - currentRenderer.clear() + try { + awaitCancellation() + } finally { + renderer.clear() } } } @@ -163,6 +158,7 @@ public fun DataLayer( * @param polygonManager an optional [PolygonManager] to manage rendered polygons. * @param polylineManager an optional [PolylineManager] to manage rendered polylines. * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onError an optional callback invoked with any [Throwable] encountered during loading or parsing. * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if * parsing failed). */ @@ -178,16 +174,28 @@ public fun DataLayer( polygonManager: PolygonManager? = null, polylineManager: PolylineManager? = null, groundOverlayManager: GroundOverlayManager? = null, + onError: ((Throwable) -> Unit)? = null, onLayerLoaded: (DataLayer?) -> Unit = {}, ) { val context = LocalContext.current val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) - var loadedLayer by remember(assetName) { mutableStateOf(null) } + val onErrorState by rememberUpdatedState(onError) + var loadedLayer by remember { mutableStateOf(null) } LaunchedEffect(context, assetName, ioDispatcher) { - val layer = DataLayerLoader(context, ioDispatcher).loadAsset(assetName) - loadedLayer = layer - onLayerLoadedState(layer) + val result = runCatching { + DataLayerLoader(context, ioDispatcher).loadAsset(assetName) + } + val layer = result.getOrNull() + if (layer != null) { + loadedLayer = layer + onLayerLoadedState(layer) + } else { + val error = result.exceptionOrNull() + ?: IllegalStateException("Failed to load DataLayer from asset: $assetName") + onErrorState?.invoke(error) + onLayerLoadedState(null) + } } loadedLayer?.let { layer -> @@ -217,6 +225,7 @@ public fun DataLayer( * @param polygonManager an optional [PolygonManager] to manage rendered polygons. * @param polylineManager an optional [PolylineManager] to manage rendered polylines. * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onError an optional callback invoked with any [Throwable] encountered during loading or parsing. * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if * parsing failed). */ @@ -233,22 +242,33 @@ public fun DataLayer( polygonManager: PolygonManager? = null, polylineManager: PolylineManager? = null, groundOverlayManager: GroundOverlayManager? = null, + onError: ((Throwable) -> Unit)? = null, onLayerLoaded: (DataLayer?) -> Unit = {}, ) { val context = LocalContext.current + val resources = LocalResources.current val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) - var loadedLayer by remember(rawResourceId, fileName) { mutableStateOf(null) } + val onErrorState by rememberUpdatedState(onError) + var loadedLayer by remember { mutableStateOf(null) } - LaunchedEffect(context, rawResourceId, fileName, ioDispatcher) { - val layer = withContext(ioDispatcher) { + LaunchedEffect(context, resources, rawResourceId, fileName, ioDispatcher) { + val result = withContext(ioDispatcher) { runCatching { - context.resources.openRawResource(rawResourceId).use { stream -> + resources.openRawResource(rawResourceId).use { stream -> DataLayerLoader(context, ioDispatcher).loadInputStream(stream, fileName) } - }.getOrNull() + } + } + val layer = result.getOrNull() + if (layer != null) { + loadedLayer = layer + onLayerLoadedState(layer) + } else { + val error = result.exceptionOrNull() + ?: IllegalStateException("Failed to load DataLayer from raw resource: $rawResourceId") + onErrorState?.invoke(error) + onLayerLoadedState(null) } - loadedLayer = layer - onLayerLoadedState(layer) } loadedLayer?.let { layer -> diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt deleted file mode 100644 index a195935e..00000000 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/geojson/GeoJsonLayer.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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.data.geojson - -import androidx.annotation.RawRes -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.google.maps.android.collections.GroundOverlayManager -import com.google.maps.android.collections.MarkerManager -import com.google.maps.android.collections.PolygonManager -import com.google.maps.android.collections.PolylineManager -import com.google.maps.android.compose.GoogleMapComposable -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.data.DataLayer -import com.google.maps.android.data.parser.geojson.GeoJsonParser -import com.google.maps.android.data.renderer.IconProvider -import com.google.maps.android.data.renderer.UrlIconProvider -import com.google.maps.android.data.renderer.mapper.toLayer -import com.google.maps.android.data.renderer.model.DataLayer -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject - -/** - * Parses a [JSONObject] containing GeoJSON data and renders it onto the map using the - * platform-agnostic [DataLayer] renderer. - * - * @param geoJsonData the [JSONObject] containing GeoJSON features or geometry. - * @param iconProvider the [IconProvider] used to fetch remote marker icons. - * @param useAdvancedMarkers whether to render point features using Advanced Markers. - * @param ioDispatcher the [CoroutineDispatcher] used to parse the GeoJSON payload. - * @param markerManager an optional [MarkerManager] to manage rendered markers. - * @param polygonManager an optional [PolygonManager] to manage rendered polygons. - * @param polylineManager an optional [PolylineManager] to manage rendered polylines. - * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. - * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if - * parsing failed). - */ -@Composable -@GoogleMapComposable -@MapsComposeExperimentalApi -public fun GeoJsonLayer( - geoJsonData: JSONObject, - iconProvider: IconProvider = remember { UrlIconProvider() }, - useAdvancedMarkers: Boolean = false, - ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - markerManager: MarkerManager? = null, - polygonManager: PolygonManager? = null, - polylineManager: PolylineManager? = null, - groundOverlayManager: GroundOverlayManager? = null, - onLayerLoaded: (DataLayer?) -> Unit = {}, -) { - val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) - var loadedLayer by remember(geoJsonData) { mutableStateOf(null) } - - LaunchedEffect(geoJsonData, ioDispatcher) { - val layer = withContext(ioDispatcher) { - parseGeoJsonLayer(geoJsonData.toString()) - } - loadedLayer = layer - onLayerLoadedState(layer) - } - - loadedLayer?.let { layer -> - DataLayer( - layer = layer, - iconProvider = iconProvider, - useAdvancedMarkers = useAdvancedMarkers, - markerManager = markerManager, - polygonManager = polygonManager, - polylineManager = polylineManager, - groundOverlayManager = groundOverlayManager, - ) - } -} - -/** - * Loads and parses GeoJSON data from a raw Android resource and renders it onto the map using the - * platform-agnostic [DataLayer] renderer. - * - * @param rawResourceId the `@RawRes` resource identifier containing GeoJSON data. - * @param iconProvider the [IconProvider] used to fetch remote marker icons. - * @param useAdvancedMarkers whether to render point features using Advanced Markers. - * @param ioDispatcher the [CoroutineDispatcher] used to read and parse the GeoJSON resource. - * @param markerManager an optional [MarkerManager] to manage rendered markers. - * @param polygonManager an optional [PolygonManager] to manage rendered polygons. - * @param polylineManager an optional [PolylineManager] to manage rendered polylines. - * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. - * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if - * parsing failed). - */ -@Composable -@GoogleMapComposable -@MapsComposeExperimentalApi -public fun GeoJsonLayer( - @RawRes rawResourceId: Int, - iconProvider: IconProvider = remember { UrlIconProvider() }, - useAdvancedMarkers: Boolean = false, - ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - markerManager: MarkerManager? = null, - polygonManager: PolygonManager? = null, - polylineManager: PolylineManager? = null, - groundOverlayManager: GroundOverlayManager? = null, - onLayerLoaded: (DataLayer?) -> Unit = {}, -) { - val context = LocalContext.current - val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) - var loadedLayer by remember(rawResourceId) { mutableStateOf(null) } - - LaunchedEffect(context, rawResourceId, ioDispatcher) { - val layer = withContext(ioDispatcher) { - runCatching { - context.resources.openRawResource(rawResourceId).use { stream -> - GeoJsonParser().parse(stream)?.toLayer() - } - }.getOrNull() - } - loadedLayer = layer - onLayerLoadedState(layer) - } - - loadedLayer?.let { layer -> - DataLayer( - layer = layer, - iconProvider = iconProvider, - useAdvancedMarkers = useAdvancedMarkers, - markerManager = markerManager, - polygonManager = polygonManager, - polylineManager = polylineManager, - groundOverlayManager = groundOverlayManager, - ) - } -} - -internal fun parseGeoJsonLayer(jsonString: String): DataLayer? = runCatching { - jsonString.byteInputStream().use { stream -> - GeoJsonParser().parse(stream)?.toLayer() - } -}.getOrNull() diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt deleted file mode 100644 index b5bc4682..00000000 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/kml/KmlLayer.kt +++ /dev/null @@ -1,241 +0,0 @@ -/* - * 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.data.kml - -import androidx.annotation.RawRes -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.google.maps.android.collections.GroundOverlayManager -import com.google.maps.android.collections.MarkerManager -import com.google.maps.android.collections.PolygonManager -import com.google.maps.android.collections.PolylineManager -import com.google.maps.android.compose.GoogleMapComposable -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.data.DataLayer -import com.google.maps.android.data.parser.kml.AndroidImageDecoder -import com.google.maps.android.data.parser.kml.ImageDecoder -import com.google.maps.android.data.parser.kml.KmlParser -import com.google.maps.android.data.parser.kml.KmzParser -import com.google.maps.android.data.renderer.IconProvider -import com.google.maps.android.data.renderer.UrlIconProvider -import com.google.maps.android.data.renderer.mapper.toLayer -import com.google.maps.android.data.renderer.model.DataLayer -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.BufferedInputStream -import java.io.InputStream -import java.nio.charset.StandardCharsets - -private const val DEFAULT_MAX_KMZ_ENTRY_COUNT = 200 -private const val DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE = 50L * 1024L * 1024L - -/** - * Loads and parses KML or KMZ data from a raw Android resource and renders it onto the map using - * the platform-agnostic [DataLayer] renderer. - * - * Automatically detects whether the stream is a plain KML document or a compressed KMZ archive and - * enforces [maxKmzEntryCount] and [maxKmzUncompressedTotalSize] limits against zip-bomb payloads. - * - * @param rawResourceId the `@RawRes` resource identifier containing KML or KMZ data. - * @param maxKmzEntryCount maximum number of ZIP entries permitted when parsing a KMZ archive. - * @param maxKmzUncompressedTotalSize maximum total uncompressed byte size permitted when parsing a - * KMZ archive. - * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. - * @param useAdvancedMarkers whether to render point features using Advanced Markers. - * @param ioDispatcher the [CoroutineDispatcher] used to read and parse the KML/KMZ resource. - * @param markerManager an optional [MarkerManager] to manage rendered markers. - * @param polygonManager an optional [PolygonManager] to manage rendered polygons. - * @param polylineManager an optional [PolylineManager] to manage rendered polylines. - * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. - * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if - * parsing failed). - */ -@Composable -@GoogleMapComposable -@MapsComposeExperimentalApi -public fun KmlLayer( - @RawRes rawResourceId: Int, - maxKmzEntryCount: Int = DEFAULT_MAX_KMZ_ENTRY_COUNT, - maxKmzUncompressedTotalSize: Long = DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE, - iconProvider: IconProvider = remember { UrlIconProvider() }, - useAdvancedMarkers: Boolean = false, - ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - markerManager: MarkerManager? = null, - polygonManager: PolygonManager? = null, - polylineManager: PolylineManager? = null, - groundOverlayManager: GroundOverlayManager? = null, - onLayerLoaded: (DataLayer?) -> Unit = {}, -) { - val context = LocalContext.current - val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) - var loadedLayer by remember( - rawResourceId, - maxKmzEntryCount, - maxKmzUncompressedTotalSize, - ) { - mutableStateOf(null) - } - - LaunchedEffect( - context, - rawResourceId, - maxKmzEntryCount, - maxKmzUncompressedTotalSize, - ioDispatcher, - ) { - val layer = withContext(ioDispatcher) { - runCatching { - context.resources.openRawResource(rawResourceId).use { stream -> - parseKmlOrKmzStream( - inputStream = stream, - maxKmzEntryCount = maxKmzEntryCount, - maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize, - ) - } - }.getOrNull() - } - loadedLayer = layer - onLayerLoadedState(layer) - } - - loadedLayer?.let { layer -> - DataLayer( - layer = layer, - iconProvider = iconProvider, - useAdvancedMarkers = useAdvancedMarkers, - markerManager = markerManager, - polygonManager = polygonManager, - polylineManager = polylineManager, - groundOverlayManager = groundOverlayManager, - ) - } -} - -/** - * Loads and parses KML or KMZ data from an application asset file and renders it onto the map using - * the platform-agnostic [DataLayer] renderer. - * - * Automatically detects whether the asset is a plain KML document or a compressed KMZ archive and - * enforces [maxKmzEntryCount] and [maxKmzUncompressedTotalSize] limits against zip-bomb payloads. - * - * @param assetName the path of the KML or KMZ file in the application's `assets/` directory. - * @param maxKmzEntryCount maximum number of ZIP entries permitted when parsing a KMZ archive. - * @param maxKmzUncompressedTotalSize maximum total uncompressed byte size permitted when parsing a - * KMZ archive. - * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. - * @param useAdvancedMarkers whether to render point features using Advanced Markers. - * @param ioDispatcher the [CoroutineDispatcher] used to read and parse the KML/KMZ asset. - * @param markerManager an optional [MarkerManager] to manage rendered markers. - * @param polygonManager an optional [PolygonManager] to manage rendered polygons. - * @param polylineManager an optional [PolylineManager] to manage rendered polylines. - * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. - * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if - * parsing failed). - */ -@Composable -@GoogleMapComposable -@MapsComposeExperimentalApi -public fun KmlLayer( - assetName: String, - maxKmzEntryCount: Int = DEFAULT_MAX_KMZ_ENTRY_COUNT, - maxKmzUncompressedTotalSize: Long = DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE, - iconProvider: IconProvider = remember { UrlIconProvider() }, - useAdvancedMarkers: Boolean = false, - ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - markerManager: MarkerManager? = null, - polygonManager: PolygonManager? = null, - polylineManager: PolylineManager? = null, - groundOverlayManager: GroundOverlayManager? = null, - onLayerLoaded: (DataLayer?) -> Unit = {}, -) { - val context = LocalContext.current - val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) - var loadedLayer by remember( - assetName, - maxKmzEntryCount, - maxKmzUncompressedTotalSize, - ) { - mutableStateOf(null) - } - - LaunchedEffect( - context, - assetName, - maxKmzEntryCount, - maxKmzUncompressedTotalSize, - ioDispatcher, - ) { - val layer = withContext(ioDispatcher) { - runCatching { - context.assets.open(assetName).use { stream -> - parseKmlOrKmzStream( - inputStream = stream, - maxKmzEntryCount = maxKmzEntryCount, - maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize, - ) - } - }.getOrNull() - } - loadedLayer = layer - onLayerLoadedState(layer) - } - - loadedLayer?.let { layer -> - DataLayer( - layer = layer, - iconProvider = iconProvider, - useAdvancedMarkers = useAdvancedMarkers, - markerManager = markerManager, - polygonManager = polygonManager, - polylineManager = polylineManager, - groundOverlayManager = groundOverlayManager, - ) - } -} - -internal fun parseKmlOrKmzStream( - inputStream: InputStream, - maxKmzEntryCount: Int = DEFAULT_MAX_KMZ_ENTRY_COUNT, - maxKmzUncompressedTotalSize: Long = DEFAULT_MAX_KMZ_UNCOMPRESSED_SIZE, - imageDecoder: ImageDecoder = AndroidImageDecoder(), -): DataLayer { - val buffered = BufferedInputStream(inputStream) - buffered.mark(1024) - val headerBytes = ByteArray(1024) - val read = buffered.read(headerBytes) - buffered.reset() - - val isKmz = - read > 0 && KmzParser.canParse(String(headerBytes, 0, read, StandardCharsets.UTF_8)) - val kml = if (isKmz) { - KmzParser( - imageDecoder = imageDecoder, - maxKmzEntryCount = maxKmzEntryCount, - maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize, - ).parse(buffered) - } else { - KmlParser().parse(buffered) - } - return kml.toLayer() -} diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt index bb605bc0..786e8d84 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt @@ -17,14 +17,12 @@ package com.google.maps.android.compose.heatmaps import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.TileOverlay import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.TileOverlay import com.google.maps.android.compose.TileOverlayState import com.google.maps.android.compose.rememberTileOverlayState @@ -54,6 +52,7 @@ import com.google.maps.android.heatmaps.WeightedLatLng */ @Composable @GoogleMapComposable +@MapsComposeExperimentalApi public fun HeatmapTileOverlay( data: Collection, state: TileOverlayState = rememberTileOverlayState(), @@ -107,6 +106,7 @@ public fun HeatmapTileOverlay( */ @Composable @GoogleMapComposable +@MapsComposeExperimentalApi @JvmName("WeightedHeatmapTileOverlay") public fun HeatmapTileOverlay( weightedData: Collection, @@ -123,21 +123,60 @@ public fun HeatmapTileOverlay( ) { if (weightedData.isEmpty()) return - var holder by remember { mutableStateOf(null) } - val currentHolder = updateHeatmapProviderHolder( - existing = holder, - weightedData = weightedData, - radius = radius, - gradient = gradient, - opacity = opacity, - maxIntensity = maxIntensity, - ) - if (holder !== currentHolder) { - holder = currentHolder + val provider = remember { + HeatmapTileProvider.Builder() + .weightedData(weightedData) + .radius(radius) + .gradient(gradient) + .opacity(opacity) + .maxIntensity(maxIntensity) + .build() + } + + val currentParams = remember { + HeatmapParams( + radius = radius, + opacity = opacity, + maxIntensity = maxIntensity, + gradient = gradient, + weightedData = weightedData, + ) + } + + SideEffect { + var changed = false + if (currentParams.radius != radius) { + provider.setRadius(radius) + currentParams.radius = radius + changed = true + } + if (currentParams.opacity != opacity) { + provider.setOpacity(opacity) + currentParams.opacity = opacity + changed = true + } + if (currentParams.maxIntensity != maxIntensity) { + provider.setMaxIntensity(maxIntensity) + currentParams.maxIntensity = maxIntensity + changed = true + } + if (!gradientsEqual(currentParams.gradient, gradient)) { + provider.setGradient(gradient) + currentParams.gradient = gradient + changed = true + } + if (currentParams.weightedData != weightedData) { + provider.updateData(weightedData) + currentParams.weightedData = weightedData + changed = true + } + if (changed) { + runCatching { state.clearTileCache() } + } } TileOverlay( - tileProvider = currentHolder.provider, + tileProvider = provider, state = state, fadeIn = fadeIn, transparency = transparency, @@ -145,79 +184,20 @@ public fun HeatmapTileOverlay( zIndex = zIndex, onClick = onClick, ) - - LaunchedEffect(currentHolder.version) { - if (currentHolder.version > 0) { - runCatching { state.clearTileCache() } - } - } } -internal class HeatmapProviderHolder( - val provider: HeatmapTileProvider, - var weightedData: Collection, +private class HeatmapParams( var radius: Int, - var gradient: Gradient, var opacity: Double, var maxIntensity: Double, - var version: Int = 0, + var gradient: Gradient, + var weightedData: Collection, ) -internal fun updateHeatmapProviderHolder( - existing: HeatmapProviderHolder?, - weightedData: Collection, - radius: Int, - gradient: Gradient, - opacity: Double, - maxIntensity: Double, -): HeatmapProviderHolder { - if (existing == null) { - val provider = HeatmapTileProvider.Builder() - .weightedData(weightedData) - .radius(radius) - .gradient(gradient) - .opacity(opacity) - .maxIntensity(maxIntensity) - .build() - return HeatmapProviderHolder( - provider = provider, - weightedData = weightedData, - radius = radius, - gradient = gradient, - opacity = opacity, - maxIntensity = maxIntensity, - version = 0, - ) - } - - var changed = false - if (existing.weightedData != weightedData) { - existing.provider.updateData(weightedData) - existing.weightedData = weightedData - changed = true - } - if (existing.radius != radius) { - existing.provider.setRadius(radius) - existing.radius = radius - changed = true - } - if (existing.gradient != gradient) { - existing.provider.setGradient(gradient) - existing.gradient = gradient - changed = true - } - if (existing.opacity != opacity) { - existing.provider.setOpacity(opacity) - existing.opacity = opacity - changed = true - } - if (existing.maxIntensity != maxIntensity) { - existing.provider.setMaxIntensity(maxIntensity) - existing.maxIntensity = maxIntensity - changed = true - } - if (changed) { - existing.version++ - } - return existing +internal fun gradientsEqual(g1: Gradient?, g2: Gradient?): Boolean { + if (g1 === g2) return true + if (g1 == null || g2 == null) return false + return g1.colorMapSize == g2.colorMapSize && + g1.colors.contentEquals(g2.colors) && + g1.startPoints.contentEquals(g2.startPoints) } diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt index c919b11e..0c82b001 100644 --- a/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt @@ -16,10 +16,9 @@ package com.google.maps.android.compose.data +import android.content.Context import android.graphics.Bitmap -import com.google.maps.android.compose.data.geojson.parseGeoJsonLayer -import com.google.maps.android.compose.data.kml.parseKmlOrKmzStream -import com.google.maps.android.data.parser.kml.ImageDecoder +import com.google.maps.android.data.DataLayerLoader import com.google.maps.android.data.renderer.mapview.MapViewRenderer import com.google.maps.android.data.renderer.model.DataLayer import com.google.maps.android.data.renderer.model.DataScene @@ -28,17 +27,16 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows import org.junit.Test import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream +@OptIn(ExperimentalCoroutinesApi::class) public class DataLayerTest { @Test @@ -61,92 +59,66 @@ public class DataLayerTest { } @Test - public fun testParseGeoJsonLayerParsesValidGeoJsonAndReturnsNullOnMalformedInput() { - val validGeoJson = """ - { - "type": "FeatureCollection", - "features": [ + public fun testDataLayerLoaderParsesGeoJsonAndKmlFromInputStream() { + runTest { + val context = mockk(relaxed = true) + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val loader = DataLayerLoader(context, dispatcher) + + val validGeoJson = """ { - "type": "Feature", - "id": "feature-1", - "geometry": { - "type": "Point", - "coordinates": [-122.084, 37.422] - }, - "properties": { - "title": "Googleplex" - } + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "feature-1", + "geometry": { + "type": "Point", + "coordinates": [-122.084, 37.422] + }, + "properties": { + "title": "Googleplex" + } + } + ] } - ] - } - """.trimIndent() - - val layer = parseGeoJsonLayer(validGeoJson) - assertNotNull(layer) - assertEquals(1, layer!!.features.size) - assertEquals("Googleplex", layer.features.first().properties["title"]) - - val invalidLayer = parseGeoJsonLayer("not valid json {{{") - assertNull(invalidLayer) - } - - @Test - public fun testParseKmlOrKmzStreamParsesKmlAndEnforcesKmzLimits() { - val kmlContent = """ - - - - - Mountain View - - -122.084,37.422,0 - - - - - """.trimIndent() + """.trimIndent() - val kmlLayer = parseKmlOrKmzStream( - inputStream = ByteArrayInputStream(kmlContent.toByteArray()), - imageDecoder = object : ImageDecoder { - override fun decode(bytes: ByteArray): Bitmap? = null - }, - ) - assertEquals(1, kmlLayer.features.size) - assertEquals("Mountain View", kmlLayer.features.first().properties["name"]) + val geoJsonLayer = loader.loadInputStream( + inputStream = ByteArrayInputStream(validGeoJson.toByteArray()), + fileName = "points.geojson", + ) + assertNotNull(geoJsonLayer) + assertEquals(1, geoJsonLayer!!.features.size) + assertEquals("Googleplex", geoJsonLayer.features.first().properties["title"]) - // Create a KMZ archive in-memory with 2 entries - val kmzBytes = ByteArrayOutputStream().apply { - ZipOutputStream(this).use { zos -> - zos.putNextEntry(ZipEntry("doc.kml")) - zos.write(kmlContent.toByteArray()) - zos.closeEntry() - zos.putNextEntry(ZipEntry("images/icon.png")) - zos.write(byteArrayOf(1, 2, 3, 4)) - zos.closeEntry() - } - }.toByteArray() + val validKml = """ + + + + + Mountain View + + -122.084,37.422,0 + + + + + """.trimIndent() - val fakeBitmap = mockk() - val fakeDecoder = object : ImageDecoder { - override fun decode(bytes: ByteArray): Bitmap = fakeBitmap - } - val kmzLayer = parseKmlOrKmzStream( - inputStream = ByteArrayInputStream(kmzBytes), - imageDecoder = fakeDecoder, - ) - assertEquals(1, kmzLayer.features.size) - val images = kmzLayer.properties["images"] as? Map<*, *> - assertNotNull(images) - assertEquals(fakeBitmap, images!!["images/icon.png"]) + val kmlLayer = loader.loadInputStream( + inputStream = ByteArrayInputStream(validKml.toByteArray()), + fileName = "places.kml", + ) + assertNotNull(kmlLayer) + assertEquals(1, kmlLayer!!.features.size) + assertEquals("Mountain View", kmlLayer.features.first().properties["name"]) - // Enforcing maxKmzEntryCount = 1 should throw IOException - assertThrows(IOException::class.java) { - parseKmlOrKmzStream( - inputStream = ByteArrayInputStream(kmzBytes), - maxKmzEntryCount = 1, - imageDecoder = fakeDecoder, + val invalidLayer = loader.loadInputStream( + inputStream = ByteArrayInputStream("not valid data {{{".toByteArray()), + fileName = "unknown.txt", ) + assertNull(invalidLayer) } } } diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt index 304e6784..b5c596a2 100644 --- a/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt @@ -16,70 +16,49 @@ package com.google.maps.android.compose.heatmaps -import com.google.android.gms.maps.model.LatLng import com.google.maps.android.heatmaps.Gradient -import com.google.maps.android.heatmaps.HeatmapTileProvider -import com.google.maps.android.heatmaps.WeightedLatLng -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertSame +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test public class HeatmapTileOverlayTest { @Test - public fun testUpdateHeatmapProviderHolderCreatesAndUpdatesProviderInPlace() { - val initialPoints = listOf( - WeightedLatLng(LatLng(37.7749, -122.4194), 1.0), - WeightedLatLng(LatLng(37.7849, -122.4094), 2.0), - ) - - val holder = updateHeatmapProviderHolder( - existing = null, - weightedData = initialPoints, - radius = HeatmapTileProvider.DEFAULT_RADIUS, - gradient = HeatmapTileProvider.DEFAULT_GRADIENT, - opacity = HeatmapTileProvider.DEFAULT_OPACITY, - maxIntensity = 0.0, + public fun testGradientsEqual() { + val g1 = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 1000, ) - - assertNotNull(holder.provider) - assertEquals(0, holder.version) - - // Calling with identical values should not increment version - val unchangedHolder = updateHeatmapProviderHolder( - existing = holder, - weightedData = initialPoints, - radius = HeatmapTileProvider.DEFAULT_RADIUS, - gradient = HeatmapTileProvider.DEFAULT_GRADIENT, - opacity = HeatmapTileProvider.DEFAULT_OPACITY, - maxIntensity = 0.0, + val g2 = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 1000, ) - assertSame(holder, unchangedHolder) - assertEquals(0, unchangedHolder.version) - - // Updating radius, opacity, maxIntensity, gradient, and data should reuse provider and bump version - val updatedPoints = listOf( - WeightedLatLng(LatLng(34.0522, -118.2437), 3.0), + val gDifferentColors = Gradient( + intArrayOf(0xFF0000FF.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 1000, ) - val customGradient = Gradient( + val gDifferentPoints = Gradient( intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), - floatArrayOf(0.1f, 0.9f), + floatArrayOf(0.5f, 1.0f), + 1000, ) - val updatedHolder = updateHeatmapProviderHolder( - existing = holder, - weightedData = updatedPoints, - radius = 30, - gradient = customGradient, - opacity = 0.5, - maxIntensity = 10.0, + val gDifferentSize = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 500, ) - assertSame(holder, updatedHolder) - assertEquals(1, updatedHolder.version) - assertEquals(30, updatedHolder.radius) - assertEquals(0.5, updatedHolder.opacity, 0.0001) - assertEquals(10.0, updatedHolder.maxIntensity, 0.0001) - assertSame(customGradient, updatedHolder.gradient) - assertSame(updatedPoints, updatedHolder.weightedData) + + assertTrue(gradientsEqual(g1, g1)) + assertTrue(gradientsEqual(g1, g2)) + assertTrue(gradientsEqual(null, null)) + + assertFalse(gradientsEqual(g1, null)) + assertFalse(gradientsEqual(null, g1)) + assertFalse(gradientsEqual(g1, gDifferentColors)) + assertFalse(gradientsEqual(g1, gDifferentPoints)) + assertFalse(gradientsEqual(g1, gDifferentSize)) } }