From d1ccccad80c833491ce5068a2891da1289c244fa Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:32:41 -0600 Subject: [PATCH 1/2] fix: resolve 10 defects uncovered by Android Maps Testing Toolkit Fixes 10 production issues across `:data`, `:heatmaps`, `:clustering`, `:library`, and `:ui` uncovered by deterministic JVM and visual testing with Android Maps Testing Toolkit v1.1.0-rc01: 1. `:data` (`KmlLayer`): Cache `KmlGroundOverlay -> ModelFeature` in `mGroundOverlayMap` so `removeLayerFromMap()` removes ground overlays from `MapViewRenderer`'s `IdentityHashMap` instead of leaking them. 2. `:data` (`Style`, `MapViewRenderer`, `GeoJsonLayer`, `KmlLayer`): Propagate `clickable` (defaulting to `true`) and `visible` to `PolylineOptions` and `PolygonOptions` so GeoJSON and KML polygons and polylines dispatch `OnFeatureClickListener` callbacks. 3. `:data` (`GeoJsonLayer`, `KmlLayer`, `MapViewRenderer`): Route markers, polygons, polylines, and ground overlays through passed-in `MarkerManager`, `PolygonManager`, `PolylineManager`, and `GroundOverlayManager` collections when provided, and register click listeners on those collections instead of clobbering global `GoogleMap` click listeners. 4. `:data` (`KmlLayer`, `GeoJsonLayer`, `Style`, `MapViewRenderer`): Aggregate placemarks and ground overlays from `` containers in `hasPlacemarks()`, `getPlacemarks()`, `features`, and `getGroundOverlays()`, and preserve styles on `MultiGeometry` features via `CompositeStyle`. 5. `:data` (`GeoJsonLayer`, `Style`, `MapViewRenderer`): Propagate `GeoJsonPointStyle` `title`, `snippet`, `isDraggable`, `isFlat`, `isVisible`, `infoWindowAnchorU/V`, and custom `icon` (`BitmapDescriptor`) to `MarkerOptions` and `AdvancedMarkerOptions`. 6. `:heatmaps` (`HeatmapTileProvider`): Clamp `zoom` when indexing `maxIntensity` (preventing `ArrayIndexOutOfBoundsException` at zoom level 22) and clamp `bucketX`/`bucketY` to `0 until gridDim` so points on the inclusive upper boundary `maxX`/`maxY` do not throw `ArrayIndexOutOfBoundsException`. 7. `:heatmaps` (`HeatmapTileProvider`): Check `&& wrappedPoints.isEmpty()` before returning `TileProvider.NO_TILE` so cross-antimeridian points render across the International Date Line, and enforce `MIN_RADIUS..MAX_RADIUS` and `0.0..1.0` bounds validation in `setRadius` and `setOpacity`. 8. `:clustering` (`DefaultClusterRenderer`): Clear stale `marker.title` and `marker.snippet` when a `ClusterItem`'s title/snippet is updated to `null`, and update `marker.zIndex` in `onClusterItemUpdated` even when `position` is unchanged. 9. `:library` (`MapObjectManager`): Invoke `setListenersOnUiThread()` synchronously when constructed on the main thread instead of unconditionally posting to the back of the main looper queue. 10. `:ui` (`AnimationUtil`, `IconGenerator`, `RotationLayout`): Snap directly to `finalPosition` when `durationInMs <= 0L` (avoiding `0 / 0.0f = NaN`), clamp `t` to `[0f, 1f]` so `AccelerateDecelerateInterpolator` never rebounds on frame overshoot, and use `degrees.mod(360)` so negative multi-turn rotations do not throw `IllegalStateException`. --- .../clustering/view/DefaultClusterRenderer.kt | 30 ++- .../android/clustering/ClusterManagerTest.kt | 47 +++++ .../maps/android/data/geojson/GeoJsonLayer.kt | 168 ++++++++++------ .../google/maps/android/data/kml/KmlLayer.kt | 183 +++++++++++++----- .../data/renderer/mapview/MapViewRenderer.kt | 107 +++++++--- .../maps/android/data/renderer/model/Style.kt | 35 ++++ .../data/geojson/GeoJsonLayerOnMapTest.kt | 78 ++++++++ .../android/data/kml/KmlLayerOnMapTest.kt | 62 ++++++ .../android/heatmaps/HeatmapTileProvider.kt | 21 +- .../heatmaps/HeatmapTileProviderTest.kt | 63 ++++++ .../android/collections/MapObjectManager.kt | 7 +- .../collections/MapObjectManagerTest.kt | 22 +++ .../google/maps/android/ui/AnimationUtil.kt | 9 +- .../google/maps/android/ui/IconGenerator.kt | 2 +- .../google/maps/android/ui/RotationLayout.kt | 2 +- .../google/maps/android/AnimationUtilTest.kt | 18 ++ .../maps/android/ui/IconGeneratorTest.kt | 10 + 17 files changed, 700 insertions(+), 164 deletions(-) diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt index ed2633829..31748f172 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt @@ -835,29 +835,25 @@ open class DefaultClusterRenderer @JvmOverloads constructor( marker: Marker, ) { var changed = false - // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() - if (item.title != null && item.snippet != null) { - if (item.title != marker.title) { - marker.title = item.title - changed = true - } - if (item.snippet != marker.snippet) { - marker.snippet = item.snippet - changed = true - } - } else if (item.snippet != null && item.snippet != marker.title) { - marker.title = item.snippet + // Update marker text if the item text changed - same logic as adding marker in onBeforeClusterItemRendered() + val expectedTitle = item.title ?: item.snippet + val expectedSnippet = if (item.title != null) item.snippet else null + if (marker.title != expectedTitle) { + marker.title = expectedTitle changed = true - } else if (item.title != null && item.title != marker.title) { - marker.title = item.title + } + if (marker.snippet != expectedSnippet) { + marker.snippet = expectedSnippet changed = true } // Update marker position if the item changed position if (marker.position != item.position) { marker.position = item.position - if (item.zIndex != null) { - marker.zIndex = item.zIndex!! - } + changed = true + } + val itemZIndex = item.zIndex + if (itemZIndex != null && marker.zIndex != itemZIndex) { + marker.zIndex = itemZIndex changed = true } if (changed && marker.isInfoWindowShown) { diff --git a/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt index 030350810..756d465d4 100644 --- a/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt +++ b/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt @@ -117,4 +117,51 @@ class ClusterManagerTest { clusterManager.setOnClusterInfoWindowClickListener(clusterInfoClickListener) clusterManager.setOnClusterItemInfoWindowClickListener(itemInfoClickListener) } + + private class MutableTestItem( + override var position: LatLng, + override var title: String?, + override var snippet: String?, + override var zIndex: Float?, + ) : ClusterItem + + private class TestableDefaultClusterRenderer( + context: Context, + map: GoogleMap, + clusterManager: ClusterManager, + ) : com.google.maps.android.clustering.view.DefaultClusterRenderer(context, map, clusterManager) { + fun invokeOnClusterItemUpdated( + item: MutableTestItem, + marker: Marker, + ) { + onClusterItemUpdated(item, marker) + } + } + + @Test + fun testOnClusterItemUpdated_clearsNullSnippetAndUpdatesZIndex() { + val mutableClusterManager = ClusterManager(context, map, markerManager) + val renderer = TestableDefaultClusterRenderer(context, map, mutableClusterManager) + val mockMarker = mockk(relaxed = true) + var markerPosition = LatLng(37.7749, -122.4194) + var markerTitle: String? = "Initial Title" + var markerSnippet: String? = "Initial Snippet" + var markerZIndex = 1.0f + + every { mockMarker.position } answers { markerPosition } + every { mockMarker.setPosition(any()) } answers { markerPosition = firstArg() } + every { mockMarker.title } answers { markerTitle } + every { mockMarker.setTitle(any()) } answers { markerTitle = firstArg() } + every { mockMarker.snippet } answers { markerSnippet } + every { mockMarker.setSnippet(any()) } answers { markerSnippet = firstArg() } + every { mockMarker.zIndex } answers { markerZIndex } + every { mockMarker.setZIndex(any()) } answers { markerZIndex = firstArg() } + every { mockMarker.isInfoWindowShown } returns false + + val item = MutableTestItem(LatLng(37.7749, -122.4194), "Initial Title", null, 10.0f) + renderer.invokeOnClusterItemUpdated(item, mockMarker) + + assertThat(markerSnippet).isNull() + assertThat(markerZIndex).isEqualTo(10.0f) + } } diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt index af09642bf..c6b2cd4d7 100644 --- a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt @@ -40,6 +40,7 @@ import com.google.maps.android.data.parser.geojson.GeoJsonPoint as ParserGeoJson import com.google.maps.android.data.parser.geojson.GeoJsonPolygon as ParserGeoJsonPolygon 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.CompositeStyle as ModelCompositeStyle import com.google.maps.android.data.renderer.model.Feature as ModelFeature import com.google.maps.android.data.renderer.model.Geometry as ModelGeometry import com.google.maps.android.data.renderer.model.LineString as ModelLineString @@ -66,6 +67,10 @@ public class GeoJsonLayer : Layer { private var mIsLayerOnMap = false private val mFeatureMap = HashMap() private val mModelToLegacyFeatures = IdentityHashMap() + private var mMarkerManager: MarkerManager? = null + private var mPolygonManager: PolygonManager? = null + private var mPolylineManager: PolylineManager? = null + private var mGroundOverlayManager: GroundOverlayManager? = null private var mFeatureClickListener: OnFeatureClickListener? = null private val mFeatureObserver = Observer { observable, _ -> @@ -85,6 +90,10 @@ public class GeoJsonLayer : Layer { groundOverlayManager: GroundOverlayManager? = null, ) { mGoogleMap = map + mMarkerManager = markerManager + mPolygonManager = polygonManager + mPolylineManager = polylineManager + mGroundOverlayManager = groundOverlayManager val stream = geoJsonFile.toString().byteInputStream() parseGeoJson(stream) initializeRenderer(map) @@ -102,6 +111,10 @@ public class GeoJsonLayer : Layer { groundOverlayManager: GroundOverlayManager? = null, ) { mGoogleMap = map + mMarkerManager = markerManager + mPolygonManager = polygonManager + mPolylineManager = polylineManager + mGroundOverlayManager = groundOverlayManager val stream = context.resources.openRawResource(resourceId) parseGeoJson(stream) initializeRenderer(map) @@ -157,7 +170,17 @@ public class GeoJsonLayer : Layer { } private fun initializeRenderer(map: GoogleMap?) { - mRenderer = map?.let { MapViewRenderer(it, UrlIconProvider()) } + mRenderer = + map?.let { + MapViewRenderer( + it, + UrlIconProvider(), + mMarkerManager, + mPolygonManager, + mPolylineManager, + mGroundOverlayManager, + ) + } } private fun toLegacyGeometry(geometry: ParserGeoJsonGeometry): Geometry = @@ -252,7 +275,8 @@ public class GeoJsonLayer : Layer { removeLayerFromMap() mRenderer = null } else { - mRenderer = MapViewRenderer(map, UrlIconProvider()) + initializeRenderer(map) + mFeatureClickListener?.let { setOnFeatureClickListener(it) } if (mIsLayerOnMap) { addLayerToMap() } @@ -275,6 +299,53 @@ public class GeoJsonLayer : Layer { mIsLayerOnMap = false } + private fun buildPointStyle(feature: GeoJsonFeature): ModelPointStyle { + val pointStyle = feature.pointStyle ?: mDefaultPointStyle + return ModelPointStyle( + // The renderer derives the marker's alpha from the color's alpha channel, so encode the + // legacy style's alpha into an otherwise-black color (hue 0 keeps the default marker look). + // A transparent color here (e.g. 0) would render the marker invisible. + color = Color.argb((pointStyle.getAlpha() * 255).toInt().coerceIn(0, 255), 0, 0, 0), + anchorU = pointStyle.getAnchorU(), + anchorV = pointStyle.getAnchorV(), + heading = pointStyle.getRotation(), + zIndex = pointStyle.getZIndex(), + title = pointStyle.getTitle(), + snippet = pointStyle.getSnippet(), + draggable = pointStyle.isDraggable(), + flat = pointStyle.isFlat(), + visible = pointStyle.isVisible(), + iconDescriptor = pointStyle.getIcon(), + infoWindowAnchorU = pointStyle.getInfoWindowAnchorU(), + infoWindowAnchorV = pointStyle.getInfoWindowAnchorV(), + ) + } + + private fun buildLineStyle(feature: GeoJsonFeature): ModelLineStyle { + val lineStyle = feature.lineStringStyle ?: mDefaultLineStringStyle + return ModelLineStyle( + color = lineStyle.color, + width = lineStyle.getWidth(), + geodesic = lineStyle.isGeodesic(), + zIndex = lineStyle.getZIndex(), + clickable = lineStyle.isClickable(), + visible = lineStyle.isVisible(), + ) + } + + private fun buildPolygonStyle(feature: GeoJsonFeature): ModelPolygonStyle { + val polygonStyle = feature.polygonStyle ?: mDefaultPolygonStyle + return ModelPolygonStyle( + fillColor = polygonStyle.fillColor, + strokeColor = polygonStyle.getStrokeColor(), + strokeWidth = polygonStyle.getStrokeWidth(), + geodesic = polygonStyle.isGeodesic(), + zIndex = polygonStyle.getZIndex(), + clickable = polygonStyle.isClickable(), + visible = polygonStyle.isVisible(), + ) + } + private fun toModelFeature(feature: GeoJsonFeature): ModelFeature { val existing = mFeatureMap[feature] if (existing != null) return existing @@ -285,57 +356,23 @@ public class GeoJsonLayer : Layer { val style = when (modelGeometry) { - is ModelPointGeometry -> { - val pointStyle = feature.pointStyle ?: mDefaultPointStyle - ModelPointStyle( - // The renderer derives the marker's alpha from the color's alpha channel, so encode the - // legacy style's alpha into an otherwise-black color (hue 0 keeps the default marker look). - // A transparent color here (e.g. 0) would render the marker invisible. - color = Color.argb((pointStyle.getAlpha() * 255).toInt().coerceIn(0, 255), 0, 0, 0), - anchorU = pointStyle.getAnchorU(), - anchorV = pointStyle.getAnchorV(), - heading = pointStyle.getRotation(), - zIndex = pointStyle.getZIndex(), - ) - } - - is ModelLineString -> { - val lineStyle = feature.lineStringStyle ?: mDefaultLineStringStyle - ModelLineStyle( - color = lineStyle.color, - width = lineStyle.getWidth(), - geodesic = lineStyle.isGeodesic(), - zIndex = lineStyle.getZIndex(), - ) - } - - is ModelPolygon -> { - val polygonStyle = feature.polygonStyle ?: mDefaultPolygonStyle - ModelPolygonStyle( - fillColor = polygonStyle.fillColor, - strokeColor = polygonStyle.getStrokeColor(), - strokeWidth = polygonStyle.getStrokeWidth(), - geodesic = polygonStyle.isGeodesic(), - zIndex = polygonStyle.getZIndex(), - ) - } - + is ModelPointGeometry -> buildPointStyle(feature) + is ModelLineString -> buildLineStyle(feature) + is ModelPolygon -> buildPolygonStyle(feature) is ModelMultiGeometry -> { - if (geometry is GeoJsonMultiPolygon) { - val polygonStyle = feature.polygonStyle ?: mDefaultPolygonStyle - ModelPolygonStyle( - fillColor = polygonStyle.fillColor, - strokeColor = polygonStyle.getStrokeColor(), - strokeWidth = polygonStyle.getStrokeWidth(), - geodesic = polygonStyle.isGeodesic(), - zIndex = polygonStyle.getZIndex(), - ) - } else null - } - - else -> { - null + when (geometry) { + is GeoJsonMultiPolygon -> buildPolygonStyle(feature) + is GeoJsonMultiLineString -> buildLineStyle(feature) + is GeoJsonMultiPoint -> buildPointStyle(feature) + else -> + ModelCompositeStyle( + pointStyle = buildPointStyle(feature), + lineStyle = buildLineStyle(feature), + polygonStyle = buildPolygonStyle(feature), + ) + } } + else -> null } val modelFeature = ModelFeature(modelGeometry, style, properties) @@ -430,26 +467,47 @@ public class GeoJsonLayer : Layer { override fun setOnFeatureClickListener(listener: OnFeatureClickListener) { mFeatureClickListener = listener - mGoogleMap?.let { map -> - map.setOnMarkerClickListener { marker -> + val renderer = mRenderer + val map = mGoogleMap ?: return + + val markerClickListener = + GoogleMap.OnMarkerClickListener { marker -> val feature = findLegacyFeatureForMapObject(marker) if (feature != null) { mFeatureClickListener?.onFeatureClick(feature) } false } - map.setOnPolygonClickListener { polygon -> + if (renderer?.markerCollection != null) { + renderer.markerCollection.setOnMarkerClickListener(markerClickListener) + } else { + map.setOnMarkerClickListener(markerClickListener) + } + + val polygonClickListener = + GoogleMap.OnPolygonClickListener { polygon -> val feature = findLegacyFeatureForMapObject(polygon) if (feature != null) { mFeatureClickListener?.onFeatureClick(feature) } } - map.setOnPolylineClickListener { polyline -> + if (renderer?.polygonCollection != null) { + renderer.polygonCollection.setOnPolygonClickListener(polygonClickListener) + } else { + map.setOnPolygonClickListener(polygonClickListener) + } + + val polylineClickListener = + GoogleMap.OnPolylineClickListener { polyline -> val feature = findLegacyFeatureForMapObject(polyline) if (feature != null) { mFeatureClickListener?.onFeatureClick(feature) } } + if (renderer?.polylineCollection != null) { + renderer.polylineCollection.setOnPolylineClickListener(polylineClickListener) + } else { + map.setOnPolylineClickListener(polylineClickListener) } } diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt index 5e0373414..1dce07748 100644 --- a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt @@ -45,8 +45,13 @@ public class KmlLayer : Layer { private var mRenderer: MapViewRenderer? = null private var mIsLayerOnMap = false private val mPlacemarkMap = HashMap() + private val mGroundOverlayMap = HashMap() private val mModelToLegacyPlacemarks = HashMap() private var mFeatureClickListener: OnFeatureClickListener? = null + private var mMarkerManager: MarkerManager? = null + private var mPolygonManager: PolygonManager? = null + private var mPolylineManager: PolylineManager? = null + private var mGroundOverlayManager: GroundOverlayManager? = null @JvmOverloads @Throws(XmlPullParserException::class, IOException::class) @@ -89,7 +94,21 @@ public class KmlLayer : Layer { maxKmzUncompressedTotalSize: Long = 50 * 1024 * 1024, ) { mGoogleMap = map - mRenderer = map?.let { MapViewRenderer(it, UrlIconProvider()) } + mMarkerManager = markerManager + mPolygonManager = polygonManager + mPolylineManager = polylineManager + mGroundOverlayManager = groundOverlayManager + mRenderer = + map?.let { + MapViewRenderer( + it, + UrlIconProvider(), + mMarkerManager, + mPolygonManager, + mPolylineManager, + mGroundOverlayManager, + ) + } val bis = BufferedInputStream(stream) bis.mark(1024) @@ -334,7 +353,16 @@ public class KmlLayer : Layer { removeLayerFromMap() mRenderer = null } else { - mRenderer = MapViewRenderer(map, UrlIconProvider()) + mRenderer = + MapViewRenderer( + map, + UrlIconProvider(), + mMarkerManager, + mPolygonManager, + mPolylineManager, + mGroundOverlayManager, + ) + mFeatureClickListener?.let { setOnFeatureClickListener(it) } if (mIsLayerOnMap) { addLayerToMap() } @@ -375,6 +403,39 @@ public class KmlLayer : Layer { container.getContainers().forEach { c -> removeContainerFromMap(c, renderer) } } + private fun buildPointStyle(inline: KmlStyle?): com.google.maps.android.data.renderer.model.PointStyle = + com.google.maps.android.data.renderer.model.PointStyle( + // mMarkerColor is a hue (0..360), not an ARGB color — convert it before handing it to the + // renderer, which derives the marker's hue and alpha from an ARGB value. Passing the raw + // hue (or 0) made the alpha channel 0 and rendered every KML point marker invisible. + color = + inline?.mMarkerColor?.let { hue -> Color.HSVToColor(floatArrayOf(hue, 1f, 1f)) } + ?: Color.BLACK, + iconUrl = inline?.getIconUrl(), + zIndex = inline?.mMarkerOptions?.zIndex ?: 0f, + ) + + private fun buildLineStyle(inline: KmlStyle?): com.google.maps.android.data.renderer.model.LineStyle = + com.google.maps.android.data.renderer.model.LineStyle( + color = inline?.mPolylineOptions?.color ?: 0xFF000000.toInt(), + width = inline?.mPolylineOptions?.width ?: 1.0f, + geodesic = inline?.mPolylineOptions?.isGeodesic ?: false, + zIndex = inline?.mPolylineOptions?.zIndex ?: 0f, + clickable = inline?.mPolylineOptions?.isClickable ?: true, + visible = inline?.mPolylineOptions?.isVisible ?: true, + ) + + private fun buildPolygonStyle(inline: KmlStyle?): com.google.maps.android.data.renderer.model.PolygonStyle = + com.google.maps.android.data.renderer.model.PolygonStyle( + fillColor = inline?.mPolygonOptions?.fillColor ?: 0x00000000, + strokeColor = inline?.mPolygonOptions?.strokeColor ?: 0xFF000000.toInt(), + strokeWidth = inline?.mPolygonOptions?.strokeWidth ?: 1.0f, + geodesic = inline?.mPolygonOptions?.isGeodesic ?: false, + zIndex = inline?.mPolygonOptions?.zIndex ?: 0f, + clickable = inline?.mPolygonOptions?.isClickable ?: true, + visible = inline?.mPolygonOptions?.isVisible ?: true, + ) + private fun toModelFeature(placemark: KmlPlacemark): com.google.maps.android.data.renderer.model.Feature { val existing = mPlacemarkMap[placemark] if (existing != null) return existing @@ -386,41 +447,16 @@ public class KmlLayer : Layer { val inline = placemark.getInlineStyle() val style = when (modelGeometry) { - is com.google.maps.android.data.renderer.model.PointGeometry -> { - com.google.maps.android.data.renderer.model.PointStyle( - // mMarkerColor is a hue (0..360), not an ARGB color — convert it before handing it to the - // renderer, which derives the marker's hue and alpha from an ARGB value. Passing the raw - // hue (or 0) made the alpha channel 0 and rendered every KML point marker invisible. - color = - inline?.mMarkerColor?.let { hue -> Color.HSVToColor(floatArrayOf(hue, 1f, 1f)) } - ?: Color.BLACK, - iconUrl = inline?.getIconUrl(), - zIndex = inline?.mMarkerOptions?.zIndex ?: 0f, - ) - } - - is com.google.maps.android.data.renderer.model.LineString -> { - com.google.maps.android.data.renderer.model.LineStyle( - color = inline?.mPolylineOptions?.color ?: 0xFF000000.toInt(), - width = inline?.mPolylineOptions?.width ?: 1.0f, - geodesic = inline?.mPolylineOptions?.isGeodesic ?: false, - zIndex = inline?.mPolylineOptions?.zIndex ?: 0f, - ) - } - - is com.google.maps.android.data.renderer.model.Polygon -> { - com.google.maps.android.data.renderer.model.PolygonStyle( - fillColor = inline?.mPolygonOptions?.fillColor ?: 0x00000000, - strokeColor = inline?.mPolygonOptions?.strokeColor ?: 0xFF000000.toInt(), - strokeWidth = inline?.mPolygonOptions?.strokeWidth ?: 1.0f, - geodesic = inline?.mPolygonOptions?.isGeodesic ?: false, - zIndex = inline?.mPolygonOptions?.zIndex ?: 0f, + is com.google.maps.android.data.renderer.model.PointGeometry -> buildPointStyle(inline) + is com.google.maps.android.data.renderer.model.LineString -> buildLineStyle(inline) + is com.google.maps.android.data.renderer.model.Polygon -> buildPolygonStyle(inline) + is com.google.maps.android.data.renderer.model.MultiGeometry -> + com.google.maps.android.data.renderer.model.CompositeStyle( + pointStyle = buildPointStyle(inline), + lineStyle = buildLineStyle(inline), + polygonStyle = buildPolygonStyle(inline), ) - } - - else -> { - null - } + else -> null } val modelFeature = @@ -432,6 +468,9 @@ public class KmlLayer : Layer { } private fun toModelFeature(groundOverlay: KmlGroundOverlay): com.google.maps.android.data.renderer.model.Feature { + val existing = mGroundOverlayMap[groundOverlay] + if (existing != null) return existing + val bounds = groundOverlay.getLatLngBox() val modelGeometry = com.google.maps.android.data.renderer.model.GroundOverlay( @@ -448,8 +487,11 @@ public class KmlLayer : Layer { visibility = groundOverlay.getGroundOverlayOptions().isVisible, ) val properties = groundOverlay.getProperties().associateWith { groundOverlay.getProperty(it) as Any } - return com.google.maps.android.data.renderer.model - .Feature(modelGeometry, style, properties) + val modelFeature = + com.google.maps.android.data.renderer.model + .Feature(modelGeometry, style, properties) + mGroundOverlayMap[groundOverlay] = modelFeature + return modelFeature } private fun toModelGeometry(geometry: Geometry): com.google.maps.android.data.renderer.model.Geometry = @@ -501,43 +543,92 @@ public class KmlLayer : Layer { } } - public fun hasPlacemarks(): Boolean = mPlacemarks.isNotEmpty() + private fun collectPlacemarks( + container: KmlContainer, + out: MutableList, + ) { + out.addAll(container.getPlacemarks()) + container.getContainers().forEach { collectPlacemarks(it, out) } + } + + private fun getAllPlacemarks(): List { + val all = ArrayList(mPlacemarks) + mContainers.forEach { collectPlacemarks(it, all) } + return all + } + + private fun collectGroundOverlays( + container: KmlContainer, + out: MutableList, + ) { + out.addAll(container.getGroundOverlays()) + container.getContainers().forEach { collectGroundOverlays(it, out) } + } + + private fun getAllGroundOverlays(): List { + val all = ArrayList(mGroundOverlays) + mContainers.forEach { collectGroundOverlays(it, all) } + return all + } + + public fun hasPlacemarks(): Boolean = getAllPlacemarks().isNotEmpty() - public fun getPlacemarks(): Iterable = mPlacemarks + public fun getPlacemarks(): Iterable = getAllPlacemarks() public fun hasContainers(): Boolean = mContainers.isNotEmpty() public fun getContainers(): Iterable = mContainers - public fun getGroundOverlays(): Iterable = mGroundOverlays + public fun getGroundOverlays(): Iterable = getAllGroundOverlays() override val features: Iterable - get() = mPlacemarks + get() = getAllPlacemarks() override fun isLayerOnMap(): Boolean = mIsLayerOnMap override fun setOnFeatureClickListener(listener: OnFeatureClickListener) { mFeatureClickListener = listener - mGoogleMap?.let { map -> - map.setOnMarkerClickListener { marker -> + val renderer = mRenderer + val map = mGoogleMap ?: return + + val markerClickListener = + GoogleMap.OnMarkerClickListener { marker -> val feature = findLegacyFeatureForMapObject(marker) if (feature != null) { mFeatureClickListener?.onFeatureClick(feature) } false } - map.setOnPolygonClickListener { polygon -> + if (renderer?.markerCollection != null) { + renderer.markerCollection.setOnMarkerClickListener(markerClickListener) + } else { + map.setOnMarkerClickListener(markerClickListener) + } + + val polygonClickListener = + GoogleMap.OnPolygonClickListener { polygon -> val feature = findLegacyFeatureForMapObject(polygon) if (feature != null) { mFeatureClickListener?.onFeatureClick(feature) } } - map.setOnPolylineClickListener { polyline -> + if (renderer?.polygonCollection != null) { + renderer.polygonCollection.setOnPolygonClickListener(polygonClickListener) + } else { + map.setOnPolygonClickListener(polygonClickListener) + } + + val polylineClickListener = + GoogleMap.OnPolylineClickListener { polyline -> val feature = findLegacyFeatureForMapObject(polyline) if (feature != null) { mFeatureClickListener?.onFeatureClick(feature) } } + if (renderer?.polylineCollection != null) { + renderer.polylineCollection.setOnPolylineClickListener(polylineClickListener) + } else { + map.setOnPolylineClickListener(polylineClickListener) } } diff --git a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt index 6dc89ccc4..7d738797d 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt @@ -24,8 +24,13 @@ import com.google.android.gms.maps.model.MarkerOptions import com.google.android.gms.maps.model.PinConfig import com.google.android.gms.maps.model.PolygonOptions import com.google.android.gms.maps.model.PolylineOptions +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.data.renderer.DataRenderer import com.google.maps.android.data.renderer.IconProvider +import com.google.maps.android.data.renderer.model.CompositeStyle import com.google.maps.android.data.renderer.model.DataLayer import com.google.maps.android.data.renderer.model.DataScene import com.google.maps.android.data.renderer.model.Feature @@ -55,10 +60,19 @@ import java.util.IdentityHashMap * * @property map The [GoogleMap] instance to render features on. */ -class MapViewRenderer( +class MapViewRenderer @JvmOverloads constructor( private val map: GoogleMap, private val iconProvider: IconProvider, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, ) : DataRenderer { + internal val markerCollection: MarkerManager.Collection? = markerManager?.newCollection() + internal val polygonCollection: PolygonManager.Collection? = polygonManager?.newCollection() + internal val polylineCollection: PolylineManager.Collection? = polylineManager?.newCollection() + internal val groundOverlayCollection: GroundOverlayManager.Collection? = groundOverlayManager?.newCollection() + // Scope for all coroutines launched by this renderer. // Using SupervisorJob so that failure of one icon load doesn't cancel others. private val rendererScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -131,10 +145,10 @@ class MapViewRenderer( when (geometry) { is PointGeometry -> { val point = geometry.point - val style = feature.style as? PointStyle + val style = (feature.style as? PointStyle) ?: (feature.style as? CompositeStyle)?.pointStyle if (useAdvancedMarkers) { val markerOptions = createAdvancedMarkerOptions(point, style, feature.properties) - val marker = map.addMarker(markerOptions)!! + val marker = (markerCollection?.addMarker(markerOptions) ?: map.addMarker(markerOptions))!! mapObjects.add(marker) style?.iconUrl?.let { url -> val localBitmap = localImages[url] @@ -155,7 +169,7 @@ class MapViewRenderer( } } else { val markerOptions = createMarkerOptions(point, style, feature.properties) - val marker = map.addMarker(markerOptions)!! + val marker = (markerCollection?.addMarker(markerOptions) ?: map.addMarker(markerOptions))!! mapObjects.add(marker) style?.iconUrl?.let { url -> val localBitmap = localImages[url] @@ -178,15 +192,17 @@ class MapViewRenderer( } is LineString -> { - val style = feature.style as? LineStyle + val style = (feature.style as? LineStyle) ?: (feature.style as? CompositeStyle)?.lineStyle val polylineOptions = createPolylineOptions(geometry, style) - mapObjects.add(map.addPolyline(polylineOptions)) + val polyline = polylineCollection?.addPolyline(polylineOptions) ?: map.addPolyline(polylineOptions) + mapObjects.add(polyline) } is Polygon -> { - val style = feature.style as? PolygonStyle + val style = (feature.style as? PolygonStyle) ?: (feature.style as? CompositeStyle)?.polygonStyle val polygonOptions = createPolygonOptions(geometry, style) - mapObjects.add(map.addPolygon(polygonOptions)) + val polygon = polygonCollection?.addPolygon(polygonOptions) ?: map.addPolygon(polygonOptions) + mapObjects.add(polygon) } is MultiGeometry -> { @@ -204,7 +220,7 @@ class MapViewRenderer( val localBitmap = localImages[url] if (localBitmap != null) { options.image(BitmapDescriptorFactory.fromBitmap(localBitmap)) - val mapOverlay = map.addGroundOverlay(options) + val mapOverlay = groundOverlayCollection?.addGroundOverlay(options) ?: map.addGroundOverlay(options) if (mapOverlay != null) { mapObjects.add(mapOverlay) } @@ -214,7 +230,7 @@ class MapViewRenderer( options.image(BitmapDescriptorFactory.fromBitmap(TRANSPARENT_BITMAP)) options.visible(false) // Hide until loaded - val mapOverlay = map.addGroundOverlay(options) + val mapOverlay = groundOverlayCollection?.addGroundOverlay(options) ?: map.addGroundOverlay(options) if (mapOverlay != null) { mapObjects.add(mapOverlay) rendererScope.launch { @@ -232,7 +248,7 @@ class MapViewRenderer( } } ?: run { // No iconUrl, add directly - val mapOverlay = map.addGroundOverlay(options) + val mapOverlay = groundOverlayCollection?.addGroundOverlay(options) ?: map.addGroundOverlay(options) if (mapOverlay != null) { mapObjects.add(mapOverlay) } @@ -243,12 +259,7 @@ class MapViewRenderer( override fun removeFeature(feature: Feature) { renderedFeatures[feature]?.forEach { mapObject -> - when (mapObject) { - is com.google.android.gms.maps.model.Marker -> mapObject.remove() - is com.google.android.gms.maps.model.Polyline -> mapObject.remove() - is com.google.android.gms.maps.model.Polygon -> mapObject.remove() - is com.google.android.gms.maps.model.GroundOverlay -> mapObject.remove() - } + removeMapObject(mapObject) } renderedFeatures.remove(feature) } @@ -265,16 +276,36 @@ class MapViewRenderer( override fun clear() { rendererScope.cancel() renderedFeatures.values.flatten().forEach { mapObject -> - when (mapObject) { - is com.google.android.gms.maps.model.Marker -> mapObject.remove() - is com.google.android.gms.maps.model.Polyline -> mapObject.remove() - is com.google.android.gms.maps.model.Polygon -> mapObject.remove() - is com.google.android.gms.maps.model.GroundOverlay -> mapObject.remove() - } + removeMapObject(mapObject) } renderedFeatures.clear() } + private fun removeMapObject(mapObject: Any) { + when (mapObject) { + is com.google.android.gms.maps.model.Marker -> { + if (markerCollection == null || !markerCollection.remove(mapObject)) { + mapObject.remove() + } + } + is com.google.android.gms.maps.model.Polyline -> { + if (polylineCollection == null || !polylineCollection.remove(mapObject)) { + mapObject.remove() + } + } + is com.google.android.gms.maps.model.Polygon -> { + if (polygonCollection == null || !polygonCollection.remove(mapObject)) { + mapObject.remove() + } + } + is com.google.android.gms.maps.model.GroundOverlay -> { + if (groundOverlayCollection == null || !groundOverlayCollection.remove(mapObject)) { + mapObject.remove() + } + } + } + } + /** * Creates the [MarkerOptions] for legacy markers. * @@ -289,19 +320,23 @@ class MapViewRenderer( ): MarkerOptions { val markerOptions = MarkerOptions().position(LatLng(point.lat, point.lng)) - // Sniff out title and description from properties to enable click interaction/Info Window - val title = (properties["title"] ?: properties["name"])?.toString() - val snippet = (properties["description"] ?: properties["snippet"])?.toString() + // Sniff out title and description from style or properties to enable click interaction/Info Window + val title = style?.title ?: (properties["title"] ?: properties["name"])?.toString() + val snippet = style?.snippet ?: (properties["description"] ?: properties["snippet"])?.toString() title?.let { markerOptions.title(it) } snippet?.let { markerOptions.snippet(it) } style?.let { - // Default icon if not loaded yet - markerOptions.icon(BitmapDescriptorFactory.defaultMarker(hueFromColor(it.color))) + // Use custom icon descriptor if provided, otherwise default marker hue + markerOptions.icon(it.iconDescriptor ?: BitmapDescriptorFactory.defaultMarker(hueFromColor(it.color))) it.heading?.let { heading -> markerOptions.rotation(heading) } markerOptions.anchor(it.anchorU, it.anchorV) + markerOptions.infoWindowAnchor(it.infoWindowAnchorU, it.infoWindowAnchorV) markerOptions.alpha(android.graphics.Color.alpha(it.color) / 255.0f) markerOptions.zIndex(it.zIndex) + markerOptions.draggable(it.draggable) + markerOptions.flat(it.flat) + markerOptions.visible(it.visible) } return markerOptions } @@ -319,9 +354,9 @@ class MapViewRenderer( ): AdvancedMarkerOptions { val markerOptions = AdvancedMarkerOptions().position(LatLng(point.lat, point.lng)) - // Sniff out title and description from properties to enable click interaction/Info Window - val title = (properties["title"] ?: properties["name"])?.toString() - val snippet = (properties["description"] ?: properties["snippet"])?.toString() + // Sniff out title and description from style or properties to enable click interaction/Info Window + val title = style?.title ?: (properties["title"] ?: properties["name"])?.toString() + val snippet = style?.snippet ?: (properties["description"] ?: properties["snippet"])?.toString() title?.let { markerOptions.title(it) } snippet?.let { markerOptions.snippet(it) } @@ -333,8 +368,12 @@ class MapViewRenderer( .setBackgroundColor(it.color) .setBorderColor(android.graphics.Color.WHITE) // Default border .build() - markerOptions.icon(BitmapDescriptorFactory.fromPinConfig(pinConfig)) + markerOptions.icon(it.iconDescriptor ?: BitmapDescriptorFactory.fromPinConfig(pinConfig)) markerOptions.zIndex(it.zIndex) + markerOptions.draggable(it.draggable) + markerOptions.flat(it.flat) + markerOptions.visible(it.visible) + markerOptions.infoWindowAnchor(it.infoWindowAnchorU, it.infoWindowAnchorV) // AdvancedMarkerOptions does not support rotation directly in the same way as MarkerOptions for flat icons, // but it supports collision behavior etc. @@ -360,11 +399,13 @@ class MapViewRenderer( ): PolylineOptions { val polylineOptions = PolylineOptions() lineString.points.map { LatLng(it.lat, it.lng) }.forEach { polylineOptions.add(it) } + polylineOptions.clickable(style?.clickable ?: true) style?.let { polylineOptions.color(it.color) polylineOptions.width(it.width) polylineOptions.geodesic(it.geodesic) polylineOptions.zIndex(it.zIndex) + polylineOptions.visible(it.visible) } return polylineOptions } @@ -386,12 +427,14 @@ class MapViewRenderer( polygon.innerBoundaries.forEach { innerBoundary -> polygonOptions.addHole(innerBoundary.map { LatLng(it.lat, it.lng) }) } + polygonOptions.clickable(style?.clickable ?: true) style?.let { polygonOptions.fillColor(it.fillColor) polygonOptions.strokeColor(it.strokeColor) polygonOptions.strokeWidth(it.strokeWidth) polygonOptions.geodesic(it.geodesic) polygonOptions.zIndex(it.zIndex) + polygonOptions.visible(it.visible) } return polygonOptions } diff --git a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt index d01d6f517..cc1c91839 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt @@ -15,6 +15,8 @@ */ package com.google.maps.android.data.renderer.model +import com.google.android.gms.maps.model.BitmapDescriptor + /** * A sealed interface representing a style for a geometric object. * @@ -32,6 +34,14 @@ sealed interface Style * @property anchorV The V-coordinate of the icon's anchor point, as a ratio of the icon's height (0.0 to 1.0). Defaults to 1.0 (bottom). * @property scale The scale factor for the icon. Defaults to 1.0. * @property zIndex The z-index of the marker. Higher values are drawn above lower values. Defaults to 0.0. + * @property title Optional title override for the marker. + * @property snippet Optional snippet override for the marker. + * @property draggable Whether the marker is draggable. Defaults to false. + * @property flat Whether the marker should be rendered flat against the map. Defaults to false. + * @property visible Whether the marker is visible. Defaults to true. + * @property iconDescriptor Optional pre-constructed [BitmapDescriptor] for the marker icon. + * @property infoWindowAnchorU The U-coordinate of the info window anchor point. Defaults to 0.5. + * @property infoWindowAnchorV The V-coordinate of the info window anchor point. Defaults to 0.0. */ data class PointStyle( val color: Int = 0xFF000000.toInt(), // Default to black @@ -41,6 +51,14 @@ data class PointStyle( val anchorV: Float = 1.0f, val scale: Float = 1.0f, val zIndex: Float = 0.0f, + val title: String? = null, + val snippet: String? = null, + val draggable: Boolean = false, + val flat: Boolean = false, + val visible: Boolean = true, + val iconDescriptor: BitmapDescriptor? = null, + val infoWindowAnchorU: Float = 0.5f, + val infoWindowAnchorV: Float = 0.0f, ) : Style /** @@ -50,12 +68,16 @@ data class PointStyle( * @property width The width of the line in pixels. Defaults to 1.0. * @property geodesic Indicates whether the line should be drawn as a geodesic (true) or a straight line (false). Defaults to false. * @property zIndex The z-index of the line. Higher values are drawn above lower values. Defaults to 0.0. + * @property clickable Indicates whether the line is clickable. Defaults to true. + * @property visible Indicates whether the line is visible. Defaults to true. */ data class LineStyle( val color: Int = 0xFF000000.toInt(), // Default to black val width: Float = 1.0f, val geodesic: Boolean = false, val zIndex: Float = 0.0f, + val clickable: Boolean = true, + val visible: Boolean = true, ) : Style /** @@ -66,6 +88,8 @@ data class LineStyle( * @property strokeWidth The stroke width of the polygon in pixels. Defaults to 1.0. * @property geodesic Indicates whether the polygon's stroke should be drawn as a geodesic (true) or a straight line (false). Defaults to false. * @property zIndex The z-index of the polygon. Higher values are drawn above lower values. Defaults to 0.0. + * @property clickable Indicates whether the polygon is clickable. Defaults to true. + * @property visible Indicates whether the polygon is visible. Defaults to true. */ data class PolygonStyle( val fillColor: Int = 0x00000000, @@ -73,4 +97,15 @@ data class PolygonStyle( val strokeWidth: Float = 1.0f, val geodesic: Boolean = false, val zIndex: Float = 0.0f, + val clickable: Boolean = true, + val visible: Boolean = true, +) : Style + +/** + * A composite style carrying point, line, and polygon styles for heterogeneous [MultiGeometry] features. + */ +data class CompositeStyle( + val pointStyle: PointStyle? = null, + val lineStyle: LineStyle? = null, + val polygonStyle: PolygonStyle? = null, ) : Style diff --git a/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerOnMapTest.kt b/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerOnMapTest.kt index 32e54b979..328f0de8b 100644 --- a/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerOnMapTest.kt +++ b/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerOnMapTest.kt @@ -54,4 +54,82 @@ class GeoJsonLayerOnMapTest { layer.removeLayerFromMap() assertFalse(layer.isLayerOnMap()) } + + @Test + fun addLayerToMap_routesThroughObjectManagersAndAppliesFullStyles() { + io.mockk.mockkStatic(com.google.android.gms.maps.model.BitmapDescriptorFactory::class) + io.mockk.every { com.google.android.gms.maps.model.BitmapDescriptorFactory.defaultMarker(any()) } returns mockk() + try { + val mockMap = mockk(relaxed = true) + val markerSlot = io.mockk.slot() + val polylineSlot = io.mockk.slot() + val polygonSlot = io.mockk.slot() + val mockMarker = mockk(relaxed = true) + io.mockk.every { mockMap.addMarker(capture(markerSlot)) } returns mockMarker + io.mockk.every { mockMap.addPolyline(capture(polylineSlot)) } returns mockk(relaxed = true) + io.mockk.every { mockMap.addPolygon(capture(polygonSlot)) } returns mockk(relaxed = true) + + val markerManager = com.google.maps.android.collections.MarkerManager(mockMap) + val polygonManager = com.google.maps.android.collections.PolygonManager(mockMap) + val polylineManager = com.google.maps.android.collections.PolylineManager(mockMap) + + val layer = GeoJsonLayer(mockMap, emptyFeatureCollection, markerManager, polygonManager, polylineManager, null) + layer.addLayerToMap() + + val customIcon = mockk() + val pointFeature = + GeoJsonFeature( + GeoJsonPoint(com.google.android.gms.maps.model.LatLng(37.7749, -122.4194)), + "pt_1", + HashMap(), + null, + ).apply { + pointStyle = + GeoJsonPointStyle().apply { + setTitle("Styled Title") + setSnippet("Styled Snippet") + setDraggable(true) + setFlat(true) + setVisible(false) + setIcon(customIcon) + } + } + layer.addFeature(pointFeature) + + org.junit.Assert.assertEquals("Styled Title", markerSlot.captured.title) + org.junit.Assert.assertEquals("Styled Snippet", markerSlot.captured.snippet) + assertTrue(markerSlot.captured.isDraggable) + assertTrue(markerSlot.captured.isFlat) + assertFalse(markerSlot.captured.isVisible) + org.junit.Assert.assertSame(customIcon, markerSlot.captured.icon) + + val multiLine = + GeoJsonMultiLineString( + listOf( + GeoJsonLineString( + listOf( + com.google.android.gms.maps.model.LatLng(37.0, -122.0), + com.google.android.gms.maps.model.LatLng(37.1, -122.1), + ), + ), + ), + ) + val multiLineFeature = + GeoJsonFeature(multiLine, "ml_1", HashMap(), null).apply { + lineStringStyle = + GeoJsonLineStringStyle().apply { + color = android.graphics.Color.RED + setWidth(15f) + } + } + layer.addFeature(multiLineFeature) + + assertTrue(polylineSlot.captured.isClickable) + org.junit.Assert.assertEquals(15f, polylineSlot.captured.width, 0.001f) + org.junit.Assert.assertEquals(android.graphics.Color.RED, polylineSlot.captured.color) + assertTrue(markerManager.remove(mockMarker)) + } finally { + io.mockk.unmockkStatic(com.google.android.gms.maps.model.BitmapDescriptorFactory::class) + } + } } diff --git a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt index 1b462c2ac..5583cf87d 100644 --- a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt +++ b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt @@ -95,6 +95,68 @@ class KmlLayerOnMapTest { val layer = KmlLayer(mockk(relaxed = true), kml.byteInputStream(), context) layer.addLayerToMap() assertTrue(layer.isLayerOnMap()) + assertTrue(layer.hasPlacemarks()) + org.junit.Assert.assertEquals(3, layer.getPlacemarks().toList().size) + org.junit.Assert.assertEquals(3, layer.features.toList().size) + } finally { + unmockkStatic(BitmapDescriptorFactory::class) + } + } + + @Test + fun removeLayerFromMap_removesGroundOverlaysAndPreservesMultiGeometryStyles() { + mockkStatic(BitmapDescriptorFactory::class) + every { BitmapDescriptorFactory.defaultMarker(any()) } returns mockk() + every { BitmapDescriptorFactory.fromBitmap(any()) } returns mockk() + try { + val mockMap = mockk(relaxed = true) + val mockGroundOverlay = mockk(relaxed = true) + val polylineSlot = io.mockk.slot() + every { mockMap.addGroundOverlay(any()) } returns mockGroundOverlay + every { mockMap.addPolyline(capture(polylineSlot)) } returns mockk(relaxed = true) + + val kml = + """ + + + + + + + + -122.084,37.422,0 -122.080,37.425,0 + + + + + Campus Map + https://example.com/overlay.png + + 37.43 + 37.41 + -122.07 + -122.09 + + + + + """.trimIndent() + val context = ApplicationProvider.getApplicationContext() + val layer = KmlLayer(mockMap, kml.byteInputStream(), context) + layer.addLayerToMap() + + org.junit.Assert.assertEquals(1, layer.getGroundOverlays().toList().size) + assertTrue(polylineSlot.captured.isClickable) + org.junit.Assert.assertEquals(12.0f, polylineSlot.captured.width, 0.001f) + org.junit.Assert.assertEquals(0xFFFF0000.toInt(), polylineSlot.captured.color) + + layer.removeLayerFromMap() + io.mockk.verify(exactly = 1) { mockGroundOverlay.remove() } } finally { unmockkStatic(BitmapDescriptorFactory::class) } diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt b/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt index d643e2bc6..11a4a4816 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt +++ b/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt @@ -201,12 +201,14 @@ class HeatmapTileProvider private constructor( } fun setRadius(radius: Int) { + require(radius in MIN_RADIUS..MAX_RADIUS) { "Radius not within bounds." } this.radius = radius this.kernel = generateKernel(this.radius, this.radius / 3.0) this.maxIntensity = getMaxIntensities(this.radius) } fun setOpacity(opacity: Double) { + require(opacity in 0.0..1.0) { "Opacity must be in range [0, 1]" } this.opacity = opacity setGradient(this.gradient) } @@ -224,7 +226,8 @@ class HeatmapTileProvider private constructor( val tileWidth = WORLD_WIDTH / 2.0.pow(zoom.toDouble()) val padding = tileWidth * radius / TILE_DIM val tileWidthPadded = tileWidth + 2 * padding - val bucketWidth = tileWidthPadded / (TILE_DIM + radius * 2) + val gridDim = TILE_DIM + radius * 2 + val bucketWidth = tileWidthPadded / gridDim val minX = x * tileWidth - padding val maxX = (x + 1) * tileWidth + padding val minY = y * tileWidth - padding @@ -251,31 +254,31 @@ class HeatmapTileProvider private constructor( bounds.minY - padding, bounds.maxY + padding, ) - if (!tileBounds.intersects(paddedBounds)) { + if (!tileBounds.intersects(paddedBounds) && wrappedPoints.isEmpty()) { return TileProvider.NO_TILE } val points = tree.search(tileBounds) - if (points.isEmpty()) { + if (points.isEmpty() && wrappedPoints.isEmpty()) { return TileProvider.NO_TILE } - val intensity = Array(TILE_DIM + radius * 2) { DoubleArray(TILE_DIM + radius * 2) } + val intensity = Array(gridDim) { DoubleArray(gridDim) } for (w in points) { val p = w.point - val bucketX = ((p.x - minX) / bucketWidth).toInt() - val bucketY = ((p.y - minY) / bucketWidth).toInt() + val bucketX = ((p.x - minX) / bucketWidth).toInt().coerceIn(0, gridDim - 1) + val bucketY = ((p.y - minY) / bucketWidth).toInt().coerceIn(0, gridDim - 1) intensity[bucketX][bucketY] += w.intensity } for (w in wrappedPoints) { val p = w.point - val bucketX = ((p.x + xOffset - minX) / bucketWidth).toInt() - val bucketY = ((p.y - minY) / bucketWidth).toInt() + val bucketX = ((p.x + xOffset - minX) / bucketWidth).toInt().coerceIn(0, gridDim - 1) + val bucketY = ((p.y - minY) / bucketWidth).toInt().coerceIn(0, gridDim - 1) intensity[bucketX][bucketY] += w.intensity } val convolved = convolve(intensity, kernel) - val bitmap = colorize(convolved, colorMap, maxIntensity[zoom]) + val bitmap = colorize(convolved, colorMap, maxIntensity[zoom.coerceIn(0, maxIntensity.lastIndex)]) return convertBitmap(bitmap) } diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt b/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt index 2d73cdec9..273a47bc8 100644 --- a/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt +++ b/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt @@ -137,4 +137,67 @@ class HeatmapTileProviderTest { val tile = provider.getTile(1, 1, 20) assertThat(tile).isEqualTo(TileProvider.NO_TILE) } + + @Test + fun testGetTile_zoomLevel22DoesNotThrowOutOfBounds() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + val numTiles = 1 shl 22 + val centerTile = numTiles / 2 + val tile = provider.getTile(centerTile, centerTile, 22) + assertThat(tile).isNotNull() + assertThat(tile).isNotEqualTo(TileProvider.NO_TILE) + } + + @Test + fun testGetTile_upperBoundaryPointDoesNotThrowOutOfBounds() { + val data = listOf(LatLng(0.0, 0.0), LatLng(10.0, 10.0)) + val provider = HeatmapTileProvider.Builder().data(data).radius(20).build() + val zoom = 2 + val numTiles = 1 shl zoom + val padding = (1.0 / numTiles) * 20 / 512.0 + val exactMaxX = 2 * (1.0 / numTiles) + padding + val exactLng = (exactMaxX - 0.5) * 360.0 + provider.updateLatLngs(listOf(LatLng(0.0, 0.0), LatLng(0.0, exactLng))) + val tile = provider.getTile(1, 1, zoom) + assertThat(tile).isNotNull() + } + + @Test + fun testGetTile_antimeridianWrappedPointsAreRendered() { + val pointNearEastDateLine = LatLng(0.0, 179.99) + val provider = + HeatmapTileProvider + .Builder() + .data(listOf(pointNearEastDateLine)) + .radius(50) + .build() + val tile = provider.getTile(0, 1, 1) + assertThat(tile).isNotEqualTo(TileProvider.NO_TILE) + } + + @Test + fun testSetRadiusAndSetOpacity_validateBounds() { + val provider = HeatmapTileProvider.Builder().data(listOf(LatLng(0.0, 0.0))).build() + try { + provider.setRadius(0) + fail("Should have thrown IllegalArgumentException") + } catch (_: IllegalArgumentException) { + } + try { + provider.setRadius(100) + fail("Should have thrown IllegalArgumentException") + } catch (_: IllegalArgumentException) { + } + try { + provider.setOpacity(-0.5) + fail("Should have thrown IllegalArgumentException") + } catch (_: IllegalArgumentException) { + } + try { + provider.setOpacity(1.5) + fail("Should have thrown IllegalArgumentException") + } catch (_: IllegalArgumentException) { + } + } } diff --git a/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt b/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt index 5c6b7116a..acca571e1 100644 --- a/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt +++ b/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt @@ -39,8 +39,13 @@ abstract class MapObjectManager.Collection>( protected val mAllObjects: MutableMap = mutableMapOf() init { - Handler(Looper.getMainLooper()).post { + val mainLooper = Looper.getMainLooper() + if (mainLooper == null || Looper.myLooper() == mainLooper) { setListenersOnUiThread() + } else { + Handler(mainLooper).post { + setListenersOnUiThread() + } } } diff --git a/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt index bb1e2f54d..be2d93ab5 100644 --- a/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt +++ b/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt @@ -154,4 +154,26 @@ class MapObjectManagerTest { assertThat(obj.isVisible).isFalse() } } + + @Test + fun testSynchronousListenerRegistrationOnUiThread() { + var listenersRegistered = false + val syncManager = + object : MapObjectManager(map) { + override fun setListenersOnUiThread() { + listenersRegistered = true + } + + override fun newCollection(): ConcreteManager.Collection = manager.newCollection() + + override fun removeObjectFromMap(mapObject: TestObject) {} + + override fun setVisible( + mapObject: TestObject, + visible: Boolean, + ) {} + } + assertThat(syncManager).isNotNull() + assertThat(listenersRegistered).isTrue() + } } diff --git a/ui/src/main/java/com/google/maps/android/ui/AnimationUtil.kt b/ui/src/main/java/com/google/maps/android/ui/AnimationUtil.kt index 0b8f95d6f..fe7ab58e7 100644 --- a/ui/src/main/java/com/google/maps/android/ui/AnimationUtil.kt +++ b/ui/src/main/java/com/google/maps/android/ui/AnimationUtil.kt @@ -42,6 +42,10 @@ object AnimationUtil { finalPosition: LatLng, durationInMs: Long = 2000, ) { + if (durationInMs <= 0L) { + marker.position = finalPosition + return + } val latLngInterpolator = LatLngInterpolator.Linear() val startPosition = marker.position val handler = Handler(Looper.getMainLooper()) @@ -52,12 +56,13 @@ object AnimationUtil { object : Runnable { override fun run() { val elapsed = SystemClock.uptimeMillis() - start - val t = elapsed / durationInMs.toFloat() + val rawT = elapsed / durationInMs.toFloat() + val t = rawT.coerceIn(0f, 1f) val v = interpolator.getInterpolation(t) marker.position = latLngInterpolator.interpolate(v, startPosition, finalPosition) // Repeat till progress is complete. - if (t < 1) { + if (rawT < 1f) { // Post again 16ms later. handler.postDelayed(this, 16) } diff --git a/ui/src/main/java/com/google/maps/android/ui/IconGenerator.kt b/ui/src/main/java/com/google/maps/android/ui/IconGenerator.kt index 716a67171..fabd8494e 100644 --- a/ui/src/main/java/com/google/maps/android/ui/IconGenerator.kt +++ b/ui/src/main/java/com/google/maps/android/ui/IconGenerator.kt @@ -133,7 +133,7 @@ class IconGenerator(private val context: Context) { * @param degrees the amount the icon should be rotated, as a multiple of 90 degrees. */ fun setRotation(degrees: Int) { - rotation = (degrees + 360) % 360 / 90 + rotation = degrees.mod(360) / 90 } /** diff --git a/ui/src/main/java/com/google/maps/android/ui/RotationLayout.kt b/ui/src/main/java/com/google/maps/android/ui/RotationLayout.kt index bbe0e224a..9169c7263 100644 --- a/ui/src/main/java/com/google/maps/android/ui/RotationLayout.kt +++ b/ui/src/main/java/com/google/maps/android/ui/RotationLayout.kt @@ -50,7 +50,7 @@ class RotationLayout * @param degrees the rotation, in degrees. */ fun setViewRotation(degrees: Int) { - rotation = (degrees + 360) % 360 / 90 + rotation = degrees.mod(360) / 90 } public override fun dispatchDraw(canvas: Canvas) { diff --git a/ui/src/test/java/com/google/maps/android/AnimationUtilTest.kt b/ui/src/test/java/com/google/maps/android/AnimationUtilTest.kt index 057daae1e..057643746 100644 --- a/ui/src/test/java/com/google/maps/android/AnimationUtilTest.kt +++ b/ui/src/test/java/com/google/maps/android/AnimationUtilTest.kt @@ -74,4 +74,22 @@ class AnimationUtilTest { assertEquals(10.0, currentPosition.latitude, 0.5) // 0.5 tolerance assertEquals(10.0, currentPosition.longitude, 0.5) // 0.5 tolerance } + + @Test + fun `animateMarkerTo with zero duration snaps directly to final position without NaN`() { + val finalPosition = LatLng(37.7749, -122.4194) + AnimationUtil.animateMarkerTo(marker, finalPosition, 0L) + assertEquals(37.7749, currentPosition.latitude, 1e-6) + assertEquals(-122.4194, currentPosition.longitude, 1e-6) + } + + @Test + fun `animateMarkerTo clamps elapsed time overshoot to final position`() { + val finalPosition = LatLng(10.0, 20.0) + AnimationUtil.animateMarkerTo(marker, finalPosition, 100L) + val mainLooper = Shadows.shadowOf(android.os.Looper.getMainLooper()) + mainLooper.idleFor(160, TimeUnit.MILLISECONDS) + assertEquals(10.0, currentPosition.latitude, 1e-6) + assertEquals(20.0, currentPosition.longitude, 1e-6) + } } diff --git a/ui/src/test/java/com/google/maps/android/ui/IconGeneratorTest.kt b/ui/src/test/java/com/google/maps/android/ui/IconGeneratorTest.kt index dbcc82a11..5ff8c50d6 100644 --- a/ui/src/test/java/com/google/maps/android/ui/IconGeneratorTest.kt +++ b/ui/src/test/java/com/google/maps/android/ui/IconGeneratorTest.kt @@ -305,4 +305,14 @@ class IconGeneratorTest { val bitmap = iconGenerator.makeIcon("Color Test") assertNotNull(bitmap) } + + @Test + fun testNegativeMultiTurnRotation() { + iconGenerator.setRotation(-450) + iconGenerator.setContentRotation(-450) + assertEquals(1.0f, iconGenerator.getAnchorU(), 0.001f) + assertEquals(0.5f, iconGenerator.getAnchorV(), 0.001f) + val bitmap = iconGenerator.makeIcon("Negative Rotation") + assertNotNull(bitmap) + } } From c0f042e9cfdb22024b49d2b42d3f729cd1905519 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:10:12 -0600 Subject: [PATCH 2/2] fix: address PR review comments on layer managers, KML accessors, Style, and cluster renderers --- MIGRATION.md | 10 + .../view/ClusterRendererMultipleItems.kt | 34 ++- .../DefaultAdvancedMarkersClusterRenderer.kt | 34 ++- .../clustering/view/DefaultClusterRenderer.kt | 36 ++- .../maps/android/data/geojson/GeoJsonLayer.kt | 70 ++++- .../google/maps/android/data/kml/KmlLayer.kt | 96 +++++-- .../data/renderer/mapview/MapViewRenderer.kt | 255 ++++++++++-------- .../maps/android/data/renderer/model/Style.kt | 74 +++-- .../android/data/kml/KmlLayerOnMapTest.kt | 10 +- 9 files changed, 391 insertions(+), 228 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 1deaca3a6..d41565daa 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -109,3 +109,13 @@ geoJsonLayer.setOnFeatureClickListener { feature -> } ``` Unlike the previous version, this SAM conversion is fully supported for native Kotlin callers without requiring anonymous object syntax (`object : Layer.OnFeatureClickListener { ... }`). + +--- + +## 4. Data Renderer Model Style Updates (v6.0.0) + +In `v6.0.0`, the `com.google.maps.android.data.renderer.model.Style` hierarchy was expanded to support complete feature styling and heterogeneous multi-geometries: + +- **`PointStyle`**, **`LineStyle`**, and **`PolygonStyle`**: Added default-valued constructor properties (`title`, `snippet`, `draggable`, `flat`, `visible`, `infoWindowAnchorU`, and `infoWindowAnchorV` on `PointStyle`; `clickable` and `visible` on `LineStyle` and `PolygonStyle`). Source calls with named or positional arguments continue to compile unchanged, though recompilation is required against the updated data class `copy()` and constructor signatures. +- **`CompositeStyle`**: Added `CompositeStyle(pointStyle, lineStyle, polygonStyle)` as a new subtype of the `sealed interface Style` to support heterogeneous `MultiGeometry` features. Exhaustive `when (style)` expressions over `Style` in consumer code should add a `is CompositeStyle ->` branch (or an `else ->` branch). + diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt index 15356bfb7..b65ea8f0e 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt @@ -904,7 +904,7 @@ open class ClusterRendererMultipleItems @JvmOverloads construct marker: Marker, ) { var changed = false - // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() + // Update marker text if the item text changed - same logic as adding marker in onBeforeClusterItemRendered() if (item.title != null && item.snippet != null) { if (item.title != marker.title) { marker.title = item.title @@ -914,19 +914,33 @@ open class ClusterRendererMultipleItems @JvmOverloads construct marker.snippet = item.snippet changed = true } - } else if (item.snippet != null && item.snippet != marker.title) { - marker.title = item.snippet - changed = true - } else if (item.title != null && item.title != marker.title) { - marker.title = item.title - changed = true + } else if (item.snippet != null) { + if (item.snippet != marker.title) { + marker.title = item.snippet + changed = true + } + if (marker.snippet != null) { + marker.snippet = null + changed = true + } + } else if (item.title != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (marker.snippet != null) { + marker.snippet = null + changed = true + } } // Update marker position if the item changed position if (marker.position != item.position) { marker.position = item.position - if (item.zIndex != null) { - marker.zIndex = item.zIndex!! - } + changed = true + } + val itemZIndex = item.zIndex + if (itemZIndex != null && marker.zIndex != itemZIndex) { + marker.zIndex = itemZIndex changed = true } if (changed && marker.isInfoWindowShown) { diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt index c36d6fa5c..86903c2ef 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt @@ -833,7 +833,7 @@ open class DefaultAdvancedMarkersClusterRenderer @JvmOverloads marker: Marker, ) { var changed = false - // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() + // Update marker text if the item text changed - same logic as adding marker in onBeforeClusterItemRendered() if (item.title != null && item.snippet != null) { if (item.title != marker.title) { marker.title = item.title @@ -843,19 +843,33 @@ open class DefaultAdvancedMarkersClusterRenderer @JvmOverloads marker.snippet = item.snippet changed = true } - } else if (item.snippet != null && item.snippet != marker.title) { - marker.title = item.snippet - changed = true - } else if (item.title != null && item.title != marker.title) { - marker.title = item.title - changed = true + } else if (item.snippet != null) { + if (item.snippet != marker.title) { + marker.title = item.snippet + changed = true + } + if (marker.snippet != null) { + marker.snippet = null + changed = true + } + } else if (item.title != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (marker.snippet != null) { + marker.snippet = null + changed = true + } } // Update marker position if the item changed position if (marker.position != item.position) { marker.position = item.position - if (item.zIndex != null) { - marker.zIndex = item.zIndex!! - } + changed = true + } + val itemZIndex = item.zIndex + if (itemZIndex != null && marker.zIndex != itemZIndex) { + marker.zIndex = itemZIndex changed = true } if (changed && marker.isInfoWindowShown) { diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt index 31748f172..ba77a72b6 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt @@ -836,15 +836,33 @@ open class DefaultClusterRenderer @JvmOverloads constructor( ) { var changed = false // Update marker text if the item text changed - same logic as adding marker in onBeforeClusterItemRendered() - val expectedTitle = item.title ?: item.snippet - val expectedSnippet = if (item.title != null) item.snippet else null - if (marker.title != expectedTitle) { - marker.title = expectedTitle - changed = true - } - if (marker.snippet != expectedSnippet) { - marker.snippet = expectedSnippet - changed = true + if (item.title != null && item.snippet != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (item.snippet != marker.snippet) { + marker.snippet = item.snippet + changed = true + } + } else if (item.snippet != null) { + if (item.snippet != marker.title) { + marker.title = item.snippet + changed = true + } + if (marker.snippet != null) { + marker.snippet = null + changed = true + } + } else if (item.title != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (marker.snippet != null) { + marker.snippet = null + changed = true + } } // Update marker position if the item changed position if (marker.position != item.position) { diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt index c6b2cd4d7..95102b3c1 100644 --- a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt @@ -71,6 +71,12 @@ public class GeoJsonLayer : Layer { private var mPolygonManager: PolygonManager? = null private var mPolylineManager: PolylineManager? = null private var mGroundOverlayManager: GroundOverlayManager? = null + private var mManagersMap: GoogleMap? = null + private var mMarkerCollection: MarkerManager.Collection? = null + private var mPolygonCollection: PolygonManager.Collection? = null + private var mPolylineCollection: PolylineManager.Collection? = null + private var mGroundOverlayCollection: GroundOverlayManager.Collection? = null + private val mCachedDescriptors = HashMap() private var mFeatureClickListener: OnFeatureClickListener? = null private val mFeatureObserver = Observer { observable, _ -> @@ -170,17 +176,32 @@ public class GeoJsonLayer : Layer { } private fun initializeRenderer(map: GoogleMap?) { - mRenderer = - map?.let { - MapViewRenderer( - it, - UrlIconProvider(), - mMarkerManager, - mPolygonManager, - mPolylineManager, - mGroundOverlayManager, - ) - } + if (map == null) { + mRenderer = null + return + } + if (mManagersMap == null && + (mMarkerManager != null || mPolygonManager != null || mPolylineManager != null || mGroundOverlayManager != null) + ) { + mManagersMap = map + mMarkerCollection = mMarkerManager?.newCollection() + mPolygonCollection = mPolygonManager?.newCollection() + mPolylineCollection = mPolylineManager?.newCollection() + mGroundOverlayCollection = mGroundOverlayManager?.newCollection() + } + val renderer = + MapViewRenderer( + map = map, + iconProvider = UrlIconProvider(), + markerCollection = mMarkerCollection, + polygonCollection = mPolygonCollection, + polylineCollection = mPolylineCollection, + groundOverlayCollection = mGroundOverlayCollection, + ) + mCachedDescriptors.forEach { (key, descriptor) -> + renderer.cacheIconDescriptor(key, descriptor) + } + mRenderer = renderer } private fun toLegacyGeometry(geometry: ParserGeoJsonGeometry): Geometry = @@ -270,14 +291,28 @@ public class GeoJsonLayer : Layer { override fun getMap(): GoogleMap? = mGoogleMap override fun setMap(map: GoogleMap?) { + val wasLayerOnMap = mIsLayerOnMap + if (wasLayerOnMap) { + removeLayerFromMap() + } + if (map != null && mManagersMap != null && map !== mManagersMap) { + mMarkerManager = null + mPolygonManager = null + mPolylineManager = null + mGroundOverlayManager = null + mMarkerCollection = null + mPolygonCollection = null + mPolylineCollection = null + mGroundOverlayCollection = null + mManagersMap = null + } mGoogleMap = map if (map == null) { - removeLayerFromMap() mRenderer = null } else { initializeRenderer(map) mFeatureClickListener?.let { setOnFeatureClickListener(it) } - if (mIsLayerOnMap) { + if (wasLayerOnMap) { addLayerToMap() } } @@ -301,11 +336,19 @@ public class GeoJsonLayer : Layer { private fun buildPointStyle(feature: GeoJsonFeature): ModelPointStyle { val pointStyle = feature.pointStyle ?: mDefaultPointStyle + val iconUrl = + pointStyle.getIcon()?.let { descriptor -> + val key = "geojson-descriptor://${System.identityHashCode(descriptor)}" + mCachedDescriptors[key] = descriptor + mRenderer?.cacheIconDescriptor(key, descriptor) + key + } return ModelPointStyle( // The renderer derives the marker's alpha from the color's alpha channel, so encode the // legacy style's alpha into an otherwise-black color (hue 0 keeps the default marker look). // A transparent color here (e.g. 0) would render the marker invisible. color = Color.argb((pointStyle.getAlpha() * 255).toInt().coerceIn(0, 255), 0, 0, 0), + iconUrl = iconUrl, anchorU = pointStyle.getAnchorU(), anchorV = pointStyle.getAnchorV(), heading = pointStyle.getRotation(), @@ -315,7 +358,6 @@ public class GeoJsonLayer : Layer { draggable = pointStyle.isDraggable(), flat = pointStyle.isFlat(), visible = pointStyle.isVisible(), - iconDescriptor = pointStyle.getIcon(), infoWindowAnchorU = pointStyle.getInfoWindowAnchorU(), infoWindowAnchorV = pointStyle.getInfoWindowAnchorV(), ) diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt index 1dce07748..3eac2d5c3 100644 --- a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt @@ -52,6 +52,12 @@ public class KmlLayer : Layer { private var mPolygonManager: PolygonManager? = null private var mPolylineManager: PolylineManager? = null private var mGroundOverlayManager: GroundOverlayManager? = null + private var mManagersMap: GoogleMap? = null + private var mMarkerCollection: MarkerManager.Collection? = null + private var mPolygonCollection: PolygonManager.Collection? = null + private var mPolylineCollection: PolylineManager.Collection? = null + private var mGroundOverlayCollection: GroundOverlayManager.Collection? = null + private val mCachedImages = HashMap() @JvmOverloads @Throws(XmlPullParserException::class, IOException::class) @@ -98,17 +104,7 @@ public class KmlLayer : Layer { mPolygonManager = polygonManager mPolylineManager = polylineManager mGroundOverlayManager = groundOverlayManager - mRenderer = - map?.let { - MapViewRenderer( - it, - UrlIconProvider(), - mMarkerManager, - mPolygonManager, - mPolylineManager, - mGroundOverlayManager, - ) - } + initializeRenderer(map) val bis = BufferedInputStream(stream) bis.mark(1024) @@ -129,6 +125,7 @@ public class KmlLayer : Layer { // Register KMZ cached images to renderer if any kmlObj.images.forEach { (name, bitmap) -> + mCachedImages[name] = bitmap mRenderer?.cacheImageData(name, bitmap) } @@ -345,25 +342,60 @@ public class KmlLayer : Layer { return (a shl 24) or (r shl 16) or (g shl 8) or b } + private fun initializeRenderer(map: GoogleMap?) { + if (map == null) { + mRenderer = null + return + } + if (mManagersMap == null && + (mMarkerManager != null || mPolygonManager != null || mPolylineManager != null || mGroundOverlayManager != null) + ) { + mManagersMap = map + mMarkerCollection = mMarkerManager?.newCollection() + mPolygonCollection = mPolygonManager?.newCollection() + mPolylineCollection = mPolylineManager?.newCollection() + mGroundOverlayCollection = mGroundOverlayManager?.newCollection() + } + val renderer = + MapViewRenderer( + map = map, + iconProvider = UrlIconProvider(), + markerCollection = mMarkerCollection, + polygonCollection = mPolygonCollection, + polylineCollection = mPolylineCollection, + groundOverlayCollection = mGroundOverlayCollection, + ) + mCachedImages.forEach { (name, bitmap) -> + renderer.cacheImageData(name, bitmap) + } + mRenderer = renderer + } + override fun getMap(): GoogleMap? = mGoogleMap override fun setMap(map: GoogleMap?) { + val wasLayerOnMap = mIsLayerOnMap + if (wasLayerOnMap) { + removeLayerFromMap() + } + if (map != null && mManagersMap != null && map !== mManagersMap) { + mMarkerManager = null + mPolygonManager = null + mPolylineManager = null + mGroundOverlayManager = null + mMarkerCollection = null + mPolygonCollection = null + mPolylineCollection = null + mGroundOverlayCollection = null + mManagersMap = null + } mGoogleMap = map if (map == null) { - removeLayerFromMap() mRenderer = null } else { - mRenderer = - MapViewRenderer( - map, - UrlIconProvider(), - mMarkerManager, - mPolygonManager, - mPolylineManager, - mGroundOverlayManager, - ) + initializeRenderer(map) mFeatureClickListener?.let { setOnFeatureClickListener(it) } - if (mIsLayerOnMap) { + if (wasLayerOnMap) { addLayerToMap() } } @@ -551,7 +583,11 @@ public class KmlLayer : Layer { container.getContainers().forEach { collectPlacemarks(it, out) } } - private fun getAllPlacemarks(): List { + /** + * Returns all [KmlPlacemark] objects in this layer, including both top-level placemarks + * and placemarks nested inside `` and `` [KmlContainer]s. + */ + public fun getAllPlacemarks(): List { val all = ArrayList(mPlacemarks) mContainers.forEach { collectPlacemarks(it, all) } return all @@ -565,24 +601,28 @@ public class KmlLayer : Layer { container.getContainers().forEach { collectGroundOverlays(it, out) } } - private fun getAllGroundOverlays(): List { + /** + * Returns all [KmlGroundOverlay] objects in this layer, including both top-level ground overlays + * and ground overlays nested inside `` and `` [KmlContainer]s. + */ + public fun getAllGroundOverlays(): List { val all = ArrayList(mGroundOverlays) mContainers.forEach { collectGroundOverlays(it, all) } return all } - public fun hasPlacemarks(): Boolean = getAllPlacemarks().isNotEmpty() + public fun hasPlacemarks(): Boolean = mPlacemarks.isNotEmpty() - public fun getPlacemarks(): Iterable = getAllPlacemarks() + public fun getPlacemarks(): Iterable = mPlacemarks public fun hasContainers(): Boolean = mContainers.isNotEmpty() public fun getContainers(): Iterable = mContainers - public fun getGroundOverlays(): Iterable = getAllGroundOverlays() + public fun getGroundOverlays(): Iterable = mGroundOverlays override val features: Iterable - get() = getAllPlacemarks() + get() = mPlacemarks override fun isLayerOnMap(): Boolean = mIsLayerOnMap diff --git a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt index 7d738797d..6696a71d4 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt @@ -60,136 +60,161 @@ import java.util.IdentityHashMap * * @property map The [GoogleMap] instance to render features on. */ -class MapViewRenderer @JvmOverloads constructor( - private val map: GoogleMap, - private val iconProvider: IconProvider, - markerManager: MarkerManager? = null, - polygonManager: PolygonManager? = null, - polylineManager: PolylineManager? = null, - groundOverlayManager: GroundOverlayManager? = null, -) : DataRenderer { - internal val markerCollection: MarkerManager.Collection? = markerManager?.newCollection() - internal val polygonCollection: PolygonManager.Collection? = polygonManager?.newCollection() - internal val polylineCollection: PolylineManager.Collection? = polylineManager?.newCollection() - internal val groundOverlayCollection: GroundOverlayManager.Collection? = groundOverlayManager?.newCollection() - - // Scope for all coroutines launched by this renderer. - // Using SupervisorJob so that failure of one icon load doesn't cancel others. - private val rendererScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - /** - * Controls whether to use the new Advanced Markers API (if available) or legacy Markers. - * Default is false (legacy Markers). - */ - var useAdvancedMarkers: Boolean = false - - // Cache for local images (e.g. from KMZ files) - private val localImages = mutableMapOf() +public class MapViewRenderer + internal constructor( + private val map: GoogleMap, + private val iconProvider: IconProvider, + internal val markerCollection: MarkerManager.Collection?, + internal val polygonCollection: PolygonManager.Collection?, + internal val polylineCollection: PolylineManager.Collection?, + internal val groundOverlayCollection: GroundOverlayManager.Collection?, + ) : DataRenderer { + @JvmOverloads + public constructor( + map: GoogleMap, + iconProvider: IconProvider, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + ) : this( + map = map, + iconProvider = iconProvider, + markerCollection = markerManager?.newCollection(), + polygonCollection = polygonManager?.newCollection(), + polylineCollection = polylineManager?.newCollection(), + groundOverlayCollection = groundOverlayManager?.newCollection(), + ) + + // Scope for all coroutines launched by this renderer. + // Using SupervisorJob so that failure of one icon load doesn't cancel others. + private val rendererScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + /** + * Controls whether to use the new Advanced Markers API (if available) or legacy Markers. + * Default is false (legacy Markers). + */ + public var useAdvancedMarkers: Boolean = false + + // Cache for local images (e.g. from KMZ files) + private val localImages = mutableMapOf() + + // Cache for pre-constructed BitmapDescriptors (e.g. from legacy GeoJsonPointStyle) + private val localDescriptors = mutableMapOf() + + /** + * Caches an image for a specific URL. + * This is useful for KMZ files or other scenarios where images are loaded locally + * and should be used instead of fetching from the network. + * + * @param url The URL associated with the image. + * @param bitmap The bitmap to cache. + */ + public fun cacheImageData( + url: String, + bitmap: android.graphics.Bitmap, + ) { + localImages[url] = bitmap + } - /** - * Caches an image for a specific URL. - * This is useful for KMZ files or other scenarios where images are loaded locally - * and should be used instead of fetching from the network. - * - * @param url The URL associated with the image. - * @param bitmap The bitmap to cache. - */ - fun cacheImageData( - url: String, - bitmap: android.graphics.Bitmap, - ) { - localImages[url] = bitmap - } + internal fun cacheIconDescriptor( + url: String, + descriptor: com.google.android.gms.maps.model.BitmapDescriptor, + ) { + localDescriptors[url] = descriptor + } - // Track rendered map objects for each feature so we can remove them later - private val renderedFeatures = IdentityHashMap>() + // Track rendered map objects for each feature so we can remove them later + private val renderedFeatures = IdentityHashMap>() - override fun render(scene: DataScene) { - scene.layers.forEach { renderLayer(it) } - } + override fun render(scene: DataScene) { + scene.layers.forEach { renderLayer(it) } + } - override fun addLayer(layer: DataLayer) { - renderLayer(layer) - } + override fun addLayer(layer: DataLayer) { + renderLayer(layer) + } - override fun removeLayer(layer: DataLayer) { - layer.features.forEach { removeFeature(it) } - } + override fun removeLayer(layer: DataLayer) { + layer.features.forEach { removeFeature(it) } + } - private fun renderLayer(layer: DataLayer) { - layer.features.forEach { feature -> - addFeature(feature) + private fun renderLayer(layer: DataLayer) { + layer.features.forEach { feature -> + addFeature(feature) + } } - } - override fun addFeature(feature: Feature) { - removeFeature(feature) - val mapObjects = mutableListOf() - addGeometry(feature.geometry, feature, mapObjects) - if (mapObjects.isNotEmpty()) { - renderedFeatures[feature] = mapObjects + override fun addFeature(feature: Feature) { + removeFeature(feature) + val mapObjects = mutableListOf() + addGeometry(feature.geometry, feature, mapObjects) + if (mapObjects.isNotEmpty()) { + renderedFeatures[feature] = mapObjects + } } - } - /** - * Renders a single [geometry] (recursing into [MultiGeometry] members) with [feature]'s style and - * properties, accumulating every created map object into [mapObjects] so nested geometries stay - * associated with the original [feature] and can be removed later via [removeFeature]. - */ - private fun addGeometry( - geometry: Geometry, - feature: Feature, - mapObjects: MutableList, - ) { - when (geometry) { - is PointGeometry -> { - val point = geometry.point - val style = (feature.style as? PointStyle) ?: (feature.style as? CompositeStyle)?.pointStyle - if (useAdvancedMarkers) { - val markerOptions = createAdvancedMarkerOptions(point, style, feature.properties) - val marker = (markerCollection?.addMarker(markerOptions) ?: map.addMarker(markerOptions))!! - mapObjects.add(marker) - style?.iconUrl?.let { url -> - val localBitmap = localImages[url] - if (localBitmap != null) { - marker.setIcon(BitmapDescriptorFactory.fromBitmap(localBitmap)) - } else { - rendererScope.launch { - val bitmap = iconProvider.loadIcon(url) - if (bitmap != null) { - try { - marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap)) - } catch (e: Exception) { - // Marker might have been removed or other issue + /** + * Renders a single [geometry] (recursing into [MultiGeometry] members) with [feature]'s style and + * properties, accumulating every created map object into [mapObjects] so nested geometries stay + * associated with the original [feature] and can be removed later via [removeFeature]. + */ + private fun addGeometry( + geometry: Geometry, + feature: Feature, + mapObjects: MutableList, + ) { + when (geometry) { + is PointGeometry -> { + val point = geometry.point + val style = (feature.style as? PointStyle) ?: (feature.style as? CompositeStyle)?.pointStyle + if (useAdvancedMarkers) { + val markerOptions = createAdvancedMarkerOptions(point, style, feature.properties) + val marker = (markerCollection?.addMarker(markerOptions) ?: map.addMarker(markerOptions))!! + mapObjects.add(marker) + style?.iconUrl?.let { url -> + if (localDescriptors.containsKey(url)) return@let + val localBitmap = localImages[url] + if (localBitmap != null) { + marker.setIcon(BitmapDescriptorFactory.fromBitmap(localBitmap)) + } else { + rendererScope.launch { + val bitmap = iconProvider.loadIcon(url) + if (bitmap != null) { + try { + marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap)) + } catch (e: Exception) { + // Marker might have been removed or other issue + } } } } } - } - } else { - val markerOptions = createMarkerOptions(point, style, feature.properties) - val marker = (markerCollection?.addMarker(markerOptions) ?: map.addMarker(markerOptions))!! - mapObjects.add(marker) - style?.iconUrl?.let { url -> - val localBitmap = localImages[url] - if (localBitmap != null) { - marker.setIcon(BitmapDescriptorFactory.fromBitmap(localBitmap)) - } else { - rendererScope.launch { - val bitmap = iconProvider.loadIcon(url) - if (bitmap != null) { - try { - marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap)) - } catch (e: Exception) { - // Marker might have been removed + } else { + val markerOptions = createMarkerOptions(point, style, feature.properties) + val marker = (markerCollection?.addMarker(markerOptions) ?: map.addMarker(markerOptions))!! + mapObjects.add(marker) + style?.iconUrl?.let { url -> + if (localDescriptors.containsKey(url)) return@let + val localBitmap = localImages[url] + if (localBitmap != null) { + marker.setIcon(BitmapDescriptorFactory.fromBitmap(localBitmap)) + } else { + rendererScope.launch { + val bitmap = iconProvider.loadIcon(url) + if (bitmap != null) { + try { + marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap)) + } catch (e: Exception) { + // Marker might have been removed + } } } } } } } - } is LineString -> { val style = (feature.style as? LineStyle) ?: (feature.style as? CompositeStyle)?.lineStyle @@ -267,7 +292,7 @@ class MapViewRenderer @JvmOverloads constructor( /** * Finds the model Feature associated with a specific rendered Google Map object. */ - fun getFeatureForMapObject(mapObject: Any): Feature? = + public fun getFeatureForMapObject(mapObject: Any): Feature? = renderedFeatures.entries .firstOrNull { entry -> entry.value.contains(mapObject) @@ -327,8 +352,9 @@ class MapViewRenderer @JvmOverloads constructor( snippet?.let { markerOptions.snippet(it) } style?.let { - // Use custom icon descriptor if provided, otherwise default marker hue - markerOptions.icon(it.iconDescriptor ?: BitmapDescriptorFactory.defaultMarker(hueFromColor(it.color))) + // Use custom icon descriptor if cached under iconUrl, otherwise default marker hue + val cachedDescriptor = it.iconUrl?.let { url -> localDescriptors[url] } + markerOptions.icon(cachedDescriptor ?: BitmapDescriptorFactory.defaultMarker(hueFromColor(it.color))) it.heading?.let { heading -> markerOptions.rotation(heading) } markerOptions.anchor(it.anchorU, it.anchorV) markerOptions.infoWindowAnchor(it.infoWindowAnchorU, it.infoWindowAnchorV) @@ -368,7 +394,8 @@ class MapViewRenderer @JvmOverloads constructor( .setBackgroundColor(it.color) .setBorderColor(android.graphics.Color.WHITE) // Default border .build() - markerOptions.icon(it.iconDescriptor ?: BitmapDescriptorFactory.fromPinConfig(pinConfig)) + val cachedDescriptor = it.iconUrl?.let { url -> localDescriptors[url] } + markerOptions.icon(cachedDescriptor ?: BitmapDescriptorFactory.fromPinConfig(pinConfig)) markerOptions.zIndex(it.zIndex) markerOptions.draggable(it.draggable) markerOptions.flat(it.flat) @@ -460,7 +487,7 @@ class MapViewRenderer @JvmOverloads constructor( return hsv[0] } - companion object { + public companion object { private val TRANSPARENT_BITMAP by lazy { android.graphics.Bitmap.createBitmap(1, 1, android.graphics.Bitmap.Config.ARGB_8888) } diff --git a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt index cc1c91839..8fa306cac 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt @@ -15,14 +15,12 @@ */ package com.google.maps.android.data.renderer.model -import com.google.android.gms.maps.model.BitmapDescriptor - /** * A sealed interface representing a style for a geometric object. * * This is a platform-agnostic representation of styling that can be applied to a rendered shape. */ -sealed interface Style +public sealed interface Style /** * A data class representing the style for a PointGeometry. @@ -39,26 +37,24 @@ sealed interface Style * @property draggable Whether the marker is draggable. Defaults to false. * @property flat Whether the marker should be rendered flat against the map. Defaults to false. * @property visible Whether the marker is visible. Defaults to true. - * @property iconDescriptor Optional pre-constructed [BitmapDescriptor] for the marker icon. * @property infoWindowAnchorU The U-coordinate of the info window anchor point. Defaults to 0.5. * @property infoWindowAnchorV The V-coordinate of the info window anchor point. Defaults to 0.0. */ -data class PointStyle( - val color: Int = 0xFF000000.toInt(), // Default to black - val iconUrl: String? = null, - val heading: Float? = null, - val anchorU: Float = 0.5f, - val anchorV: Float = 1.0f, - val scale: Float = 1.0f, - val zIndex: Float = 0.0f, - val title: String? = null, - val snippet: String? = null, - val draggable: Boolean = false, - val flat: Boolean = false, - val visible: Boolean = true, - val iconDescriptor: BitmapDescriptor? = null, - val infoWindowAnchorU: Float = 0.5f, - val infoWindowAnchorV: Float = 0.0f, +public data class PointStyle( + public val color: Int = 0xFF000000.toInt(), // Default to black + public val iconUrl: String? = null, + public val heading: Float? = null, + public val anchorU: Float = 0.5f, + public val anchorV: Float = 1.0f, + public val scale: Float = 1.0f, + public val zIndex: Float = 0.0f, + public val title: String? = null, + public val snippet: String? = null, + public val draggable: Boolean = false, + public val flat: Boolean = false, + public val visible: Boolean = true, + public val infoWindowAnchorU: Float = 0.5f, + public val infoWindowAnchorV: Float = 0.0f, ) : Style /** @@ -71,13 +67,13 @@ data class PointStyle( * @property clickable Indicates whether the line is clickable. Defaults to true. * @property visible Indicates whether the line is visible. Defaults to true. */ -data class LineStyle( - val color: Int = 0xFF000000.toInt(), // Default to black - val width: Float = 1.0f, - val geodesic: Boolean = false, - val zIndex: Float = 0.0f, - val clickable: Boolean = true, - val visible: Boolean = true, +public data class LineStyle( + public val color: Int = 0xFF000000.toInt(), // Default to black + public val width: Float = 1.0f, + public val geodesic: Boolean = false, + public val zIndex: Float = 0.0f, + public val clickable: Boolean = true, + public val visible: Boolean = true, ) : Style /** @@ -91,21 +87,21 @@ data class LineStyle( * @property clickable Indicates whether the polygon is clickable. Defaults to true. * @property visible Indicates whether the polygon is visible. Defaults to true. */ -data class PolygonStyle( - val fillColor: Int = 0x00000000, - val strokeColor: Int = 0xFF000000.toInt(), // Default to black - val strokeWidth: Float = 1.0f, - val geodesic: Boolean = false, - val zIndex: Float = 0.0f, - val clickable: Boolean = true, - val visible: Boolean = true, +public data class PolygonStyle( + public val fillColor: Int = 0x00000000, + public val strokeColor: Int = 0xFF000000.toInt(), // Default to black + public val strokeWidth: Float = 1.0f, + public val geodesic: Boolean = false, + public val zIndex: Float = 0.0f, + public val clickable: Boolean = true, + public val visible: Boolean = true, ) : Style /** * A composite style carrying point, line, and polygon styles for heterogeneous [MultiGeometry] features. */ -data class CompositeStyle( - val pointStyle: PointStyle? = null, - val lineStyle: LineStyle? = null, - val polygonStyle: PolygonStyle? = null, +public data class CompositeStyle( + public val pointStyle: PointStyle? = null, + public val lineStyle: LineStyle? = null, + public val polygonStyle: PolygonStyle? = null, ) : Style diff --git a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt index 5583cf87d..61ae209f9 100644 --- a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt +++ b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt @@ -95,9 +95,10 @@ class KmlLayerOnMapTest { val layer = KmlLayer(mockk(relaxed = true), kml.byteInputStream(), context) layer.addLayerToMap() assertTrue(layer.isLayerOnMap()) - assertTrue(layer.hasPlacemarks()) - org.junit.Assert.assertEquals(3, layer.getPlacemarks().toList().size) - org.junit.Assert.assertEquals(3, layer.features.toList().size) + org.junit.Assert.assertFalse(layer.hasPlacemarks()) + org.junit.Assert.assertEquals(0, layer.getPlacemarks().toList().size) + org.junit.Assert.assertEquals(0, layer.features.toList().size) + org.junit.Assert.assertEquals(3, layer.getAllPlacemarks().size) } finally { unmockkStatic(BitmapDescriptorFactory::class) } @@ -150,7 +151,8 @@ class KmlLayerOnMapTest { val layer = KmlLayer(mockMap, kml.byteInputStream(), context) layer.addLayerToMap() - org.junit.Assert.assertEquals(1, layer.getGroundOverlays().toList().size) + org.junit.Assert.assertEquals(0, layer.getGroundOverlays().toList().size) + org.junit.Assert.assertEquals(1, layer.getAllGroundOverlays().size) assertTrue(polylineSlot.captured.isClickable) org.junit.Assert.assertEquals(12.0f, polylineSlot.captured.width, 0.001f) org.junit.Assert.assertEquals(0xFFFF0000.toInt(), polylineSlot.captured.color)