From 47e2e74ccb92e8af32c9fd5fcbf1727cbb74a4b8 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:58:50 -0600 Subject: [PATCH 1/2] feat(collections,clustering): complete Kotlin migration for core modules and apply optimizations --- .../maps/android/clustering/ClusterManager.kt | 6 +- .../android/clustering/ClusterManagerTest.kt | 120 ++++++++++ .../android/clustering/StaticClusterTest.java | 45 ---- .../android/clustering/StaticClusterTest.kt | 76 +++++++ .../clustering/algo/AbstractAlgorithmTest.kt | 61 +++++ .../clustering/algo/GridBasedAlgorithmTest.kt | 89 ++++++++ .../NonHierarchicalViewBasedAlgorithmTest.kt | 120 ++++++++++ .../algo/PreCachingAlgorithmDecoratorTest.kt | 32 ++- .../algo/ScreenBasedAlgorithmAdapterTest.kt | 86 +++++++ .../SphericalMercatorProjectionTest.kt | 53 +++++ ...mlUrlSanitizer.java => KmlUrlSanitizer.kt} | 14 +- .../maps/android/data/kml/KmlZipBombTest.java | 92 -------- .../maps/android/data/kml/KmlZipBombTest.kt | 105 +++++++++ .../android/heatmaps/HeatmapTileProvider.kt | 6 +- .../java/com/google/maps/android/PolyUtil.kt | 1 - .../android/collections/CircleManager.java | 110 --------- .../maps/android/collections/CircleManager.kt | 87 ++++++++ .../collections/GroundOverlayManager.java | 113 ---------- .../collections/GroundOverlayManager.kt | 91 ++++++++ .../android/collections/MapObjectManager.java | 127 ----------- .../android/collections/MapObjectManager.kt | 111 +++++++++ .../android/collections/MarkerManager.java | 210 ------------------ .../maps/android/collections/MarkerManager.kt | 147 ++++++++++++ .../android/collections/PolygonManager.java | 110 --------- .../android/collections/PolygonManager.kt | 87 ++++++++ .../android/collections/PolylineManager.java | 111 --------- .../android/collections/PolylineManager.kt | 89 ++++++++ .../android/collections/CircleManagerTest.kt | 98 ++++++++ .../collections/GroundOverlayManagerTest.kt | 98 ++++++++ .../collections/MapObjectManagerTest.kt | 110 +++++++++ .../android/collections/MarkerManagerTest.kt | 156 +++++++++++++ .../android/collections/PolygonManagerTest.kt | 98 ++++++++ .../collections/PolylineManagerTest.kt | 98 ++++++++ 33 files changed, 2008 insertions(+), 949 deletions(-) create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt create mode 100644 clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt rename data/src/main/java/com/google/maps/android/data/kml/{KmlUrlSanitizer.java => KmlUrlSanitizer.kt} (83%) delete mode 100644 data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java create mode 100644 data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.kt delete mode 100644 library/src/main/java/com/google/maps/android/collections/CircleManager.java create mode 100644 library/src/main/java/com/google/maps/android/collections/CircleManager.kt delete mode 100644 library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.java create mode 100644 library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt delete mode 100644 library/src/main/java/com/google/maps/android/collections/MapObjectManager.java create mode 100644 library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt delete mode 100644 library/src/main/java/com/google/maps/android/collections/MarkerManager.java create mode 100644 library/src/main/java/com/google/maps/android/collections/MarkerManager.kt delete mode 100644 library/src/main/java/com/google/maps/android/collections/PolygonManager.java create mode 100644 library/src/main/java/com/google/maps/android/collections/PolygonManager.kt delete mode 100644 library/src/main/java/com/google/maps/android/collections/PolylineManager.java create mode 100644 library/src/main/java/com/google/maps/android/collections/PolylineManager.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt index 39960d48c..38f7c6e72 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt +++ b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt @@ -299,7 +299,7 @@ open class ClusterManager /** * Might re-cluster. */ - open override fun onCameraIdle() { + override fun onCameraIdle() { if (mRenderer is OnCameraIdleListener) { (mRenderer as OnCameraIdleListener).onCameraIdle() } @@ -317,9 +317,9 @@ open class ClusterManager } } - open override fun onMarkerClick(marker: Marker): Boolean = markerManager.onMarkerClick(marker) + override fun onMarkerClick(marker: Marker): Boolean = markerManager.onMarkerClick(marker) - open override fun onInfoWindowClick(marker: Marker) { + override fun onInfoWindowClick(marker: Marker) { markerManager.onInfoWindowClick(marker) } 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 new file mode 100644 index 000000000..030350810 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering + +import android.content.Context +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.algo.GridBasedAlgorithm +import com.google.maps.android.clustering.algo.ScreenBasedAlgorithmAdapter +import com.google.maps.android.clustering.view.ClusterRenderer +import com.google.maps.android.collections.MarkerManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Unit tests for [ClusterManager]. + */ +@RunWith(RobolectricTestRunner::class) +class ClusterManagerTest { + + private class TestItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + private lateinit var context: Context + private lateinit var map: GoogleMap + private lateinit var markerManager: MarkerManager + private lateinit var clusterManager: ClusterManager + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + map = mockk(relaxed = true) + markerManager = MarkerManager(map) + clusterManager = ClusterManager(context, map, markerManager) + } + + @Test + fun testItemLifecycle() { + val item1 = TestItem(10.0, 10.0) + val item2 = TestItem(20.0, 20.0) + + assertThat(clusterManager.addItem(item1)).isTrue() + assertThat(clusterManager.addItems(listOf(item2))).isTrue() + + assertThat(clusterManager.updateItem(item1)).isTrue() + + assertThat(clusterManager.removeItem(item1)).isTrue() + assertThat(clusterManager.removeItems(listOf(item2))).isTrue() + + clusterManager.addItem(item1) + clusterManager.clearItems() + } + + @Test + fun testAlgorithmAndRendererCustomization() { + val customScreenAlgo = ScreenBasedAlgorithmAdapter(GridBasedAlgorithm()) + clusterManager.setAlgorithm(customScreenAlgo) + assertThat(clusterManager.algorithm).isEqualTo(customScreenAlgo) + + val customBaseAlgo = GridBasedAlgorithm() + clusterManager.algorithm = customBaseAlgo + assertThat(clusterManager.algorithm).isInstanceOf(ScreenBasedAlgorithmAdapter::class.java) + + val customRenderer = mockk>(relaxed = true) + clusterManager.renderer = customRenderer + assertThat(clusterManager.renderer).isEqualTo(customRenderer) + verify { customRenderer.onAdd() } + + clusterManager.setAnimation(true) + } + + @Test + fun testDelegatedMapEvents() { + val mockMarker = mockk(relaxed = true) + + clusterManager.onCameraIdle() + clusterManager.onMarkerClick(mockMarker) + clusterManager.onInfoWindowClick(mockMarker) + } + + @Test + fun testListenerSetters() { + val clusterClickListener = ClusterManager.OnClusterClickListener { true } + val itemClickListener = ClusterManager.OnClusterItemClickListener { true } + val clusterInfoClickListener = ClusterManager.OnClusterInfoWindowClickListener {} + val itemInfoClickListener = ClusterManager.OnClusterItemInfoWindowClickListener {} + + clusterManager.setOnClusterClickListener(clusterClickListener) + clusterManager.setOnClusterItemClickListener(itemClickListener) + clusterManager.setOnClusterInfoWindowClickListener(clusterInfoClickListener) + clusterManager.setOnClusterItemInfoWindowClickListener(itemInfoClickListener) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java b/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java deleted file mode 100644 index 235d3ddda..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.clustering; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotSame; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.algo.StaticCluster; -import org.junit.Test; - -public class StaticClusterTest { - @Test - public void testEquality() { - StaticCluster cluster1 = new StaticCluster<>(new LatLng(0.1, 0.5)); - StaticCluster cluster2 = new StaticCluster<>(new LatLng(0.1, 0.5)); - - assertEquals(cluster1, cluster2); - assertNotSame(cluster1, cluster2); - assertEquals(cluster1.hashCode(), cluster2.hashCode()); - } - - @Test - public void testUnequality() { - StaticCluster cluster1 = new StaticCluster<>(new LatLng(0.1, 0.5)); - StaticCluster cluster2 = new StaticCluster<>(new LatLng(0.2, 0.3)); - - assertNotEquals(cluster1, cluster2); - assertNotEquals(cluster1.hashCode(), cluster2.hashCode()); - } -} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt new file mode 100644 index 000000000..7d4baf2d1 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.algo.StaticCluster +import org.junit.Test + +/** + * Unit tests for [StaticCluster]. + */ +class StaticClusterTest { + + private class TestItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + @Test + fun testEquality() { + val cluster1 = StaticCluster(LatLng(0.1, 0.5)) + val cluster2 = StaticCluster(LatLng(0.1, 0.5)) + + assertThat(cluster1).isEqualTo(cluster2) + assertThat(cluster1).isNotSameInstanceAs(cluster2) + assertThat(cluster1.hashCode()).isEqualTo(cluster2.hashCode()) + } + + @Test + fun testUnequality() { + val cluster1 = StaticCluster(LatLng(0.1, 0.5)) + val cluster2 = StaticCluster(LatLng(0.2, 0.3)) + + assertThat(cluster1).isNotEqualTo(cluster2) + assertThat(cluster1.hashCode()).isNotEqualTo(cluster2.hashCode()) + assertThat(cluster1).isNotEqualTo(null) + assertThat(cluster1).isNotEqualTo("not a cluster") + } + + @Test + fun testItemOperationsAndProperties() { + val center = LatLng(10.0, 20.0) + val cluster = StaticCluster(center) + + assertThat(cluster.position).isEqualTo(center) + assertThat(cluster.size).isEqualTo(0) + assertThat(cluster.items).isEmpty() + + val item = TestItem(10.0, 20.0) + cluster.add(item) + assertThat(cluster.size).isEqualTo(1) + assertThat(cluster.items).containsExactly(item) + assertThat(cluster.toString()).contains("StaticCluster") + + cluster.remove(item) + assertThat(cluster.size).isEqualTo(0) + assertThat(cluster.items).isEmpty() + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt new file mode 100644 index 000000000..29877ce52 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import org.junit.Test + +/** + * Unit tests for [AbstractAlgorithm]. + */ +class AbstractAlgorithmTest { + + private class TestItem : ClusterItem { + override val position: LatLng = LatLng(0.0, 0.0) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + private class ConcreteAlgorithm : AbstractAlgorithm() { + override fun addItem(item: TestItem): Boolean = true + override fun addItems(items: Collection): Boolean = true + override fun clearItems() {} + override fun removeItem(item: TestItem): Boolean = true + override fun removeItems(items: Collection): Boolean = true + override fun updateItem(item: TestItem): Boolean = true + override fun getClusters(zoom: Float): Set> = emptySet() + override val items: Collection get() = emptyList() + override var maxDistanceBetweenClusteredItems: Int = 100 + } + + @Test + fun testLockOperations() { + val algorithm = ConcreteAlgorithm() + var lockExecuted = false + algorithm.lock() + try { + lockExecuted = true + } finally { + algorithm.unlock() + } + assertThat(lockExecuted).isTrue() + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt new file mode 100644 index 000000000..893b81e1a --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [GridBasedAlgorithm]. + * + * Verifies grid-cell based aggregation of nearby items, item mutation, removal, and custom grid sizes. + */ +class GridBasedAlgorithmTest { + + private class TestItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + private lateinit var algorithm: GridBasedAlgorithm + + @Before + fun setUp() { + algorithm = GridBasedAlgorithm() + } + + @Test + fun testAddAndGetClusters() { + val item1 = TestItem(0.0, 0.0) + val item2 = TestItem(0.0001, 0.0001) // Very close -> same cell + val farItem = TestItem(50.0, 50.0) // Far away -> separate cell + + algorithm.addItems(listOf(item1, item2, farItem)) + assertThat(algorithm.items).hasSize(3) + + // Low zoom: item1 and item2 cluster together into 1 cell, farItem into another + val clusters = algorithm.getClusters(3f) + assertThat(clusters).hasSize(2) + } + + @Test + fun testUpdateAndRemoveItems() { + val item = TestItem(10.0, 10.0) + algorithm.addItem(item) + assertThat(algorithm.items).hasSize(1) + + assertThat(algorithm.updateItem(item)).isTrue() + assertThat(algorithm.items).hasSize(1) + + val nonExistent = TestItem(20.0, 20.0) + assertThat(algorithm.updateItem(nonExistent)).isFalse() + + assertThat(algorithm.removeItem(item)).isTrue() + assertThat(algorithm.items).isEmpty() + + algorithm.addItems(listOf(item, nonExistent)) + assertThat(algorithm.removeItems(listOf(item, nonExistent))).isTrue() + assertThat(algorithm.items).isEmpty() + + algorithm.addItem(item) + algorithm.clearItems() + assertThat(algorithm.items).isEmpty() + } + + @Test + fun testMaxDistanceProperty() { + algorithm.maxDistanceBetweenClusteredItems = 150 + assertThat(algorithm.maxDistanceBetweenClusteredItems).isEqualTo(150) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt new file mode 100644 index 000000000..5f97dfbdb --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [NonHierarchicalViewBasedAlgorithm]. + * + * Verifies that the view-based clustering algorithm properly constrains clustering to visible screen bounds, + * updates when the camera changes, handles world-wrapping across the antimeridian, and dynamically adjusts + * to screen dimension updates. + */ +class NonHierarchicalViewBasedAlgorithmTest { + + private class TestItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + private lateinit var algorithm: NonHierarchicalViewBasedAlgorithm + + @Before + fun setUp() { + // Initial viewport: 1000dp width x 1000dp height + algorithm = NonHierarchicalViewBasedAlgorithm(1000, 1000) + } + + @Test + fun testShouldReclusterOnMapMovement() { + assertThat(algorithm.shouldReclusterOnMapMovement()).isTrue() + } + + @Test + fun testEmptyBoundsWithoutCameraChange() { + val item = TestItem(0.0, 0.0) + algorithm.addItem(item) + + // With no camera position established, visible bounds default to (0,0,0,0) + val clusters = algorithm.getClusters(10f) + assertThat(clusters).isEmpty() + } + + @Test + fun testClusteringWithinVisibleBounds() { + val visibleItem = TestItem(0.0, 0.0) + val farItem = TestItem(80.0, 170.0) + + algorithm.addItem(visibleItem) + algorithm.addItem(farItem) + + // Set camera directly centered on visibleItem at high zoom + algorithm.onCameraChange(CameraPosition.builder().target(LatLng(0.0, 0.0)).zoom(10f).build()) + + val clusters = algorithm.getClusters(10f) + assertThat(clusters).hasSize(1) + val cluster = clusters.first() + assertThat(cluster.items).contains(visibleItem) + assertThat(cluster.items).doesNotContain(farItem) + } + + @Test + fun testAntimeridianWrappingBoundsWest() { + val itemNearAntimeridian = TestItem(0.0, -179.0) + algorithm.addItem(itemNearAntimeridian) + + // Center on -179.9 with huge viewport to force visibleBounds.minX < 0 + algorithm.updateViewSize(2000, 1000) + algorithm.onCameraChange(CameraPosition.builder().target(LatLng(0.0, -179.9)).zoom(1f).build()) + + val clusters = algorithm.getClusters(1f) + assertThat(clusters).isNotEmpty() + } + + @Test + fun testAntimeridianWrappingBoundsEast() { + val itemNearAntimeridian = TestItem(0.0, 179.0) + algorithm.addItem(itemNearAntimeridian) + + // Center on +179.9 with huge viewport to force visibleBounds.maxX > 1 + algorithm.updateViewSize(2000, 1000) + algorithm.onCameraChange(CameraPosition.builder().target(LatLng(0.0, 179.9)).zoom(1f).build()) + + val clusters = algorithm.getClusters(1f) + assertThat(clusters).isNotEmpty() + } + + @Test + fun testUpdateViewSize() { + algorithm.updateViewSize(500, 500) + algorithm.onCameraChange(CameraPosition.builder().target(LatLng(0.0, 0.0)).zoom(10f).build()) + + val item = TestItem(0.0, 0.0) + algorithm.addItem(item) + + val clusters = algorithm.getClusters(10f) + assertThat(clusters).hasSize(1) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt index 9770b39a5..be4548cd3 100644 --- a/clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt @@ -16,10 +16,8 @@ package com.google.maps.android.clustering.algo import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat import com.google.maps.android.clustering.ClusterItem -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -55,9 +53,9 @@ class PreCachingAlgorithmDecoratorTest { @Test fun testAddItemAndItems() { val item = TestItem(10.0, 10.0) - assertTrue("addItem should return true when adding a new item", decorator.addItem(item)) - assertEquals("Items collection size should be 1 after adding 1 item", 1, decorator.items.size) - assertTrue("Items collection should contain the inserted item", decorator.items.contains(item)) + assertThat(decorator.addItem(item)).isTrue() + assertThat(decorator.items).hasSize(1) + assertThat(decorator.items).contains(item) } /** @@ -67,11 +65,11 @@ class PreCachingAlgorithmDecoratorTest { @Test fun testAddItemsAndClearItems() { val items = listOf(TestItem(10.0, 10.0), TestItem(20.0, 20.0)) - assertTrue("addItems should return true when adding multiple items", decorator.addItems(items)) - assertEquals("Items collection size should be 2", 2, decorator.items.size) + assertThat(decorator.addItems(items)).isTrue() + assertThat(decorator.items).hasSize(2) decorator.clearItems() - assertEquals("Items collection size should be 0 after clearItems()", 0, decorator.items.size) + assertThat(decorator.items).isEmpty() } /** @@ -84,11 +82,11 @@ class PreCachingAlgorithmDecoratorTest { val item2 = TestItem(20.0, 20.0) decorator.addItems(listOf(item1, item2)) - assertTrue("removeItem should return true when removing an existing item", decorator.removeItem(item1)) - assertEquals("Items collection size should be 1 after removing 1 item", 1, decorator.items.size) + assertThat(decorator.removeItem(item1)).isTrue() + assertThat(decorator.items).hasSize(1) - assertTrue("removeItems should return true when removing remaining items", decorator.removeItems(listOf(item2))) - assertEquals("Items collection size should be 0 after removing all items", 0, decorator.items.size) + assertThat(decorator.removeItems(listOf(item2))).isTrue() + assertThat(decorator.items).isEmpty() } /** @@ -99,7 +97,7 @@ class PreCachingAlgorithmDecoratorTest { fun testUpdateItem() { val item = TestItem(10.0, 10.0) decorator.addItem(item) - assertTrue("updateItem should return true when updating an existing item", decorator.updateItem(item)) + assertThat(decorator.updateItem(item)).isTrue() } /** @@ -110,7 +108,7 @@ class PreCachingAlgorithmDecoratorTest { @Test fun testMaxDistanceBetweenClusteredItems() { decorator.maxDistanceBetweenClusteredItems = 100 - assertEquals("maxDistanceBetweenClusteredItems should reflect the newly assigned value", 100, decorator.maxDistanceBetweenClusteredItems) + assertThat(decorator.maxDistanceBetweenClusteredItems).isEqualTo(100) } /** @@ -124,9 +122,9 @@ class PreCachingAlgorithmDecoratorTest { decorator.addItems(listOf(item1, item2)) val clustersFirstCall = decorator.getClusters(10.0f) - assertFalse("Clusters should not be empty for nearby items at zoom level 10", clustersFirstCall.isEmpty()) + assertThat(clustersFirstCall).isNotEmpty() val clustersSecondCall = decorator.getClusters(10.0f) - assertEquals("Second call to getClusters for identical zoom level should return cached cluster result", clustersFirstCall, clustersSecondCall) + assertThat(clustersSecondCall).isEqualTo(clustersFirstCall) } } diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt new file mode 100644 index 000000000..834804c75 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [ScreenBasedAlgorithmAdapter]. + * + * Proves that [ScreenBasedAlgorithmAdapter] adapts standard [Algorithm] instances to the + * [ScreenBasedAlgorithm] contract by delegating item lifecycle, clustering, and distance settings. + */ +class ScreenBasedAlgorithmAdapterTest { + + private class TestItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + override val title: String? = null + override val snippet: String? = null + override val zIndex: Float? = null + } + + private lateinit var baseAlgorithm: NonHierarchicalDistanceBasedAlgorithm + private lateinit var adapter: ScreenBasedAlgorithmAdapter + + @Before + fun setUp() { + baseAlgorithm = NonHierarchicalDistanceBasedAlgorithm() + adapter = ScreenBasedAlgorithmAdapter(baseAlgorithm) + } + + @Test + fun testDelegationLifecycle() { + val item1 = TestItem(10.0, 10.0) + val item2 = TestItem(20.0, 20.0) + + // Add + assertThat(adapter.addItem(item1)).isTrue() + assertThat(adapter.addItems(listOf(item2))).isTrue() + assertThat(adapter.items).hasSize(2) + + // Update + assertThat(adapter.updateItem(item1)).isTrue() + + // Get clusters + val clusters = adapter.getClusters(10f) + assertThat(clusters).hasSize(2) + + // Max distance + adapter.maxDistanceBetweenClusteredItems = 50 + assertThat(adapter.maxDistanceBetweenClusteredItems).isEqualTo(50) + + // Remove + assertThat(adapter.removeItem(item1)).isTrue() + assertThat(adapter.items).hasSize(1) + assertThat(adapter.removeItems(listOf(item2))).isTrue() + assertThat(adapter.items).isEmpty() + + // Clear + adapter.addItem(item1) + adapter.clearItems() + assertThat(adapter.items).isEmpty() + + // Camera change stub + adapter.onCameraChange(CameraPosition.builder().target(LatLng(0.0, 0.0)).zoom(5f).build()) + assertThat(adapter.shouldReclusterOnMapMovement()).isFalse() + } +} diff --git a/clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt b/clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt new file mode 100644 index 000000000..57912766c --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.projection + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [SphericalMercatorProjection]. + */ +class SphericalMercatorProjectionTest { + + @Test + fun testRoundTripProjection() { + val projection = SphericalMercatorProjection(256.0) + val original = LatLng(37.7749, -122.4194) + + val point = projection.toPoint(original) + val reconstructed = projection.toLatLng(point) + + assertThat(reconstructed.latitude).isWithin(1e-6).of(original.latitude) + assertThat(reconstructed.longitude).isWithin(1e-6).of(original.longitude) + } + + @Test + fun testOriginProjection() { + val projection = SphericalMercatorProjection(1.0) + val center = LatLng(0.0, 0.0) + + val point = projection.toPoint(center) + assertThat(point.x).isWithin(1e-6).of(0.5) + assertThat(point.y).isWithin(1e-6).of(0.5) + + val reconstructed = projection.toLatLng(point) + assertThat(reconstructed.latitude).isWithin(1e-6).of(0.0) + assertThat(reconstructed.longitude).isWithin(1e-6).of(0.0) + } +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java b/data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.kt similarity index 83% rename from data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java rename to data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.kt index bedb7cf96..b76636d06 100644 --- a/data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.kt @@ -1,30 +1,30 @@ /* * Copyright 2026 Google LLC - * + * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * + * + * http://www.apache.org/licenses/LICENSE-2.0 + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.maps.android.data.kml; +package com.google.maps.android.data.kml /** * Interface for sanitizing URLs in KML documents. * Developers can implement this to control which external resources (images, etc.) are loaded. */ -public interface KmlUrlSanitizer { +fun interface KmlUrlSanitizer { /** * Sanitizes a URL before it is used to fetch a resource. * * @param url The raw URL from the KML. * @return A safe, validated URL string, or null to block this resource. */ - String sanitizeUrl(String url); + fun sanitizeUrl(url: String): String? } diff --git a/data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java b/data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java deleted file mode 100644 index afc1c6d69..000000000 --- a/data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.maps.android.data.kml; - -import android.content.Context; -import androidx.test.core.app.ApplicationProvider; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.fail; - -@RunWith(RobolectricTestRunner.class) -public class KmlZipBombTest { - - @Test - public void testValidKmz() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ZipOutputStream zos = new ZipOutputStream(baos); - zos.putNextEntry(new ZipEntry("doc.kml")); - zos.write("".getBytes()); - zos.closeEntry(); - zos.close(); - - Context context = ApplicationProvider.getApplicationContext(); - KmlLayer layer = new KmlLayer(null, new ByteArrayInputStream(baos.toByteArray()), context); - assertNotNull(layer); - } - - @Test - public void testMaxEntriesLimit() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ZipOutputStream zos = new ZipOutputStream(baos); - for (int i = 0; i < 202; i++) { - zos.putNextEntry(new ZipEntry("entry" + i + ".txt")); - zos.write("data".getBytes()); - zos.closeEntry(); - } - zos.close(); - - Context context = ApplicationProvider.getApplicationContext(); - try { - new KmlLayer(null, new ByteArrayInputStream(baos.toByteArray()), context); - fail("Should have thrown IOException due to too many entries"); - } catch (IOException e) { - // Expected - } - } - - @Test - public void testMaxSizeLimit() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ZipOutputStream zos = new ZipOutputStream(baos); - zos.putNextEntry(new ZipEntry("large_entry.kml")); - // 50MB + 1 byte - byte[] largeData = new byte[1024 * 1024]; - for (int i = 0; i < 51; i++) { - zos.write(largeData); - } - zos.closeEntry(); - zos.close(); - - Context context = ApplicationProvider.getApplicationContext(); - try { - new KmlLayer(null, new ByteArrayInputStream(baos.toByteArray()), context); - fail("Should have thrown IOException due to size limit"); - } catch (IOException e) { - // Expected - } - } -} diff --git a/data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.kt b/data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.kt new file mode 100644 index 000000000..977d93db2 --- /dev/null +++ b/data/src/test/java/com/google/maps/android/data/kml/KmlZipBombTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.data.kml + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Unit tests verifying zip-bomb and Denial of Service (DoS) protections when parsing KMZ (zipped KML) files. + * + * KMZ files are compressed zip archives that can contain malicious payloads such as recursive zip bombs, + * excessive entry counts, or decompression size bombs designed to exhaust device memory. + * These tests ensure [KmlLayer] enforces strict boundaries on entry counts and uncompressed byte size. + */ +@RunWith(RobolectricTestRunner::class) +class KmlZipBombTest { + + /** + * Verifies that a well-formed KMZ archive containing a standard `doc.kml` entry + * decompresses and initializes [KmlLayer] successfully without errors. + */ + @Test + fun testValidKmz() { + // Construct an in-memory KMZ containing a valid, minimal KML document + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + zos.putNextEntry(ZipEntry("doc.kml")) + zos.write("".toByteArray()) + zos.closeEntry() + } + + val context: Context = ApplicationProvider.getApplicationContext() + val layer = KmlLayer(null, ByteArrayInputStream(baos.toByteArray()), context) + assertThat(layer).isNotNull() + } + + /** + * Verifies that an archive exceeding the maximum allowed entry count (200 entries by default) + * is rejected with an [IOException] to protect against zip bomb expansion exhaustion. + */ + @Test + fun testMaxEntriesLimit() { + // Construct an in-memory KMZ containing 202 entries (exceeding the default 200 limit) + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + for (i in 0 until 202) { + zos.putNextEntry(ZipEntry("entry$i.txt")) + zos.write("data".toByteArray()) + zos.closeEntry() + } + } + + val context: Context = ApplicationProvider.getApplicationContext() + assertThrows(IOException::class.java) { + KmlLayer(null, ByteArrayInputStream(baos.toByteArray()), context) + } + } + + /** + * Verifies that an archive whose uncompressed contents exceed the maximum total allowed size + * (50MB by default) is rejected with an [IOException] to prevent out-of-memory crashes. + */ + @Test + fun testMaxSizeLimit() { + // Construct an in-memory KMZ containing 51 MB of uncompressed payload (exceeding 50 MB limit) + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + zos.putNextEntry(ZipEntry("large_entry.kml")) + val largeData = ByteArray(1024 * 1024) // 1 MB chunk + repeat(51) { + zos.write(largeData) + } + zos.closeEntry() + } + + val context: Context = ApplicationProvider.getApplicationContext() + assertThrows(IOException::class.java) { + KmlLayer(null, ByteArrayInputStream(baos.toByteArray()), context) + } + } +} 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 0475968da..d643e2bc6 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 @@ -261,13 +261,13 @@ class HeatmapTileProvider private constructor( } val intensity = Array(TILE_DIM + radius * 2) { DoubleArray(TILE_DIM + radius * 2) } - points.forEach { w -> + for (w in points) { val p = w.point val bucketX = ((p.x - minX) / bucketWidth).toInt() val bucketY = ((p.y - minY) / bucketWidth).toInt() intensity[bucketX][bucketY] += w.intensity } - wrappedPoints.forEach { w -> + for (w in wrappedPoints) { val p = w.point val bucketX = ((p.x + xOffset - minX) / bucketWidth).toInt() val bucketY = ((p.y - minY) / bucketWidth).toInt() @@ -442,7 +442,7 @@ class HeatmapTileProvider private constructor( val scale = nBuckets / boundsDim val buckets = mutableMapOf() - points.forEach { l -> + for (l in points) { val x = l.point.x val y = l.point.y val xBucket = ((x - minX) * scale).toInt() diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/main/java/com/google/maps/android/PolyUtil.kt index 8e30ecedf..d2ab855c0 100644 --- a/library/src/main/java/com/google/maps/android/PolyUtil.kt +++ b/library/src/main/java/com/google/maps/android/PolyUtil.kt @@ -26,7 +26,6 @@ import com.google.maps.android.MathUtil.sinFromHav import com.google.maps.android.MathUtil.sinSumFromHav import com.google.maps.android.MathUtil.wrap import com.google.maps.android.SphericalUtil.computeDistanceBetween -import kotlin.collections.ArrayDeque import kotlin.math.cos import kotlin.math.max import kotlin.math.min diff --git a/library/src/main/java/com/google/maps/android/collections/CircleManager.java b/library/src/main/java/com/google/maps/android/collections/CircleManager.java deleted file mode 100644 index ac466d965..000000000 --- a/library/src/main/java/com/google/maps/android/collections/CircleManager.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.collections; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.Circle; -import com.google.android.gms.maps.model.CircleOptions; - -/** - * Keeps track of collections of circles on the map. Delegates all Circle-related events to each - * collection's individually managed listeners. - * - *

All circle operations (adds and removes) should occur via its collection class. That is, don't - * add a circle via a collection, then remove it via Circle.remove() - */ -public class CircleManager extends MapObjectManager - implements GoogleMap.OnCircleClickListener { - - public CircleManager(@NonNull GoogleMap map) { - super(map); - } - - @Override - void setListenersOnUiThread() { - if (mMap != null) { - mMap.setOnCircleClickListener(this); - } - } - - @Override - public Collection newCollection() { - return new Collection(); - } - - @Override - protected void removeObjectFromMap(Circle object) { - object.remove(); - } - - @Override - public void onCircleClick(@NonNull Circle circle) { - Collection collection = mAllObjects.get(circle); - if (collection != null && collection.mCircleClickListener != null) { - collection.mCircleClickListener.onCircleClick(circle); - } - } - - /** A collection of {@link Circle}s on the map with its own set of listeners. */ - public class Collection extends MapObjectManager.Collection { - private GoogleMap.OnCircleClickListener mCircleClickListener; - - public Collection() {} - - public Circle addCircle(CircleOptions opts) { - Circle circle = mMap.addCircle(opts); - super.add(circle); - return circle; - } - - public void addAll(java.util.Collection opts) { - for (CircleOptions opt : opts) { - addCircle(opt); - } - } - - public void addAll(java.util.Collection opts, boolean defaultVisible) { - for (CircleOptions opt : opts) { - addCircle(opt).setVisible(defaultVisible); - } - } - - public void showAll() { - for (Circle circle : getCircles()) { - circle.setVisible(true); - } - } - - public void hideAll() { - for (Circle circle : getCircles()) { - circle.setVisible(false); - } - } - - public boolean remove(Circle circle) { - return super.remove(circle); - } - - public java.util.Collection getCircles() { - return getObjects(); - } - - public void setOnCircleClickListener(GoogleMap.OnCircleClickListener circleClickListener) { - mCircleClickListener = circleClickListener; - } - } -} diff --git a/library/src/main/java/com/google/maps/android/collections/CircleManager.kt b/library/src/main/java/com/google/maps/android/collections/CircleManager.kt new file mode 100644 index 000000000..d8166f95c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/CircleManager.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import kotlin.collections.Collection as KotlinCollection + +/** + * Keeps track of collections of circles on the map. Delegates all Circle-related events to each + * collection's individually managed listeners. + * + * All circle operations (adds and removes) should occur via its collection class. That is, don't + * add a circle via a collection, then remove it via Circle.remove() + */ +open class CircleManager(map: GoogleMap) : + MapObjectManager(map), + GoogleMap.OnCircleClickListener { + + override fun setListenersOnUiThread() { + mMap.setOnCircleClickListener(this) + } + + override fun newCollection(): Collection = Collection() + + override fun removeObjectFromMap(circle: Circle) { + circle.remove() + } + + override fun onCircleClick(circle: Circle) { + mAllObjects[circle]?.mCircleClickListener?.onCircleClick(circle) + } + + /** A collection of [Circle]s on the map with its own set of listeners. */ + open inner class Collection : MapObjectManager.Collection() { + internal var mCircleClickListener: GoogleMap.OnCircleClickListener? = null + + open fun addCircle(opts: CircleOptions): Circle = + mMap.addCircle(opts).also { super.add(it) } + + open fun addAll(opts: KotlinCollection) { + for (opt in opts) { + addCircle(opt) + } + } + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) { + for (opt in opts) { + addCircle(opt).isVisible = defaultVisible + } + } + + open fun showAll() { + for (circle in getCircles()) { + circle.isVisible = true + } + } + + open fun hideAll() { + for (circle in getCircles()) { + circle.isVisible = false + } + } + + override fun remove(circle: Circle?): Boolean = super.remove(circle) + + open fun getCircles(): KotlinCollection = getObjects() + + open fun setOnCircleClickListener(circleClickListener: GoogleMap.OnCircleClickListener?) { + mCircleClickListener = circleClickListener + } + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.java b/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.java deleted file mode 100644 index 5d24bfd22..000000000 --- a/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.collections; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.GroundOverlay; -import com.google.android.gms.maps.model.GroundOverlayOptions; - -/** - * Keeps track of collections of ground overlays on the map. Delegates all GroundOverlay-related - * events to each collection's individually managed listeners. - * - *

All ground overlay operations (adds and removes) should occur via its collection class. That - * is, don't add a ground overlay via a collection, then remove it via GroundOverlay.remove() - */ -public class GroundOverlayManager - extends MapObjectManager - implements GoogleMap.OnGroundOverlayClickListener { - - public GroundOverlayManager(@NonNull GoogleMap map) { - super(map); - } - - @Override - void setListenersOnUiThread() { - if (mMap != null) { - mMap.setOnGroundOverlayClickListener(this); - } - } - - @Override - public Collection newCollection() { - return new Collection(); - } - - @Override - protected void removeObjectFromMap(GroundOverlay object) { - object.remove(); - } - - @Override - public void onGroundOverlayClick(@NonNull GroundOverlay groundOverlay) { - Collection collection = mAllObjects.get(groundOverlay); - if (collection != null && collection.mGroundOverlayClickListener != null) { - collection.mGroundOverlayClickListener.onGroundOverlayClick(groundOverlay); - } - } - - /** A collection of {@link GroundOverlay}s on the map with its own set of listeners. */ - public class Collection - extends MapObjectManager.Collection { - private GoogleMap.OnGroundOverlayClickListener mGroundOverlayClickListener; - - public Collection() {} - - public GroundOverlay addGroundOverlay(GroundOverlayOptions opts) { - GroundOverlay groundOverlay = mMap.addGroundOverlay(opts); - super.add(groundOverlay); - return groundOverlay; - } - - public void addAll(java.util.Collection opts) { - for (GroundOverlayOptions opt : opts) { - addGroundOverlay(opt); - } - } - - public void addAll(java.util.Collection opts, boolean defaultVisible) { - for (GroundOverlayOptions opt : opts) { - addGroundOverlay(opt).setVisible(defaultVisible); - } - } - - public void showAll() { - for (GroundOverlay groundOverlay : getGroundOverlays()) { - groundOverlay.setVisible(true); - } - } - - public void hideAll() { - for (GroundOverlay groundOverlay : getGroundOverlays()) { - groundOverlay.setVisible(false); - } - } - - public boolean remove(GroundOverlay groundOverlay) { - return super.remove(groundOverlay); - } - - public java.util.Collection getGroundOverlays() { - return getObjects(); - } - - public void setOnGroundOverlayClickListener( - GoogleMap.OnGroundOverlayClickListener groundOverlayClickListener) { - mGroundOverlayClickListener = groundOverlayClickListener; - } - } -} diff --git a/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt b/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt new file mode 100644 index 000000000..d2157e2d3 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import kotlin.collections.Collection as KotlinCollection + +/** + * Keeps track of collections of ground overlays on the map. Delegates all GroundOverlay-related + * events to each collection's individually managed listeners. + * + * All ground overlay operations (adds and removes) should occur via its collection class. That + * is, don't add a ground overlay via a collection, then remove it via GroundOverlay.remove() + */ +open class GroundOverlayManager(map: GoogleMap) : + MapObjectManager(map), + GoogleMap.OnGroundOverlayClickListener { + + override fun setListenersOnUiThread() { + mMap.setOnGroundOverlayClickListener(this) + } + + override fun newCollection(): Collection = Collection() + + override fun removeObjectFromMap(groundOverlay: GroundOverlay) { + groundOverlay.remove() + } + + override fun onGroundOverlayClick(groundOverlay: GroundOverlay) { + mAllObjects[groundOverlay]?.mGroundOverlayClickListener?.onGroundOverlayClick(groundOverlay) + } + + /** A collection of [GroundOverlay]s on the map with its own set of listeners. */ + open inner class Collection : + MapObjectManager.Collection() { + internal var mGroundOverlayClickListener: GoogleMap.OnGroundOverlayClickListener? = null + + open fun addGroundOverlay(opts: GroundOverlayOptions): GroundOverlay = + checkNotNull(mMap.addGroundOverlay(opts)) { "Failed to add GroundOverlay to GoogleMap" } + .also { super.add(it) } + + open fun addAll(opts: KotlinCollection) { + for (opt in opts) { + addGroundOverlay(opt) + } + } + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) { + for (opt in opts) { + addGroundOverlay(opt).isVisible = defaultVisible + } + } + + open fun showAll() { + for (groundOverlay in getGroundOverlays()) { + groundOverlay.isVisible = true + } + } + + open fun hideAll() { + for (groundOverlay in getGroundOverlays()) { + groundOverlay.isVisible = false + } + } + + override fun remove(groundOverlay: GroundOverlay?): Boolean = super.remove(groundOverlay) + + open fun getGroundOverlays(): KotlinCollection = getObjects() + + open fun setOnGroundOverlayClickListener( + groundOverlayClickListener: GoogleMap.OnGroundOverlayClickListener?, + ) { + mGroundOverlayClickListener = groundOverlayClickListener + } + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/MapObjectManager.java b/library/src/main/java/com/google/maps/android/collections/MapObjectManager.java deleted file mode 100644 index b3f6b3ff4..000000000 --- a/library/src/main/java/com/google/maps/android/collections/MapObjectManager.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.collections; - -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.NonNull; -import com.google.android.gms.maps.GoogleMap; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -/** - * Abstract base implementation for map object collection manager classes. - * - *

Keeps track of collections of objects on the map. Delegates all object-related events to each - * collection's individually managed listeners. - * - *

All object operations (adds and removes) should occur via its collection class. That is, don't - * add an object via a collection, then remove it via Object.remove() - */ -abstract class MapObjectManager.Collection> { - protected final GoogleMap mMap; - - private final Map mNamedCollections = new HashMap<>(); - protected final Map mAllObjects = new HashMap<>(); - - public MapObjectManager(@NonNull GoogleMap map) { - mMap = map; - new Handler(Looper.getMainLooper()) - .post( - new Runnable() { - @Override - public void run() { - setListenersOnUiThread(); - } - }); - } - - abstract void setListenersOnUiThread(); - - public abstract C newCollection(); - - /** - * Create a new named collection, which can later be looked up by {@link #getCollection(String)} - * - * @param id a unique id for this collection. - */ - public C newCollection(String id) { - if (mNamedCollections.get(id) != null) { - throw new IllegalArgumentException("collection id is not unique: " + id); - } - C collection = newCollection(); - mNamedCollections.put(id, collection); - return collection; - } - - /** - * Gets a named collection that was created by {@link #newCollection(String)} - * - * @param id the unique id for this collection. - */ - public C getCollection(String id) { - return mNamedCollections.get(id); - } - - /** - * Removes an object from its collection. - * - * @param object the object to remove. - * @return true if the object was removed. - */ - public boolean remove(O object) { - C collection = mAllObjects.get(object); - return collection != null && collection.remove(object); - } - - protected abstract void removeObjectFromMap(O object); - - public class Collection { - private final Set mObjects = new LinkedHashSet<>(); - - public Collection() {} - - @SuppressWarnings("unchecked") - protected void add(O object) { - mObjects.add(object); - mAllObjects.put(object, (C) this); - } - - protected boolean remove(O object) { - if (mObjects.remove(object)) { - mAllObjects.remove(object); - removeObjectFromMap(object); - return true; - } - return false; - } - - public void clear() { - for (O object : mObjects) { - removeObjectFromMap(object); - mAllObjects.remove(object); - } - mObjects.clear(); - } - - protected java.util.Collection getObjects() { - return Collections.unmodifiableCollection(mObjects); - } - } -} 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 new file mode 100644 index 000000000..0c3ac82d6 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.collections + +import android.os.Handler +import android.os.Looper +import com.google.android.gms.maps.GoogleMap +import kotlin.collections.Collection as KotlinCollection + +/** + * Abstract base implementation for map object collection manager classes. + * + * Keeps track of collections of objects on the map. Delegates all object-related events to each + * collection's individually managed listeners. + * + * All object operations (adds and removes) should occur via its collection class. That is, don't + * add an object via a collection, then remove it via Object.remove() + */ +abstract class MapObjectManager.Collection>( + @JvmField + protected val mMap: GoogleMap, +) { + private val mNamedCollections: MutableMap = mutableMapOf() + + @JvmField + protected val mAllObjects: MutableMap = mutableMapOf() + + init { + Handler(Looper.getMainLooper()).post { + setListenersOnUiThread() + } + } + + internal abstract fun setListenersOnUiThread() + + abstract fun newCollection(): C + + /** + * Create a new named collection, which can later be looked up by [getCollection] + * + * @param id a unique id for this collection. + */ + open fun newCollection(id: String): C { + require(mNamedCollections[id] == null) { "collection id is not unique: $id" } + val collection = newCollection() + mNamedCollections[id] = collection + return collection + } + + /** + * Gets a named collection that was created by [newCollection] + * + * @param id the unique id for this collection. + */ + open fun getCollection(id: String): C? = mNamedCollections[id] + + /** + * Removes an object from its collection. + * + * @param mapObject the object to remove. + * @return true if the object was removed. + */ + open fun remove(mapObject: O?): Boolean = + mapObject != null && mAllObjects[mapObject]?.remove(mapObject) == true + + protected abstract fun removeObjectFromMap(mapObject: O) + + open inner class Collection { + private val mObjects: MutableSet = mutableSetOf() + + // Safe unchecked cast: this inner collection is an instance of subclass C. + @Suppress("UNCHECKED_CAST") + protected open fun add(mapObject: O) { + mObjects.add(mapObject) + mAllObjects[mapObject] = this@Collection as C + } + + open fun remove(mapObject: O?): Boolean { + if (mapObject == null) return false + if (mObjects.remove(mapObject)) { + mAllObjects.remove(mapObject) + removeObjectFromMap(mapObject) + return true + } + return false + } + + open fun clear() { + for (mapObject in mObjects) { + removeObjectFromMap(mapObject) + mAllObjects.remove(mapObject) + } + mObjects.clear() + } + + protected open fun getObjects(): KotlinCollection = mObjects + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/MarkerManager.java b/library/src/main/java/com/google/maps/android/collections/MarkerManager.java deleted file mode 100644 index ea5bd2da6..000000000 --- a/library/src/main/java/com/google/maps/android/collections/MarkerManager.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.collections; - -import android.view.View; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.AdvancedMarkerOptions; -import com.google.android.gms.maps.model.Marker; -import com.google.android.gms.maps.model.MarkerOptions; - -/** - * Keeps track of collections of markers on the map. Delegates all Marker-related events to each - * collection's individually managed listeners. - * - *

All marker operations (adds and removes) should occur via its collection class. That is, don't - * add a marker via a collection, then remove it via Marker.remove() - */ -public class MarkerManager extends MapObjectManager - implements GoogleMap.OnInfoWindowClickListener, - GoogleMap.OnMarkerClickListener, - GoogleMap.OnMarkerDragListener, - GoogleMap.InfoWindowAdapter, - GoogleMap.OnInfoWindowLongClickListener { - - public MarkerManager(GoogleMap map) { - super(map); - } - - @Override - void setListenersOnUiThread() { - if (mMap != null) { - mMap.setOnInfoWindowClickListener(this); - mMap.setOnInfoWindowLongClickListener(this); - mMap.setOnMarkerClickListener(this); - mMap.setOnMarkerDragListener(this); - mMap.setInfoWindowAdapter(this); - } - } - - @Override - public Collection newCollection() { - return new Collection(); - } - - @Override - @Nullable - public View getInfoWindow(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mInfoWindowAdapter != null) { - return collection.mInfoWindowAdapter.getInfoWindow(marker); - } - return null; - } - - @Override - @Nullable - public View getInfoContents(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mInfoWindowAdapter != null) { - return collection.mInfoWindowAdapter.getInfoContents(marker); - } - return null; - } - - @Override - public void onInfoWindowClick(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mInfoWindowClickListener != null) { - collection.mInfoWindowClickListener.onInfoWindowClick(marker); - } - } - - @Override - public void onInfoWindowLongClick(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mInfoWindowLongClickListener != null) { - collection.mInfoWindowLongClickListener.onInfoWindowLongClick(marker); - } - } - - @Override - public boolean onMarkerClick(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mMarkerClickListener != null) { - return collection.mMarkerClickListener.onMarkerClick(marker); - } - return false; - } - - @Override - public void onMarkerDragStart(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mMarkerDragListener != null) { - collection.mMarkerDragListener.onMarkerDragStart(marker); - } - } - - @Override - public void onMarkerDrag(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mMarkerDragListener != null) { - collection.mMarkerDragListener.onMarkerDrag(marker); - } - } - - @Override - public void onMarkerDragEnd(@NonNull Marker marker) { - Collection collection = mAllObjects.get(marker); - if (collection != null && collection.mMarkerDragListener != null) { - collection.mMarkerDragListener.onMarkerDragEnd(marker); - } - } - - @Override - protected void removeObjectFromMap(Marker object) { - object.remove(); - } - - /** A collection of {@link Marker}s on the map with its own set of listeners. */ - public class Collection extends MapObjectManager.Collection { - private GoogleMap.OnInfoWindowClickListener mInfoWindowClickListener; - private GoogleMap.OnInfoWindowLongClickListener mInfoWindowLongClickListener; - private GoogleMap.OnMarkerClickListener mMarkerClickListener; - private GoogleMap.OnMarkerDragListener mMarkerDragListener; - private GoogleMap.InfoWindowAdapter mInfoWindowAdapter; - - public Collection() {} - - public Marker addMarker(MarkerOptions opts) { - Marker marker = mMap.addMarker(opts); - super.add(marker); - return marker; - } - - public Marker addMarker(AdvancedMarkerOptions opts) { - Marker marker = mMap.addMarker(opts); - super.add(marker); - return marker; - } - - public void addAll(java.util.Collection opts) { - for (MarkerOptions opt : opts) { - addMarker(opt); - } - } - - public void addAll(java.util.Collection opts, boolean defaultVisible) { - for (MarkerOptions opt : opts) { - addMarker(opt).setVisible(defaultVisible); - } - } - - public void showAll() { - for (Marker marker : getMarkers()) { - marker.setVisible(true); - } - } - - public void hideAll() { - for (Marker marker : getMarkers()) { - marker.setVisible(false); - } - } - - public boolean remove(Marker marker) { - return super.remove(marker); - } - - public java.util.Collection getMarkers() { - return getObjects(); - } - - public void setOnInfoWindowClickListener( - GoogleMap.OnInfoWindowClickListener infoWindowClickListener) { - mInfoWindowClickListener = infoWindowClickListener; - } - - public void setOnInfoWindowLongClickListener( - GoogleMap.OnInfoWindowLongClickListener infoWindowLongClickListener) { - mInfoWindowLongClickListener = infoWindowLongClickListener; - } - - public void setOnMarkerClickListener(GoogleMap.OnMarkerClickListener markerClickListener) { - mMarkerClickListener = markerClickListener; - } - - public void setOnMarkerDragListener(GoogleMap.OnMarkerDragListener markerDragListener) { - mMarkerDragListener = markerDragListener; - } - - public void setInfoWindowAdapter(GoogleMap.InfoWindowAdapter infoWindowAdapter) { - mInfoWindowAdapter = infoWindowAdapter; - } - } -} diff --git a/library/src/main/java/com/google/maps/android/collections/MarkerManager.kt b/library/src/main/java/com/google/maps/android/collections/MarkerManager.kt new file mode 100644 index 000000000..8c44febc8 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/MarkerManager.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.collections + +import android.view.View +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import kotlin.collections.Collection as KotlinCollection + +/** + * Keeps track of collections of markers on the map. Delegates all Marker-related events to each + * collection's individually managed listeners. + * + * All marker operations (adds and removes) should occur via its collection class. That is, don't + * add a marker via a collection, then remove it via Marker.remove() + */ +open class MarkerManager(map: GoogleMap) : + MapObjectManager(map), + GoogleMap.OnInfoWindowClickListener, + GoogleMap.OnMarkerClickListener, + GoogleMap.OnMarkerDragListener, + GoogleMap.InfoWindowAdapter, + GoogleMap.OnInfoWindowLongClickListener { + + override fun setListenersOnUiThread() { + mMap.setOnInfoWindowClickListener(this) + mMap.setOnInfoWindowLongClickListener(this) + mMap.setOnMarkerClickListener(this) + mMap.setOnMarkerDragListener(this) + mMap.setInfoWindowAdapter(this) + } + + override fun newCollection(): Collection = Collection() + + override fun getInfoWindow(marker: Marker): View? = + mAllObjects[marker]?.mInfoWindowAdapter?.getInfoWindow(marker) + + override fun getInfoContents(marker: Marker): View? = + mAllObjects[marker]?.mInfoWindowAdapter?.getInfoContents(marker) + + override fun onInfoWindowClick(marker: Marker) { + mAllObjects[marker]?.mInfoWindowClickListener?.onInfoWindowClick(marker) + } + + override fun onInfoWindowLongClick(marker: Marker) { + mAllObjects[marker]?.mInfoWindowLongClickListener?.onInfoWindowLongClick(marker) + } + + override fun onMarkerClick(marker: Marker): Boolean = + mAllObjects[marker]?.mMarkerClickListener?.onMarkerClick(marker) ?: false + + override fun onMarkerDragStart(marker: Marker) { + mAllObjects[marker]?.mMarkerDragListener?.onMarkerDragStart(marker) + } + + override fun onMarkerDrag(marker: Marker) { + mAllObjects[marker]?.mMarkerDragListener?.onMarkerDrag(marker) + } + + override fun onMarkerDragEnd(marker: Marker) { + mAllObjects[marker]?.mMarkerDragListener?.onMarkerDragEnd(marker) + } + + override fun removeObjectFromMap(marker: Marker) { + marker.remove() + } + + /** A collection of [Marker]s on the map with its own set of listeners. */ + open inner class Collection : MapObjectManager.Collection() { + internal var mInfoWindowClickListener: GoogleMap.OnInfoWindowClickListener? = null + internal var mInfoWindowLongClickListener: GoogleMap.OnInfoWindowLongClickListener? = null + internal var mMarkerClickListener: GoogleMap.OnMarkerClickListener? = null + internal var mMarkerDragListener: GoogleMap.OnMarkerDragListener? = null + internal var mInfoWindowAdapter: GoogleMap.InfoWindowAdapter? = null + + open fun addMarker(opts: MarkerOptions): Marker = + checkNotNull(mMap.addMarker(opts)) { "Failed to add Marker to GoogleMap" } + .also { super.add(it) } + + open fun addMarker(opts: AdvancedMarkerOptions): Marker = + checkNotNull(mMap.addMarker(opts)) { "Failed to add AdvancedMarker to GoogleMap" } + .also { super.add(it) } + + open fun addAll(opts: KotlinCollection) { + for (opt in opts) { + addMarker(opt) + } + } + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) { + for (opt in opts) { + addMarker(opt).isVisible = defaultVisible + } + } + + open fun showAll() { + for (marker in getMarkers()) { + marker.isVisible = true + } + } + + open fun hideAll() { + for (marker in getMarkers()) { + marker.isVisible = false + } + } + + override fun remove(marker: Marker?): Boolean = super.remove(marker) + + open fun getMarkers(): KotlinCollection = getObjects() + + open fun setOnInfoWindowClickListener(infoWindowClickListener: GoogleMap.OnInfoWindowClickListener?) { + mInfoWindowClickListener = infoWindowClickListener + } + + open fun setOnInfoWindowLongClickListener(infoWindowLongClickListener: GoogleMap.OnInfoWindowLongClickListener?) { + mInfoWindowLongClickListener = infoWindowLongClickListener + } + + open fun setOnMarkerClickListener(markerClickListener: GoogleMap.OnMarkerClickListener?) { + mMarkerClickListener = markerClickListener + } + + open fun setOnMarkerDragListener(markerDragListener: GoogleMap.OnMarkerDragListener?) { + mMarkerDragListener = markerDragListener + } + + open fun setInfoWindowAdapter(infoWindowAdapter: GoogleMap.InfoWindowAdapter?) { + mInfoWindowAdapter = infoWindowAdapter + } + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/PolygonManager.java b/library/src/main/java/com/google/maps/android/collections/PolygonManager.java deleted file mode 100644 index 9c4714a82..000000000 --- a/library/src/main/java/com/google/maps/android/collections/PolygonManager.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.collections; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.Polygon; -import com.google.android.gms.maps.model.PolygonOptions; - -/** - * Keeps track of collections of polygons on the map. Delegates all Polygon-related events to each - * collection's individually managed listeners. - * - *

All polygon operations (adds and removes) should occur via its collection class. That is, - * don't add a polygon via a collection, then remove it via Polygon.remove() - */ -public class PolygonManager extends MapObjectManager - implements GoogleMap.OnPolygonClickListener { - - public PolygonManager(GoogleMap map) { - super(map); - } - - @Override - void setListenersOnUiThread() { - if (mMap != null) { - mMap.setOnPolygonClickListener(this); - } - } - - @Override - public Collection newCollection() { - return new Collection(); - } - - @Override - protected void removeObjectFromMap(Polygon object) { - object.remove(); - } - - @Override - public void onPolygonClick(@NonNull Polygon polygon) { - Collection collection = mAllObjects.get(polygon); - if (collection != null && collection.mPolygonClickListener != null) { - collection.mPolygonClickListener.onPolygonClick(polygon); - } - } - - /** A collection of {@link Polygon}s on the map with its own set of listeners. */ - public class Collection extends MapObjectManager.Collection { - private GoogleMap.OnPolygonClickListener mPolygonClickListener; - - public Collection() {} - - public Polygon addPolygon(PolygonOptions opts) { - Polygon polygon = mMap.addPolygon(opts); - super.add(polygon); - return polygon; - } - - public void addAll(java.util.Collection opts) { - for (PolygonOptions opt : opts) { - addPolygon(opt); - } - } - - public void addAll(java.util.Collection opts, boolean defaultVisible) { - for (PolygonOptions opt : opts) { - addPolygon(opt).setVisible(defaultVisible); - } - } - - public void showAll() { - for (Polygon polygon : getPolygons()) { - polygon.setVisible(true); - } - } - - public void hideAll() { - for (Polygon polygon : getPolygons()) { - polygon.setVisible(false); - } - } - - public boolean remove(Polygon polygon) { - return super.remove(polygon); - } - - public java.util.Collection getPolygons() { - return getObjects(); - } - - public void setOnPolygonClickListener(GoogleMap.OnPolygonClickListener polygonClickListener) { - mPolygonClickListener = polygonClickListener; - } - } -} diff --git a/library/src/main/java/com/google/maps/android/collections/PolygonManager.kt b/library/src/main/java/com/google/maps/android/collections/PolygonManager.kt new file mode 100644 index 000000000..8f0fe7efc --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/PolygonManager.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import kotlin.collections.Collection as KotlinCollection + +/** + * Keeps track of collections of polygons on the map. Delegates all Polygon-related events to each + * collection's individually managed listeners. + * + * All polygon operations (adds and removes) should occur via its collection class. That is, + * don't add a polygon via a collection, then remove it via Polygon.remove() + */ +open class PolygonManager(map: GoogleMap) : + MapObjectManager(map), + GoogleMap.OnPolygonClickListener { + + override fun setListenersOnUiThread() { + mMap.setOnPolygonClickListener(this) + } + + override fun newCollection(): Collection = Collection() + + override fun removeObjectFromMap(polygon: Polygon) { + polygon.remove() + } + + override fun onPolygonClick(polygon: Polygon) { + mAllObjects[polygon]?.mPolygonClickListener?.onPolygonClick(polygon) + } + + /** A collection of [Polygon]s on the map with its own set of listeners. */ + open inner class Collection : MapObjectManager.Collection() { + internal var mPolygonClickListener: GoogleMap.OnPolygonClickListener? = null + + open fun addPolygon(opts: PolygonOptions): Polygon = + mMap.addPolygon(opts).also { super.add(it) } + + open fun addAll(opts: KotlinCollection) { + for (opt in opts) { + addPolygon(opt) + } + } + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) { + for (opt in opts) { + addPolygon(opt).isVisible = defaultVisible + } + } + + open fun showAll() { + for (polygon in getPolygons()) { + polygon.isVisible = true + } + } + + open fun hideAll() { + for (polygon in getPolygons()) { + polygon.isVisible = false + } + } + + override fun remove(polygon: Polygon?): Boolean = super.remove(polygon) + + open fun getPolygons(): KotlinCollection = getObjects() + + open fun setOnPolygonClickListener(polygonClickListener: GoogleMap.OnPolygonClickListener?) { + mPolygonClickListener = polygonClickListener + } + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/PolylineManager.java b/library/src/main/java/com/google/maps/android/collections/PolylineManager.java deleted file mode 100644 index a236b5b00..000000000 --- a/library/src/main/java/com/google/maps/android/collections/PolylineManager.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.collections; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.Polyline; -import com.google.android.gms.maps.model.PolylineOptions; - -/** - * Keeps track of collections of polylines on the map. Delegates all Polyline-related events to each - * collection's individually managed listeners. - * - *

All polyline operations (adds and removes) should occur via its collection class. That is, - * don't add a polyline via a collection, then remove it via Polyline.remove() - */ -public class PolylineManager extends MapObjectManager - implements GoogleMap.OnPolylineClickListener { - - public PolylineManager(@NonNull GoogleMap map) { - super(map); - } - - @Override - void setListenersOnUiThread() { - if (mMap != null) { - mMap.setOnPolylineClickListener(this); - } - } - - @Override - public Collection newCollection() { - return new Collection(); - } - - @Override - protected void removeObjectFromMap(Polyline object) { - object.remove(); - } - - @Override - public void onPolylineClick(@NonNull Polyline polyline) { - Collection collection = mAllObjects.get(polyline); - if (collection != null && collection.mPolylineClickListener != null) { - collection.mPolylineClickListener.onPolylineClick(polyline); - } - } - - /** A collection of {@link Polyline}s on the map with its own set of listeners. */ - public class Collection extends MapObjectManager.Collection { - private GoogleMap.OnPolylineClickListener mPolylineClickListener; - - public Collection() {} - - public Polyline addPolyline(PolylineOptions opts) { - Polyline polyline = mMap.addPolyline(opts); - super.add(polyline); - return polyline; - } - - public void addAll(java.util.Collection opts) { - for (PolylineOptions opt : opts) { - addPolyline(opt); - } - } - - public void addAll(java.util.Collection opts, boolean defaultVisible) { - for (PolylineOptions opt : opts) { - addPolyline(opt).setVisible(defaultVisible); - } - } - - public void showAll() { - for (Polyline polyline : getPolylines()) { - polyline.setVisible(true); - } - } - - public void hideAll() { - for (Polyline polyline : getPolylines()) { - polyline.setVisible(false); - } - } - - public boolean remove(Polyline polyline) { - return super.remove(polyline); - } - - public java.util.Collection getPolylines() { - return getObjects(); - } - - public void setOnPolylineClickListener( - GoogleMap.OnPolylineClickListener polylineClickListener) { - mPolylineClickListener = polylineClickListener; - } - } -} diff --git a/library/src/main/java/com/google/maps/android/collections/PolylineManager.kt b/library/src/main/java/com/google/maps/android/collections/PolylineManager.kt new file mode 100644 index 000000000..f840af6f9 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/PolylineManager.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import kotlin.collections.Collection as KotlinCollection + +/** + * Keeps track of collections of polylines on the map. Delegates all Polyline-related events to each + * collection's individually managed listeners. + * + * All polyline operations (adds and removes) should occur via its collection class. That is, + * don't add a polyline via a collection, then remove it via Polyline.remove() + */ +open class PolylineManager(map: GoogleMap) : + MapObjectManager(map), + GoogleMap.OnPolylineClickListener { + + override fun setListenersOnUiThread() { + mMap.setOnPolylineClickListener(this) + } + + override fun newCollection(): Collection = Collection() + + override fun removeObjectFromMap(polyline: Polyline) { + polyline.remove() + } + + override fun onPolylineClick(polyline: Polyline) { + mAllObjects[polyline]?.mPolylineClickListener?.onPolylineClick(polyline) + } + + /** A collection of [Polyline]s on the map with its own set of listeners. */ + open inner class Collection : MapObjectManager.Collection() { + internal var mPolylineClickListener: GoogleMap.OnPolylineClickListener? = null + + open fun addPolyline(opts: PolylineOptions): Polyline = + mMap.addPolyline(opts).also { super.add(it) } + + open fun addAll(opts: KotlinCollection) { + for (opt in opts) { + addPolyline(opt) + } + } + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) { + for (opt in opts) { + addPolyline(opt).isVisible = defaultVisible + } + } + + open fun showAll() { + for (polyline in getPolylines()) { + polyline.isVisible = true + } + } + + open fun hideAll() { + for (polyline in getPolylines()) { + polyline.isVisible = false + } + } + + override fun remove(polyline: Polyline?): Boolean = super.remove(polyline) + + open fun getPolylines(): KotlinCollection = getObjects() + + open fun setOnPolylineClickListener( + polylineClickListener: GoogleMap.OnPolylineClickListener?, + ) { + mPolylineClickListener = polylineClickListener + } + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt new file mode 100644 index 000000000..36edb96df --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [CircleManager]. + */ +@RunWith(RobolectricTestRunner::class) +class CircleManagerTest { + + private lateinit var map: GoogleMap + private lateinit var circleManager: CircleManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + circleManager = CircleManager(map) + } + + @Test + fun testAddAndRemoveCircle() { + val mockCircle = mockk(relaxed = true) + every { map.addCircle(any()) } returns mockCircle + + val collection = circleManager.newCollection() + val circle = collection.addCircle(CircleOptions()) + + assertThat(circle).isEqualTo(mockCircle) + assertThat(collection.getCircles()).containsExactly(mockCircle) + + collection.remove(circle) + verify { mockCircle.remove() } + assertThat(collection.getCircles()).isEmpty() + } + + @Test + fun testAddAllAndVisibility() { + val circle1 = mockk(relaxed = true) + val circle2 = mockk(relaxed = true) + every { map.addCircle(any()) } returnsMany listOf(circle1, circle2) + + val collection = circleManager.newCollection() + collection.addAll(listOf(CircleOptions(), CircleOptions()), defaultVisible = false) + + assertThat(collection.getCircles()).hasSize(2) + verify { circle1.isVisible = false } + verify { circle2.isVisible = false } + + collection.showAll() + verify { circle1.isVisible = true } + verify { circle2.isVisible = true } + + collection.hideAll() + verify(atLeast = 2) { circle1.isVisible = false } + verify(atLeast = 2) { circle2.isVisible = false } + } + + @Test + fun testCircleClickDelegation() { + val circle = mockk(relaxed = true) + every { map.addCircle(any()) } returns circle + + val collection = circleManager.newCollection() + collection.addCircle(CircleOptions()) + + var clicked = false + collection.setOnCircleClickListener { clicked = true } + + circleManager.onCircleClick(circle) + assertThat(clicked).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt new file mode 100644 index 000000000..692b0d7be --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [GroundOverlayManager]. + */ +@RunWith(RobolectricTestRunner::class) +class GroundOverlayManagerTest { + + private lateinit var map: GoogleMap + private lateinit var manager: GroundOverlayManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + manager = GroundOverlayManager(map) + } + + @Test + fun testAddAndRemoveGroundOverlay() { + val mockOverlay = mockk(relaxed = true) + every { map.addGroundOverlay(any()) } returns mockOverlay + + val collection = manager.newCollection() + val overlay = collection.addGroundOverlay(GroundOverlayOptions()) + + assertThat(overlay).isEqualTo(mockOverlay) + assertThat(collection.getGroundOverlays()).containsExactly(mockOverlay) + + collection.remove(overlay) + verify { mockOverlay.remove() } + assertThat(collection.getGroundOverlays()).isEmpty() + } + + @Test + fun testAddAllAndVisibility() { + val o1 = mockk(relaxed = true) + val o2 = mockk(relaxed = true) + every { map.addGroundOverlay(any()) } returnsMany listOf(o1, o2) + + val collection = manager.newCollection() + collection.addAll(listOf(GroundOverlayOptions(), GroundOverlayOptions()), defaultVisible = false) + + assertThat(collection.getGroundOverlays()).hasSize(2) + verify { o1.isVisible = false } + verify { o2.isVisible = false } + + collection.showAll() + verify { o1.isVisible = true } + verify { o2.isVisible = true } + + collection.hideAll() + verify(atLeast = 2) { o1.isVisible = false } + verify(atLeast = 2) { o2.isVisible = false } + } + + @Test + fun testGroundOverlayClickDelegation() { + val overlay = mockk(relaxed = true) + every { map.addGroundOverlay(any()) } returns overlay + + val collection = manager.newCollection() + collection.addGroundOverlay(GroundOverlayOptions()) + + var clicked = false + collection.setOnGroundOverlayClickListener { clicked = true } + + manager.onGroundOverlayClick(overlay) + assertThat(clicked).isTrue() + } +} 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 new file mode 100644 index 000000000..9bb0c4251 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [MapObjectManager]. + */ +@RunWith(RobolectricTestRunner::class) +class MapObjectManagerTest { + + private class TestObject(val id: String) + + private class ConcreteManager(map: GoogleMap) : + MapObjectManager(map) { + + val removedObjects = mutableListOf() + + override fun setListenersOnUiThread() {} + + override fun newCollection(): Collection = Collection() + + override fun removeObjectFromMap(mapObject: TestObject) { + removedObjects.add(mapObject) + } + + inner class Collection : MapObjectManager.Collection() { + fun addObject(obj: TestObject) { + super.add(obj) + } + + fun testGetObjects(): kotlin.collections.Collection = getObjects() + } + } + + private lateinit var map: GoogleMap + private lateinit var manager: ConcreteManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + manager = ConcreteManager(map) + } + + @Test + fun testCollectionCreationAndRetrieval() { + val col1 = manager.newCollection("col1") + assertThat(manager.getCollection("col1")).isSameInstanceAs(col1) + assertThat(manager.getCollection("nonExistent")).isNull() + + assertThrows(IllegalArgumentException::class.java) { + manager.newCollection("col1") + } + } + + @Test + fun testAddAndRemoveObjectLifecycle() { + val col = manager.newCollection() + val obj = TestObject("1") + + col.addObject(obj) + assertThat(col.testGetObjects()).containsExactly(obj) + + // Base remove + assertThat(manager.remove(obj)).isTrue() + assertThat(col.testGetObjects()).isEmpty() + assertThat(manager.removedObjects).containsExactly(obj) + + // Removing already removed object + assertThat(manager.remove(obj)).isFalse() + assertThat(manager.remove(null)).isFalse() + } + + @Test + fun testCollectionClear() { + val col = manager.newCollection() + val obj1 = TestObject("1") + val obj2 = TestObject("2") + + col.addObject(obj1) + col.addObject(obj2) + assertThat(col.testGetObjects()).hasSize(2) + + col.clear() + assertThat(col.testGetObjects()).isEmpty() + assertThat(manager.removedObjects).containsExactly(obj1, obj2) + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt new file mode 100644 index 000000000..f7a8814be --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections + +import android.view.View +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [MarkerManager]. + */ +@RunWith(RobolectricTestRunner::class) +class MarkerManagerTest { + + private lateinit var map: GoogleMap + private lateinit var markerManager: MarkerManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + markerManager = MarkerManager(map) + } + + @Test + fun testAddMarkerWithOptions() { + val mockMarker = mockk(relaxed = true) + every { map.addMarker(any()) } returns mockMarker + + val collection = markerManager.newCollection() + val opts = MarkerOptions() + val added = collection.addMarker(opts) + + assertThat(added).isEqualTo(mockMarker) + assertThat(collection.getMarkers()).containsExactly(mockMarker) + + collection.remove(added) + verify { mockMarker.remove() } + assertThat(collection.getMarkers()).isEmpty() + } + + @Test + fun testAddAdvancedMarker() { + val mockMarker = mockk(relaxed = true) + every { map.addMarker(any()) } returns mockMarker + + val collection = markerManager.newCollection() + val opts = AdvancedMarkerOptions() + val added = collection.addMarker(opts) + + assertThat(added).isEqualTo(mockMarker) + assertThat(collection.getMarkers()).containsExactly(mockMarker) + } + + @Test + fun testAddAllAndVisibility() { + val marker1 = mockk(relaxed = true) + val marker2 = mockk(relaxed = true) + every { map.addMarker(any()) } returnsMany listOf(marker1, marker2) + + val collection = markerManager.newCollection() + collection.addAll(listOf(MarkerOptions(), MarkerOptions()), defaultVisible = false) + + assertThat(collection.getMarkers()).hasSize(2) + verify { marker1.isVisible = false } + verify { marker2.isVisible = false } + + collection.showAll() + verify { marker1.isVisible = true } + verify { marker2.isVisible = true } + + collection.hideAll() + verify(atLeast = 2) { marker1.isVisible = false } + verify(atLeast = 2) { marker2.isVisible = false } + } + + @Test + fun testMarkerEventDelegation() { + val marker = mockk(relaxed = true) + every { map.addMarker(any()) } returns marker + + val collection = markerManager.newCollection() + collection.addMarker(MarkerOptions()) + + var clicked = false + collection.setOnMarkerClickListener { + clicked = true + true + } + + var infoClicked = false + collection.setOnInfoWindowClickListener { infoClicked = true } + + var infoLongClicked = false + collection.setOnInfoWindowLongClickListener { infoLongClicked = true } + + var dragStarted = false + var dragging = false + var dragEnded = false + collection.setOnMarkerDragListener(object : GoogleMap.OnMarkerDragListener { + override fun onMarkerDragStart(m: Marker) { dragStarted = true } + override fun onMarkerDrag(m: Marker) { dragging = true } + override fun onMarkerDragEnd(m: Marker) { dragEnded = true } + }) + + val mockView = mockk() + collection.setInfoWindowAdapter(object : GoogleMap.InfoWindowAdapter { + override fun getInfoWindow(m: Marker): View = mockView + override fun getInfoContents(m: Marker): View? = null + }) + + assertThat(markerManager.onMarkerClick(marker)).isTrue() + assertThat(clicked).isTrue() + + markerManager.onInfoWindowClick(marker) + assertThat(infoClicked).isTrue() + + markerManager.onInfoWindowLongClick(marker) + assertThat(infoLongClicked).isTrue() + + markerManager.onMarkerDragStart(marker) + assertThat(dragStarted).isTrue() + + markerManager.onMarkerDrag(marker) + assertThat(dragging).isTrue() + + markerManager.onMarkerDragEnd(marker) + assertThat(dragEnded).isTrue() + + assertThat(markerManager.getInfoWindow(marker)).isEqualTo(mockView) + assertThat(markerManager.getInfoContents(marker)).isNull() + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt new file mode 100644 index 000000000..3fba2ce22 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [PolygonManager]. + */ +@RunWith(RobolectricTestRunner::class) +class PolygonManagerTest { + + private lateinit var map: GoogleMap + private lateinit var manager: PolygonManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + manager = PolygonManager(map) + } + + @Test + fun testAddAndRemovePolygon() { + val mockPolygon = mockk(relaxed = true) + every { map.addPolygon(any()) } returns mockPolygon + + val collection = manager.newCollection() + val poly = collection.addPolygon(PolygonOptions()) + + assertThat(poly).isEqualTo(mockPolygon) + assertThat(collection.getPolygons()).containsExactly(mockPolygon) + + collection.remove(poly) + verify { mockPolygon.remove() } + assertThat(collection.getPolygons()).isEmpty() + } + + @Test + fun testAddAllAndVisibility() { + val p1 = mockk(relaxed = true) + val p2 = mockk(relaxed = true) + every { map.addPolygon(any()) } returnsMany listOf(p1, p2) + + val collection = manager.newCollection() + collection.addAll(listOf(PolygonOptions(), PolygonOptions()), defaultVisible = false) + + assertThat(collection.getPolygons()).hasSize(2) + verify { p1.isVisible = false } + verify { p2.isVisible = false } + + collection.showAll() + verify { p1.isVisible = true } + verify { p2.isVisible = true } + + collection.hideAll() + verify(atLeast = 2) { p1.isVisible = false } + verify(atLeast = 2) { p2.isVisible = false } + } + + @Test + fun testPolygonClickDelegation() { + val poly = mockk(relaxed = true) + every { map.addPolygon(any()) } returns poly + + val collection = manager.newCollection() + collection.addPolygon(PolygonOptions()) + + var clicked = false + collection.setOnPolygonClickListener { clicked = true } + + manager.onPolygonClick(poly) + assertThat(clicked).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt new file mode 100644 index 000000000..079f16aeb --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [PolylineManager]. + */ +@RunWith(RobolectricTestRunner::class) +class PolylineManagerTest { + + private lateinit var map: GoogleMap + private lateinit var manager: PolylineManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + manager = PolylineManager(map) + } + + @Test + fun testAddAndRemovePolyline() { + val mockPolyline = mockk(relaxed = true) + every { map.addPolyline(any()) } returns mockPolyline + + val collection = manager.newCollection() + val line = collection.addPolyline(PolylineOptions()) + + assertThat(line).isEqualTo(mockPolyline) + assertThat(collection.getPolylines()).containsExactly(mockPolyline) + + collection.remove(line) + verify { mockPolyline.remove() } + assertThat(collection.getPolylines()).isEmpty() + } + + @Test + fun testAddAllAndVisibility() { + val l1 = mockk(relaxed = true) + val l2 = mockk(relaxed = true) + every { map.addPolyline(any()) } returnsMany listOf(l1, l2) + + val collection = manager.newCollection() + collection.addAll(listOf(PolylineOptions(), PolylineOptions()), defaultVisible = false) + + assertThat(collection.getPolylines()).hasSize(2) + verify { l1.isVisible = false } + verify { l2.isVisible = false } + + collection.showAll() + verify { l1.isVisible = true } + verify { l2.isVisible = true } + + collection.hideAll() + verify(atLeast = 2) { l1.isVisible = false } + verify(atLeast = 2) { l2.isVisible = false } + } + + @Test + fun testPolylineClickDelegation() { + val line = mockk(relaxed = true) + every { map.addPolyline(any()) } returns line + + val collection = manager.newCollection() + collection.addPolyline(PolylineOptions()) + + var clicked = false + collection.setOnPolylineClickListener { clicked = true } + + manager.onPolylineClick(line) + assertThat(clicked).isTrue() + } +} From 8197bcbcc36f9d39c764072ea1309c3205bdde52 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:12:51 -0600 Subject: [PATCH 2/2] feat(collections): introduce unified OverlayManager and polymorphic MapOverlay system --- .../collections/overlay/CircleOverlay.kt | 86 ++++ .../overlay/GroundOverlayOverlay.kt | 86 ++++ .../android/collections/overlay/MapOverlay.kt | 78 ++++ .../collections/overlay/MarkerOverlay.kt | 137 ++++++ .../collections/overlay/OverlayCollection.kt | 228 ++++++++++ .../collections/overlay/OverlayManager.kt | 291 +++++++++++++ .../collections/overlay/PolygonOverlay.kt | 90 ++++ .../collections/overlay/PolylineOverlay.kt | 91 ++++ .../collections/overlay/MapOverlayTest.kt | 389 ++++++++++++++++++ .../overlay/OverlayCollectionTest.kt | 265 ++++++++++++ .../collections/overlay/OverlayManagerTest.kt | 148 +++++++ 11 files changed, 1889 insertions(+) create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/CircleOverlay.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/GroundOverlayOverlay.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/MapOverlay.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/MarkerOverlay.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/OverlayCollection.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/OverlayManager.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/PolygonOverlay.kt create mode 100644 library/src/main/java/com/google/maps/android/collections/overlay/PolylineOverlay.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/overlay/MapOverlayTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/overlay/OverlayCollectionTest.kt create mode 100644 library/src/test/java/com/google/maps/android/collections/overlay/OverlayManagerTest.kt diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/CircleOverlay.kt b/library/src/main/java/com/google/maps/android/collections/overlay/CircleOverlay.kt new file mode 100644 index 000000000..1d286eb11 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/CircleOverlay.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.PatternItem + +/** + * High-level, decoupled wrapper around Google Maps [Circle]. + */ +class CircleOverlay( + override val native: Circle, + private val onRemoveCallback: (CircleOverlay) -> Boolean, +) : PointOverlay { + + override var position: LatLng + get() = native.center + set(value) { native.center = value } + + var center: LatLng + get() = native.center + set(value) { native.center = value } + + var radius: Double + get() = native.radius + set(value) { native.radius = value } + + var fillColor: Int + get() = native.fillColor + set(value) { native.fillColor = value } + + var strokeColor: Int + get() = native.strokeColor + set(value) { native.strokeColor = value } + + var strokeWidth: Float + get() = native.strokeWidth + set(value) { native.strokeWidth = value } + + var strokePattern: List? + get() = native.strokePattern + set(value) { native.strokePattern = value } + + var isClickable: Boolean + get() = native.isClickable + set(value) { native.isClickable = value } + + override var isVisible: Boolean + get() = native.isVisible + set(value) { native.isVisible = value } + + override var zIndex: Float + get() = native.zIndex + set(value) { native.zIndex = value } + + override var tag: Any? + get() = native.tag + set(value) { native.tag = value } + + override fun remove(): Boolean = onRemoveCallback(this) + + internal var clickListener: ((CircleOverlay) -> Unit)? = null + + /** + * Registers a click callback for this circle overlay. + */ + fun onClick(listener: (CircleOverlay) -> Unit) { + native.isClickable = true + clickListener = listener + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/GroundOverlayOverlay.kt b/library/src/main/java/com/google/maps/android/collections/overlay/GroundOverlayOverlay.kt new file mode 100644 index 000000000..cb6250834 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/GroundOverlayOverlay.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds + +/** + * High-level, decoupled wrapper around Google Maps [GroundOverlay]. + */ +class GroundOverlayOverlay( + override val native: GroundOverlay, + private val onRemoveCallback: (GroundOverlayOverlay) -> Boolean, +) : PointOverlay { + + override var position: LatLng + get() = native.position + set(value) { native.position = value } + + var bounds: LatLngBounds? + get() = native.bounds + set(value) { + if (value != null) { + native.setPositionFromBounds(value) + } + } + + var width: Float + get() = native.width + set(value) { native.setDimensions(value) } + + var height: Float + get() = native.height + set(value) { native.setDimensions(native.width, value) } + + var bearing: Float + get() = native.bearing + set(value) { native.bearing = value } + + var transparency: Float + get() = native.transparency + set(value) { native.transparency = value } + + var isClickable: Boolean + get() = native.isClickable + set(value) { native.isClickable = value } + + override var isVisible: Boolean + get() = native.isVisible + set(value) { native.isVisible = value } + + override var zIndex: Float + get() = native.zIndex + set(value) { native.zIndex = value } + + override var tag: Any? + get() = native.tag + set(value) { native.tag = value } + + override fun remove(): Boolean = onRemoveCallback(this) + + internal var clickListener: ((GroundOverlayOverlay) -> Unit)? = null + + /** + * Registers a click callback for this ground overlay. + */ + fun onClick(listener: (GroundOverlayOverlay) -> Unit) { + native.isClickable = true + clickListener = listener + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/MapOverlay.kt b/library/src/main/java/com/google/maps/android/collections/overlay/MapOverlay.kt new file mode 100644 index 000000000..759264391 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/MapOverlay.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import com.google.android.gms.maps.model.LatLng + +/** + * Common abstraction representing an overlay object displayed on a Google Map. + * + * This interface decouples map operations from concrete platform types (such as [com.google.android.gms.maps.model.Marker], + * [com.google.android.gms.maps.model.Polyline], or [com.google.android.gms.maps.model.Polygon]) by providing a unified + * contract for visibility, z-ordering, tags, event listeners, and lifecycle removal. + * + * @param T The underlying native platform object type. + */ +interface MapOverlay { + + /** + * The underlying native platform object instance. + */ + val native: T + + /** + * Controls the visibility of this overlay on the map. + */ + var isVisible: Boolean + + /** + * The z-index order of this overlay relative to other overlays on the map. + */ + var zIndex: Float + + /** + * An optional application metadata tag associated with this overlay. + */ + var tag: Any? + + /** + * Removes this overlay from the map and its containing collection. + * + * @return `true` if this overlay was successfully removed, or `false` if it was already removed. + */ + fun remove(): Boolean +} + +/** + * An overlay positioned at a discrete geographic coordinate. + */ +interface PointOverlay : MapOverlay { + /** + * The geographic position of this overlay. + */ + var position: LatLng +} + +/** + * An overlay defined by a continuous sequence of geographic coordinates. + */ +interface PathOverlay : MapOverlay { + /** + * The sequence of points defining the path. + */ + var points: List +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/MarkerOverlay.kt b/library/src/main/java/com/google/maps/android/collections/overlay/MarkerOverlay.kt new file mode 100644 index 000000000..eb64f7ff7 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/MarkerOverlay.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import android.view.View +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker + +/** + * High-level, decoupled wrapper around Google Maps [Marker]. + * + * Provides property accessors, lifecycle controls, and direct lambda listener bindings. + */ +class MarkerOverlay( + override val native: Marker, + private val onRemoveCallback: (MarkerOverlay) -> Boolean, +) : PointOverlay { + + override var position: LatLng + get() = native.position + set(value) { native.position = value } + + override var isVisible: Boolean + get() = native.isVisible + set(value) { native.isVisible = value } + + override var zIndex: Float + get() = native.zIndex + set(value) { native.zIndex = value } + + override var tag: Any? + get() = native.tag + set(value) { native.tag = value } + + var title: String? + get() = native.title + set(value) { native.title = value } + + var snippet: String? + get() = native.snippet + set(value) { native.snippet = value } + + var isDraggable: Boolean + get() = native.isDraggable + set(value) { native.isDraggable = value } + + var alpha: Float + get() = native.alpha + set(value) { native.alpha = value } + + var rotation: Float + get() = native.rotation + set(value) { native.rotation = value } + + var isFlat: Boolean + get() = native.isFlat + set(value) { native.isFlat = value } + + val isInfoWindowShown: Boolean + get() = native.isInfoWindowShown + + fun showInfoWindow() = native.showInfoWindow() + + fun hideInfoWindow() = native.hideInfoWindow() + + override fun remove(): Boolean = onRemoveCallback(this) + + internal var clickListener: ((MarkerOverlay) -> Boolean)? = null + internal var infoWindowClickListener: ((MarkerOverlay) -> Unit)? = null + internal var infoWindowLongClickListener: ((MarkerOverlay) -> Unit)? = null + internal var dragStartListener: ((MarkerOverlay) -> Unit)? = null + internal var dragListener: ((MarkerOverlay) -> Unit)? = null + internal var dragEndListener: ((MarkerOverlay) -> Unit)? = null + internal var infoWindowProvider: ((MarkerOverlay) -> View?)? = null + internal var infoContentsProvider: ((MarkerOverlay) -> View?)? = null + + /** + * Registers a click listener for this specific marker overlay. + * + * @param listener Callback returning `true` if the event is consumed, `false` otherwise. + */ + fun onClick(listener: (MarkerOverlay) -> Boolean) { + clickListener = listener + } + + /** + * Registers an info window click listener for this specific marker. + */ + fun onInfoWindowClick(listener: (MarkerOverlay) -> Unit) { + infoWindowClickListener = listener + } + + /** + * Registers an info window long-click listener for this specific marker. + */ + fun onInfoWindowLongClick(listener: (MarkerOverlay) -> Unit) { + infoWindowLongClickListener = listener + } + + /** + * Registers drag event callbacks for this marker. + */ + fun onDrag( + onStart: ((MarkerOverlay) -> Unit)? = null, + onDrag: ((MarkerOverlay) -> Unit)? = null, + onEnd: ((MarkerOverlay) -> Unit)? = null, + ) { + dragStartListener = onStart + dragListener = onDrag + dragEndListener = onEnd + } + + /** + * Sets custom info window view providers for this marker. + */ + fun setCustomInfoWindow( + infoWindow: ((MarkerOverlay) -> View?)? = null, + infoContents: ((MarkerOverlay) -> View?)? = null, + ) { + infoWindowProvider = infoWindow + infoContentsProvider = infoContents + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/OverlayCollection.kt b/library/src/main/java/com/google/maps/android/collections/overlay/OverlayCollection.kt new file mode 100644 index 000000000..eff254dc3 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/OverlayCollection.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import android.view.View +import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.PolylineOptions +import java.util.Collections +import kotlin.jvm.JvmName + +/** + * A logical grouping / feature layer of heterogeneous [MapOverlay] instances on a map. + * + * Allows batch lifecycle operations (show, hide, clear, remove) and unified `add(...)` methods across + * markers, circles, polygons, polylines, and ground overlays. + */ +class OverlayCollection internal constructor( + val id: String?, + private val manager: OverlayManager, +) { + private val mOverlays: MutableSet> = Collections.synchronizedSet(LinkedHashSet()) + + // Direct typed lookups + val overlays: Set> + get() = synchronized(mOverlays) { HashSet(mOverlays) } + + val markers: List + get() = synchronized(mOverlays) { mOverlays.filterIsInstance() } + + val circles: List + get() = synchronized(mOverlays) { mOverlays.filterIsInstance() } + + val polygons: List + get() = synchronized(mOverlays) { mOverlays.filterIsInstance() } + + val polylines: List + get() = synchronized(mOverlays) { mOverlays.filterIsInstance() } + + val groundOverlays: List + get() = synchronized(mOverlays) { mOverlays.filterIsInstance() } + + val size: Int + get() = mOverlays.size + + val isEmpty: Boolean + get() = mOverlays.isEmpty() + + /** + * Controls the visibility of all overlays in this collection. + */ + var isVisible: Boolean = true + set(value) { + field = value + synchronized(mOverlays) { + for (overlay in mOverlays) { + overlay.isVisible = value + } + } + } + + fun showAll() { + isVisible = true + } + + fun hideAll() { + isVisible = false + } + + // Collection-wide event listeners + internal var markerClickListener: ((MarkerOverlay) -> Boolean)? = null + internal var circleClickListener: ((CircleOverlay) -> Unit)? = null + internal var polygonClickListener: ((PolygonOverlay) -> Unit)? = null + internal var polylineClickListener: ((PolylineOverlay) -> Unit)? = null + internal var groundOverlayClickListener: ((GroundOverlayOverlay) -> Unit)? = null + internal var infoWindowClickListener: ((MarkerOverlay) -> Unit)? = null + internal var infoWindowLongClickListener: ((MarkerOverlay) -> Unit)? = null + internal var infoWindowProvider: ((MarkerOverlay) -> View?)? = null + internal var infoContentsProvider: ((MarkerOverlay) -> View?)? = null + + // --- Overloaded add methods --- + + fun add(options: MarkerOptions): MarkerOverlay = + manager.createMarker(options, this).also { mOverlays.add(it) } + + fun add(options: AdvancedMarkerOptions): MarkerOverlay = + manager.createAdvancedMarker(options, this).also { mOverlays.add(it) } + + fun add(options: CircleOptions): CircleOverlay = + manager.createCircle(options, this).also { mOverlays.add(it) } + + fun add(options: PolygonOptions): PolygonOverlay = + manager.createPolygon(options, this).also { mOverlays.add(it) } + + fun add(options: PolylineOptions): PolylineOverlay = + manager.createPolyline(options, this).also { mOverlays.add(it) } + + fun add(options: GroundOverlayOptions): GroundOverlayOverlay = + manager.createGroundOverlay(options, this).also { mOverlays.add(it) } + + // --- Operator overloads --- + + operator fun plusAssign(options: MarkerOptions) { + add(options) + } + + operator fun plusAssign(options: AdvancedMarkerOptions) { + add(options) + } + + operator fun plusAssign(options: CircleOptions) { + add(options) + } + + operator fun plusAssign(options: PolygonOptions) { + add(options) + } + + operator fun plusAssign(options: PolylineOptions) { + add(options) + } + + operator fun plusAssign(options: GroundOverlayOptions) { + add(options) + } + + // --- Batch addAll methods --- + + @JvmName("addAllMarkers") + fun addAll(optionsList: Collection): List = + optionsList.map { add(it) } + + @JvmName("addAllAdvancedMarkers") + fun addAllAdvancedMarkers(optionsList: Collection): List = + optionsList.map { add(it) } + + @JvmName("addAllCircles") + fun addAll(optionsList: Collection): List = + optionsList.map { add(it) } + + @JvmName("addAllPolygons") + fun addAll(optionsList: Collection): List = + optionsList.map { add(it) } + + @JvmName("addAllPolylines") + fun addAll(optionsList: Collection): List = + optionsList.map { add(it) } + + @JvmName("addAllGroundOverlays") + fun addAll(optionsList: Collection): List = + optionsList.map { add(it) } + + // --- Removal & Clearing --- + + fun remove(overlay: MapOverlay<*>): Boolean { + if (mOverlays.remove(overlay)) { + manager.onOverlayRemoved(overlay) + return true + } + return false + } + + fun clear() { + val snapshot = synchronized(mOverlays) { + val list = ArrayList(mOverlays) + mOverlays.clear() + list + } + for (overlay in snapshot) { + manager.onOverlayRemoved(overlay) + } + } + + // --- Collection-level listener registration --- + + fun onMarkerClick(listener: (MarkerOverlay) -> Boolean) { + markerClickListener = listener + } + + fun onCircleClick(listener: (CircleOverlay) -> Unit) { + circleClickListener = listener + } + + fun onPolygonClick(listener: (PolygonOverlay) -> Unit) { + polygonClickListener = listener + } + + fun onPolylineClick(listener: (PolylineOverlay) -> Unit) { + polylineClickListener = listener + } + + fun onGroundOverlayClick(listener: (GroundOverlayOverlay) -> Unit) { + groundOverlayClickListener = listener + } + + fun onInfoWindowClick(listener: (MarkerOverlay) -> Unit) { + infoWindowClickListener = listener + } + + fun onInfoWindowLongClick(listener: (MarkerOverlay) -> Unit) { + infoWindowLongClickListener = listener + } + + fun setCustomInfoWindow( + infoWindow: ((MarkerOverlay) -> View?)? = null, + infoContents: ((MarkerOverlay) -> View?)? = null, + ) { + infoWindowProvider = infoWindow + infoContentsProvider = infoContents + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/OverlayManager.kt b/library/src/main/java/com/google/maps/android/collections/overlay/OverlayManager.kt new file mode 100644 index 000000000..6eaaf8b65 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/OverlayManager.kt @@ -0,0 +1,291 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import android.view.View +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.collections.CircleManager +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 java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +/** + * Unified coordinator and factory for all map overlays on a [GoogleMap]. + * + * This manager provides a single entry point for creating heterogeneous [OverlayCollection]s, + * dispatching map interaction events, and supporting full coexistence with legacy collection managers + * ([MarkerManager], [CircleManager], [PolygonManager], [PolylineManager], [GroundOverlayManager]). + * + * @param map The target [GoogleMap]. + * @param markerManager Optional legacy [MarkerManager] to share listeners with. + * @param circleManager Optional legacy [CircleManager] to share listeners with. + * @param polygonManager Optional legacy [PolygonManager] to share listeners with. + * @param polylineManager Optional legacy [PolylineManager] to share listeners with. + * @param groundOverlayManager Optional legacy [GroundOverlayManager] to share listeners with. + */ +class OverlayManager @JvmOverloads constructor( + val map: GoogleMap, + val markerManager: MarkerManager = MarkerManager(map), + val circleManager: CircleManager = CircleManager(map), + val polygonManager: PolygonManager = PolygonManager(map), + val polylineManager: PolylineManager = PolylineManager(map), + val groundOverlayManager: GroundOverlayManager = GroundOverlayManager(map), +) { + private val mNamedCollections: MutableMap = ConcurrentHashMap() + private val mAnonymousCollections: MutableSet = Collections.synchronizedSet(LinkedHashSet()) + + // Internal maps from native GMS objects to their respective Overlay wrapper and collection + private val mMarkerWrappers: MutableMap> = ConcurrentHashMap() + private val mCircleWrappers: MutableMap> = ConcurrentHashMap() + private val mPolygonWrappers: MutableMap> = ConcurrentHashMap() + private val mPolylineWrappers: MutableMap> = ConcurrentHashMap() + private val mGroundOverlayWrappers: MutableMap> = ConcurrentHashMap() + + // Legacy collections used for bridging event listeners + private val mLegacyMarkerCollection = markerManager.newCollection() + private val mLegacyCircleCollection = circleManager.newCollection() + private val mLegacyPolygonCollection = polygonManager.newCollection() + private val mLegacyPolylineCollection = polylineManager.newCollection() + private val mLegacyGroundOverlayCollection = groundOverlayManager.newCollection() + + init { + setupLegacyListeners() + } + + private fun setupLegacyListeners() { + mLegacyMarkerCollection.setOnMarkerClickListener { marker -> + val pair = mMarkerWrappers[marker] ?: return@setOnMarkerClickListener false + val overlay = pair.first + val collection = pair.second + + // Check individual overlay listener first, then collection-level listener + overlay.clickListener?.invoke(overlay) ?: collection.markerClickListener?.invoke(overlay) ?: false + } + + mLegacyMarkerCollection.setOnInfoWindowClickListener { marker -> + val pair = mMarkerWrappers[marker] ?: return@setOnInfoWindowClickListener + val overlay = pair.first + val collection = pair.second + + overlay.infoWindowClickListener?.invoke(overlay) + ?: collection.infoWindowClickListener?.invoke(overlay) + } + + mLegacyMarkerCollection.setOnInfoWindowLongClickListener { marker -> + val pair = mMarkerWrappers[marker] ?: return@setOnInfoWindowLongClickListener + val overlay = pair.first + val collection = pair.second + + overlay.infoWindowLongClickListener?.invoke(overlay) + ?: collection.infoWindowLongClickListener?.invoke(overlay) + } + + mLegacyMarkerCollection.setOnMarkerDragListener(object : GoogleMap.OnMarkerDragListener { + override fun onMarkerDragStart(marker: Marker) { + mMarkerWrappers[marker]?.first?.let { it.dragStartListener?.invoke(it) } + } + + override fun onMarkerDrag(marker: Marker) { + mMarkerWrappers[marker]?.first?.let { it.dragListener?.invoke(it) } + } + + override fun onMarkerDragEnd(marker: Marker) { + mMarkerWrappers[marker]?.first?.let { it.dragEndListener?.invoke(it) } + } + }) + + mLegacyMarkerCollection.setInfoWindowAdapter(object : GoogleMap.InfoWindowAdapter { + override fun getInfoWindow(marker: Marker): View? { + val pair = mMarkerWrappers[marker] ?: return null + return pair.first.infoWindowProvider?.invoke(pair.first) + ?: pair.second.infoWindowProvider?.invoke(pair.first) + } + + override fun getInfoContents(marker: Marker): View? { + val pair = mMarkerWrappers[marker] ?: return null + return pair.first.infoContentsProvider?.invoke(pair.first) + ?: pair.second.infoContentsProvider?.invoke(pair.first) + } + }) + + mLegacyCircleCollection.setOnCircleClickListener { circle -> + val pair = mCircleWrappers[circle] ?: return@setOnCircleClickListener + pair.first.clickListener?.invoke(pair.first) + ?: pair.second.circleClickListener?.invoke(pair.first) + } + + mLegacyPolygonCollection.setOnPolygonClickListener { polygon -> + val pair = mPolygonWrappers[polygon] ?: return@setOnPolygonClickListener + pair.first.clickListener?.invoke(pair.first) + ?: pair.second.polygonClickListener?.invoke(pair.first) + } + + mLegacyPolylineCollection.setOnPolylineClickListener { polyline -> + val pair = mPolylineWrappers[polyline] ?: return@setOnPolylineClickListener + pair.first.clickListener?.invoke(pair.first) + ?: pair.second.polylineClickListener?.invoke(pair.first) + } + + mLegacyGroundOverlayCollection.setOnGroundOverlayClickListener { groundOverlay -> + val pair = mGroundOverlayWrappers[groundOverlay] ?: return@setOnGroundOverlayClickListener + pair.first.clickListener?.invoke(pair.first) + ?: pair.second.groundOverlayClickListener?.invoke(pair.first) + } + } + + /** + * Creates and registers a new [OverlayCollection]. + * + * @param id Optional unique identifier for looking up the collection via [getCollection]. + * @return The newly created [OverlayCollection]. + */ + fun newCollection(id: String? = null): OverlayCollection { + val collection = OverlayCollection(id, this) + if (id != null) { + require(mNamedCollections.putIfAbsent(id, collection) == null) { + "OverlayCollection id is not unique: $id" + } + } else { + mAnonymousCollections.add(collection) + } + return collection + } + + /** + * Retrieves a named collection by its [id]. + */ + fun getCollection(id: String): OverlayCollection? = mNamedCollections[id] + + /** + * Removes and clears a named collection by its [id]. + */ + fun removeCollection(id: String): Boolean { + val col = mNamedCollections.remove(id) ?: return false + col.clear() + return true + } + + /** + * Clears all overlays and collections managed by this instance. + */ + fun clearAll() { + for (col in mNamedCollections.values) { + col.clear() + } + mNamedCollections.clear() + + for (col in mAnonymousCollections) { + col.clear() + } + mAnonymousCollections.clear() + } + + // --- Overlay creation helpers used by OverlayCollection --- + + internal fun createMarker(options: MarkerOptions, collection: OverlayCollection): MarkerOverlay { + val marker = mLegacyMarkerCollection.addMarker(options) + val wrapper = MarkerOverlay(marker) { overlay -> + collection.remove(overlay) + } + mMarkerWrappers[marker] = Pair(wrapper, collection) + return wrapper + } + + internal fun createAdvancedMarker(options: AdvancedMarkerOptions, collection: OverlayCollection): MarkerOverlay { + val marker = mLegacyMarkerCollection.addMarker(options) + val wrapper = MarkerOverlay(marker) { overlay -> + collection.remove(overlay) + } + mMarkerWrappers[marker] = Pair(wrapper, collection) + return wrapper + } + + internal fun createCircle(options: CircleOptions, collection: OverlayCollection): CircleOverlay { + val circle = mLegacyCircleCollection.addCircle(options) + val wrapper = CircleOverlay(circle) { overlay -> + collection.remove(overlay) + } + mCircleWrappers[circle] = Pair(wrapper, collection) + return wrapper + } + + internal fun createPolygon(options: PolygonOptions, collection: OverlayCollection): PolygonOverlay { + val polygon = mLegacyPolygonCollection.addPolygon(options) + val wrapper = PolygonOverlay(polygon) { overlay -> + collection.remove(overlay) + } + mPolygonWrappers[polygon] = Pair(wrapper, collection) + return wrapper + } + + internal fun createPolyline(options: PolylineOptions, collection: OverlayCollection): PolylineOverlay { + val polyline = mLegacyPolylineCollection.addPolyline(options) + val wrapper = PolylineOverlay(polyline) { overlay -> + collection.remove(overlay) + } + mPolylineWrappers[polyline] = Pair(wrapper, collection) + return wrapper + } + + internal fun createGroundOverlay(options: GroundOverlayOptions, collection: OverlayCollection): GroundOverlayOverlay { + val groundOverlay = mLegacyGroundOverlayCollection.addGroundOverlay(options) + val wrapper = GroundOverlayOverlay(groundOverlay) { overlay -> + collection.remove(overlay) + } + mGroundOverlayWrappers[groundOverlay] = Pair(wrapper, collection) + return wrapper + } + + internal fun onOverlayRemoved(overlay: MapOverlay<*>) { + when (overlay) { + is MarkerOverlay -> { + mMarkerWrappers.remove(overlay.native) + mLegacyMarkerCollection.remove(overlay.native) + } + is CircleOverlay -> { + mCircleWrappers.remove(overlay.native) + mLegacyCircleCollection.remove(overlay.native) + } + is PolygonOverlay -> { + mPolygonWrappers.remove(overlay.native) + mLegacyPolygonCollection.remove(overlay.native) + } + is PolylineOverlay -> { + mPolylineWrappers.remove(overlay.native) + mLegacyPolylineCollection.remove(overlay.native) + } + is GroundOverlayOverlay -> { + mGroundOverlayWrappers.remove(overlay.native) + mLegacyGroundOverlayCollection.remove(overlay.native) + } + } + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/PolygonOverlay.kt b/library/src/main/java/com/google/maps/android/collections/overlay/PolygonOverlay.kt new file mode 100644 index 000000000..df27a14fc --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/PolygonOverlay.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.PatternItem +import com.google.android.gms.maps.model.Polygon + +/** + * High-level, decoupled wrapper around Google Maps [Polygon]. + */ +class PolygonOverlay( + override val native: Polygon, + private val onRemoveCallback: (PolygonOverlay) -> Boolean, +) : PathOverlay { + + override var points: List + get() = native.points + set(value) { native.points = value } + + var holes: List> + get() = native.holes + set(value) { native.holes = value } + + var fillColor: Int + get() = native.fillColor + set(value) { native.fillColor = value } + + var strokeColor: Int + get() = native.strokeColor + set(value) { native.strokeColor = value } + + var strokeWidth: Float + get() = native.strokeWidth + set(value) { native.strokeWidth = value } + + var strokeJointType: Int + get() = native.strokeJointType + set(value) { native.strokeJointType = value } + + var strokePattern: List? + get() = native.strokePattern + set(value) { native.strokePattern = value } + + var isGeodesic: Boolean + get() = native.isGeodesic + set(value) { native.isGeodesic = value } + + var isClickable: Boolean + get() = native.isClickable + set(value) { native.isClickable = value } + + override var isVisible: Boolean + get() = native.isVisible + set(value) { native.isVisible = value } + + override var zIndex: Float + get() = native.zIndex + set(value) { native.zIndex = value } + + override var tag: Any? + get() = native.tag + set(value) { native.tag = value } + + override fun remove(): Boolean = onRemoveCallback(this) + + internal var clickListener: ((PolygonOverlay) -> Unit)? = null + + /** + * Registers a click callback for this polygon overlay. + */ + fun onClick(listener: (PolygonOverlay) -> Unit) { + native.isClickable = true + clickListener = listener + } +} diff --git a/library/src/main/java/com/google/maps/android/collections/overlay/PolylineOverlay.kt b/library/src/main/java/com/google/maps/android/collections/overlay/PolylineOverlay.kt new file mode 100644 index 000000000..09f3ff966 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/overlay/PolylineOverlay.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import com.google.android.gms.maps.model.Cap +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.PatternItem +import com.google.android.gms.maps.model.Polyline + +/** + * High-level, decoupled wrapper around Google Maps [Polyline]. + */ +class PolylineOverlay( + override val native: Polyline, + private val onRemoveCallback: (PolylineOverlay) -> Boolean, +) : PathOverlay { + + override var points: List + get() = native.points + set(value) { native.points = value } + + var color: Int + get() = native.color + set(value) { native.color = value } + + var width: Float + get() = native.width + set(value) { native.width = value } + + var jointType: Int + get() = native.jointType + set(value) { native.jointType = value } + + var startCap: Cap + get() = native.startCap + set(value) { native.startCap = value } + + var endCap: Cap + get() = native.endCap + set(value) { native.endCap = value } + + var pattern: List? + get() = native.pattern + set(value) { native.pattern = value } + + var isGeodesic: Boolean + get() = native.isGeodesic + set(value) { native.isGeodesic = value } + + var isClickable: Boolean + get() = native.isClickable + set(value) { native.isClickable = value } + + override var isVisible: Boolean + get() = native.isVisible + set(value) { native.isVisible = value } + + override var zIndex: Float + get() = native.zIndex + set(value) { native.zIndex = value } + + override var tag: Any? + get() = native.tag + set(value) { native.tag = value } + + override fun remove(): Boolean = onRemoveCallback(this) + + internal var clickListener: ((PolylineOverlay) -> Unit)? = null + + /** + * Registers a click callback for this polyline overlay. + */ + fun onClick(listener: (PolylineOverlay) -> Unit) { + native.isClickable = true + clickListener = listener + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/overlay/MapOverlayTest.kt b/library/src/test/java/com/google/maps/android/collections/overlay/MapOverlayTest.kt new file mode 100644 index 000000000..f464039ba --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/overlay/MapOverlayTest.kt @@ -0,0 +1,389 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import android.view.View +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests verifying property bindings and interactions on wrapper classes. + */ +@RunWith(RobolectricTestRunner::class) +class MapOverlayTest { + + @Test + fun testMarkerOverlayPropertiesAndControls() { + val mockMarker = mockk(relaxed = true) + every { mockMarker.position } returns LatLng(1.0, 2.0) + every { mockMarker.isVisible } returns true + every { mockMarker.zIndex } returns 5f + every { mockMarker.tag } returns "tag1" + every { mockMarker.title } returns "title" + every { mockMarker.snippet } returns "snippet" + every { mockMarker.isDraggable } returns true + every { mockMarker.alpha } returns 0.8f + every { mockMarker.rotation } returns 45f + every { mockMarker.isFlat } returns true + every { mockMarker.isInfoWindowShown } returns false + + val wrapper = MarkerOverlay(mockMarker) { true } + + assertThat(wrapper.position).isEqualTo(LatLng(1.0, 2.0)) + assertThat(wrapper.isVisible).isTrue() + assertThat(wrapper.zIndex).isEqualTo(5f) + assertThat(wrapper.tag).isEqualTo("tag1") + assertThat(wrapper.title).isEqualTo("title") + assertThat(wrapper.snippet).isEqualTo("snippet") + assertThat(wrapper.isDraggable).isTrue() + assertThat(wrapper.alpha).isEqualTo(0.8f) + assertThat(wrapper.rotation).isEqualTo(45f) + assertThat(wrapper.isFlat).isTrue() + assertThat(wrapper.isInfoWindowShown).isFalse() + + // Mutators + wrapper.position = LatLng(3.0, 4.0) + verify { mockMarker.position = LatLng(3.0, 4.0) } + + wrapper.isVisible = false + verify { mockMarker.isVisible = false } + + wrapper.zIndex = 10f + verify { mockMarker.zIndex = 10f } + + wrapper.tag = "tag2" + verify { mockMarker.tag = "tag2" } + + wrapper.title = "new_title" + verify { mockMarker.title = "new_title" } + + wrapper.snippet = "new_snippet" + verify { mockMarker.snippet = "new_snippet" } + + wrapper.isDraggable = false + verify { mockMarker.isDraggable = false } + + wrapper.alpha = 1.0f + verify { mockMarker.alpha = 1.0f } + + wrapper.rotation = 90f + verify { mockMarker.rotation = 90f } + + wrapper.isFlat = false + verify { mockMarker.isFlat = false } + + wrapper.showInfoWindow() + verify { mockMarker.showInfoWindow() } + + wrapper.hideInfoWindow() + verify { mockMarker.hideInfoWindow() } + + // Info window & drag listeners + var dragStarted = false + var dragging = false + var dragEnded = false + wrapper.onDrag( + onStart = { dragStarted = true }, + onDrag = { dragging = true }, + onEnd = { dragEnded = true } + ) + wrapper.dragStartListener?.invoke(wrapper) + wrapper.dragListener?.invoke(wrapper) + wrapper.dragEndListener?.invoke(wrapper) + assertThat(dragStarted).isTrue() + assertThat(dragging).isTrue() + assertThat(dragEnded).isTrue() + + var infoClicked = false + wrapper.onInfoWindowClick { infoClicked = true } + wrapper.infoWindowClickListener?.invoke(wrapper) + assertThat(infoClicked).isTrue() + + var infoLongClicked = false + wrapper.onInfoWindowLongClick { infoLongClicked = true } + wrapper.infoWindowLongClickListener?.invoke(wrapper) + assertThat(infoLongClicked).isTrue() + + val mockView = mockk() + wrapper.setCustomInfoWindow(infoWindow = { mockView }, infoContents = { null }) + assertThat(wrapper.infoWindowProvider?.invoke(wrapper)).isEqualTo(mockView) + assertThat(wrapper.infoContentsProvider?.invoke(wrapper)).isNull() + } + + @Test + fun testCircleOverlayProperties() { + val mockCircle = mockk(relaxed = true) + every { mockCircle.center } returns LatLng(10.0, 20.0) + every { mockCircle.radius } returns 50.0 + + val wrapper = CircleOverlay(mockCircle) { true } + assertThat(wrapper.position).isEqualTo(LatLng(10.0, 20.0)) + assertThat(wrapper.center).isEqualTo(LatLng(10.0, 20.0)) + assertThat(wrapper.radius).isEqualTo(50.0) + + wrapper.center = LatLng(15.0, 25.0) + verify { mockCircle.center = LatLng(15.0, 25.0) } + + wrapper.radius = 100.0 + verify { mockCircle.radius = 100.0 } + + wrapper.fillColor = 0xFF0000 + verify { mockCircle.fillColor = 0xFF0000 } + + wrapper.strokeColor = 0x00FF00 + verify { mockCircle.strokeColor = 0x00FF00 } + + every { mockCircle.isClickable } returns true + wrapper.isClickable = true + verify { mockCircle.isClickable = true } + assertThat(wrapper.isClickable).isTrue() + + every { mockCircle.strokePattern } returns null + wrapper.strokePattern = null + assertThat(wrapper.strokePattern).isNull() + verify { mockCircle.strokePattern = null } + + every { mockCircle.zIndex } returns 3f + wrapper.zIndex = 3f + assertThat(wrapper.zIndex).isEqualTo(3f) + verify { mockCircle.zIndex = 3f } + + every { mockCircle.tag } returns "circle_tag" + wrapper.tag = "circle_tag" + assertThat(wrapper.tag).isEqualTo("circle_tag") + verify { mockCircle.tag = "circle_tag" } + + every { mockCircle.isVisible } returns false + wrapper.isVisible = false + assertThat(wrapper.isVisible).isFalse() + verify { mockCircle.isVisible = false } + + assertThat(wrapper.isClickable).isTrue() + assertThat(wrapper.remove()).isTrue() + } + + @Test + fun testPolygonOverlayProperties() { + val mockPolygon = mockk(relaxed = true) + val points = listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0)) + every { mockPolygon.points } returns points + every { mockPolygon.fillColor } returns 0x123456 + every { mockPolygon.strokeColor } returns 0x654321 + every { mockPolygon.strokeWidth } returns 5f + every { mockPolygon.strokeJointType } returns 2 + every { mockPolygon.isGeodesic } returns true + every { mockPolygon.isClickable } returns true + every { mockPolygon.isVisible } returns true + every { mockPolygon.zIndex } returns 4f + every { mockPolygon.tag } returns "poly_tag" + + val wrapper = PolygonOverlay(mockPolygon) { true } + assertThat(wrapper.points).isEqualTo(points) + assertThat(wrapper.fillColor).isEqualTo(0x123456) + assertThat(wrapper.strokeColor).isEqualTo(0x654321) + assertThat(wrapper.strokeWidth).isEqualTo(5f) + assertThat(wrapper.strokeJointType).isEqualTo(2) + assertThat(wrapper.isGeodesic).isTrue() + assertThat(wrapper.isClickable).isTrue() + assertThat(wrapper.isVisible).isTrue() + assertThat(wrapper.zIndex).isEqualTo(4f) + assertThat(wrapper.tag).isEqualTo("poly_tag") + + wrapper.points = points + verify { mockPolygon.points = points } + + val holes = listOf(points) + wrapper.holes = holes + verify { mockPolygon.holes = holes } + every { mockPolygon.holes } returns holes + assertThat(wrapper.holes).isEqualTo(holes) + + wrapper.fillColor = 0x123456 + verify { mockPolygon.fillColor = 0x123456 } + + wrapper.strokeColor = 0x654321 + verify { mockPolygon.strokeColor = 0x654321 } + + wrapper.strokeWidth = 5f + verify { mockPolygon.strokeWidth = 5f } + + wrapper.strokeJointType = 2 + verify { mockPolygon.strokeJointType = 2 } + + wrapper.isGeodesic = true + verify { mockPolygon.isGeodesic = true } + + every { mockPolygon.strokePattern } returns null + wrapper.strokePattern = null + assertThat(wrapper.strokePattern).isNull() + verify { mockPolygon.strokePattern = null } + + wrapper.isClickable = false + verify { mockPolygon.isClickable = false } + + wrapper.isVisible = false + verify { mockPolygon.isVisible = false } + + wrapper.zIndex = 1f + verify { mockPolygon.zIndex = 1f } + + wrapper.tag = "new_tag" + verify { mockPolygon.tag = "new_tag" } + + assertThat(wrapper.remove()).isTrue() + } + + @Test + fun testPolylineOverlayProperties() { + val mockPolyline = mockk(relaxed = true) + val points = listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0)) + every { mockPolyline.points } returns points + every { mockPolyline.color } returns 0xFF00FF + every { mockPolyline.width } returns 8f + every { mockPolyline.jointType } returns 1 + every { mockPolyline.isGeodesic } returns true + every { mockPolyline.isClickable } returns true + every { mockPolyline.isVisible } returns true + every { mockPolyline.zIndex } returns 2f + every { mockPolyline.tag } returns "line_tag" + + val wrapper = PolylineOverlay(mockPolyline) { true } + assertThat(wrapper.points).isEqualTo(points) + assertThat(wrapper.color).isEqualTo(0xFF00FF) + assertThat(wrapper.width).isEqualTo(8f) + assertThat(wrapper.jointType).isEqualTo(1) + assertThat(wrapper.isGeodesic).isTrue() + assertThat(wrapper.isClickable).isTrue() + assertThat(wrapper.isVisible).isTrue() + assertThat(wrapper.zIndex).isEqualTo(2f) + assertThat(wrapper.tag).isEqualTo("line_tag") + + wrapper.points = points + verify { mockPolyline.points = points } + + wrapper.color = 0xFF00FF + verify { mockPolyline.color = 0xFF00FF } + + wrapper.width = 8f + verify { mockPolyline.width = 8f } + + wrapper.jointType = 1 + verify { mockPolyline.jointType = 1 } + + val cap = com.google.android.gms.maps.model.RoundCap() + wrapper.startCap = cap + verify { mockPolyline.startCap = cap } + every { mockPolyline.startCap } returns cap + assertThat(wrapper.startCap).isEqualTo(cap) + + wrapper.endCap = cap + verify { mockPolyline.endCap = cap } + every { mockPolyline.endCap } returns cap + assertThat(wrapper.endCap).isEqualTo(cap) + + every { mockPolyline.pattern } returns null + wrapper.pattern = null + assertThat(wrapper.pattern).isNull() + verify { mockPolyline.pattern = null } + + wrapper.isGeodesic = true + verify { mockPolyline.isGeodesic = true } + + wrapper.isClickable = false + verify { mockPolyline.isClickable = false } + + wrapper.isVisible = false + verify { mockPolyline.isVisible = false } + + wrapper.zIndex = 7f + verify { mockPolyline.zIndex = 7f } + + wrapper.tag = "line_new" + verify { mockPolyline.tag = "line_new" } + + assertThat(wrapper.remove()).isTrue() + } + + @Test + fun testGroundOverlayOverlayProperties() { + val mockOverlay = mockk(relaxed = true) + every { mockOverlay.position } returns LatLng(5.0, 5.0) + every { mockOverlay.width } returns 200f + every { mockOverlay.height } returns 100f + every { mockOverlay.bearing } returns 45f + every { mockOverlay.transparency } returns 0.5f + every { mockOverlay.isClickable } returns true + every { mockOverlay.isVisible } returns true + every { mockOverlay.zIndex } returns 6f + every { mockOverlay.tag } returns "ground_tag" + + val wrapper = GroundOverlayOverlay(mockOverlay) { true } + assertThat(wrapper.position).isEqualTo(LatLng(5.0, 5.0)) + assertThat(wrapper.width).isEqualTo(200f) + assertThat(wrapper.height).isEqualTo(100f) + assertThat(wrapper.bearing).isEqualTo(45f) + assertThat(wrapper.transparency).isEqualTo(0.5f) + assertThat(wrapper.isClickable).isTrue() + assertThat(wrapper.isVisible).isTrue() + assertThat(wrapper.zIndex).isEqualTo(6f) + assertThat(wrapper.tag).isEqualTo("ground_tag") + + wrapper.position = LatLng(6.0, 6.0) + verify { mockOverlay.position = LatLng(6.0, 6.0) } + + wrapper.height = 150f + verify { mockOverlay.setDimensions(200f, 150f) } + + val bounds = LatLngBounds(LatLng(0.0, 0.0), LatLng(1.0, 1.0)) + wrapper.bounds = bounds + verify { mockOverlay.setPositionFromBounds(bounds) } + every { mockOverlay.bounds } returns bounds + assertThat(wrapper.bounds).isEqualTo(bounds) + + wrapper.bearing = 45f + verify { mockOverlay.bearing = 45f } + + wrapper.transparency = 0.5f + verify { mockOverlay.transparency = 0.5f } + + wrapper.isClickable = true + verify { mockOverlay.isClickable = true } + + wrapper.isVisible = false + verify { mockOverlay.isVisible = false } + + wrapper.zIndex = 9f + verify { mockOverlay.zIndex = 9f } + + wrapper.tag = "new_ground" + verify { mockOverlay.tag = "new_ground" } + + assertThat(wrapper.remove()).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/overlay/OverlayCollectionTest.kt b/library/src/test/java/com/google/maps/android/collections/overlay/OverlayCollectionTest.kt new file mode 100644 index 000000000..eaee87630 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/overlay/OverlayCollectionTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import android.view.View +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [OverlayCollection]. + */ +@RunWith(RobolectricTestRunner::class) +class OverlayCollectionTest { + + private lateinit var map: GoogleMap + private lateinit var manager: OverlayManager + private lateinit var collection: OverlayCollection + + private lateinit var mockMarker: Marker + private lateinit var mockCircle: Circle + private lateinit var mockPolygon: Polygon + private lateinit var mockPolyline: Polyline + private lateinit var mockGroundOverlay: GroundOverlay + + @Before + fun setUp() { + map = mockk(relaxed = true) + mockMarker = mockk(relaxed = true) + mockCircle = mockk(relaxed = true) + mockPolygon = mockk(relaxed = true) + mockPolyline = mockk(relaxed = true) + mockGroundOverlay = mockk(relaxed = true) + + every { map.addMarker(any()) } returns mockMarker + every { map.addMarker(any()) } returns mockMarker + every { map.addCircle(any()) } returns mockCircle + every { map.addPolygon(any()) } returns mockPolygon + every { map.addPolyline(any()) } returns mockPolyline + every { map.addGroundOverlay(any()) } returns mockGroundOverlay + + manager = OverlayManager(map) + collection = manager.newCollection("test_layer") + } + + @Test + fun testHeterogeneousAdditionAndQueries() { + val marker = collection.add(MarkerOptions().position(LatLng(10.0, 10.0))) + val advMarker = collection.add(AdvancedMarkerOptions().position(LatLng(15.0, 15.0))) + val circle = collection.add(CircleOptions().center(LatLng(20.0, 20.0))) + val polygon = collection.add(PolygonOptions().add(LatLng(0.0, 0.0), LatLng(1.0, 1.0), LatLng(1.0, 0.0))) + val polyline = collection.add(PolylineOptions().add(LatLng(0.0, 0.0), LatLng(1.0, 1.0))) + val groundOverlay = collection.add(GroundOverlayOptions().position(LatLng(5.0, 5.0), 100f)) + + assertThat(collection.size).isEqualTo(6) + assertThat(collection.isEmpty).isFalse() + + assertThat(collection.markers).containsExactly(marker, advMarker) + assertThat(collection.circles).containsExactly(circle) + assertThat(collection.polygons).containsExactly(polygon) + assertThat(collection.polylines).containsExactly(polyline) + assertThat(collection.groundOverlays).containsExactly(groundOverlay) + } + + @Test + fun testOperatorPlusAssign() { + collection += MarkerOptions() + collection += AdvancedMarkerOptions() + collection += CircleOptions() + collection += PolygonOptions() + collection += PolylineOptions() + collection += GroundOverlayOptions() + + assertThat(collection.size).isEqualTo(6) + } + + @Test + fun testBatchAddAll() { + val markers = collection.addAll(listOf(MarkerOptions(), MarkerOptions())) + val advMarkers = collection.addAllAdvancedMarkers(listOf(AdvancedMarkerOptions())) + val circles = collection.addAll(listOf(CircleOptions())) + val polygons = collection.addAll(listOf(PolygonOptions())) + val polylines = collection.addAll(listOf(PolylineOptions())) + val groundOverlays = collection.addAll(listOf(GroundOverlayOptions())) + + assertThat(markers).hasSize(2) + assertThat(advMarkers).hasSize(1) + assertThat(circles).hasSize(1) + assertThat(polygons).hasSize(1) + assertThat(polylines).hasSize(1) + assertThat(groundOverlays).hasSize(1) + } + + @Test + fun testBatchVisibilityAndClearing() { + val marker = collection.add(MarkerOptions()) + val polyline = collection.add(PolylineOptions()) + + collection.hideAll() + assertThat(collection.isVisible).isFalse() + verify { mockMarker.isVisible = false } + verify { mockPolyline.isVisible = false } + + collection.showAll() + assertThat(collection.isVisible).isTrue() + verify { mockMarker.isVisible = true } + verify { mockPolyline.isVisible = true } + + collection.clear() + assertThat(collection.isEmpty).isTrue() + verify { mockMarker.remove() } + verify { mockPolyline.remove() } + } + + @Test + fun testIndividualRemoval() { + val marker = collection.add(MarkerOptions()) + assertThat(collection.size).isEqualTo(1) + + assertThat(marker.remove()).isTrue() + assertThat(collection.isEmpty).isTrue() + verify { mockMarker.remove() } + + // Second remove returns false + assertThat(collection.remove(marker)).isFalse() + } + + @Test + fun testEventDelegation() { + val marker = collection.add(MarkerOptions()) + val circle = collection.add(CircleOptions()) + val polygon = collection.add(PolygonOptions()) + val polyline = collection.add(PolylineOptions()) + val groundOverlay = collection.add(GroundOverlayOptions()) + + // Individual overlay listeners + var markerClicked = false + marker.onClick { + markerClicked = true + true + } + + var circleClicked = false + circle.onClick { circleClicked = true } + + var polygonClicked = false + polygon.onClick { polygonClicked = true } + + var polylineClicked = false + polyline.onClick { polylineClicked = true } + + var groundOverlayClicked = false + groundOverlay.onClick { groundOverlayClicked = true } + + // Dispatch via manager's legacy collections + assertThat(manager.markerManager.onMarkerClick(mockMarker)).isTrue() + assertThat(markerClicked).isTrue() + + manager.circleManager.onCircleClick(mockCircle) + assertThat(circleClicked).isTrue() + + manager.polygonManager.onPolygonClick(mockPolygon) + assertThat(polygonClicked).isTrue() + + manager.polylineManager.onPolylineClick(mockPolyline) + assertThat(polylineClicked).isTrue() + + manager.groundOverlayManager.onGroundOverlayClick(mockGroundOverlay) + assertThat(groundOverlayClicked).isTrue() + } + + @Test + fun testCollectionLevelListeners() { + val marker = collection.add(MarkerOptions()) + var colMarkerClicked = false + collection.onMarkerClick { + colMarkerClicked = true + true + } + + var colInfoClicked = false + collection.onInfoWindowClick { colInfoClicked = true } + + var colInfoLongClicked = false + collection.onInfoWindowLongClick { colInfoLongClicked = true } + + val mockView = mockk() + collection.setCustomInfoWindow( + infoWindow = { mockView }, + infoContents = { null } + ) + + assertThat(manager.markerManager.onMarkerClick(mockMarker)).isTrue() + assertThat(colMarkerClicked).isTrue() + + manager.markerManager.onInfoWindowClick(mockMarker) + assertThat(colInfoClicked).isTrue() + + manager.markerManager.onInfoWindowLongClick(mockMarker) + assertThat(colInfoLongClicked).isTrue() + + assertThat(manager.markerManager.getInfoWindow(mockMarker)).isEqualTo(mockView) + assertThat(manager.markerManager.getInfoContents(mockMarker)).isNull() + + // Test collection-level fallback listeners for other geometries + val circle = collection.add(CircleOptions()) + val polygon = collection.add(PolygonOptions()) + val polyline = collection.add(PolylineOptions()) + val groundOverlay = collection.add(GroundOverlayOptions()) + + var circleClicked = false + var polygonClicked = false + var polylineClicked = false + var groundOverlayClicked = false + + collection.onCircleClick { circleClicked = true } + collection.onPolygonClick { polygonClicked = true } + collection.onPolylineClick { polylineClicked = true } + collection.onGroundOverlayClick { groundOverlayClicked = true } + + manager.circleManager.onCircleClick(mockCircle) + assertThat(circleClicked).isTrue() + + manager.polygonManager.onPolygonClick(mockPolygon) + assertThat(polygonClicked).isTrue() + + manager.polylineManager.onPolylineClick(mockPolyline) + assertThat(polylineClicked).isTrue() + + manager.groundOverlayManager.onGroundOverlayClick(mockGroundOverlay) + assertThat(groundOverlayClicked).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/overlay/OverlayManagerTest.kt b/library/src/test/java/com/google/maps/android/collections/overlay/OverlayManagerTest.kt new file mode 100644 index 000000000..903bbfbc8 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/overlay/OverlayManagerTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.collections.overlay + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.collections.MarkerManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [OverlayManager]. + */ +@RunWith(RobolectricTestRunner::class) +class OverlayManagerTest { + + private lateinit var map: GoogleMap + private lateinit var sharedMarkerManager: MarkerManager + private lateinit var overlayManager: OverlayManager + + @Before + fun setUp() { + map = mockk(relaxed = true) + sharedMarkerManager = MarkerManager(map) + // Initialize OverlayManager sharing the same MarkerManager (Coexistence mode) + overlayManager = OverlayManager(map = map, markerManager = sharedMarkerManager) + } + + @Test + fun testCollectionManagement() { + val col1 = overlayManager.newCollection("routes") + assertThat(overlayManager.getCollection("routes")).isSameInstanceAs(col1) + + // Unique ID constraint + assertThrows(IllegalArgumentException::class.java) { + overlayManager.newCollection("routes") + } + + // Anonymous collection + val anon = overlayManager.newCollection() + assertThat(anon.id).isNull() + + // Remove collection + assertThat(overlayManager.removeCollection("routes")).isTrue() + assertThat(overlayManager.getCollection("routes")).isNull() + assertThat(overlayManager.removeCollection("non_existent")).isFalse() + } + + @Test + fun testCoexistenceWithLegacyManager() { + val mockLegacyMarker = mockk(relaxed = true) + val mockOverlayMarker = mockk(relaxed = true) + + every { map.addMarker(any()) } returnsMany listOf(mockLegacyMarker, mockOverlayMarker) + + // Legacy marker created via shared MarkerManager + val legacyCollection = sharedMarkerManager.newCollection() + val legacyMarker = legacyCollection.addMarker(MarkerOptions()) + + // Unified overlay created via OverlayManager + val overlayCollection = overlayManager.newCollection("unified") + val overlayMarker = overlayCollection.add(MarkerOptions()) + + var legacyClicked = false + legacyCollection.setOnMarkerClickListener { + legacyClicked = true + true + } + + var overlayClicked = false + overlayMarker.onClick { + overlayClicked = true + true + } + + // Clicking legacy marker invokes legacy listener + assertThat(sharedMarkerManager.onMarkerClick(legacyMarker)).isTrue() + assertThat(legacyClicked).isTrue() + assertThat(overlayClicked).isFalse() + + // Clicking overlay marker invokes overlay listener + assertThat(sharedMarkerManager.onMarkerClick(mockOverlayMarker)).isTrue() + assertThat(overlayClicked).isTrue() + + // Test drag delegation through shared manager + var dragStarted = false + var dragging = false + var dragEnded = false + overlayMarker.onDrag( + onStart = { dragStarted = true }, + onDrag = { dragging = true }, + onEnd = { dragEnded = true } + ) + + sharedMarkerManager.onMarkerDragStart(mockOverlayMarker) + assertThat(dragStarted).isTrue() + + sharedMarkerManager.onMarkerDrag(mockOverlayMarker) + assertThat(dragging).isTrue() + + sharedMarkerManager.onMarkerDragEnd(mockOverlayMarker) + assertThat(dragEnded).isTrue() + + // Test info window delegation + val mockView = mockk() + overlayMarker.setCustomInfoWindow(infoWindow = { mockView }) + assertThat(sharedMarkerManager.getInfoWindow(mockOverlayMarker)).isEqualTo(mockView) + assertThat(sharedMarkerManager.getInfoContents(mockOverlayMarker)).isNull() + } + + @Test + fun testClearAll() { + val mockMarker = mockk(relaxed = true) + every { map.addMarker(any()) } returns mockMarker + + val col1 = overlayManager.newCollection("col1") + col1.add(MarkerOptions()) + val anon = overlayManager.newCollection() + anon.add(MarkerOptions()) + + overlayManager.clearAll() + assertThat(col1.isEmpty).isTrue() + assertThat(anon.isEmpty).isTrue() + assertThat(overlayManager.getCollection("col1")).isNull() + } +}