diff --git a/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt index 374890256..2856c3eea 100644 --- a/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt @@ -27,7 +27,12 @@ class BomPublishingConventionPlugin : Plugin { extensions.configure { publishToMavenCentral() - signAllPublications() + if (findProperty("signing.keyId")?.toString()?.isNotBlank() == true || + findProperty("signing.secretKeyRingFile")?.toString()?.isNotBlank() == true || + findProperty("signingInMemoryKey")?.toString()?.isNotBlank() == true + ) { + signAllPublications() + } coordinates( artifactId = "maps-utils-bom", diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 6ff1dc533..228c8a06a 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -83,7 +83,12 @@ class PublishingConventionPlugin : Plugin { ) publishToMavenCentral() - signAllPublications() + if (findProperty("signing.keyId")?.toString()?.isNotBlank() == true || + findProperty("signing.secretKeyRingFile")?.toString()?.isNotBlank() == true || + findProperty("signingInMemoryKey")?.toString()?.isNotBlank() == true + ) { + signAllPublications() + } val artifactIdName = when (project.name) { "maps-utils" -> "android-maps-utils" 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..519edaf1c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/CircleManager.kt @@ -0,0 +1,71 @@ +/* + * 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 setVisible(mapObject: Circle, visible: Boolean) { + mapObject.isVisible = visible + } + + 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 = + checkAndAdd(mMap.addCircle(opts), "Circle") + + open fun addAll(opts: KotlinCollection) = + addAll(opts, ::addCircle) + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) = + addAll(opts, defaultVisible, ::addCircle) + + 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..7dd9d07d1 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt @@ -0,0 +1,74 @@ +/* + * 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 setVisible(mapObject: GroundOverlay, visible: Boolean) { + mapObject.isVisible = visible + } + + 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 = + checkAndAdd(mMap.addGroundOverlay(opts), "GroundOverlay") + + open fun addAll(opts: KotlinCollection) = + addAll(opts, ::addGroundOverlay) + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) = + addAll(opts, defaultVisible, ::addGroundOverlay) + + 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..5c6b7116a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt @@ -0,0 +1,141 @@ +/* + * 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) + + protected open fun setVisible(mapObject: O, visible: Boolean) {} + + 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 + } + + protected open fun checkAndAdd(mapObject: O?, typeName: String): O = + checkNotNull(mapObject) { "Failed to add $typeName to GoogleMap" }.also { add(it) } + + open fun showAll() { + for (mapObject in mObjects) { + setVisible(mapObject, true) + } + } + + open fun hideAll() { + for (mapObject in mObjects) { + setVisible(mapObject, false) + } + } + + protected open fun addAll(opts: KotlinCollection, adder: (T) -> O) { + for (opt in opts) { + adder(opt) + } + } + + protected open fun addAll(opts: KotlinCollection, defaultVisible: Boolean, adder: (T) -> O) { + for (opt in opts) { + val obj = adder(opt) + setVisible(obj, defaultVisible) + } + } + + 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..ebb2b873c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/MarkerManager.kt @@ -0,0 +1,129 @@ +/* + * 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() + } + + override fun setVisible(mapObject: Marker, visible: Boolean) { + mapObject.isVisible = visible + } + + /** 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 = + checkAndAdd(mMap.addMarker(opts), "Marker") + + open fun addMarker(opts: AdvancedMarkerOptions): Marker = + checkAndAdd(mMap.addMarker(opts), "AdvancedMarker") + + open fun addAll(opts: KotlinCollection) = + addAll(opts, ::addMarker) + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) = + addAll(opts, defaultVisible, ::addMarker) + + 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..98eef696a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/PolygonManager.kt @@ -0,0 +1,71 @@ +/* + * 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 setVisible(mapObject: Polygon, visible: Boolean) { + mapObject.isVisible = visible + } + + 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 = + checkAndAdd(mMap.addPolygon(opts), "Polygon") + + open fun addAll(opts: KotlinCollection) = + addAll(opts, ::addPolygon) + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) = + addAll(opts, defaultVisible, ::addPolygon) + + 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..61de4b7ef --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/PolylineManager.kt @@ -0,0 +1,73 @@ +/* + * 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 setVisible(mapObject: Polyline, visible: Boolean) { + mapObject.isVisible = visible + } + + 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 = + checkAndAdd(mMap.addPolyline(opts), "Polyline") + + open fun addAll(opts: KotlinCollection) = + addAll(opts, ::addPolyline) + + open fun addAll(opts: KotlinCollection, defaultVisible: Boolean) = + addAll(opts, defaultVisible, ::addPolyline) + + 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..bb1e2f54d --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt @@ -0,0 +1,157 @@ +/* + * 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) { + var isVisible: Boolean = true + } + + 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) + } + + override fun setVisible(mapObject: TestObject, visible: Boolean) { + mapObject.isVisible = visible + } + + inner class Collection : MapObjectManager.Collection() { + fun addObject(obj: TestObject) { + super.add(obj) + } + + fun addTestObject(id: String): TestObject = + checkAndAdd(TestObject(id), "TestObject") + + fun addAll(ids: kotlin.collections.Collection) = + addAll(ids, ::addTestObject) + + fun addAll(ids: kotlin.collections.Collection, defaultVisible: Boolean) = + addAll(ids, defaultVisible, ::addTestObject) + + 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) + } + + @Test + fun testShowAllAndHideAll() { + val col = manager.newCollection() + val obj1 = TestObject("1") + val obj2 = TestObject("2") + + col.addObject(obj1) + col.addObject(obj2) + + col.hideAll() + assertThat(obj1.isVisible).isFalse() + assertThat(obj2.isVisible).isFalse() + + col.showAll() + assertThat(obj1.isVisible).isTrue() + assertThat(obj2.isVisible).isTrue() + } + + @Test + fun testAddAll() { + val col = manager.newCollection() + col.addAll(listOf("a", "b")) + assertThat(col.testGetObjects()).hasSize(2) + + val col2 = manager.newCollection() + col2.addAll(listOf("c", "d"), defaultVisible = false) + assertThat(col2.testGetObjects()).hasSize(2) + for (obj in col2.testGetObjects()) { + assertThat(obj.isVisible).isFalse() + } + } +} 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() + } +}