From 85db3abd065a1c639160144ce9bec26dc359be46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:01:16 +0700 Subject: [PATCH 01/11] feat: experimental KMP support for clustering module Introduces a maps-model multiplatform module with common LatLng and CameraPosition types (typealiased to the Play Services classes on Android, plain value holders on iOS) and converts the clustering module to Kotlin Multiplatform: the full algorithm layer (quadtree, geometry, projection, all clustering algorithms) now lives in commonMain and compiles for Android and iOS, while ClusterManager and the renderers remain Android-only in androidMain. JVM-only constructs in common code were replaced with multiplatform equivalents: an expect/actual PlatformLock replaces synchronized blocks and ReentrantReadWriteLock, java.util collections were swapped for Kotlin stdlib ones, and Math.* calls for kotlin.math. The three remaining Java test files were converted to Kotlin because KMP compilations do not compile Java host-test sources. Known gaps (prototype): publishing, jacoco, lint-checks and consumer proguard rules are not yet wired for the KMP module layout. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- clustering/build.gradle.kts | 111 ++++---- .../android/clustering/ClusterManagerTest.kt | 0 .../maps/android/clustering/QuadItemTest.kt | 227 ++++++++++++++++ .../android/clustering/StaticClusterTest.kt | 0 .../clustering/algo/AbstractAlgorithmTest.kt | 0 .../android/clustering/algo/BenchmarkTest.kt | 0 ...nHierarchicalDistanceBasedAlgorithmTest.kt | 56 ++++ ...nuousZoomEuclideanCentroidAlgorithmTest.kt | 88 ++++++ .../clustering/algo/GridBasedAlgorithmTest.kt | 0 .../NonHierarchicalViewBasedAlgorithmTest.kt | 0 .../algo/PreCachingAlgorithmDecoratorTest.kt | 0 .../algo/ScreenBasedAlgorithmAdapterTest.kt | 0 .../maps/android/geometry/BoundsTest.kt | 0 .../google/maps/android/geometry/PointTest.kt | 0 .../maps/android/projection/PointTest.kt | 0 .../SphericalMercatorProjectionTest.kt | 0 .../android/quadtree/PointQuadTreeTest.kt | 0 .../resources/robolectric.properties | 0 .../{main => androidMain}/AndroidManifest.xml | 0 .../maps/android/clustering/ClusterManager.kt | 0 .../clustering/algo/PlatformLock.android.kt | 26 ++ .../algo/PreCachingAlgorithmDecorator.kt | 0 .../clustering/view/ClusterRenderer.kt | 0 .../view/ClusterRendererMultipleItems.kt | 0 .../DefaultAdvancedMarkersClusterRenderer.kt | 0 .../clustering/view/DefaultClusterRenderer.kt | 0 .../google/maps/android/clustering/Cluster.kt | 2 +- .../maps/android/clustering/ClusterItem.kt | 2 +- .../clustering/algo/AbstractAlgorithm.kt | 8 +- .../maps/android/clustering/algo/Algorithm.kt | 0 ...idNonHierarchicalDistanceBasedAlgorithm.kt | 5 +- ...ontinuousZoomEuclideanCentroidAlgorithm.kt | 5 +- .../clustering/algo/GridBasedAlgorithm.kt | 20 +- .../NonHierarchicalDistanceBasedAlgorithm.kt | 36 +-- .../algo/NonHierarchicalViewBasedAlgorithm.kt | 8 +- .../android/clustering/algo/PlatformLock.kt | 43 +++ .../clustering/algo/ScreenBasedAlgorithm.kt | 2 +- .../algo/ScreenBasedAlgorithmAdapter.kt | 2 +- .../android/clustering/algo/StaticCluster.kt | 2 +- .../google/maps/android/geometry/Bounds.kt | 2 + .../com/google/maps/android/geometry/Point.kt | 4 +- .../google/maps/android/projection/Point.kt | 0 .../projection/SphericalMercatorProjection.kt | 8 +- .../maps/android/quadtree/PointQuadTree.kt | 1 + .../clustering/algo/PlatformLock.ios.kt | 26 ++ .../maps/android/clustering/QuadItemTest.java | 252 ------------------ ...ierarchicalDistanceBasedAlgorithmTest.java | 74 ----- ...ousZoomEuclideanCentroidAlgorithmTest.java | 115 -------- gradle/libs.versions.toml | 1 + maps-model/build.gradle.kts | 41 +++ .../maps/android/model/Model.android.kt | 34 +++ .../maps/android/model/CameraPosition.kt | 31 +++ .../com/google/maps/android/model/LatLng.kt | 35 +++ .../google/maps/android/model/Model.ios.kt | 60 +++++ settings.gradle.kts | 2 +- 55 files changed, 772 insertions(+), 557 deletions(-) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/ClusterManagerTest.kt (100%) create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/StaticClusterTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/BenchmarkTest.kt (100%) create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/geometry/BoundsTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/geometry/PointTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/projection/PointTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/quadtree/PointQuadTreeTest.kt (100%) rename clustering/src/{test => androidHostTest}/resources/robolectric.properties (100%) rename clustering/src/{main => androidMain}/AndroidManifest.xml (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/ClusterManager.kt (100%) create mode 100644 clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/ClusterRenderer.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt (100%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/Cluster.kt (94%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/ClusterItem.kt (95%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt (79%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/Algorithm.kt (100%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt (95%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt (97%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt (87%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt (91%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt (94%) create mode 100644 clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt (95%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt (97%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/StaticCluster.kt (97%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/geometry/Bounds.kt (98%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/geometry/Point.kt (92%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/projection/Point.kt (100%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/projection/SphericalMercatorProjection.kt (83%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/quadtree/PointQuadTree.kt (99%) create mode 100644 clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java create mode 100644 maps-model/build.gradle.kts create mode 100644 maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt create mode 100644 maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt create mode 100644 maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt create mode 100644 maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index a17bcc2f1..c915ca9ec 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** * Copyright 2026 Google LLC * @@ -16,79 +14,60 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { - + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") } -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { +// NOTE (KMP prototype): the module previously applied android.maps.utils.PublishingConventionPlugin, +// which is hard-wired to com.android.library + AndroidSingleVariantLibrary publishing. A KMP-aware +// variant (vanniktech KotlinMultiplatform() publishing + jacoco for the android target) is needed +// before this module can be released from this branch. Lint publishing (lint-checks), the amu_ +// resourcePrefix and consumer proguard rules from the old build also need re-wiring. + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android.clustering" compileSdk = libs.versions.compileSdk.get().toInt() minSdk = 23 - testOptions.targetSdk = libs.versions.targetSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true } } - resourcePrefix = "amu_" - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } + iosArm64() + iosSimulatorArm64() + iosX64() - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + // androidx.collection is multiplatform; LongSparseArray/LruCache work in common code + implementation(libs.androidx.collection) + } + androidMain.dependencies { + implementation(project(":ui")) + implementation(project(":library")) + implementation(project(":data")) + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.kotlin.test) + implementation(libs.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockito.core) } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true } - namespace = "com.google.maps.android.clustering" -} - -dependencies { - implementation(project(":ui")) - implementation(project(":library")) - implementation(project(":data")) - api(libs.play.services.maps) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - lintPublish(project(":lint-checks")) - testImplementation(libs.junit) - testImplementation(libs.robolectric) - testImplementation(libs.kxml2) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.truth) - implementation(libs.kotlin.stdlib.jdk8) - - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.mockito.core) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") } diff --git a/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/ClusterManagerTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/ClusterManagerTest.kt diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt new file mode 100644 index 000000000..92b89ce48 --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt @@ -0,0 +1,227 @@ +/* + * 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.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.projection.SphericalMercatorProjection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuadItemTest { + @Test + fun testAddRemoveUpdateClear() { + val item15: ClusterItem = TestingItem("title1", 0.1, 0.5) + val item23 = TestingItem("title2", 0.2, 0.3) + + val algo = NonHierarchicalDistanceBasedAlgorithm() + assertTrue(algo.addItem(item15)) + assertTrue(algo.addItem(item23)) + + assertEquals(2, algo.items.size) + + assertTrue(algo.removeItem(item15)) + + assertEquals(1, algo.items.size) + + assertFalse(algo.items.contains(item15)) + assertTrue(algo.items.contains(item23)) + + // Update the item still in the algorithm + item23.title = "newTitle" + assertTrue(algo.updateItem(item23)) + + // Try to remove the item that was already removed + assertFalse(algo.removeItem(item15)) + + // Try to update the item that was already removed + assertFalse(algo.updateItem(item15)) + + algo.clearItems() + assertEquals(0, algo.items.size) + + // Test bulk operations + val items = listOf(item15, item23) + assertTrue(algo.addItems(items)) + + // Try to bulk add items that were already added + assertFalse(algo.addItems(items)) + + assertTrue(algo.removeItems(items)) + + // Try to bulk remove items that were already removed + assertFalse(algo.removeItems(items)) + } + + /** + * Test if insertion order into the algorithm is the same as returned item order. This matters + * because we want repeatable clustering behavior when updating model values and re-clustering. + */ + @Test + fun testInsertionOrder() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + for (i in 0 until 100) { + algo.addItem(TestingItem(i.toString(), 0.0, 0.0)) + } + + assertEquals(100, algo.items.size) + + var counter = 0 + for (item in algo.items) { + assertEquals(counter.toString(), item.title) + counter++ + } + } + + @Test + fun testUpdateItemAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item = TestingItem("title1", 0.0, 0.0) + algo.addItem(item) + assertEquals(1, algo.items.size) + + // Update the position of the mutable item + item.setPosition(10.0, 10.0) + + // Call updateItem + assertTrue("updateItem should return true after position change", algo.updateItem(item)) + assertEquals(1, algo.items.size) + + // Verify that the old QuadItem at (0, 0) was removed from the tree + // and only the new position (10, 10) is indexed + val clusters = algo.getClusters(4.0f) + assertEquals(1, clusters.size) + val cluster = clusters.iterator().next() + assertEquals(10.0, cluster.position.latitude, 0.001) + assertEquals(10.0, cluster.position.longitude, 0.001) + } + + @Test + fun testRemoveItemAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item = TestingItem("title1", 0.0, 0.0) + algo.addItem(item) + assertEquals(1, algo.items.size) + + // Update the position of the mutable item + item.setPosition(10.0, 10.0) + + // Removing the item should succeed and remove it from the tree + assertTrue("removeItem should return true after position change", algo.removeItem(item)) + assertEquals(0, algo.items.size) + assertEquals(0, algo.getClusters(4.0f).size) + } + + @Test + fun testUpdateItemPreventsStaleQuadTreeEntries() { + val algo = TestAlgorithm() + + // Add 60 filler items to force PointQuadTree to split (MAX_ELEMENTS = 50) + for (i in 0 until 60) { + algo.addItem(TestingItem("filler$i", 10.0 + i * 0.001, 10.0 + i * 0.001)) + } + + // Add item1 in top-left quadrant + val item1 = TestingItem("item1", 1.0, 1.0) + algo.addItem(item1) + + assertEquals( + "QuadTree should contain item1 at (1, 1)", + 1, + algo.getQuadTreeItemCount(1.0, 1.0, 0.001), + ) + + // Move item1 far across quadrant boundary to (50.0, 50.0) and update + item1.setPosition(50.0, 50.0) + algo.updateItem(item1) + + // Without fix, old QuadItem remains at (1.0, 1.0) in mQuadTree because remove traversed the new coordinates + assertEquals( + "QuadTree should NOT contain stale entry at (1, 1) after update", + 0, + algo.getQuadTreeItemCount(1.0, 1.0, 0.001), + ) + assertEquals( + "QuadTree should contain item1 at (50, 50)", + 1, + algo.getQuadTreeItemCount(50.0, 50.0, 0.001), + ) + } + + @Test + fun testRemoveItemsAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item1 = TestingItem("title1", 0.0, 0.0) + val item2 = TestingItem("title2", 1.0, 1.0) + algo.addItems(listOf(item1, item2)) + assertEquals(2, algo.items.size) + + // Update the position of both items + item1.setPosition(10.0, 10.0) + item2.setPosition(20.0, 20.0) + + assertTrue( + "removeItems should return true after position change", + algo.removeItems(listOf(item1, item2)), + ) + assertEquals(0, algo.items.size) + assertEquals(0, algo.getClusters(4.0f).size) + } + + @Test + fun testClearItemsAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item1 = TestingItem("title1", 0.0, 0.0) + algo.addItem(item1) + item1.setPosition(10.0, 10.0) + + algo.clearItems() + assertEquals(0, algo.items.size) + assertEquals(0, algo.getClusters(4.0f).size) + } + + private class TestAlgorithm : NonHierarchicalDistanceBasedAlgorithm() { + fun getQuadTreeItemCount(lat: Double, lng: Double, span: Double): Int { + val p = PROJ.toPoint(LatLng(lat, lng)) + val bounds = Bounds(p.x - span, p.x + span, p.y - span, p.y + span) + return mQuadTree.search(bounds).size + } + + companion object { + private val PROJ = SphericalMercatorProjection(1.0) + } + } + + private class TestingItem( + override var title: String, + lat: Double, + lng: Double, + ) : ClusterItem { + override var position: LatLng = LatLng(lat, lng) + private set + + fun setPosition(lat: Double, lng: Double) { + position = LatLng(lat, lng) + } + + override val snippet: String? = null + + override val zIndex: Float? = null + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/StaticClusterTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/StaticClusterTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/BenchmarkTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/BenchmarkTest.kt diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt new file mode 100644 index 000000000..f4811e49c --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt @@ -0,0 +1,56 @@ +/* + * 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.maps.android.clustering.ClusterItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class CentroidNonHierarchicalDistanceBasedAlgorithmTest { + class TestClusterItem(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 = 0f + } + + // computeCentroid is protected; the previous Java test relied on JVM package-level + // access, which Kotlin does not have. + private class ExposedAlgorithm : CentroidNonHierarchicalDistanceBasedAlgorithm() { + fun centroidOf(items: Collection): LatLng = computeCentroid(items) + } + + @Test + fun testComputeCentroid() { + val algo = ExposedAlgorithm() + + val items = + listOf( + TestClusterItem(10.0, 20.0), + TestClusterItem(20.0, 30.0), + TestClusterItem(30.0, 40.0), + ) + + val centroid = algo.centroidOf(items) + + assertEquals(20.0, centroid.latitude, 0.0001) + assertEquals(30.0, centroid.longitude, 0.0001) + } +} diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt new file mode 100644 index 000000000..73f88fdcd --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt @@ -0,0 +1,88 @@ +/* + * 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.maps.android.clustering.ClusterItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContinuousZoomEuclideanCentroidAlgorithmTest { + class TestClusterItem(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 = 0f + } + + @Test + fun testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() { + val algo = ContinuousZoomEuclideanCentroidAlgorithm() + + val items = + listOf( + TestClusterItem(10.0, 10.0), + // very close to the first + TestClusterItem(10.0001, 10.0001), + // far away + TestClusterItem(20.0, 20.0), + ) + + algo.addItems(items) + + // At a high zoom, the close pair should be separate (small radius) + val highZoom = algo.getClusters(20.0f) + assertEquals(3, highZoom.size) + + // At a lower zoom, the close pair should merge (larger radius) + val lowZoom = algo.getClusters(5.0f) + assertTrue(lowZoom.size < 3) + + // Specifically, we expect one cluster of size 2 and one singleton + assertTrue(lowZoom.any { it.items.size == 2 }) + assertTrue(lowZoom.any { it.items.size == 1 }) + } + + @Test + fun testClusterPositionsAreCentroids() { + val algo = ContinuousZoomEuclideanCentroidAlgorithm() + + val items = + listOf( + TestClusterItem(0.0, 0.0), + TestClusterItem(0.0, 2.0), + TestClusterItem(2.0, 0.0), + ) + + algo.addItems(items) + + val clusters = algo.getClusters(1.0f) + + // Expect all items clustered into one + assertEquals(1, clusters.size) + + val cluster = clusters.iterator().next() + + // The centroid should be approximately (0.6667, 0.6667) + val centroid = cluster.position + assertEquals(0.6667, centroid.latitude, 0.0001) + assertEquals(0.6667, centroid.longitude, 0.0001) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/BoundsTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/BoundsTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/PointTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/PointTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/projection/PointTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/PointTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/projection/PointTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/PointTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/quadtree/PointQuadTreeTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/quadtree/PointQuadTreeTest.kt diff --git a/clustering/src/test/resources/robolectric.properties b/clustering/src/androidHostTest/resources/robolectric.properties similarity index 100% rename from clustering/src/test/resources/robolectric.properties rename to clustering/src/androidHostTest/resources/robolectric.properties diff --git a/clustering/src/main/AndroidManifest.xml b/clustering/src/androidMain/AndroidManifest.xml similarity index 100% rename from clustering/src/main/AndroidManifest.xml rename to clustering/src/androidMain/AndroidManifest.xml diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/ClusterManager.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/ClusterManager.kt diff --git a/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt new file mode 100644 index 000000000..358feab81 --- /dev/null +++ b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt @@ -0,0 +1,26 @@ +/* + * 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 java.util.concurrent.locks.ReentrantLock + +internal actual class PlatformLock { + private val delegate = ReentrantLock() + + actual fun lock(): Unit = delegate.lock() + + actual fun unlock(): Unit = delegate.unlock() +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRenderer.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRenderer.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/Cluster.kt similarity index 94% rename from clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/Cluster.kt index e239cec37..c007f6214 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/Cluster.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng /** * A collection of ClusterItems that are nearby each other. diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/ClusterItem.kt similarity index 95% rename from clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/ClusterItem.kt index e3409f893..2c50d975e 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/ClusterItem.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng /** * ClusterItem represents a marker on the map. diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt similarity index 79% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt index 8ba76a038..4916f901b 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt @@ -16,20 +16,18 @@ package com.google.maps.android.clustering.algo import com.google.maps.android.clustering.ClusterItem -import java.util.concurrent.locks.ReadWriteLock -import java.util.concurrent.locks.ReentrantReadWriteLock /** * Base Algorithm class that implements lock/unlock functionality. */ abstract class AbstractAlgorithm : Algorithm { - private val mLock: ReadWriteLock = ReentrantReadWriteLock() + private val mLock = PlatformLock() override fun lock() { - mLock.writeLock().lock() + mLock.lock() } override fun unlock() { - mLock.writeLock().unlock() + mLock.unlock() } } diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/Algorithm.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/Algorithm.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt similarity index 95% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt index 521e7971f..0967908ea 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt @@ -15,10 +15,11 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.longitude +import com.google.maps.android.model.latitude import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem -import java.util.HashSet /** * A variant of [NonHierarchicalDistanceBasedAlgorithm] that clusters items diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt similarity index 97% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt index b9c048aca..76e01900c 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt @@ -17,9 +17,6 @@ package com.google.maps.android.clustering.algo import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem -import java.util.ArrayList -import java.util.HashMap -import java.util.HashSet import kotlin.math.pow /** @@ -41,7 +38,7 @@ class ContinuousZoomEuclideanCentroidAlgorithm : CentroidNonHie val distanceToCluster = HashMap, Double>() val itemToCluster = HashMap, StaticCluster>() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (candidate in getClusteringItems(mQuadTree, zoom)) { if (visitedCandidates.contains(candidate)) { // Candidate is already part of another cluster. diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt similarity index 87% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt index 4a77032b8..e97af5183 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt @@ -20,7 +20,6 @@ import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.geometry.Point import com.google.maps.android.projection.SphericalMercatorProjection -import java.util.Collections import kotlin.math.ceil import kotlin.math.floor import kotlin.math.pow @@ -37,23 +36,24 @@ import kotlin.math.pow */ class GridBasedAlgorithm : AbstractAlgorithm() { private var mGridSize = DEFAULT_GRID_SIZE - private val mItems: MutableSet = Collections.synchronizedSet(HashSet()) + private val mItemsLock = PlatformLock() + private val mItems: MutableSet = HashSet() - override fun addItem(item: T): Boolean = mItems.add(item) + override fun addItem(item: T): Boolean = mItemsLock.withLock { mItems.add(item) } - override fun addItems(items: Collection): Boolean = mItems.addAll(items) + override fun addItems(items: Collection): Boolean = mItemsLock.withLock { mItems.addAll(items) } override fun clearItems() { - mItems.clear() + mItemsLock.withLock { mItems.clear() } } - override fun removeItem(item: T): Boolean = mItems.remove(item) + override fun removeItem(item: T): Boolean = mItemsLock.withLock { mItems.remove(item) } - override fun removeItems(items: Collection): Boolean = mItems.removeAll(items.toSet()) + override fun removeItems(items: Collection): Boolean = mItemsLock.withLock { mItems.removeAll(items.toSet()) } override fun updateItem(item: T): Boolean { var result: Boolean - synchronized(mItems) { + mItemsLock.withLock { result = removeItem(item) if (result) { // Only add the item if it was removed (to help prevent accidental duplicates on map) @@ -76,7 +76,7 @@ class GridBasedAlgorithm : AbstractAlgorithm() { val clusters = HashSet>() val sparseArray = LongSparseArray>() - synchronized(mItems) { + mItemsLock.withLock { for (item in mItems) { val p = proj.toPoint(item.position) val coord = getCoord(numCells, p.x, p.y) @@ -101,7 +101,7 @@ class GridBasedAlgorithm : AbstractAlgorithm() { } override val items: Collection - get() = mItems + get() = mItemsLock.withLock { mItems.toSet() } companion object { private const val DEFAULT_GRID_SIZE = 100 diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt similarity index 91% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt index 5fcac43e9..f560655a0 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt @@ -15,17 +15,15 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.geometry.Bounds import com.google.maps.android.geometry.Point import com.google.maps.android.projection.SphericalMercatorProjection import com.google.maps.android.quadtree.PointQuadTree -import java.util.Collections -import java.util.HashMap -import java.util.HashSet -import java.util.LinkedHashSet +import kotlin.jvm.JvmField +import kotlin.math.pow /** * A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not @@ -42,14 +40,20 @@ import java.util.LinkedHashSet */ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgorithm() { /** - * Any modifications should be synchronized on mQuadTree. + * Guards [mItems], [mItemMap] and [mQuadTree]. Replaces the pre-multiplatform + * `synchronized(mQuadTree)` blocks; reentrant so nested locking keeps working. + */ + internal val quadTreeLock = PlatformLock() + + /** + * Any modifications must hold [quadTreeLock]. */ @JvmField protected val mItems: MutableCollection> = LinkedHashSet() protected val mItemMap = HashMap>() /** - * Any modifications should be synchronized on mQuadTree. + * Any modifications must hold [quadTreeLock]. */ @JvmField protected val mQuadTree: PointQuadTree> = PointQuadTree(0.0, 1.0, 0.0, 1.0) @@ -58,7 +62,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun addItem(item: T): Boolean { val quadItem = QuadItem(item) - synchronized(mQuadTree) { + quadTreeLock.withLock { val result = mItems.add(quadItem) if (result) { mItemMap[item] = quadItem @@ -80,7 +84,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo } override fun clearItems() { - synchronized(mQuadTree) { + quadTreeLock.withLock { mItems.clear() mItemMap.clear() mQuadTree.clear() @@ -90,7 +94,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun removeItem(item: T): Boolean { // QuadItem delegates hashcode() and equals() to its item so, // removing any QuadItem to that item will remove the item - synchronized(mQuadTree) { + quadTreeLock.withLock { val quadItem = mItemMap.remove(item) ?: QuadItem(item) val result = mItems.remove(quadItem) if (result) { @@ -102,7 +106,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun removeItems(items: Collection): Boolean { var result = false - synchronized(mQuadTree) { + quadTreeLock.withLock { for (item in items) { // QuadItem delegates hashcode() and equals() to its item so, // removing any QuadItem to that item will remove the item @@ -119,7 +123,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun updateItem(item: T): Boolean { // TODO - Can this be optimized to update the item in-place if the location hasn't changed? - synchronized(mQuadTree) { + quadTreeLock.withLock { var result = removeItem(item) if (result) { // Only add the item if it was removed (to help prevent accidental duplicates on map) @@ -132,14 +136,14 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun getClusters(zoom: Float): Set> { val discreteZoom = zoom.toInt() - val zoomSpecificSpan = maxDistanceBetweenClusteredItems.toDouble() / Math.pow(2.0, discreteZoom.toDouble()) / 256.0 + val zoomSpecificSpan = maxDistanceBetweenClusteredItems.toDouble() / 2.0.pow(discreteZoom.toDouble()) / 256.0 val visitedCandidates = HashSet>() val results = HashSet>() val distanceToCluster = HashMap, Double>() val itemToCluster = HashMap, StaticCluster>() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (candidate in getClusteringItems(mQuadTree, zoom)) { if (visitedCandidates.contains(candidate)) { // Candidate is already part of another cluster. @@ -187,7 +191,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override val items: Collection get() { val items = LinkedHashSet() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (quadItem in mItems) { items.add(quadItem.mClusterItem) } @@ -235,7 +239,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo Cluster { private val mPoint: Point = PROJECTION.toPoint(mClusterItem.position) private val mPosition: LatLng = mClusterItem.position - private val singletonSet: Set = Collections.singleton(mClusterItem) + private val singletonSet: Set = setOf(mClusterItem) override val point: Point get() = mPoint diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt similarity index 94% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt index bdd28d678..82bfa4d4f 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt @@ -15,13 +15,15 @@ */ 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.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.target +import com.google.maps.android.model.longitude +import com.google.maps.android.model.latitude import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.geometry.Bounds import com.google.maps.android.projection.SphericalMercatorProjection import com.google.maps.android.quadtree.PointQuadTree -import java.util.ArrayList import kotlin.math.pow /** diff --git a/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt new file mode 100644 index 000000000..c195f8e87 --- /dev/null +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt @@ -0,0 +1,43 @@ +/* + * 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 kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * A reentrant mutual-exclusion lock usable from common code. Replaces the JVM-only + * `synchronized` blocks and `ReentrantReadWriteLock` the algorithms used before the + * multiplatform migration. Exclusive locking is a strict (safe) narrowing of the previous + * read/write locking. + */ +internal expect class PlatformLock() { + fun lock() + + fun unlock() +} + +@OptIn(ExperimentalContracts::class) +internal inline fun PlatformLock.withLock(block: () -> T): T { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + lock() + try { + return block() + } finally { + unlock() + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt similarity index 95% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt index f6aab988f..fd83ba064 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.model.CameraPosition import com.google.maps.android.clustering.ClusterItem /** diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt similarity index 97% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt index 52f08c02d..88c620672 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.model.CameraPosition import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/StaticCluster.kt similarity index 97% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/StaticCluster.kt index e292b6e33..86d51bf16 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/StaticCluster.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem diff --git a/clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Bounds.kt similarity index 98% rename from clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Bounds.kt index cafd350b2..4c34178a0 100755 --- a/clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Bounds.kt @@ -15,6 +15,8 @@ */ package com.google.maps.android.geometry +import kotlin.jvm.JvmField + /** * Represents an area in the cartesian plane. */ diff --git a/clustering/src/main/java/com/google/maps/android/geometry/Point.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Point.kt similarity index 92% rename from clustering/src/main/java/com/google/maps/android/geometry/Point.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Point.kt index 14038e200..049d562e4 100644 --- a/clustering/src/main/java/com/google/maps/android/geometry/Point.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Point.kt @@ -15,6 +15,8 @@ */ package com.google.maps.android.geometry +import kotlin.jvm.JvmField + open class Point( @JvmField val x: Double, @JvmField val y: Double, @@ -23,7 +25,7 @@ open class Point( override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false + if (other == null || this::class != other::class) return false other as Point diff --git a/clustering/src/main/java/com/google/maps/android/projection/Point.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/projection/Point.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/projection/Point.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/projection/Point.kt diff --git a/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/projection/SphericalMercatorProjection.kt similarity index 83% rename from clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/projection/SphericalMercatorProjection.kt index 971a81bdd..9d3f53d4d 100644 --- a/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/projection/SphericalMercatorProjection.kt @@ -15,7 +15,9 @@ */ package com.google.maps.android.projection -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.longitude +import com.google.maps.android.model.latitude import kotlin.math.* class SphericalMercatorProjection( @@ -23,7 +25,7 @@ class SphericalMercatorProjection( ) { fun toPoint(latLng: LatLng): com.google.maps.android.geometry.Point { val x = latLng.longitude / 360 + .5 - val siny = sin(Math.toRadians(latLng.latitude)) + val siny = sin(latLng.latitude * PI / 180.0) val y = 0.5 * ln((1 + siny) / (1 - siny)) / -(2 * PI) + .5 return com.google.maps.android.geometry.Point(x * worldWidth, y * worldWidth) @@ -34,7 +36,7 @@ class SphericalMercatorProjection( val lng = x * 360 val y = .5 - (point.y / worldWidth) - val lat = 90 - Math.toDegrees(atan(exp(-y * 2 * PI)) * 2) + val lat = 90 - atan(exp(-y * 2 * PI)) * 2 * 180.0 / PI return LatLng(lat, lng) } diff --git a/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/quadtree/PointQuadTree.kt similarity index 99% rename from clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/quadtree/PointQuadTree.kt index 2227e5c9d..d79bfab84 100644 --- a/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/quadtree/PointQuadTree.kt @@ -17,6 +17,7 @@ package com.google.maps.android.quadtree import com.google.maps.android.geometry.Bounds import com.google.maps.android.geometry.Point +import kotlin.jvm.JvmOverloads /** * A quad tree which tracks items with a Point geometry. diff --git a/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt b/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt new file mode 100644 index 000000000..39f9cca9a --- /dev/null +++ b/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt @@ -0,0 +1,26 @@ +/* + * 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 platform.Foundation.NSRecursiveLock + +internal actual class PlatformLock { + private val delegate = NSRecursiveLock() + + actual fun lock(): Unit = delegate.lock() + + actual fun unlock(): Unit = delegate.unlock() +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java b/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java deleted file mode 100644 index 8d6b12ef4..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java +++ /dev/null @@ -1,252 +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.assertFalse; -import static org.junit.Assert.assertTrue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import org.junit.Test; - -public class QuadItemTest { - - @Test - public void testAddRemoveUpdateClear() { - ClusterItem item_1_5 = new TestingItem("title1", 0.1, 0.5); - TestingItem item_2_3 = new TestingItem("title2", 0.2, 0.3); - - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - assertTrue(algo.addItem(item_1_5)); - assertTrue(algo.addItem(item_2_3)); - - assertEquals(2, algo.getItems().size()); - - assertTrue(algo.removeItem(item_1_5)); - - assertEquals(1, algo.getItems().size()); - - assertFalse(algo.getItems().contains(item_1_5)); - assertTrue(algo.getItems().contains(item_2_3)); - - // Update the item still in the algorithm - item_2_3.setTitle("newTitle"); - assertTrue(algo.updateItem(item_2_3)); - - // Try to remove the item that was already removed - assertFalse(algo.removeItem(item_1_5)); - - // Try to update the item that was already removed - assertFalse(algo.updateItem(item_1_5)); - - algo.clearItems(); - assertEquals(0, algo.getItems().size()); - - // Test bulk operations - List items = Arrays.asList(item_1_5, item_2_3); - assertTrue(algo.addItems(items)); - - // Try to bulk add items that were already added - assertFalse(algo.addItems(items)); - - assertTrue(algo.removeItems(items)); - - // Try to bulk remove items that were already removed - assertFalse(algo.removeItems(items)); - } - - /** - * Test if insertion order into the algorithm is the same as returned item order. This matters - * because we want repeatable clustering behavior when updating model values and re-clustering. - */ - @Test - public void testInsertionOrder() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - for (int i = 0; i < 100; i++) { - algo.addItem(new TestingItem(Integer.toString(i), 0.0, 0.0)); - } - - assertEquals(100, algo.getItems().size()); - - Collection items = algo.getItems(); - int counter = 0; - for (ClusterItem item : items) { - assertEquals(Integer.toString(counter), item.getTitle()); - counter++; - } - } - - @Test - public void testUpdateItemAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item = new TestingItem("title1", 0.0, 0.0); - algo.addItem(item); - assertEquals(1, algo.getItems().size()); - - // Update the position of the mutable item - item.setPosition(10.0, 10.0); - - // Call updateItem - assertTrue("updateItem should return true after position change", algo.updateItem(item)); - assertEquals(1, algo.getItems().size()); - - // Verify that the old QuadItem at (0, 0) was removed from the tree - // and only the new position (10, 10) is indexed - java.util.Set> clusters = algo.getClusters(4.0f); - assertEquals(1, clusters.size()); - Cluster cluster = clusters.iterator().next(); - assertEquals(10.0, cluster.getPosition().latitude, 0.001); - assertEquals(10.0, cluster.getPosition().longitude, 0.001); - } - - @Test - public void testRemoveItemAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item = new TestingItem("title1", 0.0, 0.0); - algo.addItem(item); - assertEquals(1, algo.getItems().size()); - - // Update the position of the mutable item - item.setPosition(10.0, 10.0); - - // Removing the item should succeed and remove it from the tree - assertTrue("removeItem should return true after position change", algo.removeItem(item)); - assertEquals(0, algo.getItems().size()); - assertEquals(0, algo.getClusters(4.0f).size()); - } - - @Test - public void testUpdateItemPreventsStaleQuadTreeEntries() { - TestAlgorithm algo = new TestAlgorithm<>(); - - // Add 60 filler items to force PointQuadTree to split (MAX_ELEMENTS = 50) - for (int i = 0; i < 60; i++) { - algo.addItem(new TestingItem("filler" + i, 10.0 + i * 0.001, 10.0 + i * 0.001)); - } - - // Add item1 in top-left quadrant - TestingItem item1 = new TestingItem("item1", 1.0, 1.0); - algo.addItem(item1); - - assertEquals("QuadTree should contain item1 at (1, 1)", 1, algo.getQuadTreeItemCount(1.0, 1.0, 0.001)); - - // Move item1 far across quadrant boundary to (50.0, 50.0) and update - item1.setPosition(50.0, 50.0); - algo.updateItem(item1); - - // Without fix, old QuadItem remains at (1.0, 1.0) in mQuadTree because remove traversed the new coordinates - assertEquals("QuadTree should NOT contain stale entry at (1, 1) after update", 0, algo.getQuadTreeItemCount(1.0, 1.0, 0.001)); - assertEquals("QuadTree should contain item1 at (50, 50)", 1, algo.getQuadTreeItemCount(50.0, 50.0, 0.001)); - } - - @Test - public void testRemoveItemsAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item1 = new TestingItem("title1", 0.0, 0.0); - TestingItem item2 = new TestingItem("title2", 1.0, 1.0); - algo.addItems(java.util.Arrays.asList(item1, item2)); - assertEquals(2, algo.getItems().size()); - - // Update the position of both items - item1.setPosition(10.0, 10.0); - item2.setPosition(20.0, 20.0); - - assertTrue("removeItems should return true after position change", - algo.removeItems(java.util.Arrays.asList(item1, item2))); - assertEquals(0, algo.getItems().size()); - assertEquals(0, algo.getClusters(4.0f).size()); - } - - @Test - public void testClearItemsAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item1 = new TestingItem("title1", 0.0, 0.0); - algo.addItem(item1); - item1.setPosition(10.0, 10.0); - - algo.clearItems(); - assertEquals(0, algo.getItems().size()); - assertEquals(0, algo.getClusters(4.0f).size()); - } - - private static class TestAlgorithm extends NonHierarchicalDistanceBasedAlgorithm { - private static final com.google.maps.android.projection.SphericalMercatorProjection PROJ = - new com.google.maps.android.projection.SphericalMercatorProjection(1.0); - - public int getQuadTreeItemCount(double lat, double lng, double span) { - com.google.maps.android.geometry.Point p = PROJ.toPoint(new LatLng(lat, lng)); - com.google.maps.android.geometry.Bounds bounds = new com.google.maps.android.geometry.Bounds( - p.x - span, p.x + span, p.y - span, p.y + span); - return mQuadTree.search(bounds).size(); - } - } - - private static class TestingItem implements ClusterItem { - private LatLng mPosition; - private String mTitle; - - TestingItem(String title, double lat, double lng) { - mTitle = title; - mPosition = new LatLng(lat, lng); - } - - TestingItem(double lat, double lng) { - mTitle = ""; - mPosition = new LatLng(lat, lng); - } - public void setPosition(double lat, double lng) { - mPosition = new LatLng(lat, lng); - } - - @NonNull - @Override - public LatLng getPosition() { - return mPosition; - } - - @Override - public String getTitle() { - return mTitle; - } - - @Override - public String getSnippet() { - return null; - } - - @Nullable - @Override - public Float getZIndex() { - return null; - } - - public void setTitle(String title) { - mTitle = title; - } - } -} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java b/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java deleted file mode 100644 index 33a43b1ca..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java +++ /dev/null @@ -1,74 +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.algo; - -import static org.junit.Assert.assertEquals; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.ClusterItem; -import java.util.Arrays; -import java.util.Collection; -import org.junit.Test; - -public class CentroidNonHierarchicalDistanceBasedAlgorithmTest { - - static class TestClusterItem implements ClusterItem { - private final LatLng position; - - TestClusterItem(double lat, double lng) { - this.position = new LatLng(lat, lng); - } - - @NonNull - @Override - public LatLng getPosition() { - return position; - } - - @Override - public String getTitle() { - return null; - } - - @Override - public String getSnippet() { - return null; - } - - @Override - public Float getZIndex() { - return 0f; - } - } - - @Test - public void testComputeCentroid() { - CentroidNonHierarchicalDistanceBasedAlgorithm algo = - new CentroidNonHierarchicalDistanceBasedAlgorithm<>(); - - Collection items = - Arrays.asList( - new TestClusterItem(10.0, 20.0), - new TestClusterItem(20.0, 30.0), - new TestClusterItem(30.0, 40.0)); - - LatLng centroid = algo.computeCentroid(items); - - assertEquals(20.0, centroid.latitude, 0.0001); - assertEquals(30.0, centroid.longitude, 0.0001); - } -} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java b/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java deleted file mode 100644 index f6b5d7c61..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java +++ /dev/null @@ -1,115 +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.algo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.Cluster; -import com.google.maps.android.clustering.ClusterItem; -import java.util.Arrays; -import java.util.Collection; -import java.util.Set; -import org.junit.Test; - -public class ContinuousZoomEuclideanCentroidAlgorithmTest { - - static class TestClusterItem implements ClusterItem { - private final LatLng position; - - TestClusterItem(double lat, double lng) { - this.position = new LatLng(lat, lng); - } - - @NonNull - @Override - public LatLng getPosition() { - return position; - } - - @Override - public String getTitle() { - return null; - } - - @Override - public String getSnippet() { - return null; - } - - @Override - public Float getZIndex() { - return 0f; - } - } - - @Test - public void testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() { - ContinuousZoomEuclideanCentroidAlgorithm algo = - new ContinuousZoomEuclideanCentroidAlgorithm<>(); - - Collection items = - Arrays.asList( - new TestClusterItem(10.0, 10.0), - new TestClusterItem(10.0001, 10.0001), // very close to the first - new TestClusterItem(20.0, 20.0) // far away - ); - - algo.addItems(items); - - // At a high zoom, the close pair should be separate (small radius) - Set> highZoom = algo.getClusters(20.0f); - assertEquals(3, highZoom.size()); - - // At a lower zoom, the close pair should merge (larger radius) - Set> lowZoom = algo.getClusters(5.0f); - assertTrue(lowZoom.size() < 3); - - // Specifically, we expect one cluster of size 2 and one singleton - boolean hasClusterOfTwo = lowZoom.stream().anyMatch(c -> c.getItems().size() == 2); - boolean hasClusterOfOne = lowZoom.stream().anyMatch(c -> c.getItems().size() == 1); - assertTrue(hasClusterOfTwo); - assertTrue(hasClusterOfOne); - } - - @Test - public void testClusterPositionsAreCentroids() { - ContinuousZoomEuclideanCentroidAlgorithm algo = - new ContinuousZoomEuclideanCentroidAlgorithm<>(); - - Collection items = - Arrays.asList( - new TestClusterItem(0.0, 0.0), - new TestClusterItem(0.0, 2.0), - new TestClusterItem(2.0, 0.0)); - - algo.addItems(items); - - Set> clusters = algo.getClusters(1.0f); - - // Expect all items clustered into one - assertEquals(1, clusters.size()); - - Cluster cluster = clusters.iterator().next(); - - // The centroid should be approximately (0.6667, 0.6667) - LatLng centroid = cluster.getPosition(); - assertEquals(0.6667, centroid.latitude, 0.0001); - assertEquals(0.6667, centroid.longitude, 0.0001); - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2aec7119c..a5d6794f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ serialization = "1.0.1" [libraries] # --- Core Android & Kotlin --- # Essential libraries for Android development, providing backwards compatibility, lifecycle management, and Kotlin support. +androidx-collection = { module = "androidx.collection:collection", version = "1.5.0" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts new file mode 100644 index 000000000..6aec57ddc --- /dev/null +++ b/maps-model/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * 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. + */ +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android.model" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = 23 + } + + iosArm64() + iosSimulatorArm64() + iosX64() + + sourceSets { + androidMain.dependencies { + // The Android actuals are typealiases to the Play Services types, so + // this must be api(): consumers see GMS LatLng in our public API. + api(libs.play.services.maps) + } + } +} diff --git a/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt b/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt new file mode 100644 index 000000000..c3e2bbf3f --- /dev/null +++ b/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt @@ -0,0 +1,34 @@ +/* + * 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.model + +actual typealias LatLng = com.google.android.gms.maps.model.LatLng + +// These getters bind to the Java fields of the Play Services classes (member resolution +// wins over the extension inside the getter body, so this is not self-recursive). +actual val LatLng.latitude: Double get() = this.latitude + +actual val LatLng.longitude: Double get() = this.longitude + +actual typealias CameraPosition = com.google.android.gms.maps.model.CameraPosition + +actual val CameraPosition.target: LatLng get() = this.target + +actual val CameraPosition.zoom: Float get() = this.zoom + +actual val CameraPosition.tilt: Float get() = this.tilt + +actual val CameraPosition.bearing: Float get() = this.bearing diff --git a/maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt new file mode 100644 index 000000000..ca6042c6a --- /dev/null +++ b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt @@ -0,0 +1,31 @@ +/* + * 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.model + +/** + * An immutable camera position: target location, zoom, tilt and bearing. + * + * On Android this is a typealias for [com.google.android.gms.maps.model.CameraPosition]. + */ +expect class CameraPosition(target: LatLng, zoom: Float, tilt: Float, bearing: Float) + +expect val CameraPosition.target: LatLng + +expect val CameraPosition.zoom: Float + +expect val CameraPosition.tilt: Float + +expect val CameraPosition.bearing: Float diff --git a/maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt new file mode 100644 index 000000000..dd71523ef --- /dev/null +++ b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt @@ -0,0 +1,35 @@ +/* + * 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.model + +/** + * An immutable pair of latitude and longitude, in degrees. + * + * On Android this is a typealias for [com.google.android.gms.maps.model.LatLng], so existing + * code that uses the Maps SDK type keeps working unchanged. On other platforms it is a plain + * value holder with the same clamping/wrapping semantics: latitude is clamped to [-90, 90] and + * longitude is wrapped to [-180, 180). + * + * The coordinates are exposed as extension properties rather than members because the Play + * Services class exposes them as Java fields, and Java fields cannot actualize `expect` + * member properties. On each platform, member resolution wins over these extensions, so + * platform code binds directly to the underlying field/property with no indirection. + */ +expect class LatLng(latitude: Double, longitude: Double) + +expect val LatLng.latitude: Double + +expect val LatLng.longitude: Double diff --git a/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt b/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt new file mode 100644 index 000000000..7ca37fd7e --- /dev/null +++ b/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt @@ -0,0 +1,60 @@ +/* + * 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.model + +/** + * Mirrors the clamping/wrapping behavior of the Play Services LatLng constructor so that + * algorithms behave identically across platforms. + */ +actual class LatLng actual constructor(latitude: Double, longitude: Double) { + val latitude: Double = latitude.coerceIn(-90.0, 90.0) + val longitude: Double = + if (longitude in -180.0..180.0) { + if (longitude == 180.0) -180.0 else longitude + } else { + (longitude - 180.0).mod(360.0) - 180.0 + } + + override fun equals(other: Any?): Boolean = + other is LatLng && latitude == other.latitude && longitude == other.longitude + + override fun hashCode(): Int { + var result = 31 + latitude.toRawBits().let { (it xor (it ushr 32)).toInt() } + result = 31 * result + longitude.toRawBits().let { (it xor (it ushr 32)).toInt() } + return result + } + + override fun toString(): String = "lat/lng: ($latitude,$longitude)" +} + +actual val LatLng.latitude: Double get() = this.latitude + +actual val LatLng.longitude: Double get() = this.longitude + +actual class CameraPosition actual constructor( + val target: LatLng, + val zoom: Float, + val tilt: Float, + val bearing: Float +) + +actual val CameraPosition.target: LatLng get() = this.target + +actual val CameraPosition.zoom: Float get() = this.zoom + +actual val CameraPosition.tilt: Float get() = this.tilt + +actual val CameraPosition.bearing: Float get() = this.bearing diff --git a/settings.gradle.kts b/settings.gradle.kts index cb268f75b..db5b8ff3d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,4 @@ pluginManagement { } } -include("demo", "clustering", "heatmaps", "ui", "data", "lint-checks", "library", "visual-testing", "maps-utils") +include("demo", "clustering", "maps-model", "heatmaps", "ui", "data", "lint-checks", "library", "visual-testing", "maps-utils") From e75f03edfd574ecc1ced99a348fbe6b0aeea8f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:29:28 +0700 Subject: [PATCH 02/11] feat: enable mavenLocal publishing of KMP modules and bump AGP to 9.4.0 Apply maven-publish to maps-model and clustering so their multiplatform publications can be published to mavenLocal for consumption by the android-maps-compose KMP branch. Bump AGP 9.3.1 -> 9.4.0: AGP forbids mixing versions across composite builds, and android-maps-compose is already on 9.4.0. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- clustering/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 2 +- maps-model/build.gradle.kts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index c915ca9ec..18c4a4241 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -17,6 +17,9 @@ plugins { id("org.jetbrains.kotlin.multiplatform") id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } // NOTE (KMP prototype): the module previously applied android.maps.utils.PublishingConventionPlugin, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5d6794f2..e9d3fc21d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,7 +57,7 @@ org-jacoco-core = "0.8.15" # --- Gradle Plugins --- # Versions for Gradle plugins used in the build process. dokka-gradle-plugin = "2.2.0" -gradle = "9.3.1" +gradle = "9.4.0" gradleMavenPublishPlugin = "0.37.0" secrets-gradle-plugin = "2.0.1" serialization = "1.0.1" diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts index 6aec57ddc..cf0d7a8c7 100644 --- a/maps-model/build.gradle.kts +++ b/maps-model/build.gradle.kts @@ -16,6 +16,9 @@ plugins { id("org.jetbrains.kotlin.multiplatform") id("com.android.kotlin.multiplatform.library") + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } kotlin { From 4526aab6f1eeacf6d8a8161e619542c216ae38c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:48:10 +0700 Subject: [PATCH 03/11] fix: publish KMP modules under public android-maps-utils artifactIds Publishing the KMP clustering module as com.google.maps.android:clustering gave the same classes a second module identity next to the android-maps-utils-clustering AAR on Maven Central, producing duplicate class errors in apps that pull both (e.g. android-maps-compose's maps-app). Remap the publication artifactIds to the repo's public android-maps-utils- scheme so both dependency paths conflict-resolve to a single module. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- clustering/build.gradle.kts | 9 +++++++++ maps-model/build.gradle.kts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index 18c4a4241..3c5cf7845 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -74,3 +74,12 @@ kotlin { } } } + +// Publish under the repo's public artifactId scheme (android-maps-utils-) so these +// coordinates conflict-resolve against the AARs already on Maven Central instead of +// duplicating their classes under a second module identity. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-${project.name}") + } +} diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts index cf0d7a8c7..b5b7ccc92 100644 --- a/maps-model/build.gradle.kts +++ b/maps-model/build.gradle.kts @@ -42,3 +42,12 @@ kotlin { } } } + +// Publish under the repo's public artifactId scheme (android-maps-utils-) so these +// coordinates conflict-resolve against the AARs already on Maven Central instead of +// duplicating their classes under a second module identity. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-${project.name}") + } +} From 56aab16c01e009d1241f7671448e2e51ff602c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 23:34:42 +0700 Subject: [PATCH 04/11] feat: migrate library and heatmaps modules to Kotlin Multiplatform Converts the library module (PolyUtil, SphericalUtil, MathUtil in commonMain; StreetView utilities, collections managers and the attribution initializer in androidMain) and the heatmaps module (WeightedLatLng, Gradient and shared constants in commonMain; the Bitmap/Tile-based HeatmapTileProvider in androidMain) following the pattern established by the clustering migration. Gradient's android.graphics.Color usage is replaced by a common ColorUtils that reproduces the Android/Skia RGB<->HSV conversions exactly; GradientTest's hardcoded Android color values verify parity. The AttributionId codegen task is ported into the KMP build and wired into androidMain. Math.toRadians/toDegrees become common helpers. The Java test suites (PolyUtilTest, SphericalUtilTest, MathUtilTest, heatmaps UtilTest) are converted to Kotlin; the three math suites move to commonTest and now also run on iOS (36 tests green on the iOS simulator, 60 android host tests for library, 24 for heatmaps). robolectric.properties pins sdk=28 for host tests as Robolectric does not support targetSdk 37. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- heatmaps/build.gradle.kts | 112 ++- .../maps/android/heatmaps/GradientTest.kt | 0 .../heatmaps/HeatmapTileProviderTest.kt | 0 .../google/maps/android/heatmaps/UtilTest.kt | 167 +++++ .../android/heatmaps/WeightedLatLngTest.kt | 0 .../resources/robolectric.properties | 1 + .../{main => androidMain}/AndroidManifest.xml | 0 .../android/heatmaps/HeatmapTileProvider.kt | 4 +- .../maps/android/heatmaps/ColorUtils.kt | 100 +++ .../google/maps/android/heatmaps/Gradient.kt | 34 +- .../maps/android/heatmaps/HeatmapConstants.kt | 29 + .../maps/android/heatmaps/WeightedLatLng.kt | 5 +- .../maps/android/heatmaps/UtilTest.java | 154 ---- library/build.gradle.kts | 140 ++-- .../maps/android/StreetViewHelperTest.kt | 0 .../android/collections/CircleManagerTest.kt | 0 .../collections/GroundOverlayManagerTest.kt | 0 .../collections/MapObjectManagerTest.kt | 0 .../android/collections/MarkerManagerTest.kt | 0 .../android/collections/PolygonManagerTest.kt | 0 .../collections/PolylineManagerTest.kt | 0 .../AttributionIdInitializerTest.kt | 0 .../resources/robolectric.properties | 1 + .../{main => androidMain}/AndroidManifest.xml | 0 .../maps/android/StreetViewJavaHelper.kt | 0 .../com/google/maps/android/StreetViewUtil.kt | 0 .../maps/android/collections/CircleManager.kt | 0 .../collections/GroundOverlayManager.kt | 0 .../android/collections/MapObjectManager.kt | 0 .../maps/android/collections/MarkerManager.kt | 0 .../android/collections/PolygonManager.kt | 0 .../android/collections/PolylineManager.kt | 0 .../attribution/AttributionIdInitializer.kt | 0 .../google/maps/android/AngleConversions.kt | 23 + .../com/google/maps/android/MathUtil.kt | 5 +- .../com/google/maps/android/PolyUtil.kt | 71 +- .../com/google/maps/android/SphericalUtil.kt | 65 +- .../com/google/maps/android/MathUtilTest.kt | 101 +++ .../com/google/maps/android/PolyUtilTest.kt | 640 +++++++++++++++++ .../google/maps/android/SphericalUtilTest.kt | 405 +++++++++++ .../com/google/maps/android/MathUtilTest.java | 99 --- .../com/google/maps/android/PolyUtilTest.java | 665 ------------------ .../maps/android/SphericalUtilTest.java | 423 ----------- 43 files changed, 1672 insertions(+), 1572 deletions(-) rename heatmaps/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/heatmaps/GradientTest.kt (100%) rename heatmaps/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt (100%) create mode 100644 heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt rename heatmaps/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/heatmaps/WeightedLatLngTest.kt (100%) create mode 100644 heatmaps/src/androidHostTest/resources/robolectric.properties rename heatmaps/src/{main => androidMain}/AndroidManifest.xml (100%) rename heatmaps/src/{main/java => androidMain/kotlin}/com/google/maps/android/heatmaps/HeatmapTileProvider.kt (99%) create mode 100644 heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt rename heatmaps/src/{main/java => commonMain/kotlin}/com/google/maps/android/heatmaps/Gradient.kt (85%) create mode 100644 heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt rename heatmaps/src/{main/java => commonMain/kotlin}/com/google/maps/android/heatmaps/WeightedLatLng.kt (93%) delete mode 100644 heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/StreetViewHelperTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/CircleManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/GroundOverlayManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/MapObjectManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/MarkerManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/PolygonManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/PolylineManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt (100%) create mode 100644 library/src/androidHostTest/resources/robolectric.properties rename library/src/{main => androidMain}/AndroidManifest.xml (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/StreetViewJavaHelper.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/StreetViewUtil.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/CircleManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/GroundOverlayManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/MapObjectManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/MarkerManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/PolygonManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/PolylineManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt (100%) create mode 100644 library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt rename library/src/{main/java => commonMain/kotlin}/com/google/maps/android/MathUtil.kt (97%) rename library/src/{main/java => commonMain/kotlin}/com/google/maps/android/PolyUtil.kt (92%) rename library/src/{main/java => commonMain/kotlin}/com/google/maps/android/SphericalUtil.kt (87%) create mode 100644 library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt create mode 100644 library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt create mode 100644 library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt delete mode 100644 library/src/test/java/com/google/maps/android/MathUtilTest.java delete mode 100644 library/src/test/java/com/google/maps/android/PolyUtilTest.java delete mode 100644 library/src/test/java/com/google/maps/android/SphericalUtilTest.java diff --git a/heatmaps/build.gradle.kts b/heatmaps/build.gradle.kts index 08b2f81e1..9f6e7a6e0 100644 --- a/heatmaps/build.gradle.kts +++ b/heatmaps/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** * Copyright 2026 Google LLC * @@ -16,78 +14,66 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { - + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { +// NOTE (KMP prototype): see clustering/build.gradle.kts — release publishing (vanniktech), +// jacoco, lint-checks and the amu_ resourcePrefix still need KMP-aware re-wiring. + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android.heatmaps" compileSdk = libs.versions.compileSdk.get().toInt() minSdk = 23 - testOptions.targetSdk = libs.versions.targetSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true } } - resourcePrefix = "amu_" - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } + iosArm64() + iosSimulatorArm64() + iosX64() - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + // Quadtree, geometry and Mercator projection live in the clustering module. + api(project(":clustering")) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(project(":data")) + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.truth) } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true } - namespace = "com.google.maps.android.heatmaps" } -dependencies { - implementation(project(":clustering")) - implementation(project(":data")) - api(libs.play.services.maps) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - lintPublish(project(":lint-checks")) - testImplementation(libs.junit) - testImplementation(libs.robolectric) - testImplementation(libs.kxml2) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.truth) - implementation(libs.kotlin.stdlib.jdk8) - - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.mockito.core) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") +// Publish under the repo's public artifactId scheme so these coordinates conflict-resolve +// against the AARs already on Maven Central instead of duplicating their classes. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-${project.name}") + } } diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/GradientTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/GradientTest.kt diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt diff --git a/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt new file mode 100644 index 000000000..ed6460031 --- /dev/null +++ b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt @@ -0,0 +1,167 @@ +/* + * 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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.geometry.Bounds +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Tests for heatmap utility functions */ +@RunWith(RobolectricTestRunner::class) +class UtilTest { + @Test + fun testGenerateKernel() { + val testKernel = HeatmapTileProvider.generateKernel(5, 1.5) + val expectedKernel = + doubleArrayOf( + 0.0038659201394728076, + 0.028565500784550377, + 0.1353352832366127, + 0.41111229050718745, + 0.8007374029168081, + 1.0, + 0.8007374029168081, + 0.41111229050718745, + 0.1353352832366127, + 0.028565500784550377, + 0.0038659201394728076, + ) + + assertArrayEquals(expectedKernel, testKernel, 0.0) + } + + @Test + fun testConvolveCorners() { + /* + 1 0 0 0 1 + 0 0 0 0 0 + 0 0 0 0 0 + 0 0 0 0 0 + 1 0 0 0 1 + */ + val grid = Array(5) { DoubleArray(5) } + grid[0][0] = 1.0 + grid[4][4] = 1.0 + grid[4][0] = 1.0 + grid[0][4] = 1.0 + val testKernel = doubleArrayOf(0.5, 1.0, 0.5) + val convolved = HeatmapTileProvider.convolve(grid, testKernel) + val expected = + arrayOf( + doubleArrayOf(0.25, 0.0, 0.25), + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(0.25, 0.0, 0.25), + ) + assertArrayEquals(expected, convolved) + } + + @Test + fun testConvolveEdges() { + /* + 0 0 1 0 0 + 0 0 0 0 0 + 1 0 0 0 1 + 0 0 0 0 0 + 0 0 1 0 0 + */ + val grid = Array(5) { DoubleArray(5) } + grid[0][2] = 1.0 + grid[2][0] = 1.0 + grid[2][4] = 1.0 + grid[4][2] = 1.0 + val testKernel = doubleArrayOf(0.5, 1.0, 0.5) + val convolved = HeatmapTileProvider.convolve(grid, testKernel) + val expected = + arrayOf( + doubleArrayOf(0.5, 0.5, 0.5), + doubleArrayOf(0.5, 0.0, 0.5), + doubleArrayOf(0.5, 0.5, 0.5), + ) + assertArrayEquals(expected, convolved) + } + + @Test + fun testConvolveCentre() { + /* + 0 0 0 0 0 + 0 0 1 0 0 + 0 1 2 1 0 + 0 0 1 0 0 + 0 0 0 0 0 + */ + val grid = Array(5) { DoubleArray(5) } + grid[2][2] = 2.0 + grid[2][1] = 1.0 + grid[1][2] = 1.0 + grid[2][3] = 1.0 + grid[3][2] = 1.0 + val testKernel = doubleArrayOf(0.5, 1.0, 0.5) + val convolved = HeatmapTileProvider.convolve(grid, testKernel) + val expected = + arrayOf( + doubleArrayOf(1.5, 2.5, 1.5), + doubleArrayOf(2.5, 4.0, 2.5), + doubleArrayOf(1.5, 2.5, 1.5), + ) + assertArrayEquals(expected, convolved) + } + + @Test + fun testGetBounds() { + /* + y + ^ + | 3 + | 1 + | 2 + ------------> x + */ + + val data = ArrayList() + val first = WeightedLatLng(LatLng(10.0, 20.0)) + data.add(first) + val x1 = first.point.x + val y1 = first.point.y + + var bounds = HeatmapTileProvider.getBounds(data) + var expected = Bounds(x1, x1, y1, y1) + + assertTrue(bounds.contains(expected) && expected.contains(bounds)) + + val second = WeightedLatLng(LatLng(20.0, 30.0)) + data.add(second) + val x2 = second.point.x + val y2 = second.point.y + + bounds = HeatmapTileProvider.getBounds(data) + expected = Bounds(x1, x2, y2, y1) + + assertTrue(bounds.contains(expected) && expected.contains(bounds)) + + val third = WeightedLatLng(LatLng(5.0, 10.0)) + data.add(third) + val x3 = third.point.x + val y3 = third.point.y + + bounds = HeatmapTileProvider.getBounds(data) + expected = Bounds(x3, x2, y2, y3) + assertTrue(bounds.contains(expected) && expected.contains(bounds)) + } +} diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/WeightedLatLngTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/WeightedLatLngTest.kt diff --git a/heatmaps/src/androidHostTest/resources/robolectric.properties b/heatmaps/src/androidHostTest/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/heatmaps/src/androidHostTest/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/heatmaps/src/main/AndroidManifest.xml b/heatmaps/src/androidMain/AndroidManifest.xml similarity index 100% rename from heatmaps/src/main/AndroidManifest.xml rename to heatmaps/src/androidMain/AndroidManifest.xml diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt b/heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/HeatmapTileProvider.kt similarity index 99% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt rename to heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/HeatmapTileProvider.kt index d643e2bc6..8d91e0be9 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt +++ b/heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/HeatmapTileProvider.kt @@ -301,13 +301,13 @@ class HeatmapTileProvider private constructor( companion object { const val DEFAULT_RADIUS = 20 - const val DEFAULT_OPACITY = 0.7 + const val DEFAULT_OPACITY = HeatmapConstants.DEFAULT_OPACITY private val DEFAULT_GRADIENT_COLORS = intArrayOf(Color.rgb(102, 225, 0), Color.rgb(255, 0, 0)) private val DEFAULT_GRADIENT_START_POINTS = floatArrayOf(0.2f, 1f) @JvmField val DEFAULT_GRADIENT = Gradient(DEFAULT_GRADIENT_COLORS, DEFAULT_GRADIENT_START_POINTS) - internal const val WORLD_WIDTH = 1.0 + internal const val WORLD_WIDTH = HeatmapConstants.WORLD_WIDTH private const val TILE_DIM = 512 private const val SCREEN_SIZE = 1280 private const val DEFAULT_MIN_ZOOM = 5 diff --git a/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt new file mode 100644 index 000000000..acf5e0ba9 --- /dev/null +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt @@ -0,0 +1,100 @@ +/* + * 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.heatmaps + +import kotlin.math.floor +import kotlin.math.roundToInt + +/** + * Multiplatform replacements for the `android.graphics.Color` operations the heatmap gradient + * uses. The HSV conversions reproduce the Android/Skia implementations exactly (as of Android O), + * which GradientTest verifies against hardcoded Android color values. + */ +internal object ColorUtils { + fun alpha(color: Int): Int = color ushr 24 + + fun red(color: Int): Int = (color shr 16) and 0xFF + + fun green(color: Int): Int = (color shr 8) and 0xFF + + fun blue(color: Int): Int = color and 0xFF + + fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int = + (alpha shl 24) or (red shl 16) or (green shl 8) or blue + + fun rgbToHsv(red: Int, green: Int, blue: Int, hsv: FloatArray) { + require(hsv.size >= 3) { "3 components required for hsv" } + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + val delta = max - min + + val v = max / 255f + if (delta == 0) { + hsv[0] = 0f + hsv[1] = 0f + hsv[2] = v + return + } + + val s = delta / max.toFloat() + var h = + when (max) { + red -> (green - blue) / delta.toFloat() + green -> 2f + (blue - red) / delta.toFloat() + else -> 4f + (red - green) / delta.toFloat() + } + h *= 60f + if (h < 0f) { + h += 360f + } + + hsv[0] = h + hsv[1] = s + hsv[2] = v + } + + fun hsvToColor(alpha: Int, hsv: FloatArray): Int { + require(hsv.size >= 3) { "3 components required for hsv" } + val h = hsv[0] + val s = hsv[1].coerceIn(0f, 1f) + val v = hsv[2].coerceIn(0f, 1f) + + if (s <= 0f) { + val unit = (v * 255f).roundToInt() + return argb(alpha, unit, unit, unit) + } + + val hx = if (h < 0f || h >= 360f) 0f else h / 60f + val w = floor(hx).toInt() + val f = hx - w + + val p = v * (1f - s) + val q = v * (1f - s * f) + val t = v * (1f - s * (1f - f)) + + val (r, g, b) = + when (w) { + 0 -> Triple(v, t, p) + 1 -> Triple(q, v, p) + 2 -> Triple(p, v, t) + 3 -> Triple(p, q, v) + 4 -> Triple(t, p, v) + else -> Triple(v, p, q) + } + + return argb(alpha, (r * 255f).roundToInt(), (g * 255f).roundToInt(), (b * 255f).roundToInt()) + } +} diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/Gradient.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/Gradient.kt similarity index 85% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/Gradient.kt rename to heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/Gradient.kt index bcdcedc05..610eb425d 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/Gradient.kt +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/Gradient.kt @@ -15,8 +15,8 @@ */ package com.google.maps.android.heatmaps -import android.graphics.Color -import java.util.HashMap +import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic /** * A class to generate a color map from a given array of colors and the fractions @@ -60,11 +60,11 @@ class Gradient // The initial color is transparent by default if (startPoints[0] != 0f) { val initialColor = - Color.argb( + ColorUtils.argb( 0, - Color.red(colors[0]), - Color.green(colors[0]), - Color.blue(colors[0]), + ColorUtils.red(colors[0]), + ColorUtils.green(colors[0]), + ColorUtils.blue(colors[0]), ) colorIntervals[0] = ColorInterval(initialColor, colors[0], colorMapSize * startPoints[0]) } @@ -93,11 +93,11 @@ class Gradient * a smooth transition. * * @param opacity The overall opacity of the entire color map. Each color's alpha value will be - * multiplied by this factor. The default value is [HeatmapTileProvider.DEFAULT_OPACITY]. + * multiplied by this factor. The default value is [HeatmapConstants.DEFAULT_OPACITY]. * @return An integer array representing the color map, where each element is a color integer. */ @JvmOverloads - fun generateColorMap(opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY): IntArray { + fun generateColorMap(opacity: Double = HeatmapConstants.DEFAULT_OPACITY): IntArray { val colorIntervals = generateColorIntervals() val colorMap = IntArray(colorMapSize) var interval = colorIntervals[0] @@ -114,11 +114,11 @@ class Gradient for (i in 0 until colorMapSize) { val c = colorMap[i] colorMap[i] = - Color.argb( - (Color.alpha(c) * opacity).toInt(), - Color.red(c), - Color.green(c), - Color.blue(c), + ColorUtils.argb( + (ColorUtils.alpha(c) * opacity).toInt(), + ColorUtils.red(c), + ColorUtils.green(c), + ColorUtils.blue(c), ) } } @@ -143,11 +143,11 @@ class Gradient color2: Int, ratio: Float, ): Int { - val alpha = ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)).toInt() + val alpha = ((ColorUtils.alpha(color2) - ColorUtils.alpha(color1)) * ratio + ColorUtils.alpha(color1)).toInt() val hsv1 = FloatArray(3) - Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1) + ColorUtils.rgbToHsv(ColorUtils.red(color1), ColorUtils.green(color1), ColorUtils.blue(color1), hsv1) val hsv2 = FloatArray(3) - Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2) + ColorUtils.rgbToHsv(ColorUtils.red(color2), ColorUtils.green(color2), ColorUtils.blue(color2), hsv2) // adjust so that the shortest path on the color wheel will be taken if (hsv1[0] - hsv2[0] > 180) { @@ -161,7 +161,7 @@ class Gradient for (i in 0..2) { result[i] = (hsv2[i] - hsv1[i]) * ratio + hsv1[i] } - return Color.HSVToColor(alpha, result) + return ColorUtils.hsvToColor(alpha, result) } } } diff --git a/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt new file mode 100644 index 000000000..dea89e3b4 --- /dev/null +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt @@ -0,0 +1,29 @@ +/* + * 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.heatmaps + +/** + * Constants shared between the multiplatform heatmap model and the platform tile providers. + * They were historically defined on HeatmapTileProvider, which remains Android-only; its + * companion aliases these values to preserve the public API. + */ +object HeatmapConstants { + /** Default opacity of heatmap overlay. */ + const val DEFAULT_OPACITY: Double = 0.7 + + /** Width of the world (in the Mercator projection used by the heatmap quadtree). */ + internal const val WORLD_WIDTH: Double = 1.0 +} diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/WeightedLatLng.kt similarity index 93% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt rename to heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/WeightedLatLng.kt index 8601dd082..c4d1d1710 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/WeightedLatLng.kt @@ -15,7 +15,8 @@ */ package com.google.maps.android.heatmaps -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import kotlin.jvm.JvmOverloads import com.google.maps.android.geometry.Point import com.google.maps.android.projection.SphericalMercatorProjection import com.google.maps.android.quadtree.PointQuadTree @@ -39,6 +40,6 @@ data class WeightedLatLng( companion object { const val DEFAULT_INTENSITY = 1.0 - private val sProjection = SphericalMercatorProjection(HeatmapTileProvider.WORLD_WIDTH) + private val sProjection = SphericalMercatorProjection(HeatmapConstants.WORLD_WIDTH) } } diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java b/heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java deleted file mode 100644 index 85a87817b..000000000 --- a/heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java +++ /dev/null @@ -1,154 +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.heatmaps; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertTrue; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.geometry.Bounds; -import java.util.ArrayList; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -/** Tests for heatmap utility functions */ -@RunWith(RobolectricTestRunner.class) -public class UtilTest { - @Test - public void testGenerateKernel() { - double[] testKernel = HeatmapTileProvider.generateKernel(5, 1.5); - double[] expectedKernel = { - 0.0038659201394728076, - 0.028565500784550377, - 0.1353352832366127, - 0.41111229050718745, - 0.8007374029168081, - 1.0, - 0.8007374029168081, - 0.41111229050718745, - 0.1353352832366127, - 0.028565500784550377, - 0.0038659201394728076 - }; - - assertArrayEquals(expectedKernel, testKernel, 0.0); - } - - @Test - public void testConvolveCorners() { - /* - 1 0 0 0 1 - 0 0 0 0 0 - 0 0 0 0 0 - 0 0 0 0 0 - 1 0 0 0 1 - */ - double[][] grid = new double[5][5]; - grid[0][0] = 1; - grid[4][4] = 1; - grid[4][0] = 1; - grid[0][4] = 1; - double[] testKernel = {0.5, 1, 0.5}; - double[][] convolved = HeatmapTileProvider.convolve(grid, testKernel); - double[][] expected = {{0.25, 0, 0.25}, {0, 0, 0}, {0.25, 0, 0.25}}; - assertArrayEquals(expected, convolved); - } - - @Test - public void testConvolveEdges() { - /* - 0 0 1 0 0 - 0 0 0 0 0 - 1 0 0 0 1 - 0 0 0 0 0 - 0 0 1 0 0 - */ - double[][] grid = new double[5][5]; - grid[0][2] = 1; - grid[2][0] = 1; - grid[2][4] = 1; - grid[4][2] = 1; - double[] testKernel = {0.5, 1, 0.5}; - double[][] convolved = HeatmapTileProvider.convolve(grid, testKernel); - double[][] expected = {{0.5, 0.5, 0.5}, {0.5, 0, 0.5}, {0.5, 0.5, 0.5}}; - assertArrayEquals(expected, convolved); - } - - @Test - public void testConvolveCentre() { - /* - 0 0 0 0 0 - 0 0 1 0 0 - 0 1 2 1 0 - 0 0 1 0 0 - 0 0 0 0 0 - */ - double[][] grid = new double[5][5]; - grid[2][2] = 2; - grid[2][1] = 1; - grid[1][2] = 1; - grid[2][3] = 1; - grid[3][2] = 1; - double[] testKernel = {0.5, 1, 0.5}; - double[][] convolved = HeatmapTileProvider.convolve(grid, testKernel); - double[][] expected = {{1.5, 2.5, 1.5}, {2.5, 4.0, 2.5}, {1.5, 2.5, 1.5}}; - assertArrayEquals(expected, convolved); - } - - @Test - public void testGetBounds() { - - /* - y - ^ - | 3 - | 1 - | 2 - ------------> x - */ - - ArrayList data = new ArrayList<>(); - WeightedLatLng first = new WeightedLatLng(new LatLng(10, 20)); - data.add(first); - double x1 = first.getPoint().x; - double y1 = first.getPoint().y; - - Bounds bounds = HeatmapTileProvider.getBounds(data); - Bounds expected = new Bounds(x1, x1, y1, y1); - - assertTrue(bounds.contains(expected) && expected.contains(bounds)); - - WeightedLatLng second = new WeightedLatLng(new LatLng(20, 30)); - data.add(second); - double x2 = second.getPoint().x; - double y2 = second.getPoint().y; - - bounds = HeatmapTileProvider.getBounds(data); - expected = new Bounds(x1, x2, y2, y1); - - assertTrue(bounds.contains(expected) && expected.contains(bounds)); - - WeightedLatLng third = new WeightedLatLng(new LatLng(5, 10)); - data.add(third); - double x3 = third.getPoint().x; - double y3 = third.getPoint().y; - - bounds = HeatmapTileProvider.getBounds(data); - expected = new Bounds(x3, x2, y2, y3); - assertTrue(bounds.contains(expected) && expected.contains(bounds)); - } -} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 88119e578..befac9d11 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** * Copyright 2026 Google LLC * @@ -16,80 +14,16 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") -} - -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { - compileSdk = libs.versions.compileSdk.get().toInt() - minSdk = libs.versions.minimumSdk.get().toInt() - testOptions.targetSdk = libs.versions.targetSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - resourcePrefix = "amu_" - - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } - - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true - } - namespace = "com.google.maps.android" + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } -dependencies { - api(libs.play.services.maps) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - implementation(libs.startup.runtime) - lintPublish(project(":lint-checks")) - testImplementation(libs.junit) - testImplementation(libs.robolectric) - testImplementation(libs.kxml2) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.androidx.test.core) - testImplementation(libs.truth) - implementation(libs.kotlin.stdlib.jdk8) - - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.mockito.core) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") -} +// NOTE (KMP prototype): see clustering/build.gradle.kts — release publishing (vanniktech), +// jacoco, lint-checks and the amu_ resourcePrefix still need KMP-aware re-wiring. abstract class GenerateArtifactIdTask : DefaultTask() { @get:OutputDirectory @@ -127,15 +61,59 @@ val generateArtifactIdFile = tasks.register("generateArt version.set(project.version.toString()) } -androidComponents { - onVariants { variant -> - variant.sources.java?.addGeneratedSourceDirectory( - generateArtifactIdFile, - GenerateArtifactIdTask::outputDir - ) +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = 23 + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } + + iosArm64() + iosSimulatorArm64() + iosX64() + + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain { + kotlin.srcDir(generateArtifactIdFile) + } + androidMain.dependencies { + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + implementation(libs.startup.runtime) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.androidx.test.core) + implementation(libs.truth) + implementation(libs.kotlinx.coroutines.test) + } } } -tasks.named("dokkaGeneratePublicationHtml") { - dependsOn(generateArtifactIdFile) +// Publish under the repo's public artifactId scheme so these coordinates conflict-resolve +// against the AARs already on Maven Central instead of duplicating their classes. +// This module's public artifactId is android-maps-utils-core. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-core") + } } diff --git a/library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/StreetViewHelperTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/StreetViewHelperTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/CircleManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/CircleManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/GroundOverlayManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/GroundOverlayManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/MapObjectManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/MapObjectManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/MarkerManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/MarkerManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolygonManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolygonManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolylineManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolylineManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt diff --git a/library/src/androidHostTest/resources/robolectric.properties b/library/src/androidHostTest/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/library/src/androidHostTest/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/library/src/main/AndroidManifest.xml b/library/src/androidMain/AndroidManifest.xml similarity index 100% rename from library/src/main/AndroidManifest.xml rename to library/src/androidMain/AndroidManifest.xml diff --git a/library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt b/library/src/androidMain/kotlin/com/google/maps/android/StreetViewJavaHelper.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt rename to library/src/androidMain/kotlin/com/google/maps/android/StreetViewJavaHelper.kt diff --git a/library/src/main/java/com/google/maps/android/StreetViewUtil.kt b/library/src/androidMain/kotlin/com/google/maps/android/StreetViewUtil.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/StreetViewUtil.kt rename to library/src/androidMain/kotlin/com/google/maps/android/StreetViewUtil.kt diff --git a/library/src/main/java/com/google/maps/android/collections/CircleManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/CircleManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/CircleManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/CircleManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/GroundOverlayManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/GroundOverlayManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/MapObjectManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/MapObjectManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/MarkerManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/MarkerManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/MarkerManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/MarkerManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/PolygonManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/PolygonManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/PolygonManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/PolygonManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/PolylineManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/PolylineManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/PolylineManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/PolylineManager.kt diff --git a/library/src/main/java/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt b/library/src/androidMain/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt rename to library/src/androidMain/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt diff --git a/library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt b/library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt new file mode 100644 index 000000000..cbc4bf1b7 --- /dev/null +++ b/library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt @@ -0,0 +1,23 @@ +/* + * 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 + +import kotlin.math.PI + +/** Multiplatform replacements for JVM-only Math.toRadians/Math.toDegrees. */ +internal fun toRadians(degrees: Double): Double = degrees * PI / 180.0 + +internal fun toDegrees(radians: Double): Double = radians * 180.0 / PI diff --git a/library/src/main/java/com/google/maps/android/MathUtil.kt b/library/src/commonMain/kotlin/com/google/maps/android/MathUtil.kt similarity index 97% rename from library/src/main/java/com/google/maps/android/MathUtil.kt rename to library/src/commonMain/kotlin/com/google/maps/android/MathUtil.kt index 62728d59b..b38612cec 100644 --- a/library/src/main/java/com/google/maps/android/MathUtil.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/MathUtil.kt @@ -24,6 +24,7 @@ import kotlin.math.ln import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.tan +import kotlin.jvm.JvmStatic /** * Utility functions that are used my both PolyUtil and SphericalUtil. @@ -84,10 +85,10 @@ object MathUtil { */ @JvmStatic fun mercator(lat: Double): Double { - if (lat > Math.PI / 2 - 1e-9) { + if (lat > PI / 2 - 1e-9) { return Double.POSITIVE_INFINITY } - if (lat < -Math.PI / 2 + 1e-9) { + if (lat < -PI / 2 + 1e-9) { return Double.NEGATIVE_INFINITY } return ln(tan(lat * 0.5 + PI / 4)) diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/commonMain/kotlin/com/google/maps/android/PolyUtil.kt similarity index 92% rename from library/src/main/java/com/google/maps/android/PolyUtil.kt rename to library/src/commonMain/kotlin/com/google/maps/android/PolyUtil.kt index d2ab855c0..18b41681e 100644 --- a/library/src/main/java/com/google/maps/android/PolyUtil.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/PolyUtil.kt @@ -15,7 +15,9 @@ */ package com.google.maps.android -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude import com.google.maps.android.MathUtil.clamp import com.google.maps.android.MathUtil.hav import com.google.maps.android.MathUtil.havDistance @@ -33,6 +35,9 @@ import kotlin.math.round import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.tan +import kotlin.math.PI +import kotlin.jvm.JvmStatic +import kotlin.jvm.JvmOverloads /** * A utility class containing geometric calculations for polygons and polylines. @@ -81,23 +86,23 @@ object PolyUtil { return false } - val lat3 = Math.toRadians(latitude) - val lng3 = Math.toRadians(longitude) + val lat3 = toRadians(latitude) + val lng3 = toRadians(longitude) val prev = polygon.last() - var lat1 = Math.toRadians(prev.latitude) - var lng1 = Math.toRadians(prev.longitude) + var lat1 = toRadians(prev.latitude) + var lng1 = toRadians(prev.longitude) var nIntersect = 0 for (point2 in polygon) { - val dLng3 = wrap(lng3 - lng1, -Math.PI, Math.PI) + val dLng3 = wrap(lng3 - lng1, -PI, PI) // Special case: point equal to vertex is inside. if (lat3 == lat1 && dLng3 == 0.0) { return true } - val lat2 = Math.toRadians(point2.latitude) - val lng2 = Math.toRadians(point2.longitude) + val lat2 = toRadians(point2.latitude) + val lng2 = toRadians(point2.longitude) // Offset longitudes by -lng1. - if (intersects(lat1, lat2, wrap(lng2 - lng1, -Math.PI, Math.PI), lat3, dLng3, geodesic)) { + if (intersects(lat1, lat2, wrap(lng2 - lng1, -PI, PI), lat3, dLng3, geodesic)) { ++nIntersect } lat1 = lat2 @@ -213,16 +218,16 @@ object PolyUtil { } val tolerance = toleranceEarth / MathUtil.EARTH_RADIUS val havTolerance = hav(tolerance) - val lat3 = Math.toRadians(point.latitude) - val lng3 = Math.toRadians(point.longitude) + val lat3 = toRadians(point.latitude) + val lng3 = toRadians(point.longitude) val prev = poly[if (closed) poly.size - 1 else 0] - var lat1 = Math.toRadians(prev.latitude) - var lng1 = Math.toRadians(prev.longitude) + var lat1 = toRadians(prev.latitude) + var lng1 = toRadians(prev.longitude) var idx = 0 if (geodesic) { for (point2 in poly) { - val lat2 = Math.toRadians(point2.latitude) - val lng2 = Math.toRadians(point2.longitude) + val lat2 = toRadians(point2.latitude) + val lng2 = toRadians(point2.longitude) if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { return max(0, idx - 1) } @@ -242,17 +247,17 @@ object PolyUtil { val y3 = mercator(lat3) val xTry = DoubleArray(3) for (point2 in poly) { - val lat2 = Math.toRadians(point2.latitude) + val lat2 = toRadians(point2.latitude) val y2 = mercator(lat2) - val lng2 = Math.toRadians(point2.longitude) + val lng2 = toRadians(point2.longitude) if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { // We offset longitudes by -lng1; the implicit x1 is 0. - val x2 = wrap(lng2 - lng1, -Math.PI, Math.PI) - val x3Base = wrap(lng3 - lng1, -Math.PI, Math.PI) + val x2 = wrap(lng2 - lng1, -PI, PI) + val x3Base = wrap(lng3 - lng1, -PI, PI) xTry[0] = x3Base // Also explore wrapping of x3Base around the world in both directions. - xTry[1] = x3Base + 2 * Math.PI - xTry[2] = x3Base - 2 * Math.PI + xTry[1] = x3Base + 2 * PI + xTry[2] = x3Base - 2 * PI for (x3 in xTry) { val dy = y2 - y1 val len2 = x2 * x2 + dy * dy @@ -419,12 +424,12 @@ object PolyUtil { return computeDistanceBetween(end, p) } - val s0lat = Math.toRadians(p.latitude) - val s0lng = Math.toRadians(p.longitude) - val s1lat = Math.toRadians(start.latitude) - val s1lng = Math.toRadians(start.longitude) - val s2lat = Math.toRadians(end.latitude) - val s2lng = Math.toRadians(end.longitude) + val s0lat = toRadians(p.latitude) + val s0lng = toRadians(p.longitude) + val s1lat = toRadians(start.latitude) + val s1lng = toRadians(start.longitude) + val s2lat = toRadians(end.latitude) + val s2lng = toRadians(end.longitude) val lonCorrection = cos(s1lat) val s2s1lat = s2lat - s1lat @@ -515,10 +520,10 @@ object PolyUtil { ) { var value = if (v < 0) (v shl 1).inv() else (v shl 1) while (value >= 0x20) { - result.append(Character.toChars(((0x20 or (value and 0x1f).toInt()) + 63))) + result.append((((0x20 or (value and 0x1f).toInt()) + 63)).toChar()) value = value shr 5 } - result.append(Character.toChars((value + 63).toInt())) + result.append(((value + 63).toInt()).toChar()) } /** @@ -560,14 +565,14 @@ object PolyUtil { return false } // Point is South Pole. - if (lat3 <= -Math.PI / 2) { + if (lat3 <= -PI / 2) { return false } // Any segment end is a pole. - if (lat1 <= -Math.PI / 2 || lat2 <= -Math.PI / 2 || lat1 >= Math.PI / 2 || lat2 >= Math.PI / 2) { + if (lat1 <= -PI / 2 || lat2 <= -PI / 2 || lat1 >= PI / 2 || lat2 >= PI / 2) { return false } - if (lng2 <= -Math.PI) { + if (lng2 <= -PI) { return false } val linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2 @@ -580,7 +585,7 @@ object PolyUtil { return true } // North Pole. - if (lat3 >= Math.PI / 2) { + if (lat3 >= PI / 2) { return true } // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.kt b/library/src/commonMain/kotlin/com/google/maps/android/SphericalUtil.kt similarity index 87% rename from library/src/main/java/com/google/maps/android/SphericalUtil.kt rename to library/src/commonMain/kotlin/com/google/maps/android/SphericalUtil.kt index 504b4f693..64cbddec9 100644 --- a/library/src/main/java/com/google/maps/android/SphericalUtil.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/SphericalUtil.kt @@ -15,7 +15,9 @@ */ package com.google.maps.android -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude import com.google.maps.android.MathUtil.EARTH_RADIUS import com.google.maps.android.MathUtil.arcHav import com.google.maps.android.MathUtil.havDistance @@ -28,6 +30,7 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.tan +import kotlin.jvm.JvmStatic object SphericalUtil { /** @@ -42,17 +45,17 @@ object SphericalUtil { to: LatLng, ): Double { // http://williams.best.vwh.net/avform.htm#Crs - val fromLat = Math.toRadians(from.latitude) - val fromLng = Math.toRadians(from.longitude) - val toLat = Math.toRadians(to.latitude) - val toLng = Math.toRadians(to.longitude) + val fromLat = toRadians(from.latitude) + val fromLng = toRadians(from.longitude) + val toLat = toRadians(to.latitude) + val toLng = toRadians(to.longitude) val dLng = toLng - fromLng val heading = atan2( sin(dLng) * cos(toLat), cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng), ) - return wrap(Math.toDegrees(heading), -180.0, 180.0) + return wrap(toDegrees(heading), -180.0, 180.0) } /** @@ -72,10 +75,10 @@ object SphericalUtil { var distance = distance var heading = heading distance /= EARTH_RADIUS - heading = Math.toRadians(heading) + heading = toRadians(heading) // http://williams.best.vwh.net/avform.htm#LL - val fromLat = Math.toRadians(from.latitude) - val fromLng = Math.toRadians(from.longitude) + val fromLat = toRadians(from.latitude) + val fromLng = toRadians(from.longitude) val cosDistance = cos(distance) val sinDistance = sin(distance) val sinFromLat = sin(fromLat) @@ -86,7 +89,7 @@ object SphericalUtil { sinDistance * cosFromLat * sin(heading), cosDistance - sinFromLat * sinLat, ) - return LatLng(Math.toDegrees(asin(sinLat)), Math.toDegrees(fromLng + dLng)) + return LatLng(toDegrees(asin(sinLat)), toDegrees(fromLng + dLng)) } /** @@ -107,13 +110,13 @@ object SphericalUtil { ): LatLng? { var distance = distance var heading = heading - heading = Math.toRadians(heading) + heading = toRadians(heading) distance /= EARTH_RADIUS // http://lists.maptools.org/pipermail/proj/2008-October/003939.html val n1 = cos(distance) val n2 = sin(distance) * cos(heading) val n3 = sin(distance) * sin(heading) - val n4 = sin(Math.toRadians(to.latitude)) + val n4 = sin(toRadians(to.latitude)) // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results // in the latitude outside the [-90, 90] range. We first try one solution and // back off to the other if we are outside that range. @@ -137,9 +140,9 @@ object SphericalUtil { return null } val fromLngRadians = - Math.toRadians(to.longitude) - + toRadians(to.longitude) - atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)) - return LatLng(Math.toDegrees(fromLatRadians), Math.toDegrees(fromLngRadians)) + return LatLng(toDegrees(fromLatRadians), toDegrees(fromLngRadians)) } /** @@ -158,10 +161,10 @@ object SphericalUtil { fraction: Double, ): LatLng { // http://en.wikipedia.org/wiki/Slerp - val fromLat = Math.toRadians(from.latitude) - val fromLng = Math.toRadians(from.longitude) - val toLat = Math.toRadians(to.latitude) - val toLng = Math.toRadians(to.longitude) + val fromLat = toRadians(from.latitude) + val fromLng = toRadians(from.longitude) + val toLat = toRadians(to.latitude) + val toLng = toRadians(to.longitude) val cosFromLat = cos(fromLat) val cosToLat = cos(toLat) @@ -185,7 +188,7 @@ object SphericalUtil { // Converts interpolated vector back to polar. val lat = atan2(z, sqrt(x * x + y * y)) val lng = atan2(y, x) - return LatLng(Math.toDegrees(lat), Math.toDegrees(lng)) + return LatLng(toDegrees(lat), toDegrees(lng)) } /** @@ -208,10 +211,10 @@ object SphericalUtil { to: LatLng, ): Double = distanceRadians( - Math.toRadians(from.latitude), - Math.toRadians(from.longitude), - Math.toRadians(to.latitude), - Math.toRadians(to.longitude), + toRadians(from.latitude), + toRadians(from.longitude), + toRadians(to.latitude), + toRadians(to.longitude), ) /** @@ -235,10 +238,10 @@ object SphericalUtil { var prev: LatLng? = null for (point in path) { if (prev != null) { - val prevLat = Math.toRadians(prev.latitude) - val prevLng = Math.toRadians(prev.longitude) - val lat = Math.toRadians(point.latitude) - val lng = Math.toRadians(point.longitude) + val prevLat = toRadians(prev.latitude) + val prevLng = toRadians(prev.longitude) + val lat = toRadians(point.latitude) + val lng = toRadians(point.longitude) length += distanceRadians(prevLat, prevLng, lat, lng) } prev = point @@ -282,13 +285,13 @@ object SphericalUtil { } var total = 0.0 val prev = path[size - 1] - var prevTanLat = tan((PI / 2 - Math.toRadians(prev.latitude)) / 2) - var prevLng = Math.toRadians(prev.longitude) + var prevTanLat = tan((PI / 2 - toRadians(prev.latitude)) / 2) + var prevLng = toRadians(prev.longitude) // For each edge, accumulate the signed area of the triangle formed by the North Pole // and that edge ("polar triangle"). for (point in path) { - val tanLat = tan((PI / 2 - Math.toRadians(point.latitude)) / 2) - val lng = Math.toRadians(point.longitude) + val tanLat = tan((PI / 2 - toRadians(point.latitude)) / 2) + val lng = toRadians(point.longitude) total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng) prevTanLat = tanLat prevLng = lng diff --git a/library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt b/library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt new file mode 100644 index 000000000..a29c9e05d --- /dev/null +++ b/library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt @@ -0,0 +1,101 @@ +/* + * 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 + +import kotlin.math.PI +import kotlin.test.Test +import kotlin.test.assertEquals + +class MathUtilTest { + @Test + fun testClamp() { + assertEquals(1.0, MathUtil.clamp(1.0, 0.0, 2.0), DELTA) + assertEquals(0.0, MathUtil.clamp(-1.0, 0.0, 2.0), DELTA) + assertEquals(2.0, MathUtil.clamp(3.0, 0.0, 2.0), DELTA) + } + + @Test + fun testWrap() { + assertEquals(1.0, MathUtil.wrap(1.0, 0.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.wrap(3.0, 0.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.wrap(-1.0, 0.0, 2.0), DELTA) + } + + @Test + fun testMod() { + assertEquals(1.0, MathUtil.mod(1.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.mod(3.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.mod(-1.0, 2.0), DELTA) + } + + @Test + fun testMercator() { + assertEquals(0.0, MathUtil.mercator(0.0), DELTA) + assertEquals(Double.POSITIVE_INFINITY, MathUtil.mercator(PI / 2)) + assertEquals(Double.NEGATIVE_INFINITY, MathUtil.mercator(-PI / 2)) + } + + @Test + fun testInverseMercator() { + assertEquals(0.0, MathUtil.inverseMercator(0.0), DELTA) + assertEquals(PI / 2, MathUtil.inverseMercator(Double.POSITIVE_INFINITY), DELTA) + assertEquals(-PI / 2, MathUtil.inverseMercator(Double.NEGATIVE_INFINITY), DELTA) + } + + @Test + fun testHav() { + assertEquals(0.0, MathUtil.hav(0.0), DELTA) + assertEquals(1.0, MathUtil.hav(PI), DELTA) + assertEquals(0.5, MathUtil.hav(PI / 2), DELTA) + } + + @Test + fun testArcHav() { + assertEquals(0.0, MathUtil.arcHav(0.0), DELTA) + assertEquals(PI, MathUtil.arcHav(1.0), DELTA) + assertEquals(PI / 2, MathUtil.arcHav(0.5), DELTA) + } + + @Test + fun testSinFromHav() { + assertEquals(0.0, MathUtil.sinFromHav(0.0), DELTA) + assertEquals(0.0, MathUtil.sinFromHav(1.0), DELTA) + assertEquals(1.0, MathUtil.sinFromHav(0.5), DELTA) + } + + @Test + fun testHavFromSin() { + assertEquals(0.0, MathUtil.havFromSin(0.0), DELTA) + assertEquals(0.5, MathUtil.havFromSin(1.0), DELTA) + } + + @Test + fun testSinSumFromHav() { + assertEquals(0.0, MathUtil.sinSumFromHav(0.0, 0.0), DELTA) + assertEquals(1.0, MathUtil.sinSumFromHav(0.5, 0.0), DELTA) + assertEquals(1.0, MathUtil.sinSumFromHav(0.0, 0.5), DELTA) + } + + @Test + fun testHavDistance() { + assertEquals(0.0, MathUtil.havDistance(0.0, 0.0, 0.0), DELTA) + assertEquals(1.0, MathUtil.havDistance(0.0, PI, 0.0), DELTA) + } + + companion object { + private const val DELTA = 1e-15 + } +} diff --git a/library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt b/library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt new file mode 100644 index 000000000..459779fff --- /dev/null +++ b/library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt @@ -0,0 +1,640 @@ +/* + * 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 + +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * This class defines a series of tests for the [PolyUtil] class. Each test is designed to + * verify the correctness of a specific geometric utility function provided by [PolyUtil], + * such as checking if a point is contained within a polygon, if it lies on an edge, or simplifying + * a polyline. + * + * The tests are structured to cover a wide range of scenarios, including edge cases like empty + * polygons, polygons that cross the international date line, and polygons near the poles. This + * comprehensive testing ensures that the geometric calculations are robust and reliable. + */ +class PolyUtilTest { + /** + * This test verifies the behavior of the `isLocationOnEdge` and `isLocationOnPath` methods. It + * covers a variety of scenarios, including empty polylines, endpoints, and segments on the + * equator, meridians, and slanted lines. It also tests cases near the poles and with long arcs. + * The test uses a small tolerance to check for points that are very close to the edge, and a + * larger tolerance to check for points that are further away. + */ + @Test + fun testOnEdge() { + // Empty + onEdgeCase(makeList(), makeList(), makeList(0.0, 0.0)) + + val small = 5e-7 // About 5cm on equator, half the default tolerance. + val big = 2e-6 // About 10cm on equator, double the default tolerance. + + // Endpoints + onEdgeCase(makeList(1.0, 2.0), makeList(1.0, 2.0), makeList(3.0, 5.0)) + onEdgeCase(makeList(1.0, 2.0, 3.0, 5.0), makeList(1.0, 2.0, 3.0, 5.0), makeList(0.0, 0.0)) + + // On equator. + onEdgeCase( + makeList(0.0, 90.0, 0.0, 180.0), + makeList(0.0, 90 - small, 0.0, 90 + small, 0 - small, 90.0, 0.0, 135.0, small, 135.0), + makeList(0.0, 90 - big, 0.0, 0.0, 0.0, -90.0, big, 135.0), + ) + + // Ends on same latitude. + onEdgeCase( + makeList(-45.0, -180.0, -45.0, -small), + makeList(-45.0, 180 + small, -45.0, 180 - small, -45 - small, 180 - small, -45.0, 0.0), + makeList(-45.0, big, -45.0, 180 - big, -45 + big, -90.0, -45.0, 90.0), + ) + + // Meridian. + onEdgeCase( + makeList(-10.0, 30.0, 45.0, 30.0), + makeList(10.0, 30 - small, 20.0, 30 + small, -10 - small, 30 + small), + makeList(-10 - big, 30.0, 10.0, -150.0, 0.0, 30 - big), + ) + + // Slanted close to meridian, close to North pole. + onEdgeCase( + makeList(0.0, 0.0, 90 - small, 0 + big), + makeList(1.0, 0 + small, 2.0, 0 - small, 90 - small, -90.0, 90 - small, 10.0), + makeList(-big, 0.0, 90 - big, 180.0, 10.0, big), + ) + + // Arc > 120 deg. + onEdgeCase( + makeList(0.0, 0.0, 0.0, 179.999), + makeList(0.0, 90.0, 0.0, small, 0.0, 179.0, small, 90.0), + makeList(0.0, -90.0, small, -100.0, 0.0, 180.0, 0.0, -big, 90.0, 0.0, -90.0, 180.0), + ) + + onEdgeCase( + makeList(10.0, 5.0, 30.0, 15.0), + makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), + makeList( + 20.0, 10.0, 10 - big, 5 - big / 2, 30 + 2 * big, 15 + big, 10 + 2 * big, 5.0, 10.0, 5 + big, + ), + ) + + onEdgeCase( + makeList(90 - small, 0.0, 0.0, 180 - small / 2), + makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), + makeList(-big, -180 + small / 2, -big, 180.0, -big, 180 - small), + ) + + // Reaching close to North pole. + onEdgeCase( + true, + makeList(80.0, 0.0, 80.0, 180 - small), + makeList(90 - small, -90.0, 90.0, -135.0, 80 - small, 0.0, 80 + small, 0.0), + makeList(80.0, 90.0, 79.0, big), + ) + + onEdgeCase( + false, + makeList(80.0, 0.0, 80.0, 180 - small), + makeList(80 - small, 0.0, 80 + small, 0.0, 80.0, 90.0), + makeList(79.0, big, 90 - small, -90.0, 90.0, -135.0), + ) + } + + /** + * This test verifies the `locationIndexOnPath` method, which determines the index of the segment + * a point lies on. It tests empty polylines, single-point polylines, and multi-segment polylines, + * ensuring that the correct segment index is returned for points on and off the path. + */ + @Test + fun testLocationIndex() { + // Empty. + locationIndexCase(makeList(), LatLng(0.0, 0.0), -1) + + // One point. + locationIndexCase(makeList(1.0, 2.0), LatLng(1.0, 2.0), 0) + locationIndexCase(makeList(1.0, 2.0), LatLng(3.0, 5.0), -1) + + // Two points. + locationIndexCase(makeList(1.0, 2.0, 3.0, 5.0), LatLng(1.0, 2.0), 0) + locationIndexCase(makeList(1.0, 2.0, 3.0, 5.0), LatLng(3.0, 5.0), 0) + locationIndexCase(makeList(1.0, 2.0, 3.0, 5.0), LatLng(4.0, 6.0), -1) + + // Three points. + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 80.0), 0) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 85.0), 0) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 90.0), 0) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 95.0), 1) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 100.0), 1) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 110.0), -1) + } + + /** + * This test specifically focuses on the tolerance parameter of the `locationIndexOnPath` method. + * It verifies that the method correctly identifies points as being on a path segment within a + * given tolerance, and correctly identifies points as being off the path if they are outside the + * tolerance. + */ + @Test + fun testLocationIndexTolerance() { + val small = 5e-7 // About 5cm on equator, half the default tolerance. + val big = 2e-6 // About 10cm on equator, double the default tolerance. + + // Test tolerance. + locationIndexToleranceCase(makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), LatLng(0.0, 90.0), 0) + locationIndexToleranceCase( + makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), + LatLng(0.0, 90 + small), + 0, + ) + locationIndexToleranceCase( + makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), + LatLng(0.0, 90 + 2 * small), + 1, + ) + locationIndexToleranceCase( + makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), + LatLng(0.0, 90 + 3 * small), + -1, + ) + locationIndexToleranceCase(makeList(0.0, 90 - big, 0.0, 90.0, 0.0, 90 + big), LatLng(0.0, 90.0), 0) + locationIndexToleranceCase( + makeList(0.0, 90 - big, 0.0, 90.0, 0.0, 90 + big), + LatLng(0.0, 90 + big), + 1, + ) + locationIndexToleranceCase( + makeList(0.0, 90 - big, 0.0, 90.0, 0.0, 90 + big), + LatLng(0.0, 90 + 2 * big), + -1, + ) + } + + /** + * This test verifies the `containsLocation` method, which checks if a point is inside a polygon. + * It includes tests for empty polygons, single-point polygons, and various shapes of polygons. + * Special attention is given to polygons that are near the North and South poles, as these can be + * tricky edge cases for geometric calculations. + */ + @Test + fun testContainsLocation() { + // Empty. + containsCase(makeList(), makeList(), makeList(0.0, 0.0)) + + // One point. + containsCase(makeList(1.0, 2.0), makeList(1.0, 2.0), makeList(0.0, 0.0)) + + // Two points. + containsCase(makeList(1.0, 2.0, 3.0, 5.0), makeList(1.0, 2.0, 3.0, 5.0), makeList(0.0, 0.0, 40.0, 4.0)) + + // Some arbitrary triangle. + containsCase( + makeList(0.0, 0.0, 10.0, 12.0, 20.0, 5.0), + makeList(10.0, 12.0, 10.0, 11.0, 19.0, 5.0), + makeList(0.0, 1.0, 11.0, 12.0, 30.0, 5.0, 0.0, -180.0, 0.0, 90.0), + ) + + // Around North Pole. + containsCase( + makeList(89.0, 0.0, 89.0, 120.0, 89.0, -120.0), + makeList(90.0, 0.0, 90.0, 180.0, 90.0, -90.0), + makeList(-90.0, 0.0, 0.0, 0.0), + ) + + // Around South Pole. + containsCase( + makeList(-89.0, 0.0, -89.0, 120.0, -89.0, -120.0), + makeList(90.0, 0.0, 90.0, 180.0, 90.0, -90.0, 0.0, 0.0), + makeList(-90.0, 0.0, -90.0, 90.0), + ) + + // Over/under segment on meridian and equator. + containsCase( + makeList(5.0, 10.0, 10.0, 10.0, 0.0, 20.0, 0.0, -10.0), + makeList(2.5, 10.0, 1.0, 0.0), + makeList(15.0, 10.0, 0.0, -15.0, 0.0, 25.0, -1.0, 0.0), + ) + } + + /** + * This test verifies the `simplify` method, which uses the Douglas-Peucker algorithm to reduce + * the number of points in a polyline or polygon. The test checks the simplification at various + * tolerance levels, from small to large, and asserts that the simplified line has the expected + * number of points. It also verifies that the endpoints of the simplified line are the same as + * the original, that the simplified points are a subset of the original points, and that the + * length of the simplified line is less than or equal to the original. + */ + @Test + fun testSimplify() { + /* + * Polyline + */ + val encodedLine = + "elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@" + val line = PolyUtil.decode(encodedLine) + assertEquals(95, line.size) + + var simplifiedLine: List + var copy: List + + var tolerance = 5.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(20, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 10.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(14, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 15.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(10, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 20.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(8, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 50.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(6, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 500.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(3, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 1000.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(2, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + /* + * Polygons + */ + // Open triangle + val triangle = ArrayList() + triangle.add(LatLng(28.06025, -82.41030)) + triangle.add(LatLng(28.06129, -82.40945)) + triangle.add(LatLng(28.06206, -82.40917)) + triangle.add(LatLng(28.06125, -82.40850)) + triangle.add(LatLng(28.06035, -82.40834)) + triangle.add(LatLng(28.06038, -82.40924)) + assertFalse(PolyUtil.isClosedPolygon(triangle)) + + copy = ArrayList(triangle) + tolerance = 88.0 // meters + var simplifiedTriangle = PolyUtil.simplify(triangle, tolerance) + assertEquals(4, simplifiedTriangle.size) + assertEndPoints(triangle, simplifiedTriangle) + assertSimplifiedPointsFromLine(triangle, simplifiedTriangle) + assertLineLength(triangle, simplifiedTriangle) + assertInputUnchanged(triangle, copy) + + // Close the triangle + var p = triangle[0] + var closePoint = LatLng(p.latitude, p.longitude) + triangle.add(closePoint) + assertTrue(PolyUtil.isClosedPolygon(triangle)) + + copy = ArrayList(triangle) + tolerance = 88.0 // meters + simplifiedTriangle = PolyUtil.simplify(triangle, tolerance) + assertEquals(4, simplifiedTriangle.size) + assertEndPoints(triangle, simplifiedTriangle) + assertSimplifiedPointsFromLine(triangle, simplifiedTriangle) + assertLineLength(triangle, simplifiedTriangle) + assertInputUnchanged(triangle, copy) + + // Open oval + val encodedOvalPolygon = + "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ" + val oval = PolyUtil.decode(encodedOvalPolygon).toMutableList() + assertFalse(PolyUtil.isClosedPolygon(oval)) + + copy = ArrayList(oval) + tolerance = 10.0 // meters + var simplifiedOval = PolyUtil.simplify(oval, tolerance) + assertEquals(13, simplifiedOval.size) + assertEndPoints(oval, simplifiedOval) + assertSimplifiedPointsFromLine(oval, simplifiedOval) + assertLineLength(oval, simplifiedOval) + assertInputUnchanged(oval, copy) + + // Close the oval + p = oval[0] + closePoint = LatLng(p.latitude, p.longitude) + oval.add(closePoint) + assertTrue(PolyUtil.isClosedPolygon(oval)) + + copy = ArrayList(oval) + tolerance = 10.0 // meters + simplifiedOval = PolyUtil.simplify(oval, tolerance) + assertEquals(13, simplifiedOval.size) + assertEndPoints(oval, simplifiedOval) + assertSimplifiedPointsFromLine(oval, simplifiedOval) + assertLineLength(oval, simplifiedOval) + assertInputUnchanged(oval, copy) + } + + /** + * This test verifies the `isClosedPolygon` method. It checks that the method correctly identifies + * a polygon as closed only when its first and last points are identical. + */ + @Test + fun testIsClosedPolygon() { + val poly = ArrayList() + poly.add(LatLng(28.06025, -82.41030)) + poly.add(LatLng(28.06129, -82.40945)) + poly.add(LatLng(28.06206, -82.40917)) + poly.add(LatLng(28.06125, -82.40850)) + poly.add(LatLng(28.06035, -82.40834)) + + assertFalse(PolyUtil.isClosedPolygon(poly)) + + // Add the closing point that's same as the first + poly.add(LatLng(28.06025, -82.41030)) + assertTrue(PolyUtil.isClosedPolygon(poly)) + } + + /** + * The following method checks whether [PolyUtil.distanceToLine] is determining the distance + * between a point and a segment accurately. + * + * Currently there are tests for different orders of magnitude (i.e., 1X, 10X, 100X, 1000X), as + * well as a test where the segment and the point lie in different hemispheres. + * + * If further tests need to be added here, make sure that the distance has been verified with + * [QGIS](https://www.qgis.org/). + */ + @Test + fun testDistanceToLine() { + var startLine = LatLng(28.05359, -82.41632) + var endLine = LatLng(28.05310, -82.41634) + var p = LatLng(28.05342, -82.41594) + + var distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(37.94596795917082, distance, 1e-6) + + startLine = LatLng(49.321045, 12.097749) + endLine = LatLng(49.321016, 12.097795) + p = LatLng(49.3210674, 12.0978238) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(5.559443879999753, distance, 1e-6) + + startLine = LatLng(48.125961, 11.548998) + endLine = LatLng(48.125918, 11.549005) + p = LatLng(48.125941, 11.549028) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(1.9733966358947437, distance, 1e-6) + + startLine = LatLng(78.924669, 11.925521) + endLine = LatLng(78.924707, 11.929060) + p = LatLng(78.923164, 11.924029) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(170.35662670453187, distance, 1e-6) + + startLine = LatLng(69.664036, 18.957124) + endLine = LatLng(69.664029, 18.957109) + p = LatLng(69.672901, 18.967911) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(1070.222749990837, distance, 1e-6) + + startLine = LatLng(-0.018200, 109.343282) + endLine = LatLng(-0.017877, 109.343537) + p = LatLng(0.058299, 109.408054) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(11100.157563150981, distance, 1e-6) + } + + /** + * This test ensures that the distance from a point to a line segment is always less than or equal + * to the distance from the point to either of the segment's endpoints. This is a fundamental + * property of Euclidean geometry that should also hold true for spherical geometry for short + * distances. + */ + @Test + fun testDistanceToLineLessThanDistanceToExtremes() { + val startLine = LatLng(28.05359, -82.41632) + val endLine = LatLng(28.05310, -82.41634) + val p = LatLng(28.05342, -82.41594) + + val distance = PolyUtil.distanceToLine(p, startLine, endLine) + val distanceToStart = SphericalUtil.computeDistanceBetween(p, startLine) + val distanceToEnd = SphericalUtil.computeDistanceBetween(p, endLine) + + assertTrue(distance <= distanceToStart) + assertTrue(distance <= distanceToEnd) + } + + /** + * This test verifies the `decode` method, which decodes an encoded polyline string into a list of + * `LatLng` points. It checks that the decoded path has the correct number of points and that the + * last point has the expected latitude and longitude. + */ + @Test + fun testDecodePath() { + val latLngs = PolyUtil.decode(TEST_LINE) + + val expectedLength = 21 + assertEquals(expectedLength, latLngs.size) + + val lastPoint = latLngs[expectedLength - 1] + assertEquals(37.76953, lastPoint.latitude, 1e-6) + assertEquals(-122.41488, lastPoint.longitude, 1e-6) + } + + /** + * This test verifies the `encode` method, which encodes a list of `LatLng` points into a polyline + * string. It first decodes a test string, then re-encodes the resulting list of points, and + * finally asserts that the re-encoded string is identical to the original. This ensures the + * encode and decode methods are inverse operations. + */ + @Test + fun testEncodePath() { + val path = PolyUtil.decode(TEST_LINE) + val encoded = PolyUtil.encode(path) + assertEquals(TEST_LINE, encoded) + } + + companion object { + private const val TEST_LINE = + "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC" + + /** + * A helper method to construct a [List] of [LatLng] objects from a series of latitude + * and longitude coordinates. This simplifies the creation of test polygons and polylines. + */ + private fun makeList(vararg coords: Double): List { + val size = coords.size / 2 + val list = ArrayList(size) + for (i in 0 until size) { + list.add(LatLng(coords[i + i], coords[i + i + 1])) + } + return list + } + + /** + * A helper method to test [PolyUtil.containsLocation]. It asserts that all points in the + * [yes] list are contained within the polygon, and all points in the [no] list are not. + * This is tested for both geodesic and rhumb line paths. + */ + private fun containsCase(poly: List, yes: List, no: List) { + for (point in yes) { + assertTrue(PolyUtil.containsLocation(point, poly, true)) + assertTrue(PolyUtil.containsLocation(point, poly, false)) + } + for (point in no) { + assertFalse(PolyUtil.containsLocation(point, poly, true)) + assertFalse(PolyUtil.containsLocation(point, poly, false)) + } + } + + /** + * A helper method to test [PolyUtil.isLocationOnEdge] and [PolyUtil.isLocationOnPath]. + * It asserts that all points in the [yes] list are on the edge of the polygon, and all + * points in the [no] list are not. + */ + private fun onEdgeCase(geodesic: Boolean, poly: List, yes: List, no: List) { + for (point in yes) { + assertTrue(PolyUtil.isLocationOnEdge(point, poly, geodesic)) + assertTrue(PolyUtil.isLocationOnPath(point, poly, geodesic)) + } + for (point in no) { + assertFalse(PolyUtil.isLocationOnEdge(point, poly, geodesic)) + assertFalse(PolyUtil.isLocationOnPath(point, poly, geodesic)) + } + } + + /** Overload of [onEdgeCase] that tests for both geodesic and rhumb line paths. */ + private fun onEdgeCase(poly: List, yes: List, no: List) { + onEdgeCase(true, poly, yes, no) + onEdgeCase(false, poly, yes, no) + } + + /** + * A helper method to test [PolyUtil.locationIndexOnPath]. It asserts that the returned + * index for a given point on a polyline is as expected. + */ + private fun locationIndexCase(geodesic: Boolean, poly: List, point: LatLng, idx: Int) { + assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic)) + } + + /** Overload of [locationIndexCase] that tests for both geodesic and rhumb line paths. */ + private fun locationIndexCase(poly: List, point: LatLng, idx: Int) { + locationIndexCase(true, poly, point, idx) + locationIndexCase(false, poly, point, idx) + } + + /** A helper method to test [PolyUtil.locationIndexOnPath] with a specific tolerance. */ + private fun locationIndexToleranceCase(geodesic: Boolean, poly: List, point: LatLng, idx: Int) { + assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)) + } + + /** Overload of [locationIndexToleranceCase] that tests both geodesic and rhumb line paths. */ + private fun locationIndexToleranceCase(poly: List, point: LatLng, idx: Int) { + locationIndexToleranceCase(true, poly, point, idx) + locationIndexToleranceCase(false, poly, point, idx) + } + + /** + * Asserts that the beginning and end points of the original line match those of the + * simplified line. + */ + private fun assertEndPoints(line: List, simplifiedLine: List) { + assertEquals(line[0], simplifiedLine[0]) + assertEquals(line[line.size - 1], simplifiedLine[simplifiedLine.size - 1]) + } + + /** Asserts that the simplified line is composed of points from the original line. */ + private fun assertSimplifiedPointsFromLine(line: List, simplifiedLine: List) { + for (l in simplifiedLine) { + assertTrue(line.contains(l)) + } + } + + /** + * Asserts that the length of the simplified line is always equal to or less than the length + * of the original line, if simplification has eliminated any points from the original line. + */ + private fun assertLineLength(line: List, simplifiedLine: List) { + if (line.size == simplifiedLine.size) { + // If no points were eliminated, then the length of both lines should be the same + assertEquals(SphericalUtil.computeLength(line), SphericalUtil.computeLength(simplifiedLine), 0.0) + } else { + assertTrue(simplifiedLine.size < line.size) + // If points were eliminated, then the simplified line should always be shorter + assertTrue(SphericalUtil.computeLength(simplifiedLine) < SphericalUtil.computeLength(line)) + } + } + + /** + * Asserts that the contents of the original List passed into the PolyUtil.simplify() method + * doesn't change after the method is executed. We test for this because the poly is modified + * (a small offset is added to the last point) to allow for polygon simplification. + */ + private fun assertInputUnchanged(afterInput: List, beforeInput: List) { + // Check values + assertEquals(beforeInput, afterInput) + + // Check references + for (i in beforeInput.indices) { + assertSame(beforeInput[i], afterInput[i]) + } + } + } +} diff --git a/library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt b/library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt new file mode 100644 index 000000000..f2fd879ea --- /dev/null +++ b/library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt @@ -0,0 +1,405 @@ +/* + * 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 + +import com.google.maps.android.MathUtil.EARTH_RADIUS +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SphericalUtilTest { + // The vertices of an octahedron, for testing + private val up = LatLng(90.0, 0.0) + private val down = LatLng(-90.0, 0.0) + private val front = LatLng(0.0, 0.0) + private val right = LatLng(0.0, 90.0) + private val back = LatLng(0.0, -180.0) + private val left = LatLng(0.0, -90.0) + + @Test + fun testAngles() { + // Same vertex + assertEquals(0.0, SphericalUtil.computeAngleBetween(up, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(down, down), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(left, left), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(right, right), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(front, front), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(back, back), 1e-6) + + // Adjacent vertices + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, front), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, right), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, back), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, left), 1e-6) + + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, front), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, right), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, back), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, left), 1e-6) + + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, up), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, right), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, down), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, left), 1e-6) + + // Opposite vertices + assertEquals(PI, SphericalUtil.computeAngleBetween(up, down), 1e-6) + assertEquals(PI, SphericalUtil.computeAngleBetween(front, back), 1e-6) + assertEquals(PI, SphericalUtil.computeAngleBetween(left, right), 1e-6) + } + + @Test + fun testDistances() { + assertEquals(PI * EARTH_RADIUS, SphericalUtil.computeDistanceBetween(up, down), 1e-6) + } + + @Test + fun testHeadings() { + // Opposing vertices for which there is a result + assertEquals(-180.0, SphericalUtil.computeHeading(up, down), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(down, up), 1e-6) + + // Adjacent vertices for which there is a result + assertEquals(0.0, SphericalUtil.computeHeading(front, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(right, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(back, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(down, up), 1e-6) + + assertEquals(-180.0, SphericalUtil.computeHeading(front, down), 1e-6) + assertEquals(-180.0, SphericalUtil.computeHeading(right, down), 1e-6) + assertEquals(-180.0, SphericalUtil.computeHeading(back, down), 1e-6) + assertEquals(-180.0, SphericalUtil.computeHeading(left, down), 1e-6) + + assertEquals(-90.0, SphericalUtil.computeHeading(right, front), 1e-6) + assertEquals(90.0, SphericalUtil.computeHeading(left, front), 1e-6) + + assertEquals(90.0, SphericalUtil.computeHeading(front, right), 1e-6) + assertEquals(-90.0, SphericalUtil.computeHeading(back, right), 1e-6) + } + + @Test + fun testComputeOffset() { + // From front + expectLatLngApproxEquals(front, SphericalUtil.computeOffset(front, 0.0, 0.0)) + expectLatLngApproxEquals(up, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, 0.0)) + expectLatLngApproxEquals(down, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, 180.0)) + expectLatLngApproxEquals(left, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, -90.0)) + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, 90.0)) + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS, 0.0)) + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS, 90.0)) + + // From left + expectLatLngApproxEquals(left, SphericalUtil.computeOffset(left, 0.0, 0.0)) + expectLatLngApproxEquals(up, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, 0.0)) + expectLatLngApproxEquals(down, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, 180.0)) + expectLatLngApproxEquals(front, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, 90.0)) + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, -90.0)) + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS, 0.0)) + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS, 90.0)) + + // NOTE(appleton): Heading is undefined at the poles, so we do not test + // from up/down. + } + + @Test + fun testComputeOffsetOrigin() { + expectLatLngApproxEquals(front, assertNotNull(SphericalUtil.computeOffsetOrigin(front, 0.0, 0.0))) + + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(0.0, 45.0), PI * EARTH_RADIUS / 4, 90.0)), + ) + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(0.0, -45.0), PI * EARTH_RADIUS / 4, -90.0)), + ) + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(45.0, 0.0), PI * EARTH_RADIUS / 4, 0.0)), + ) + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(-45.0, 0.0), PI * EARTH_RADIUS / 4, 180.0)), + ) + + // Situations with no solution, should return null. + // + // First 'over' the pole. + assertNull(SphericalUtil.computeOffsetOrigin(LatLng(80.0, 0.0), PI * EARTH_RADIUS / 4, 180.0)) + // Second a distance that doesn't fit on the earth. + assertNull(SphericalUtil.computeOffsetOrigin(LatLng(80.0, 0.0), PI * EARTH_RADIUS / 4, 90.0)) + } + + @Test + fun testComputeOffsetAndBackToOrigin() { + var start = LatLng(40.0, 40.0) + var distance = 1e5 + var heading = 15.0 + var end: LatLng + + // Some semi-random values to demonstrate going forward and backward yields + // the same location. + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + heading = -37.0 + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + distance = 3.8e+7 + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + start = LatLng(-21.0, -73.0) + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + // computeOffsetOrigin with multiple solutions, all we care about is that + // going from there yields the requested result. + // + // First, for this particular situation the latitude is completely arbitrary. + val start1 = SphericalUtil.computeOffsetOrigin(LatLng(0.0, 90.0), PI * EARTH_RADIUS / 2, 90.0) + assertNotNull(start1) + expectLatLngApproxEquals(LatLng(0.0, 90.0), SphericalUtil.computeOffset(start1, PI * EARTH_RADIUS / 2, 90.0)) + + // Second, for this particular situation the longitude is completely + // arbitrary. + val start2 = SphericalUtil.computeOffsetOrigin(LatLng(90.0, 0.0), PI * EARTH_RADIUS / 4, 0.0) + assertNotNull(start2) + expectLatLngApproxEquals(LatLng(90.0, 0.0), SphericalUtil.computeOffset(start2, PI * EARTH_RADIUS / 4, 0.0)) + } + + @Test + fun testInterpolate() { + // Same point + expectLatLngApproxEquals(up, SphericalUtil.interpolate(up, up, 1 / 2.0)) + expectLatLngApproxEquals(down, SphericalUtil.interpolate(down, down, 1 / 2.0)) + expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, left, 1 / 2.0)) + + // Between front and up + expectLatLngApproxEquals(LatLng(1.0, 0.0), SphericalUtil.interpolate(front, up, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(1.0, 0.0), SphericalUtil.interpolate(up, front, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(89.0, 0.0), SphericalUtil.interpolate(front, up, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(89.0, 0.0), SphericalUtil.interpolate(up, front, 1 / 90.0)) + + // Between front and down + expectLatLngApproxEquals(LatLng(-1.0, 0.0), SphericalUtil.interpolate(front, down, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(-1.0, 0.0), SphericalUtil.interpolate(down, front, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(-89.0, 0.0), SphericalUtil.interpolate(front, down, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(-89.0, 0.0), SphericalUtil.interpolate(down, front, 1 / 90.0)) + + // Between left and back + expectLatLngApproxEquals(LatLng(0.0, -91.0), SphericalUtil.interpolate(left, back, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -91.0), SphericalUtil.interpolate(back, left, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -179.0), SphericalUtil.interpolate(left, back, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -179.0), SphericalUtil.interpolate(back, left, 1 / 90.0)) + + // geodesic crosses pole + expectLatLngApproxEquals(up, SphericalUtil.interpolate(LatLng(45.0, 0.0), LatLng(45.0, 180.0), 1 / 2.0)) + expectLatLngApproxEquals(down, SphericalUtil.interpolate(LatLng(-45.0, 0.0), LatLng(-45.0, 180.0), 1 / 2.0)) + + // boundary values for fraction, between left and back + expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0.0)) + expectLatLngApproxEquals(back, SphericalUtil.interpolate(left, back, 1.0)) + + // two nearby points, separated by ~4m, for which the Slerp algorithm is not stable and we + // have to fall back to linear interpolation. + expectLatLngApproxEquals( + LatLng(-37.756872, 175.325252), + SphericalUtil.interpolate(LatLng(-37.756891, 175.325262), LatLng(-37.756853, 175.325242), 0.5), + ) + } + + @Test + fun testComputeLength() { + assertEquals(0.0, SphericalUtil.computeLength(emptyList()), 1e-6) + assertEquals(0.0, SphericalUtil.computeLength(listOf(LatLng(0.0, 0.0))), 1e-6) + + var latLngs = listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1)) + assertEquals(toRadians(0.1) * sqrt(2.0) * EARTH_RADIUS, SphericalUtil.computeLength(latLngs), 1.0) + + latLngs = listOf(LatLng(0.0, 0.0), LatLng(90.0, 0.0), LatLng(0.0, 90.0)) + assertEquals(PI * EARTH_RADIUS, SphericalUtil.computeLength(latLngs), 1e-6) + } + + @Test + fun testIsCCW() { + // One face of the octahedron + assertEquals(1, isCCW(right, up, front)) + assertEquals(1, isCCW(up, front, right)) + assertEquals(1, isCCW(front, right, up)) + assertEquals(-1, isCCW(front, up, right)) + assertEquals(-1, isCCW(up, right, front)) + assertEquals(-1, isCCW(right, front, up)) + } + + @Test + fun testComputeTriangleArea() { + assertEquals(PI / 2, computeTriangleArea(right, up, front), 1e-6) + assertEquals(PI / 2, computeTriangleArea(front, up, right), 1e-6) + + // computeArea returns area of zero on small polys + val area = + computeTriangleArea( + LatLng(0.0, 0.0), + LatLng(0.0, toDegrees(1E-6)), + LatLng(toDegrees(1E-6), 0.0), + ) + val expectedArea = 1E-12 / 2 + + assertTrue(abs(expectedArea - area) < 1e-20) + } + + @Test + fun testComputeSignedTriangleArea() { + assertEquals( + toRadians(0.1) * toRadians(0.1) / 2, + computeSignedTriangleArea(LatLng(0.0, 0.0), LatLng(0.0, 0.1), LatLng(0.1, 0.1)), + 1e-6, + ) + + assertEquals(PI / 2, computeSignedTriangleArea(right, up, front), 1e-6) + + assertEquals(-PI / 2, computeSignedTriangleArea(front, up, right), 1e-6) + } + + @Test + fun testComputeArea() { + assertEquals( + PI * EARTH_RADIUS * EARTH_RADIUS, + SphericalUtil.computeArea(listOf(right, up, front, down, right)), + .4, + ) + + assertEquals( + PI * EARTH_RADIUS * EARTH_RADIUS, + SphericalUtil.computeArea(listOf(right, down, front, up, right)), + .4, + ) + } + + @Test + fun testComputeSignedArea() { + val path = listOf(right, up, front, down, right) + val pathReversed = listOf(right, down, front, up, right) + assertEquals(SphericalUtil.computeSignedArea(pathReversed), -SphericalUtil.computeSignedArea(path), 0.0) + } + + @Test + fun testGetPointOnPolyline() { + val a = LatLng(0.0, 0.0) + val b = LatLng(0.0, 10.0) + val c = LatLng(10.0, 10.0) + val d = LatLng(10.0, 0.0) + val polyline = listOf(a, b, c, d) + + // Test for null cases + assertNull(SphericalUtil.getPointOnPolyline(emptyList(), 0.5)) + assertNull(SphericalUtil.getPointOnPolyline(polyline, -0.1)) + assertNull(SphericalUtil.getPointOnPolyline(polyline, 1.1)) + + // Test for start and end points + expectLatLngApproxEquals(a, assertNotNull(SphericalUtil.getPointOnPolyline(polyline, 0.0))) + expectLatLngApproxEquals(d, assertNotNull(SphericalUtil.getPointOnPolyline(polyline, 1.0))) + + // Test for a point in the middle of a segment + val midAB = LatLng(0.0, 5.0) + val totalLength = SphericalUtil.computeLength(polyline) + val abLength = SphericalUtil.computeDistanceBetween(a, b) + expectLatLngApproxEquals( + midAB, + assertNotNull(SphericalUtil.getPointOnPolyline(polyline, (abLength / 2) / totalLength)), + ) + + // Test for a point on a vertex + val aToCLength = SphericalUtil.computeLength(listOf(a, b, c)) + expectLatLngApproxEquals( + c, + assertNotNull(SphericalUtil.getPointOnPolyline(polyline, aToCLength / totalLength)), + ) + } + + @Test + fun testGetPolylinePrefix() { + val a = LatLng(0.0, 0.0) + val b = LatLng(0.0, 10.0) + val c = LatLng(10.0, 10.0) + val d = LatLng(10.0, 0.0) + val polyline = listOf(a, b, c, d) + + // Test for empty list cases + assertTrue(SphericalUtil.getPolylinePrefix(emptyList(), 0.5).isEmpty()) + assertTrue(SphericalUtil.getPolylinePrefix(polyline, -0.1).isEmpty()) + assertTrue(SphericalUtil.getPolylinePrefix(polyline, 1.1).isEmpty()) + + // Test for 0% + val prefix0 = SphericalUtil.getPolylinePrefix(polyline, 0.0) + assertEquals(1, prefix0.size) + expectLatLngApproxEquals(a, prefix0[0]) + + // Test for 100% + val prefix100 = SphericalUtil.getPolylinePrefix(polyline, 1.0) + assertEquals(polyline.size, prefix100.size) + for (i in polyline.indices) { + expectLatLngApproxEquals(polyline[i], prefix100[i]) + } + + // Test for a prefix that ends in the middle of a segment + val midAB = LatLng(0.0, 5.0) + val totalLength = SphericalUtil.computeLength(polyline) + val abLength = SphericalUtil.computeDistanceBetween(a, b) + val prefixMidAB = SphericalUtil.getPolylinePrefix(polyline, (abLength / 2) / totalLength) + assertEquals(2, prefixMidAB.size) + expectLatLngApproxEquals(a, prefixMidAB[0]) + expectLatLngApproxEquals(midAB, prefixMidAB[1]) + + // Test for a prefix that ends on a vertex + val aToCLength = SphericalUtil.computeLength(listOf(a, b, c)) + val prefixC = SphericalUtil.getPolylinePrefix(polyline, aToCLength / totalLength) + assertEquals(3, prefixC.size) + expectLatLngApproxEquals(a, prefixC[0]) + expectLatLngApproxEquals(b, prefixC[1]) + expectLatLngApproxEquals(c, prefixC[2]) + } + + companion object { + /** Tests for approximate equality. */ + private fun expectLatLngApproxEquals(expected: LatLng, actual: LatLng) { + assertEquals(expected.latitude, actual.latitude, 1e-6) + // Account for the convergence of longitude lines at the poles + val cosLat = cos(toRadians(actual.latitude)) + assertEquals(cosLat * expected.longitude, cosLat * actual.longitude, 1e-6) + } + + private fun computeSignedTriangleArea(a: LatLng, b: LatLng, c: LatLng): Double = + SphericalUtil.computeSignedArea(listOf(a, b, c), 1.0) + + private fun computeTriangleArea(a: LatLng, b: LatLng, c: LatLng): Double = + abs(computeSignedTriangleArea(a, b, c)) + + private fun isCCW(a: LatLng, b: LatLng, c: LatLng): Int = + if (computeSignedTriangleArea(a, b, c) > 0) 1 else -1 + } +} diff --git a/library/src/test/java/com/google/maps/android/MathUtilTest.java b/library/src/test/java/com/google/maps/android/MathUtilTest.java deleted file mode 100644 index 5371eddc9..000000000 --- a/library/src/test/java/com/google/maps/android/MathUtilTest.java +++ /dev/null @@ -1,99 +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; - -import static com.google.common.truth.Truth.assertThat; - -import org.junit.Test; - -public class MathUtilTest { - private static final double DELTA = 1e-15; - - @Test - public void testClamp() { - assertThat(MathUtil.clamp(1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.clamp(-1.0, 0.0, 2.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.clamp(3.0, 0.0, 2.0)).isWithin(DELTA).of(2.0); - } - - @Test - public void testWrap() { - assertThat(MathUtil.wrap(1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.wrap(3.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.wrap(-1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); - } - - @Test - public void testMod() { - assertThat(MathUtil.mod(1.0, 2.0)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.mod(3.0, 2.0)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.mod(-1.0, 2.0)).isWithin(DELTA).of(1.0); - } - - @Test - public void testMercator() { - assertThat(MathUtil.mercator(0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.mercator(Math.PI / 2)).isPositiveInfinity(); - assertThat(MathUtil.mercator(-Math.PI / 2)).isNegativeInfinity(); - } - - @Test - public void testInverseMercator() { - assertThat(MathUtil.inverseMercator(0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.inverseMercator(Double.POSITIVE_INFINITY)).isWithin(DELTA).of(Math.PI / 2); - assertThat(MathUtil.inverseMercator(Double.NEGATIVE_INFINITY)).isWithin(DELTA).of(-Math.PI / 2); - } - - @Test - public void testHav() { - assertThat(MathUtil.hav(0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.hav(Math.PI)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.hav(Math.PI / 2)).isWithin(DELTA).of(0.5); - } - - @Test - public void testArcHav() { - assertThat(MathUtil.arcHav(0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.arcHav(1.0)).isWithin(DELTA).of(Math.PI); - assertThat(MathUtil.arcHav(0.5)).isWithin(DELTA).of(Math.PI / 2); - } - - @Test - public void testSinFromHav() { - assertThat(MathUtil.sinFromHav(0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.sinFromHav(1.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.sinFromHav(0.5)).isWithin(DELTA).of(1.0); - } - - @Test - public void testHavFromSin() { - assertThat(MathUtil.havFromSin(0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.havFromSin(1.0)).isWithin(DELTA).of(0.5); - } - - @Test - public void testSinSumFromHav() { - assertThat(MathUtil.sinSumFromHav(0.0, 0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.sinSumFromHav(0.5, 0.0)).isWithin(DELTA).of(1.0); - assertThat(MathUtil.sinSumFromHav(0.0, 0.5)).isWithin(DELTA).of(1.0); - } - - @Test - public void testHavDistance() { - assertThat(MathUtil.havDistance(0.0, 0.0, 0.0)).isWithin(DELTA).of(0.0); - assertThat(MathUtil.havDistance(0.0, Math.PI, 0.0)).isWithin(DELTA).of(1.0); - } -} diff --git a/library/src/test/java/com/google/maps/android/PolyUtilTest.java b/library/src/test/java/com/google/maps/android/PolyUtilTest.java deleted file mode 100644 index 42a80207f..000000000 --- a/library/src/test/java/com/google/maps/android/PolyUtilTest.java +++ /dev/null @@ -1,665 +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; - -import static com.google.common.truth.Truth.assertThat; - -import com.google.android.gms.maps.model.LatLng; -import java.util.ArrayList; -import java.util.List; -import org.junit.Test; - -/** - * This class defines a series of tests for the {@link PolyUtil} class. Each test is designed to - * verify the correctness of a specific geometric utility function provided by {@link PolyUtil}, - * such as checking if a point is contained within a polygon, if it lies on an edge, or simplifying - * a polyline. - * - *

The tests are structured to cover a wide range of scenarios, including edge cases like empty - * polygons, polygons that cross the international date line, and polygons near the poles. This - * comprehensive testing ensures that the geometric calculations are robust and reliable. - */ -public class PolyUtilTest { - private static final String TEST_LINE = - "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC"; - - /** - * A helper method to construct a {@link List} of {@link LatLng} objects from a series of latitude - * and longitude coordinates. This simplifies the creation of test polygons and polylines. - * - * @param coords A varargs array of doubles, representing latitude and longitude pairs. - * @return A {@link List} of {@link LatLng} objects. - */ - private static List makeList(double... coords) { - int size = coords.length / 2; - List list = new ArrayList<>(size); - for (int i = 0; i < size; ++i) { - list.add(new LatLng(coords[i + i], coords[i + i + 1])); - } - return list; - } - - /** - * A helper method to test the {@link PolyUtil#containsLocation(LatLng, List, boolean)} method. It - * asserts that all points in the {@code yes} list are contained within the polygon, and all - * points in the {@code no} list are not. This is tested for both geodesic and rhumb line paths. - * - * @param poly The polygon to test against. - * @param yes A list of points that are expected to be inside the polygon. - * @param no A list of points that are expected to be outside the polygon. - */ - private static void containsCase(List poly, List yes, List no) { - for (LatLng point : yes) { - assertThat(PolyUtil.containsLocation(point, poly, true)).isTrue(); - assertThat(PolyUtil.containsLocation(point, poly, false)).isTrue(); - } - for (LatLng point : no) { - assertThat(PolyUtil.containsLocation(point, poly, true)).isFalse(); - assertThat(PolyUtil.containsLocation(point, poly, false)).isFalse(); - } - } - - /** - * A helper method to test the {@link PolyUtil#isLocationOnEdge(LatLng, List, boolean)} and {@link - * PolyUtil#isLocationOnPath(LatLng, List, boolean)} methods. It asserts that all points in the - * {@code yes} list are on the edge of the polygon, and all points in the {@code no} list are not. - * - * @param geodesic Whether to use geodesic or rhumb line paths. - * @param poly The polygon or polyline to test against. - * @param yes A list of points that are expected to be on the edge. - * @param no A list of points that are expected to not be on the edge. - */ - private static void onEdgeCase( - boolean geodesic, List poly, List yes, List no) { - for (LatLng point : yes) { - assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isTrue(); - assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isTrue(); - } - for (LatLng point : no) { - assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isFalse(); - assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isFalse(); - } - } - - /** - * Overloaded helper method for {@link #onEdgeCase(boolean, List, List, List)} that tests for both - * geodesic and rhumb line paths. - */ - private static void onEdgeCase(List poly, List yes, List no) { - onEdgeCase(true, poly, yes, no); - onEdgeCase(false, poly, yes, no); - } - - /** - * A helper method to test the {@link PolyUtil#locationIndexOnPath(LatLng, List, boolean)}. It - * asserts that the returned index for a given point on a polyline is as expected. - * - * @param geodesic Whether to use geodesic or rhumb line paths. - * @param poly The polyline to test against. - * @param point The point to find the index for. - * @param idx The expected index. - */ - private static void locationIndexCase( - boolean geodesic, List poly, LatLng point, int idx) { - assertThat(PolyUtil.locationIndexOnPath(point, poly, geodesic)).isEqualTo(idx); - } - - /** - * Overloaded helper method for {@link #locationIndexCase(boolean, List, LatLng, int)} that tests - * for both geodesic and rhumb line paths. - */ - private static void locationIndexCase(List poly, LatLng point, int idx) { - locationIndexCase(true, poly, point, idx); - locationIndexCase(false, poly, point, idx); - } - - /** - * A helper method to test {@link PolyUtil#locationIndexOnPath(LatLng, List, boolean, double)} - * with a specific tolerance. - * - * @param geodesic Whether to use geodesic or rhumb line paths. - * @param poly The polyline to test against. - * @param point The point to find the index for. - * @param idx The expected index. - */ - private static void locationIndexToleranceCase( - boolean geodesic, List poly, LatLng point, int idx) { - assertThat(PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)).isEqualTo(idx); - } - - /** - * Overloaded helper method for {@link #locationIndexToleranceCase(boolean, List, LatLng, int)} - * that tests for both geodesic and rhumb line paths. - */ - private static void locationIndexToleranceCase(List poly, LatLng point, int idx) { - locationIndexToleranceCase(true, poly, point, idx); - locationIndexToleranceCase(false, poly, point, idx); - } - - /** - * This test verifies the behavior of the `isLocationOnEdge` and `isLocationOnPath` methods. It - * covers a variety of scenarios, including empty polylines, endpoints, and segments on the - * equator, meridians, and slanted lines. It also tests cases near the poles and with long arcs. - * The test uses a small tolerance to check for points that are very close to the edge, and a - * larger tolerance to check for points that are further away. - */ - @Test - public void testOnEdge() { - // Empty - onEdgeCase(makeList(), makeList(), makeList(0, 0)); - - final double small = 5e-7; // About 5cm on equator, half the default tolerance. - final double big = 2e-6; // About 10cm on equator, double the default tolerance. - - // Endpoints - onEdgeCase(makeList(1, 2), makeList(1, 2), makeList(3, 5)); - onEdgeCase(makeList(1, 2, 3, 5), makeList(1, 2, 3, 5), makeList(0, 0)); - - // On equator. - onEdgeCase( - makeList(0, 90, 0, 180), - makeList(0, 90 - small, 0, 90 + small, 0 - small, 90, 0, 135, small, 135), - makeList(0, 90 - big, 0, 0, 0, -90, big, 135)); - - // Ends on same latitude. - onEdgeCase( - makeList(-45, -180, -45, -small), - makeList(-45, 180 + small, -45, 180 - small, -45 - small, 180 - small, -45, 0), - makeList(-45, big, -45, 180 - big, -45 + big, -90, -45, 90)); - - // Meridian. - onEdgeCase( - makeList(-10, 30, 45, 30), - makeList(10, 30 - small, 20, 30 + small, -10 - small, 30 + small), - makeList(-10 - big, 30, 10, -150, 0, 30 - big)); - - // Slanted close to meridian, close to North pole. - onEdgeCase( - makeList(0, 0, 90 - small, 0 + big), - makeList(1, 0 + small, 2, 0 - small, 90 - small, -90, 90 - small, 10), - makeList(-big, 0, 90 - big, 180, 10, big)); - - // Arc > 120 deg. - onEdgeCase( - makeList(0, 0, 0, 179.999), - makeList(0, 90, 0, small, 0, 179, small, 90), - makeList(0, -90, small, -100, 0, 180, 0, -big, 90, 0, -90, 180)); - - onEdgeCase( - makeList(10, 5, 30, 15), - makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), - makeList( - 20, 10, 10 - big, 5 - big / 2, 30 + 2 * big, 15 + big, 10 + 2 * big, 5, 10, 5 + big)); - - onEdgeCase( - makeList(90 - small, 0, 0, 180 - small / 2), - makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), - makeList(-big, -180 + small / 2, -big, 180, -big, 180 - small)); - - // Reaching close to North pole. - onEdgeCase( - true, - makeList(80, 0, 80, 180 - small), - makeList(90 - small, -90, 90, -135, 80 - small, 0, 80 + small, 0), - makeList(80, 90, 79, big)); - - onEdgeCase( - false, - makeList(80, 0, 80, 180 - small), - makeList(80 - small, 0, 80 + small, 0, 80, 90), - makeList(79, big, 90 - small, -90, 90, -135)); - } - - /** - * This test verifies the `locationIndexOnPath` method, which determines the index of the segment - * a point lies on. It tests empty polylines, single-point polylines, and multi-segment polylines, - * ensuring that the correct segment index is returned for points on and off the path. - */ - @Test - public void testLocationIndex() { - // Empty. - locationIndexCase(makeList(), new LatLng(0, 0), -1); - - // One point. - locationIndexCase(makeList(1, 2), new LatLng(1, 2), 0); - locationIndexCase(makeList(1, 2), new LatLng(3, 5), -1); - - // Two points. - locationIndexCase(makeList(1, 2, 3, 5), new LatLng(1, 2), 0); - locationIndexCase(makeList(1, 2, 3, 5), new LatLng(3, 5), 0); - locationIndexCase(makeList(1, 2, 3, 5), new LatLng(4, 6), -1); - - // Three points. - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 80), 0); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 85), 0); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 90), 0); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 95), 1); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 100), 1); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 110), -1); - } - - /** - * This test specifically focuses on the tolerance parameter of the `locationIndexOnPath` method. - * It verifies that the method correctly identifies points as being on a path segment within a - * given tolerance, and correctly identifies points as being off the path if they are outside the - * tolerance. - */ - @Test - public void testLocationIndexTolerance() { - final double small = 5e-7; // About 5cm on equator, half the default tolerance. - final double big = 2e-6; // About 10cm on equator, double the default tolerance. - - // Test tolerance. - locationIndexToleranceCase(makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90), 0); - locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + small), 0); - locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 2 * small), 1); - locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 3 * small), -1); - locationIndexToleranceCase(makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90), 0); - locationIndexToleranceCase( - makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + big), 1); - locationIndexToleranceCase( - makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + 2 * big), -1); - } - - /** - * This test verifies the `containsLocation` method, which checks if a point is inside a polygon. - * It includes tests for empty polygons, single-point polygons, and various shapes of polygons. - * Special attention is given to polygons that are near the North and South poles, as these can be - * tricky edge cases for geometric calculations. - */ - @Test - public void testContainsLocation() { - // Empty. - containsCase(makeList(), makeList(), makeList(0, 0)); - - // One point. - containsCase(makeList(1, 2), makeList(1, 2), makeList(0, 0)); - - // Two points. - containsCase(makeList(1, 2, 3, 5), makeList(1, 2, 3, 5), makeList(0, 0, 40, 4)); - - // Some arbitrary triangle. - containsCase( - makeList(0., 0., 10., 12., 20., 5.), - makeList(10., 12., 10, 11, 19, 5), - makeList(0, 1, 11, 12, 30, 5, 0, -180, 0, 90)); - - // Around North Pole. - containsCase( - makeList(89, 0, 89, 120, 89, -120), - makeList(90, 0, 90, 180, 90, -90), - makeList(-90, 0, 0, 0)); - - // Around South Pole. - containsCase( - makeList(-89, 0, -89, 120, -89, -120), - makeList(90, 0, 90, 180, 90, -90, 0, 0), - makeList(-90, 0, -90, 90)); - - // Over/under segment on meridian and equator. - containsCase( - makeList(5, 10, 10, 10, 0, 20, 0, -10), - makeList(2.5, 10, 1, 0), - makeList(15, 10, 0, -15, 0, 25, -1, 0)); - } - - /** - * This test verifies the `simplify` method, which uses the Douglas-Peucker algorithm to reduce - * the number of points in a polyline or polygon. The test checks the simplification at various - * tolerance levels, from small to large, and asserts that the simplified line has the expected - * number of points. It also verifies that the endpoints of the simplified line are the same as - * the original, that the simplified points are a subset of the original points, and that the - * length of the simplified line is less than or equal to the original. - */ - @Test - public void testSimplify() { - /* - * Polyline - */ - final String LINE = - "elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@"; - List line = PolyUtil.decode(LINE); - assertThat(line.size()).isEqualTo(95); - - List simplifiedLine; - List copy; - - double tolerance = 5; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(20); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 10; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(14); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 15; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(10); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 20; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(8); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 50; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(6); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 500; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(3); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 1000; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(2); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - /* - * Polygons - */ - // Open triangle - ArrayList triangle = new ArrayList<>(); - triangle.add(new LatLng(28.06025, -82.41030)); - triangle.add(new LatLng(28.06129, -82.40945)); - triangle.add(new LatLng(28.06206, -82.40917)); - triangle.add(new LatLng(28.06125, -82.40850)); - triangle.add(new LatLng(28.06035, -82.40834)); - triangle.add(new LatLng(28.06038, -82.40924)); - assertThat(PolyUtil.isClosedPolygon(triangle)).isFalse(); - - copy = new ArrayList<>(triangle); - tolerance = 88; // meters - List simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertThat(simplifiedTriangle.size()).isEqualTo(4); - assertEndPoints(triangle, simplifiedTriangle); - assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); - assertLineLength(triangle, simplifiedTriangle); - assertInputUnchanged(triangle, copy); - - // Close the triangle - LatLng p = triangle.get(0); - LatLng closePoint = new LatLng(p.latitude, p.longitude); - triangle.add(closePoint); - assertThat(PolyUtil.isClosedPolygon(triangle)).isTrue(); - - copy = new ArrayList<>(triangle); - tolerance = 88; // meters - simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertThat(simplifiedTriangle.size()).isEqualTo(4); - assertEndPoints(triangle, simplifiedTriangle); - assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); - assertLineLength(triangle, simplifiedTriangle); - assertInputUnchanged(triangle, copy); - - // Open oval - final String OVAL_POLYGON = - "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ"; - List oval = PolyUtil.decode(OVAL_POLYGON); - assertThat(PolyUtil.isClosedPolygon(oval)).isFalse(); - - copy = new ArrayList<>(oval); - tolerance = 10; // meters - List simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertThat(simplifiedOval.size()).isEqualTo(13); - assertEndPoints(oval, simplifiedOval); - assertSimplifiedPointsFromLine(oval, simplifiedOval); - assertLineLength(oval, simplifiedOval); - assertInputUnchanged(oval, copy); - - // Close the oval - p = oval.get(0); - closePoint = new LatLng(p.latitude, p.longitude); - oval.add(closePoint); - assertThat(PolyUtil.isClosedPolygon(oval)).isTrue(); - - copy = new ArrayList<>(oval); - tolerance = 10; // meters - simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertThat(simplifiedOval.size()).isEqualTo(13); - assertEndPoints(oval, simplifiedOval); - assertSimplifiedPointsFromLine(oval, simplifiedOval); - assertLineLength(oval, simplifiedOval); - assertInputUnchanged(oval, copy); - } - - /** - * Asserts that the beginning point of the original line matches the beginning point of the - * simplified line, and that the end point of the original line matches the end point of the - * simplified line. - * - * @param line original line - * @param simplifiedLine simplified line - */ - private void assertEndPoints(List line, List simplifiedLine) { - assertThat(simplifiedLine.get(0)).isEqualTo(line.get(0)); - assertThat(simplifiedLine.get(simplifiedLine.size() - 1)).isEqualTo(line.get(line.size() - 1)); - } - - /** - * Asserts that the simplified line is composed of points from the original line. - * - * @param line original line - * @param simplifiedLine simplified line - */ - private void assertSimplifiedPointsFromLine(List line, List simplifiedLine) { - for (LatLng l : simplifiedLine) { - assertThat(line).contains(l); - } - } - - /** - * Asserts that the length of the simplified line is always equal to or less than the length of - * the original line, if simplification has eliminated any points from the original line - * - * @param line original line - * @param simplifiedLine simplified line - */ - private void assertLineLength(List line, List simplifiedLine) { - if (line.size() == simplifiedLine.size()) { - // If no points were eliminated, then the length of both lines should be the same - assertThat(SphericalUtil.computeLength(simplifiedLine)) - .isWithin(0.0) - .of(SphericalUtil.computeLength(line)); - } else { - assertThat(simplifiedLine.size()).isLessThan(line.size()); - // If points were eliminated, then the simplified line should always be shorter - assertThat(SphericalUtil.computeLength(simplifiedLine)) - .isLessThan(SphericalUtil.computeLength(line)); - } - } - - /** - * Asserts that the contents of the original List passed into the PolyUtil.simplify() method - * doesn't change after the method is executed. We test for this because the poly is modified (a - * small offset is added to the last point) to allow for polygon simplification. - * - * @param afterInput the list passed into PolyUtil.simplify(), after PolyUtil.simplify() has - * finished executing - * @param beforeInput a copy of the list before it is passed into PolyUtil.simplify() - */ - private void assertInputUnchanged(List afterInput, List beforeInput) { - // Check values - assertThat(afterInput).isEqualTo(beforeInput); - - // Check references - for (int i = 0; i < beforeInput.size(); i++) { - assertThat(afterInput.get(i)).isSameInstanceAs(beforeInput.get(i)); - } - } - - /** - * This test verifies the `isClosedPolygon` method. It checks that the method correctly identifies - * a polygon as closed only when its first and last points are identical. - */ - @Test - public void testIsClosedPolygon() { - ArrayList poly = new ArrayList<>(); - poly.add(new LatLng(28.06025, -82.41030)); - poly.add(new LatLng(28.06129, -82.40945)); - poly.add(new LatLng(28.06206, -82.40917)); - poly.add(new LatLng(28.06125, -82.40850)); - poly.add(new LatLng(28.06035, -82.40834)); - - assertThat(PolyUtil.isClosedPolygon(poly)).isFalse(); - - // Add the closing point that's same as the first - poly.add(new LatLng(28.06025, -82.41030)); - assertThat(PolyUtil.isClosedPolygon(poly)).isTrue(); - } - - /** - * The following method checks whether {@link PolyUtil#distanceToLine(LatLng, LatLng, LatLng) - * distanceToLine()} } is determining the distance between a point and a segment accurately. - * - *

Currently there are tests for different orders of magnitude (i.e., 1X, 10X, 100X, 1000X), as - * well as a test where the segment and the point lie in different hemispheres. - * - *

If further tests need to be added here, make sure that the distance has been verified with - * QGIS. - * - * @see QGIS - */ - @Test - public void testDistanceToLine() { - LatLng startLine = new LatLng(28.05359, -82.41632); - LatLng endLine = new LatLng(28.05310, -82.41634); - LatLng p = new LatLng(28.05342, -82.41594); - - double distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertThat(distance).isWithin(1e-6).of(37.94596795917082); - - startLine = new LatLng(49.321045, 12.097749); - endLine = new LatLng(49.321016, 12.097795); - p = new LatLng(49.3210674, 12.0978238); - - distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertThat(distance).isWithin(1e-6).of(5.559443879999753); - - startLine = new LatLng(48.125961, 11.548998); - endLine = new LatLng(48.125918, 11.549005); - p = new LatLng(48.125941, 11.549028); - - distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertThat(distance).isWithin(1e-6).of(1.9733966358947437); - - startLine = new LatLng(78.924669, 11.925521); - endLine = new LatLng(78.924707, 11.929060); - p = new LatLng(78.923164, 11.924029); - - distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertThat(distance).isWithin(1e-6).of(170.35662670453187); - - startLine = new LatLng(69.664036, 18.957124); - endLine = new LatLng(69.664029, 18.957109); - p = new LatLng(69.672901, 18.967911); - - distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertThat(distance).isWithin(1e-6).of(1070.222749990837); - - startLine = new LatLng(-0.018200, 109.343282); - endLine = new LatLng(-0.017877, 109.343537); - p = new LatLng(0.058299, 109.408054); - - distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertThat(distance).isWithin(1e-6).of(11100.157563150981); - } - - /** - * This test ensures that the distance from a point to a line segment is always less than or equal - * to the distance from the point to either of the segment's endpoints. This is a fundamental - * property of Euclidean geometry that should also hold true for spherical geometry for short - * distances. - */ - @Test - public void testDistanceToLineLessThanDistanceToExtremes() { - LatLng startLine = new LatLng(28.05359, -82.41632); - LatLng endLine = new LatLng(28.05310, -82.41634); - LatLng p = new LatLng(28.05342, -82.41594); - - double distance = PolyUtil.distanceToLine(p, startLine, endLine); - double distanceToStart = SphericalUtil.computeDistanceBetween(p, startLine); - double distanceToEnd = SphericalUtil.computeDistanceBetween(p, endLine); - - assertThat(distance).isAtMost(distanceToStart); - assertThat(distance).isAtMost(distanceToEnd); - } - - /** - * This test verifies the `decode` method, which decodes an encoded polyline string into a list of - * `LatLng` points. It checks that the decoded path has the correct number of points and that the - * last point has the expected latitude and longitude. - */ - @Test - public void testDecodePath() { - List latLngs = PolyUtil.decode(TEST_LINE); - - int expectedLength = 21; - assertThat(latLngs.size()).isEqualTo(expectedLength); - - LatLng lastPoint = latLngs.get(expectedLength - 1); - assertThat(lastPoint.latitude).isWithin(1e-6).of(37.76953); - assertThat(lastPoint.longitude).isWithin(1e-6).of(-122.41488); - } - - /** - * This test verifies the `encode` method, which encodes a list of `LatLng` points into a polyline - * string. It first decodes a test string, then re-encodes the resulting list of points, and - * finally asserts that the re-encoded string is identical to the original. This ensures the - * encode and decode methods are inverse operations. - */ - @Test - public void testEncodePath() { - List path = PolyUtil.decode(TEST_LINE); - String encoded = PolyUtil.encode(path); - assertThat(encoded).isEqualTo(TEST_LINE); - } -} diff --git a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java deleted file mode 100644 index 520a66a83..000000000 --- a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java +++ /dev/null @@ -1,423 +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; - -import static com.google.common.truth.Truth.assertThat; -import static com.google.maps.android.MathUtil.EARTH_RADIUS; - -import com.google.android.gms.maps.model.LatLng; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import org.junit.Assert; -import org.junit.Test; - -public class SphericalUtilTest { - // The vertices of an octahedron, for testing - private final LatLng up = new LatLng(90, 0); - private final LatLng down = new LatLng(-90, 0); - private final LatLng front = new LatLng(0, 0); - private final LatLng right = new LatLng(0, 90); - private final LatLng back = new LatLng(0, -180); - private final LatLng left = new LatLng(0, -90); - - /** Tests for approximate equality. */ - private static void expectLatLngApproxEquals(LatLng actual, LatLng expected) { - assertThat(actual.latitude).isWithin(1e-6).of(expected.latitude); - // Account for the convergence of longitude lines at the poles - double cosLat = Math.cos(Math.toRadians(actual.latitude)); - assertThat(cosLat * actual.longitude).isWithin(1e-6).of(cosLat * expected.longitude); - } - - private static double computeSignedTriangleArea(LatLng a, LatLng b, LatLng c) { - List path = Arrays.asList(a, b, c); - return SphericalUtil.computeSignedArea(path, 1); - } - - private static double computeTriangleArea(LatLng a, LatLng b, LatLng c) { - return Math.abs(computeSignedTriangleArea(a, b, c)); - } - - private static int isCCW(LatLng a, LatLng b, LatLng c) { - return computeSignedTriangleArea(a, b, c) > 0 ? 1 : -1; - } - - @Test - public void testAngles() { - // Same vertex - assertThat(SphericalUtil.computeAngleBetween(up, up)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeAngleBetween(down, down)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeAngleBetween(left, left)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeAngleBetween(right, right)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeAngleBetween(front, front)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeAngleBetween(back, back)).isWithin(1e-6).of(0); - - // Adjacent vertices - assertThat(SphericalUtil.computeAngleBetween(up, front)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(up, right)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(up, back)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(up, left)).isWithin(1e-6).of(Math.PI / 2); - - assertThat(SphericalUtil.computeAngleBetween(down, front)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(down, right)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(down, back)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(down, left)).isWithin(1e-6).of(Math.PI / 2); - - assertThat(SphericalUtil.computeAngleBetween(back, up)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(back, right)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(back, down)).isWithin(1e-6).of(Math.PI / 2); - assertThat(SphericalUtil.computeAngleBetween(back, left)).isWithin(1e-6).of(Math.PI / 2); - - // Opposite vertices - assertThat(SphericalUtil.computeAngleBetween(up, down)).isWithin(1e-6).of(Math.PI); - assertThat(SphericalUtil.computeAngleBetween(front, back)).isWithin(1e-6).of(Math.PI); - assertThat(SphericalUtil.computeAngleBetween(left, right)).isWithin(1e-6).of(Math.PI); - } - - @Test - public void testDistances() { - assertThat(SphericalUtil.computeDistanceBetween(up, down)) - .isWithin(1e-6) - .of(Math.PI * EARTH_RADIUS); - } - - @Test - public void testHeadings() { - // Opposing vertices for which there is a result - assertThat(SphericalUtil.computeHeading(up, down)).isWithin(1e-6).of(-180); - assertThat(SphericalUtil.computeHeading(down, up)).isWithin(1e-6).of(0); - - // Adjacent vertices for which there is a result - assertThat(SphericalUtil.computeHeading(front, up)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeHeading(right, up)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeHeading(back, up)).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeHeading(down, up)).isWithin(1e-6).of(0); - - assertThat(SphericalUtil.computeHeading(front, down)).isWithin(1e-6).of(-180); - assertThat(SphericalUtil.computeHeading(right, down)).isWithin(1e-6).of(-180); - assertThat(SphericalUtil.computeHeading(back, down)).isWithin(1e-6).of(-180); - assertThat(SphericalUtil.computeHeading(left, down)).isWithin(1e-6).of(-180); - - assertThat(SphericalUtil.computeHeading(right, front)).isWithin(1e-6).of(-90); - assertThat(SphericalUtil.computeHeading(left, front)).isWithin(1e-6).of(90); - - assertThat(SphericalUtil.computeHeading(front, right)).isWithin(1e-6).of(90); - assertThat(SphericalUtil.computeHeading(back, right)).isWithin(1e-6).of(-90); - } - - @Test - public void testComputeOffset() { - // From front - expectLatLngApproxEquals(front, SphericalUtil.computeOffset(front, 0, 0)); - expectLatLngApproxEquals(up, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 0)); - expectLatLngApproxEquals( - down, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 180)); - expectLatLngApproxEquals( - left, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, -90)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 90)); - expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 0)); - expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 90)); - - // From left - expectLatLngApproxEquals(left, SphericalUtil.computeOffset(left, 0, 0)); - expectLatLngApproxEquals(up, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 0)); - expectLatLngApproxEquals( - down, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 180)); - expectLatLngApproxEquals( - front, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 90)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, -90)); - expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 0)); - expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 90)); - - // NOTE(appleton): Heading is undefined at the poles, so we do not test - // from up/down. - } - - @Test - public void testComputeOffsetOrigin() { - expectLatLngApproxEquals( - front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(front, 0, 0))); - - expectLatLngApproxEquals( - front, - Objects.requireNonNull( - SphericalUtil.computeOffsetOrigin(new LatLng(0, 45), Math.PI * EARTH_RADIUS / 4, 90))); - expectLatLngApproxEquals( - front, - Objects.requireNonNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(0, -45), Math.PI * EARTH_RADIUS / 4, -90))); - expectLatLngApproxEquals( - front, - Objects.requireNonNull( - SphericalUtil.computeOffsetOrigin(new LatLng(45, 0), Math.PI * EARTH_RADIUS / 4, 0))); - expectLatLngApproxEquals( - front, - Objects.requireNonNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(-45, 0), Math.PI * EARTH_RADIUS / 4, 180))); - - // Situations with no solution, should return null. - // - // First 'over' the pole. - assertThat( - SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)) - .isNull(); - // Second a distance that doesn't fit on the earth. - assertThat(SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)) - .isNull(); - } - - @Test - public void testComputeOffsetAndBackToOrigin() { - LatLng start = new LatLng(40, 40); - double distance = 1e5; - double heading = 15; - LatLng end; - - // Some semi-random values to demonstrate going forward and backward yields - // the same location. - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - heading = -37; - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - distance = 3.8e+7; - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - start = new LatLng(-21, -73); - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - // computeOffsetOrigin with multiple solutions, all we care about is that - // going from there yields the requested result. - // - // First, for this particular situation the latitude is completely arbitrary. - start = SphericalUtil.computeOffsetOrigin(new LatLng(0, 90), Math.PI * EARTH_RADIUS / 2, 90); - Assert.assertNotNull(start); - expectLatLngApproxEquals( - new LatLng(0, 90), SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 2, 90)); - - // Second, for this particular situation the longitude is completely - // arbitrary. - start = SphericalUtil.computeOffsetOrigin(new LatLng(90, 0), Math.PI * EARTH_RADIUS / 4, 0); - Assert.assertNotNull(start); - expectLatLngApproxEquals( - new LatLng(90, 0), SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 4, 0)); - } - - @Test - public void testInterpolate() { - // Same point - expectLatLngApproxEquals(up, SphericalUtil.interpolate(up, up, 1 / 2.0)); - expectLatLngApproxEquals(down, SphericalUtil.interpolate(down, down, 1 / 2.0)); - expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, left, 1 / 2.0)); - - // Between front and up - expectLatLngApproxEquals(new LatLng(1, 0), SphericalUtil.interpolate(front, up, 1 / 90.0)); - expectLatLngApproxEquals(new LatLng(1, 0), SphericalUtil.interpolate(up, front, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(89, 0), SphericalUtil.interpolate(front, up, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(89, 0), SphericalUtil.interpolate(up, front, 1 / 90.0)); - - // Between front and down - expectLatLngApproxEquals(new LatLng(-1, 0), SphericalUtil.interpolate(front, down, 1 / 90.0)); - expectLatLngApproxEquals(new LatLng(-1, 0), SphericalUtil.interpolate(down, front, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(-89, 0), SphericalUtil.interpolate(front, down, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(-89, 0), SphericalUtil.interpolate(down, front, 1 / 90.0)); - - // Between left and back - expectLatLngApproxEquals(new LatLng(0, -91), SphericalUtil.interpolate(left, back, 1 / 90.0)); - expectLatLngApproxEquals(new LatLng(0, -91), SphericalUtil.interpolate(back, left, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(0, -179), SphericalUtil.interpolate(left, back, 89 / 90.0)); - expectLatLngApproxEquals(new LatLng(0, -179), SphericalUtil.interpolate(back, left, 1 / 90.0)); - - // geodesic crosses pole - expectLatLngApproxEquals( - up, SphericalUtil.interpolate(new LatLng(45, 0), new LatLng(45, 180), 1 / 2.0)); - expectLatLngApproxEquals( - down, SphericalUtil.interpolate(new LatLng(-45, 0), new LatLng(-45, 180), 1 / 2.0)); - - // boundary values for fraction, between left and back - expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0)); - expectLatLngApproxEquals(back, SphericalUtil.interpolate(left, back, 1.0)); - - // two nearby points, separated by ~4m, for which the Slerp algorithm is not stable and we - // have to fall back to linear interpolation. - expectLatLngApproxEquals( - new LatLng(-37.756872, 175.325252), - SphericalUtil.interpolate( - new LatLng(-37.756891, 175.325262), new LatLng(-37.756853, 175.325242), 0.5)); - } - - @Test - public void testComputeLength() { - List latLngs; - - assertThat(SphericalUtil.computeLength(Collections.emptyList())).isWithin(1e-6).of(0); - assertThat(SphericalUtil.computeLength(List.of(new LatLng(0, 0)))).isWithin(1e-6).of(0); - - latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(0.1, 0.1)); - assertThat(SphericalUtil.computeLength(latLngs)) - .isWithin(1) - .of(Math.toRadians(0.1) * Math.sqrt(2) * EARTH_RADIUS); - - latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(90, 0), new LatLng(0, 90)); - assertThat(SphericalUtil.computeLength(latLngs)).isWithin(1e-6).of(Math.PI * EARTH_RADIUS); - } - - @Test - public void testIsCCW() { - // One face of the octahedron - assertThat(isCCW(right, up, front)).isEqualTo(1); - assertThat(isCCW(up, front, right)).isEqualTo(1); - assertThat(isCCW(front, right, up)).isEqualTo(1); - assertThat(isCCW(front, up, right)).isEqualTo(-1); - assertThat(isCCW(up, right, front)).isEqualTo(-1); - assertThat(isCCW(right, front, up)).isEqualTo(-1); - } - - @Test - public void testComputeTriangleArea() { - assertThat(computeTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); - assertThat(computeTriangleArea(front, up, right)).isWithin(1e-6).of(Math.PI / 2); - - // computeArea returns area of zero on small polys - double area = - computeTriangleArea( - new LatLng(0, 0), - new LatLng(0, Math.toDegrees(1E-6)), - new LatLng(Math.toDegrees(1E-6), 0)); - double expectedArea = 1E-12 / 2; - - assertThat(Math.abs(expectedArea - area)).isLessThan(1e-20); - } - - @Test - public void testComputeSignedTriangleArea() { - assertThat( - computeSignedTriangleArea(new LatLng(0, 0), new LatLng(0, 0.1), new LatLng(0.1, 0.1))) - .isWithin(1e-6) - .of(Math.toRadians(0.1) * Math.toRadians(0.1) / 2); - - assertThat(computeSignedTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); - - assertThat(computeSignedTriangleArea(front, up, right)).isWithin(1e-6).of(-Math.PI / 2); - } - - @Test - public void testComputeArea() { - assertThat(SphericalUtil.computeArea(Arrays.asList(right, up, front, down, right))) - .isWithin(.4) - .of(Math.PI * EARTH_RADIUS * EARTH_RADIUS); - - assertThat(SphericalUtil.computeArea(Arrays.asList(right, down, front, up, right))) - .isWithin(.4) - .of(Math.PI * EARTH_RADIUS * EARTH_RADIUS); - } - - @Test - public void testComputeSignedArea() { - List path = Arrays.asList(right, up, front, down, right); - List pathReversed = Arrays.asList(right, down, front, up, right); - assertThat(-SphericalUtil.computeSignedArea(path)) - .isWithin(0) - .of(SphericalUtil.computeSignedArea(pathReversed)); - } - - @Test - public void testGetPointOnPolyline() { - final LatLng a = new LatLng(0, 0); - final LatLng b = new LatLng(0, 10); - final LatLng c = new LatLng(10, 10); - final LatLng d = new LatLng(10, 0); - final List polyline = Arrays.asList(a, b, c, d); - - // Test for null cases - Assert.assertNull(SphericalUtil.getPointOnPolyline(Collections.emptyList(), 0.5)); - Assert.assertNull(SphericalUtil.getPointOnPolyline(polyline, -0.1)); - Assert.assertNull(SphericalUtil.getPointOnPolyline(polyline, 1.1)); - - // Test for start and end points - expectLatLngApproxEquals(a, SphericalUtil.getPointOnPolyline(polyline, 0)); - expectLatLngApproxEquals(d, SphericalUtil.getPointOnPolyline(polyline, 1)); - - // Test for a point in the middle of a segment - final LatLng midAB = new LatLng(0, 5); - final double totalLength = SphericalUtil.computeLength(polyline); - final double abLength = SphericalUtil.computeDistanceBetween(a, b); - expectLatLngApproxEquals( - midAB, SphericalUtil.getPointOnPolyline(polyline, (abLength / 2) / totalLength)); - - // Test for a point on a vertex - final double aToCLength = SphericalUtil.computeLength(Arrays.asList(a, b, c)); - expectLatLngApproxEquals( - c, SphericalUtil.getPointOnPolyline(polyline, aToCLength / totalLength)); - } - - @Test - public void testGetPolylinePrefix() { - final LatLng a = new LatLng(0, 0); - final LatLng b = new LatLng(0, 10); - final LatLng c = new LatLng(10, 10); - final LatLng d = new LatLng(10, 0); - final List polyline = Arrays.asList(a, b, c, d); - - // Test for empty list cases - Assert.assertTrue(SphericalUtil.getPolylinePrefix(Collections.emptyList(), 0.5).isEmpty()); - Assert.assertTrue(SphericalUtil.getPolylinePrefix(polyline, -0.1).isEmpty()); - Assert.assertTrue(SphericalUtil.getPolylinePrefix(polyline, 1.1).isEmpty()); - - // Test for 0% - List prefix0 = SphericalUtil.getPolylinePrefix(polyline, 0); - Assert.assertEquals(1, prefix0.size()); - expectLatLngApproxEquals(a, prefix0.get(0)); - - // Test for 100% - List prefix100 = SphericalUtil.getPolylinePrefix(polyline, 1); - Assert.assertEquals(polyline.size(), prefix100.size()); - for (int i = 0; i < polyline.size(); i++) { - expectLatLngApproxEquals(polyline.get(i), prefix100.get(i)); - } - - // Test for a prefix that ends in the middle of a segment - final LatLng midAB = new LatLng(0, 5); - final double totalLength = SphericalUtil.computeLength(polyline); - final double abLength = SphericalUtil.computeDistanceBetween(a, b); - List prefixMidAB = - SphericalUtil.getPolylinePrefix(polyline, (abLength / 2) / totalLength); - Assert.assertEquals(2, prefixMidAB.size()); - expectLatLngApproxEquals(a, prefixMidAB.get(0)); - expectLatLngApproxEquals(midAB, prefixMidAB.get(1)); - - // Test for a prefix that ends on a vertex - final double aToCLength = SphericalUtil.computeLength(Arrays.asList(a, b, c)); - List prefixC = SphericalUtil.getPolylinePrefix(polyline, aToCLength / totalLength); - Assert.assertEquals(3, prefixC.size()); - expectLatLngApproxEquals(a, prefixC.get(0)); - expectLatLngApproxEquals(b, prefixC.get(1)); - expectLatLngApproxEquals(c, prefixC.get(2)); - } -} From deb93a2b73f27993338eb534a17e1e62ec107ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 23:46:31 +0700 Subject: [PATCH 05/11] ci: lint remaining Android modules instead of the KMP library module The KMP Android library plugin does not create lintDebug/SARIF reporting tasks, so :library:lintDebug no longer exists after the multiplatform migration. Lint data, ui and demo instead; KMP-module lint reporting is tracked as a known gap of the migration. --- .github/workflows/lint-report.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 84c0a0eb0..18792225b 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -43,14 +43,23 @@ jobs: - name: Create dummy secrets.properties run: echo "MAPS_API_KEY=dummy" > secrets.properties + # The library module is now Kotlin Multiplatform; the KMP Android library plugin + # does not create lintDebug/SARIF reporting tasks yet (tracked as a known gap of + # the KMP migration). Lint the remaining plain Android modules instead. - name: Run Android Lint - run: ./gradlew :library:lintDebug :demo:lintStandardDebug + run: ./gradlew :data:lintDebug :ui:lintDebug :demo:lintStandardDebug - - name: Upload SARIF for library + - name: Upload SARIF for data uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: - sarif_file: library/build/reports/lint-results.sarif - category: library + sarif_file: data/build/reports/lint-results.sarif + category: data + + - name: Upload SARIF for ui + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: ui/build/reports/lint-results.sarif + category: ui - name: Upload SARIF for demo uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 From e69e76a415d66d468edd3e8cec3565756e147d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 15:24:59 +0200 Subject: [PATCH 06/11] ci: publish from macOS and add a snapshot publishing workflow The Kotlin Multiplatform modules publish iOS klibs, which can only be built on macOS, so publish.yml moves to macos-latest. Its sed calls use -i.bak, which works with both GNU and BSD sed. publish-snapshot.yml is a manual workflow that publishes a -SNAPSHOT version of every module to the Maven Central snapshot repository. It rejects versions that do not end with -SNAPSHOT and does not tag or commit anything. --- .github/workflows/publish-snapshot.yml | 83 ++++++++++++++++++++++++++ .github/workflows/publish.yml | 13 ++-- 2 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/publish-snapshot.yml diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml new file mode 100644 index 000000000..6cd92a1e7 --- /dev/null +++ b/.github/workflows/publish-snapshot.yml @@ -0,0 +1,83 @@ +# 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. + +# Publishes a -SNAPSHOT build of every module to the Maven Central snapshot repository +# (https://central.sonatype.com/repository/maven-snapshots/). Snapshots are not signed +# releases and never go through release-please; nothing is tagged or committed. +name: Publish snapshot + +on: + workflow_dispatch: + inputs: + version: + description: 'Snapshot version, must end with -SNAPSHOT (e.g. 6.1.0-SNAPSHOT)' + required: true + +permissions: + contents: read + +jobs: + publish-snapshot: + # The Kotlin Multiplatform modules publish iOS klibs, which can only be built on macOS. + runs-on: macos-latest + steps: + - name: Check version + env: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + *-SNAPSHOT) ;; + *) echo "::error::Snapshot versions must end with -SNAPSHOT, got '$VERSION'"; exit 1 ;; + esac + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + - name: Set snapshot version + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak 's/version = "[^"]*"/version = "'"$VERSION"'"/' build.gradle.kts + grep -n 'version = ' build.gradle.kts + + - name: Configure Maven Central credentials and signing + run: | + echo $GPG_KEY_ARMOR | base64 --decode > ./release.asc + gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc + sed -i.bak -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') + sed -i.bak -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i.bak -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties + sed -i.bak -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties + sed -i.bak -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties + env: + GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} + GPG_KEY_ID: ${{ secrets.SYNCED_GPG_KEY_ID }} + GPG_PASSWORD: ${{ secrets.SYNCED_GPG_KEY_PASSWORD }} + SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }} + SONATYPE_TOKEN_USERNAME: ${{ secrets.SONATYPE_TOKEN }} + + - name: Publish snapshot + run: ./gradlew publishToMavenCentral --stacktrace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4f85c1054..2d3da0419 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,8 @@ permissions: jobs: publish: - runs-on: ubuntu-latest + # The Kotlin Multiplatform modules publish iOS klibs, which can only be built on macOS. + runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -46,12 +47,12 @@ jobs: gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc echo "Build and publish" - sed -i -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + sed -i.bak -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') - sed -i -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties - sed -i -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties - sed -i -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties - sed -i -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties + sed -i.bak -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i.bak -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties + sed -i.bak -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties + sed -i.bak -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties env: GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} From 7536f638ff28744b32ee3cbf07cfbf9dfa1d5ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 15:24:59 +0200 Subject: [PATCH 07/11] ci: lint the library module again now that it has lint tasks The multiplatform library module gets lint tasks from com.android.lint, so lint-report goes back to linting library and demo, with :library:lint instead of :library:lintDebug. --- .github/workflows/lint-report.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index d07dea2f7..6d23619d8 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -43,23 +43,14 @@ jobs: - name: Create dummy secrets.properties run: echo "MAPS_API_KEY=dummy" > secrets.properties - # The library module is now Kotlin Multiplatform; the KMP Android library plugin - # does not create lintDebug/SARIF reporting tasks yet (tracked as a known gap of - # the KMP migration). Lint the remaining plain Android modules instead. - name: Run Android Lint - run: ./gradlew :data:lintDebug :ui:lintDebug :demo:lintStandardDebug + run: ./gradlew :library:lint :demo:lintStandardDebug - - name: Upload SARIF for data + - name: Upload SARIF for library uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: - sarif_file: data/build/reports/lint-results.sarif - category: data - - - name: Upload SARIF for ui - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 - with: - sarif_file: ui/build/reports/lint-results.sarif - category: ui + sarif_file: library/build/reports/lint-results.sarif + category: library - name: Upload SARIF for demo uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 From 2b11b260f3e59c54fe6d5689f4d15f024d1b24de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 15:24:59 +0200 Subject: [PATCH 08/11] docs: describe the experimental Kotlin Multiplatform modules README: which artifacts are multiplatform, what is shared and what stays Android-only, and the snapshot repository. AGENTS.md: maps-model, the source set layout, API files and the macOS requirement for iOS targets. --- AGENTS.md | 22 +++++++++++++++++++++- README.md | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7322ed840..2554c097f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ the 4.x to modular layout mapping. | Module | Purpose | | --- | --- | | `library` | Core utilities shared by the other modules | +| `maps-model` | Multiplatform `LatLng` and `CameraPosition` (typealiases to the Play Services types on Android) | | `clustering` | Marker clustering | | `heatmaps` | Heatmap tile overlays | | `data` | GeoJSON and KML parsing/rendering | @@ -24,11 +25,30 @@ the 4.x to modular layout mapping. Shared Gradle conventions are in `build-logic/` (included build). +### Kotlin Multiplatform modules (experimental) + +`maps-model`, `library`, `clustering` and `heatmaps` are Kotlin Multiplatform +modules targeting Android and iOS (`iosArm64`, `iosSimulatorArm64`, `iosX64`). +They use `KmpPublishingConventionPlugin`; `data`, `ui` and `maps-utils` stay +Android-only and use `PublishingConventionPlugin`. + +- Platform-independent code (geometry, clustering algorithms, heatmap model) + lives in `src/commonMain`. Anything touching the Maps SDK, Android or + `java.*` APIs goes in `src/androidMain`. Host tests are in + `src/androidHostTest`, shared tests in `src/commonTest`. +- The Android API must not change: `apiCheck` compares the Android API against + `api/.api` and the iOS ABI against `api/.klib.api`. +- Building or publishing the iOS targets needs macOS with Xcode. On Linux they + are skipped, which is why `publish.yml` and `publish-snapshot.yml` run on + `macos-latest`. +- Run the shared tests on iOS with `./gradlew :library:iosSimulatorArm64Test`. + ## Building and testing ```bash ./gradlew assembleDebug # build everything -./gradlew :clustering:testDebugUnitTest # unit tests for one module +./gradlew :data:testDebugUnitTest # unit tests for an Android-only module +./gradlew :clustering:testAndroidHostTest # unit tests for a multiplatform module ./gradlew test # all unit tests ./gradlew koverXmlReportDebug # all unit tests and coverage reports ./gradlew lint # Android Lint (includes lint-checks rules) diff --git a/README.md b/README.md index 2c1115b6b..f5c0cec58 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ configurations.all { } ``` +### Kotlin Multiplatform (experimental) + +`android-maps-utils-core`, `android-maps-utils-clustering` and `android-maps-utils-heatmaps` +are also published as Kotlin Multiplatform libraries for iOS (`iosArm64`, +`iosSimulatorArm64`, `iosX64`). The platform-independent parts are available from common +code: `PolyUtil`, `SphericalUtil` and `MathUtil`, the clustering algorithms, and the heatmap +`Gradient` and `WeightedLatLng`, on top of a common `LatLng` and `CameraPosition` from +`android-maps-utils-maps-model`. On Android those are the Maps SDK types, so Android apps +are not affected. Rendering (`ClusterManager`, `HeatmapTileProvider`, the data layers and UI +helpers) stays Android-only. + +Snapshot builds are published to the Maven Central snapshot repository: + +```kotlin +repositories { + maven("https://central.sonatype.com/repository/maven-snapshots/") +} +``` + ## Sample App From 0a43d0c88574ec596b7b39a2af398a03b9642a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Mon, 12 Oct 2026 04:46:30 +0200 Subject: [PATCH 09/11] fix: give the iOS CameraPosition value equality Match the Play Services CameraPosition (and the iOS LatLng): equals, hashCode and toString by value, so identical positions compare equal in common code on iOS. Adds common ModelTest run on Android and iOS. Also deletes the empty clustering and heatmaps consumer-rules.pro, and runs apiCheck before publishing a snapshot. --- .github/workflows/publish-snapshot.yml | 5 ++ clustering/consumer-rules.pro | 0 heatmaps/consumer-rules.pro | 0 maps-model/api/maps-model.klib.api | 4 ++ maps-model/build.gradle.kts | 8 ++++ .../google/maps/android/model/ModelTest.kt | 47 +++++++++++++++++++ .../google/maps/android/model/Model.ios.kt | 22 ++++++++- 7 files changed, 85 insertions(+), 1 deletion(-) delete mode 100644 clustering/consumer-rules.pro delete mode 100644 heatmaps/consumer-rules.pro create mode 100644 maps-model/src/commonTest/kotlin/com/google/maps/android/model/ModelTest.kt diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 6cd92a1e7..d233d8def 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -79,5 +79,10 @@ jobs: SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }} SONATYPE_TOKEN_USERNAME: ${{ secrets.SONATYPE_TOKEN }} + # Branch CI runs the full test suite; this job only guards against publishing a + # snapshot whose API drifted from the committed api/*.api dumps. + - name: Check API compatibility + run: ./gradlew apiCheck --stacktrace + - name: Publish snapshot run: ./gradlew publishToMavenCentral --stacktrace diff --git a/clustering/consumer-rules.pro b/clustering/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/heatmaps/consumer-rules.pro b/heatmaps/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/maps-model/api/maps-model.klib.api b/maps-model/api/maps-model.klib.api index b6733b8b5..4d7385976 100644 --- a/maps-model/api/maps-model.klib.api +++ b/maps-model/api/maps-model.klib.api @@ -17,6 +17,10 @@ final class com.google.maps.android.model/CameraPosition { // com.google.maps.an final fun (): kotlin/Float // com.google.maps.android.model/CameraPosition.tilt.|(){}[0] final val zoom // com.google.maps.android.model/CameraPosition.zoom|{}zoom[0] final fun (): kotlin/Float // com.google.maps.android.model/CameraPosition.zoom.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.model/CameraPosition.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.model/CameraPosition.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.google.maps.android.model/CameraPosition.toString|toString(){}[0] } final class com.google.maps.android.model/LatLng { // com.google.maps.android.model/LatLng|null[0] diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts index e54944a08..1d19fbd01 100644 --- a/maps-model/build.gradle.kts +++ b/maps-model/build.gradle.kts @@ -22,6 +22,11 @@ kotlin { namespace = "com.google.maps.android.model" compileSdk = libs.versions.compileSdk.get().toInt() minSdk = libs.versions.minimumSdk.get().toInt() + + withHostTestBuilder { + }.configure { + isReturnDefaultValues = true + } } sourceSets { @@ -30,5 +35,8 @@ kotlin { // this must be api(): consumers see GMS LatLng in our public API. api(libs.play.services.maps) } + commonTest.dependencies { + implementation(libs.kotlin.test) + } } } diff --git a/maps-model/src/commonTest/kotlin/com/google/maps/android/model/ModelTest.kt b/maps-model/src/commonTest/kotlin/com/google/maps/android/model/ModelTest.kt new file mode 100644 index 000000000..05cc4308a --- /dev/null +++ b/maps-model/src/commonTest/kotlin/com/google/maps/android/model/ModelTest.kt @@ -0,0 +1,47 @@ +/* + * 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.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class ModelTest { + @Test + fun latLngValueSemantics() { + val a = LatLng(10.0, 20.0) + val b = LatLng(10.0, 20.0) + + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertEquals(a.toString(), b.toString()) + assertNotEquals(a, LatLng(10.0, 21.0)) + } + + @Test + fun cameraPositionValueSemantics() { + val a = CameraPosition(LatLng(10.0, 20.0), 12f, 30f, 45f) + val b = CameraPosition(LatLng(10.0, 20.0), 12f, 30f, 45f) + + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertEquals(a.toString(), b.toString()) + assertNotEquals(a, CameraPosition(LatLng(10.0, 21.0), 12f, 30f, 45f)) + assertNotEquals(a, CameraPosition(LatLng(10.0, 20.0), 13f, 30f, 45f)) + assertNotEquals(a, CameraPosition(LatLng(10.0, 20.0), 12f, 31f, 45f)) + assertNotEquals(a, CameraPosition(LatLng(10.0, 20.0), 12f, 30f, 46f)) + } +} diff --git a/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt b/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt index 9432cd111..f9a4d7906 100644 --- a/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt +++ b/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt @@ -49,7 +49,27 @@ public actual class CameraPosition actual constructor( public val zoom: Float, public val tilt: Float, public val bearing: Float -) +) { + // Value semantics matching the Play Services CameraPosition: floats compare by bits, as + // Float.floatToIntBits does there. + override fun equals(other: Any?): Boolean = + other is CameraPosition && + target == other.target && + zoom.toBits() == other.zoom.toBits() && + tilt.toBits() == other.tilt.toBits() && + bearing.toBits() == other.bearing.toBits() + + override fun hashCode(): Int { + var result = 31 + target.hashCode() + result = 31 * result + zoom.toBits() + result = 31 * result + tilt.toBits() + result = 31 * result + bearing.toBits() + return result + } + + override fun toString(): String = + "CameraPosition{target=$target, zoom=$zoom, tilt=$tilt, bearing=$bearing}" +} public actual val CameraPosition.target: LatLng get() = this.target From e959ae02be44038eb628e300efe0db1bd5469291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Mon, 12 Oct 2026 04:46:31 +0200 Subject: [PATCH 10/11] fix: keep synchronized(mQuadTree) working for algorithm subclasses NonHierarchicalDistanceBasedAlgorithm documented that subclasses should synchronize on mQuadTree. The multiplatform lock replaced that monitor with a private ReentrantLock, so such subclasses silently stopped excluding the base class. On Android the lock now synchronizes on mQuadTree again; iOS keeps NSRecursiveLock. --- ...nHierarchicalDistanceBasedAlgorithmTest.kt | 74 +++++++++++++++++++ .../clustering/algo/PlatformLock.android.kt | 4 + .../NonHierarchicalDistanceBasedAlgorithm.kt | 18 +++-- .../android/clustering/algo/PlatformLock.kt | 10 +++ .../clustering/algo/PlatformLock.ios.kt | 6 ++ 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithmTest.kt diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithmTest.kt new file mode 100644 index 000000000..77f230133 --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithmTest.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.clustering.algo + +import com.google.android.gms.maps.model.LatLng +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.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class NonHierarchicalDistanceBasedAlgorithmTest { + class TestClusterItem(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 = 0f + } + + /** A subclass guarding its own work the way the KDoc told subclasses to before the multiplatform migration. */ + class SynchronizingAlgorithm : NonHierarchicalDistanceBasedAlgorithm() { + fun holdingQuadTree(block: () -> R): R = synchronized(mQuadTree) { block() } + } + + @Test + fun testSubclassSynchronizingOnQuadTreeExcludesAddItem() { + val algo = SynchronizingAlgorithm() + val added = CountDownLatch(1) + + algo.holdingQuadTree { + thread { + algo.addItem(TestClusterItem(10.0, 10.0)) + added.countDown() + } + // addItem must wait for the subclass to release mQuadTree. + assertFalse(added.await(200, TimeUnit.MILLISECONDS)) + } + + assertTrue(added.await(5, TimeUnit.SECONDS)) + assertEquals(1, algo.items.size) + } + + @Test + fun testLockIsReentrantFromSubclass() { + val algo = SynchronizingAlgorithm() + + // Calling base methods while already holding the monitor must not deadlock. + algo.holdingQuadTree { + algo.addItem(TestClusterItem(10.0, 10.0)) + algo.removeItem(algo.items.first()) + } + + assertTrue(algo.items.isEmpty()) + } +} diff --git a/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt index 358feab81..5dd1c8847 100644 --- a/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt +++ b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt @@ -24,3 +24,7 @@ internal actual class PlatformLock { actual fun unlock(): Unit = delegate.unlock() } + +internal actual class MonitorLock actual constructor(internal val monitor: Any) + +internal actual inline fun MonitorLock.withLock(block: () -> T): T = synchronized(monitor, block) diff --git a/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt index 710b271a8..90583a78d 100644 --- a/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt @@ -40,24 +40,26 @@ import kotlin.math.pow */ public open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgorithm() { /** - * Guards [mItems], [mItemMap] and [mQuadTree]. Replaces the pre-multiplatform - * `synchronized(mQuadTree)` blocks; reentrant so nested locking keeps working. - */ - internal val quadTreeLock = PlatformLock() - - /** - * Any modifications must hold [quadTreeLock]. + * Any modifications should be synchronized on mQuadTree. */ @JvmField protected val mItems: MutableCollection> = LinkedHashSet() protected val mItemMap: MutableMap> = HashMap() /** - * Any modifications must hold [quadTreeLock]. + * Any modifications should be synchronized on mQuadTree. On Android, subclasses can keep + * using `synchronized(mQuadTree)`: this class holds the same monitor. */ @JvmField protected val mQuadTree: PointQuadTree> = PointQuadTree(0.0, 1.0, 0.0, 1.0) + /** + * Guards [mItems], [mItemMap] and [mQuadTree]. On Android it synchronizes on [mQuadTree], + * as this class did before the multiplatform migration. Declared after [mQuadTree] so the + * monitor is initialized. + */ + internal val quadTreeLock = MonitorLock(mQuadTree) + override var maxDistanceBetweenClusteredItems: Int = DEFAULT_MAX_DISTANCE_AT_ZOOM override fun addItem(item: T): Boolean { diff --git a/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt index c195f8e87..37c37819f 100644 --- a/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt @@ -41,3 +41,13 @@ internal inline fun PlatformLock.withLock(block: () -> T): T { unlock() } } + +/** + * A reentrant lock tied to [monitor]. On Android it synchronizes on [monitor] itself, so it + * excludes any `synchronized(monitor)` block in code outside this module, which is how + * subclasses were told to guard shared state before the multiplatform migration. On other + * platforms it is a plain [PlatformLock]. + */ +internal expect class MonitorLock(monitor: Any) + +internal expect inline fun MonitorLock.withLock(block: () -> T): T diff --git a/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt b/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt index 39f9cca9a..4ccfe57d0 100644 --- a/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt +++ b/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt @@ -24,3 +24,9 @@ internal actual class PlatformLock { actual fun unlock(): Unit = delegate.unlock() } + +internal actual class MonitorLock actual constructor(monitor: Any) { + internal val delegate = PlatformLock() +} + +internal actual inline fun MonitorLock.withLock(block: () -> T): T = delegate.withLock(block) From f9a38c483ee2c5e1f1ad69b40a636b5afa36991f Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:47:23 -0600 Subject: [PATCH 11/11] feat(location): add multiplatform Location, LocationSource, and iOS CLLocationManager Flow helpers (#1817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(location): add multiplatform Location, LocationSource, and iOS CLLocationManager Flow helpers * fix(location): move FusedLocationProviderClient.asLocationSource to FusedLocationProvider.kt * fix(location): close location Flows cleanly on SecurityException * docs(location): add TODOs referencing #1818 for unified LocationSource semantics * fix(location): keep SecurityException and the app's CLLocationManager delegate - Android location flows fail with SecurityException again when location permission is missing, as in 6.0.0, instead of completing silently. - iOS locationEvents restores the delegate the manager had before collection, and documents the single-collector limitation. - iOS altitudeMeters reads ellipsoidalAltitude (WGS 84), matching Android's getAltitude(); CLLocation.altitude is above mean sea level. --------- Co-authored-by: Enrique López Mañas --- library/api/library.api | 33 +++ library/api/library.klib.api | 39 ++++ library/build.gradle.kts | 3 +- .../location/FusedLocationProviderTest.kt | 20 ++ .../android/location/LocationManagerTest.kt | 39 ++++ .../android/location/LocationSourceTest.kt | 109 ++++++++++ .../android/location/FusedLocationProvider.kt | 39 +++- .../maps/android/location/Location.android.kt | 107 +++++++++ .../maps/android/location/LocationManager.kt | 56 +++-- .../google/maps/android/location/Location.kt | 144 +++++++++++++ .../maps/android/location/Location.ios.kt | 203 ++++++++++++++++++ .../android/location/CLLocationManagerTest.kt | 167 ++++++++++++++ 12 files changed, 937 insertions(+), 22 deletions(-) create mode 100644 library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt create mode 100644 library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt create mode 100644 library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt create mode 100644 library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt create mode 100644 library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt diff --git a/library/api/library.api b/library/api/library.api index ca48c5952..195149e45 100644 --- a/library/api/library.api +++ b/library/api/library.api @@ -712,12 +712,20 @@ public final class com/google/maps/android/ktx/utils/location/LocationManagerKt } public final class com/google/maps/android/location/FusedLocationProviderKt { + public static final fun asLocationSource (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; + public static synthetic fun asLocationSource$default (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; public static final fun fusedLocationEvents (Lcom/google/android/gms/location/FusedLocationProviderClient;JFILandroid/os/Looper;)Lkotlinx/coroutines/flow/Flow; public static synthetic fun fusedLocationEvents$default (Lcom/google/android/gms/location/FusedLocationProviderClient;JFILandroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; public static final fun locationEvents (Lcom/google/android/gms/location/FusedLocationProviderClient;Lcom/google/android/gms/location/LocationRequest;Landroid/os/Looper;)Lkotlinx/coroutines/flow/Flow; public static synthetic fun locationEvents$default (Lcom/google/android/gms/location/FusedLocationProviderClient;Lcom/google/android/gms/location/LocationRequest;Landroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/google/maps/android/location/LocationKt { + public static final fun getLatLng (Landroid/location/Location;)Lcom/google/android/gms/maps/model/LatLng; + public static final fun locationEvents (Lcom/google/maps/android/location/LocationSource;JFLcom/google/maps/android/location/LocationPriority;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun locationEvents$default (Lcom/google/maps/android/location/LocationSource;JFLcom/google/maps/android/location/LocationPriority;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + public final class com/google/maps/android/location/LocationManagerKt { public static final fun coarseLocationEvents (Landroid/location/LocationManager;JFLandroid/os/Looper;)Lkotlinx/coroutines/flow/Flow; public static synthetic fun coarseLocationEvents$default (Landroid/location/LocationManager;JFLandroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; @@ -725,6 +733,31 @@ public final class com/google/maps/android/location/LocationManagerKt { public static synthetic fun fineLocationEvents$default (Landroid/location/LocationManager;JFLandroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/google/maps/android/location/LocationPriority : java/lang/Enum { + public static final field BALANCED_POWER_ACCURACY Lcom/google/maps/android/location/LocationPriority; + public static final field HIGH_ACCURACY Lcom/google/maps/android/location/LocationPriority; + public static final field LOW_POWER Lcom/google/maps/android/location/LocationPriority; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/google/maps/android/location/LocationPriority; + public static fun values ()[Lcom/google/maps/android/location/LocationPriority; +} + +public abstract interface class com/google/maps/android/location/LocationSource { + public abstract fun locationEvents (JFLcom/google/maps/android/location/LocationPriority;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/google/maps/android/location/Location_androidKt { + public static final fun asLocationSource (Landroid/location/LocationManager;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; + public static synthetic fun asLocationSource$default (Landroid/location/LocationManager;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; + public static final fun getAccuracyMeters (Landroid/location/Location;)F + public static final fun getAltitudeMeters (Landroid/location/Location;)D + public static final fun getBearingDegrees (Landroid/location/Location;)F + public static final fun getLatitude (Landroid/location/Location;)D + public static final fun getLongitude (Landroid/location/Location;)D + public static final fun getSpeedMetersPerSecond (Landroid/location/Location;)F + public static final fun getTimestampMillis (Landroid/location/Location;)J +} + public final class com/google/maps/android/model/CameraPositionKt { public static final fun cameraPosition (Lkotlin/jvm/functions/Function1;)Lcom/google/android/gms/maps/model/CameraPosition; } diff --git a/library/api/library.klib.api b/library/api/library.klib.api index a6ac7adaf..b4284098f 100644 --- a/library/api/library.klib.api +++ b/library/api/library.klib.api @@ -6,6 +6,22 @@ // - Show declarations: true // Library unique name: +final enum class com.google.maps.android.location/LocationPriority : kotlin/Enum { // com.google.maps.android.location/LocationPriority|null[0] + enum entry BALANCED_POWER_ACCURACY // com.google.maps.android.location/LocationPriority.BALANCED_POWER_ACCURACY|null[0] + enum entry HIGH_ACCURACY // com.google.maps.android.location/LocationPriority.HIGH_ACCURACY|null[0] + enum entry LOW_POWER // com.google.maps.android.location/LocationPriority.LOW_POWER|null[0] + + final val entries // com.google.maps.android.location/LocationPriority.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.google.maps.android.location/LocationPriority.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.google.maps.android.location/LocationPriority // com.google.maps.android.location/LocationPriority.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.google.maps.android.location/LocationPriority.values|values#static(){}[0] +} + +abstract fun interface com.google.maps.android.location/LocationSource { // com.google.maps.android.location/LocationSource|null[0] + abstract fun locationEvents(kotlin/Long, kotlin/Float, com.google.maps.android.location/LocationPriority): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/LocationSource.locationEvents|locationEvents(kotlin.Long;kotlin.Float;com.google.maps.android.location.LocationPriority){}[0] +} + final object com.google.maps.android/MathUtil { // com.google.maps.android/MathUtil|null[0] final const val EARTH_RADIUS // com.google.maps.android/MathUtil.EARTH_RADIUS|{}EARTH_RADIUS[0] final fun (): kotlin/Double // com.google.maps.android/MathUtil.EARTH_RADIUS.|(){}[0] @@ -54,3 +70,26 @@ final object com.google.maps.android/SphericalUtil { // com.google.maps.android/ final fun getPolylinePrefix(kotlin.collections/List, kotlin/Double): kotlin.collections/List // com.google.maps.android/SphericalUtil.getPolylinePrefix|getPolylinePrefix(kotlin.collections.List;kotlin.Double){}[0] final fun interpolate(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng, kotlin/Double): com.google.maps.android.model/LatLng // com.google.maps.android/SphericalUtil.interpolate|interpolate(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng;kotlin.Double){}[0] } + +final val com.google.maps.android.location/accuracyMeters // com.google.maps.android.location/accuracyMeters|@platform.CoreLocation.CLLocation{}accuracyMeters[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Float // com.google.maps.android.location/accuracyMeters.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/altitudeMeters // com.google.maps.android.location/altitudeMeters|@platform.CoreLocation.CLLocation{}altitudeMeters[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Double // com.google.maps.android.location/altitudeMeters.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/bearingDegrees // com.google.maps.android.location/bearingDegrees|@platform.CoreLocation.CLLocation{}bearingDegrees[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Float // com.google.maps.android.location/bearingDegrees.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/latLng // com.google.maps.android.location/latLng|@platform.CoreLocation.CLLocation{}latLng[0] + final fun (platform.CoreLocation/CLLocation).(): com.google.maps.android.model/LatLng // com.google.maps.android.location/latLng.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/latitude // com.google.maps.android.location/latitude|@platform.CoreLocation.CLLocation{}latitude[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Double // com.google.maps.android.location/latitude.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/longitude // com.google.maps.android.location/longitude|@platform.CoreLocation.CLLocation{}longitude[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Double // com.google.maps.android.location/longitude.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/speedMetersPerSecond // com.google.maps.android.location/speedMetersPerSecond|@platform.CoreLocation.CLLocation{}speedMetersPerSecond[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Float // com.google.maps.android.location/speedMetersPerSecond.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/timestampMillis // com.google.maps.android.location/timestampMillis|@platform.CoreLocation.CLLocation{}timestampMillis[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Long // com.google.maps.android.location/timestampMillis.|@platform.CoreLocation.CLLocation(){}[0] + +final fun (com.google.maps.android.location/LocationSource).com.google.maps.android.location/locationEvents(kotlin/Long = ..., kotlin/Float = ..., com.google.maps.android.location/LocationPriority = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/locationEvents|locationEvents@com.google.maps.android.location.LocationSource(kotlin.Long;kotlin.Float;com.google.maps.android.location.LocationPriority){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/asLocationSource(): com.google.maps.android.location/LocationSource // com.google.maps.android.location/asLocationSource|asLocationSource@platform.CoreLocation.CLLocationManager(){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/coarseLocationEvents(kotlin/Float = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/coarseLocationEvents|coarseLocationEvents@platform.CoreLocation.CLLocationManager(kotlin.Float){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/fineLocationEvents(kotlin/Float = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/fineLocationEvents|fineLocationEvents@platform.CoreLocation.CLLocationManager(kotlin.Float){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/locationEvents(kotlin/Float = ..., kotlin/Double = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/locationEvents|locationEvents@platform.CoreLocation.CLLocationManager(kotlin.Float;kotlin.Double){}[0] diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 6d6dbe579..c66b4f9aa 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -75,9 +75,11 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":maps-model")) + api(libs.kotlinx.coroutines.core) } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } androidMain { kotlin.srcDir(generateArtifactIdFile) @@ -85,7 +87,6 @@ kotlin { androidMain.dependencies { api(libs.play.services.maps) compileOnly(libs.play.services.location) - api(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) implementation(libs.appcompat) implementation(libs.core.ktx) diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt index a9ac1a50c..1e226ddba 100644 --- a/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt @@ -26,8 +26,10 @@ import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.Priority import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -38,6 +40,7 @@ import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Captor import org.mockito.Mock +import org.mockito.Mockito.`when` import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnitRunner @@ -113,4 +116,21 @@ public class FusedLocationProviderTest { verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) } + + @SuppressLint("MissingPermission") + @Test + public fun testLocationEventsPermissionDeniedFailsWithSecurityException(): Unit = runTest { + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L).build() + `when`( + fusedLocationClient.requestLocationUpdates( + eq(request), + any(LocationCallback::class.java), + eq(looper) + ) + ).thenThrow(SecurityException("Location permission denied")) + + assertFailsWith { + fusedLocationClient.locationEvents(request, looper).toList() + } + } } diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt index f2dcd8ce2..ea9a2aa0b 100644 --- a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt @@ -23,6 +23,7 @@ import android.location.LocationListener import android.location.LocationManager import android.os.Looper import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first @@ -166,4 +167,42 @@ public class LocationManagerTest { any() ) } + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationPermissionDeniedFailsWithSecurityException(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + `when`( + locationManager.requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + anyLong(), + anyFloat(), + any(), + any() + ) + ).thenThrow(SecurityException("Location permission denied")) + + assertFailsWith { + locationManager.coarseLocationEvents(1_000L, 1f, looper).toList() + } + } + + @SuppressLint("MissingPermission") + @Test + public fun testFineLocationPermissionDeniedFailsWithSecurityException(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.GPS_PROVIDER)) + `when`( + locationManager.requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + anyLong(), + anyFloat(), + any(), + any() + ) + ).thenThrow(SecurityException("Location permission denied")) + + assertFailsWith { + locationManager.fineLocationEvents(2_000L, 2f, looper).toList() + } + } } diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt new file mode 100644 index 000000000..f7898b0bb --- /dev/null +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt @@ -0,0 +1,109 @@ +/* + * 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.location + +import android.annotation.SuppressLint +import android.location.LocationListener +import android.location.LocationManager +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.model.LatLng +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class LocationSourceTest { + + @Mock + private lateinit var locationManager: LocationManager + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var listenerCaptor: ArgumentCaptor + + @Test + public fun locationExtensionPropertiesMapFromAndroidLocation() { + `when`(location.latitude).thenReturn(51.5074) + `when`(location.longitude).thenReturn(-0.1278) + `when`(location.hasAccuracy()).thenReturn(true) + `when`(location.accuracy).thenReturn(4.5f) + `when`(location.hasAltitude()).thenReturn(true) + `when`(location.altitude).thenReturn(35.5) + `when`(location.hasBearing()).thenReturn(true) + `when`(location.bearing).thenReturn(180.0f) + `when`(location.hasSpeed()).thenReturn(true) + `when`(location.speed).thenReturn(12.25f) + `when`(location.time).thenReturn(1_728_500_000_000L) + + assertThat(location.latLng).isEqualTo(LatLng(51.5074, -0.1278)) + assertThat(location.accuracyMeters).isEqualTo(4.5f) + assertThat(location.altitudeMeters).isEqualTo(35.5) + assertThat(location.bearingDegrees).isEqualTo(180.0f) + assertThat(location.speedMetersPerSecond).isEqualTo(12.25f) + assertThat(location.timestampMillis).isEqualTo(1_728_500_000_000L) + } + + @SuppressLint("MissingPermission") + @Test + public fun locationManagerAsLocationSourceStreamsThroughCommonAbstraction(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.GPS_PROVIDER)) + `when`(location.latitude).thenReturn(37.4220) + `when`(location.longitude).thenReturn(-122.0841) + + val source: LocationSource = locationManager.asLocationSource(looper) + val deferred = async { + source.locationEvents( + intervalMs = 2_000L, + minUpdateDistanceM = 3f, + priority = LocationPriority.HIGH_ACCURACY + ).first() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + eq(2_000L), + eq(3f), + listenerCaptor.capture(), + eq(looper) + ) + + listenerCaptor.value.onLocationChanged(location) + val emitted = deferred.await() + + assertThat(emitted.latLng).isEqualTo(LatLng(37.4220, -122.0841)) + verify(locationManager).removeUpdates(eq(listenerCaptor.value)) + } +} diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt index 0dab48af6..3a2d12579 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt @@ -34,7 +34,8 @@ import kotlinx.coroutines.flow.callbackFlow * Returns a cold flow that emits device location updates using [FusedLocationProviderClient.requestLocationUpdates]. * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately - * when the collector cancels or closes the subscription. + * when the collector cancels or closes the subscription. Without location permission the flow fails + * with a [SecurityException]. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -58,10 +59,21 @@ public fun FusedLocationProviderClient.locationEvents( } } - requestLocationUpdates(locationRequest, callback, looper) + try { + requestLocationUpdates(locationRequest, callback, looper) + Unit + } catch (e: SecurityException) { + close(e) + return@callbackFlow + } awaitClose { - removeLocationUpdates(callback) + try { + removeLocationUpdates(callback) + Unit + } catch (_: SecurityException) { + // Ignore if location permission was revoked while the flow was active. + } } } @@ -85,3 +97,24 @@ public fun FusedLocationProviderClient.fusedLocationEvents( .build() return locationEvents(request, looper) } + +/** + * Adapts a Google Play Services [FusedLocationProviderClient] into a multiplatform + * [LocationSource] that can be consumed from shared `commonMain` code. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.asLocationSource( + looper: Looper = Looper.getMainLooper() +): LocationSource = LocationSource { intervalMs, minUpdateDistanceM, priority -> + val playPriority = when (priority) { + LocationPriority.HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY + LocationPriority.BALANCED_POWER_ACCURACY -> Priority.PRIORITY_BALANCED_POWER_ACCURACY + LocationPriority.LOW_POWER -> Priority.PRIORITY_LOW_POWER + } + fusedLocationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = playPriority, + looper = looper + ) +} diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt new file mode 100644 index 000000000..14e47e31d --- /dev/null +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt @@ -0,0 +1,107 @@ +/* + * 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.location + +import android.Manifest +import android.location.LocationManager +import android.os.Looper +import androidx.annotation.RequiresPermission + +/** + * On Android, [Location] is a direct `typealias` for [android.location.Location]. + * + * This preserves binary and source compatibility with existing Android callers of + * [coarseLocationEvents], [fineLocationEvents], and [fusedLocationEvents]. + */ +public actual typealias Location = android.location.Location + +/** + * Binds to [android.location.Location.getLatitude]. Inside the getter body, Java member/bean + * property resolution takes precedence over this extension property, avoiding recursion. + */ +public actual val Location.latitude: Double + get() = this.latitude + +/** + * Binds to [android.location.Location.getLongitude]. + */ +public actual val Location.longitude: Double + get() = this.longitude + +/** + * Returns [android.location.Location.getAccuracy] in meters when present, or `-1f` when + * [android.location.Location.hasAccuracy] is `false` (matching iOS `CLLocation.horizontalAccuracy` + * semantics for invalid/unavailable accuracy). + */ +public actual val Location.accuracyMeters: Float + get() = if (hasAccuracy()) accuracy else -1f + +/** + * Returns [android.location.Location.getAltitude] in meters when present, or `0.0` otherwise. + */ +public actual val Location.altitudeMeters: Double + get() = if (hasAltitude()) altitude else 0.0 + +/** + * Returns [android.location.Location.getBearing] in degrees when present, or `-1f` when + * [android.location.Location.hasBearing] is `false` (matching iOS `CLLocation.course` semantics). + */ +public actual val Location.bearingDegrees: Float + get() = if (hasBearing()) bearing else -1f + +/** + * Returns [android.location.Location.getSpeed] in meters/second when present, or `-1f` when + * [android.location.Location.hasSpeed] is `false` (matching iOS `CLLocation.speed` semantics). + */ +public actual val Location.speedMetersPerSecond: Float + get() = if (hasSpeed()) speed else -1f + +/** + * Returns the UTC fix timestamp in milliseconds via [android.location.Location.getTime]. + */ +public actual val Location.timestampMillis: Long + get() = time + +/** + * Adapts an Android platform [LocationManager] into a multiplatform [LocationSource] that can be + * consumed from shared `commonMain` code. + * + * [LocationPriority.HIGH_ACCURACY] delegates to [fineLocationEvents] (`GPS_PROVIDER`), while + * [LocationPriority.BALANCED_POWER_ACCURACY] and [LocationPriority.LOW_POWER] delegate to + * [coarseLocationEvents] (`NETWORK_PROVIDER`). + */ +// TODO(https://github.com/googlemaps/android-maps-utils/issues/1818): Emit the freshest cached +// lastKnownLocation on collection start and merge coarseLocationEvents as an indoor / coarse-only +// permission fallback for HIGH_ACCURACY so Android callers match iOS CLLocationManager semantics. +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun LocationManager.asLocationSource( + looper: Looper = Looper.getMainLooper() +): LocationSource = LocationSource { intervalMs, minUpdateDistanceM, priority -> + when (priority) { + LocationPriority.HIGH_ACCURACY -> fineLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + looper = looper + ) + LocationPriority.BALANCED_POWER_ACCURACY, + LocationPriority.LOW_POWER -> coarseLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + looper = looper + ) + } +} diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt index ac8b8b8a2..6a86aea0c 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.flow.callbackFlow * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately * when the collector cancels or closes the subscription. When the underlying location provider is unavailable - * or disabled, the flow completes normally. + * or disabled, the flow completes normally. Without location permission the flow fails with a + * [SecurityException]. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -71,16 +72,25 @@ public fun LocationManager.coarseLocationEvents( } } - requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - minTimeMs, - minDistanceM, - listener, - looper - ) + try { + requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + minTimeMs, + minDistanceM, + listener, + looper + ) + } catch (e: SecurityException) { + close(e) + return@callbackFlow + } awaitClose { - removeUpdates(listener) + try { + removeUpdates(listener) + } catch (_: SecurityException) { + // Ignore if location permission was revoked while the flow was active. + } } } @@ -89,7 +99,8 @@ public fun LocationManager.coarseLocationEvents( * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately * when the collector cancels or closes the subscription. When the underlying location provider is unavailable - * or disabled, the flow completes normally. + * or disabled, the flow completes normally. Without location permission the flow fails with a + * [SecurityException]. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -127,15 +138,24 @@ public fun LocationManager.fineLocationEvents( } } - requestLocationUpdates( - LocationManager.GPS_PROVIDER, - minTimeMs, - minDistanceM, - listener, - looper - ) + try { + requestLocationUpdates( + LocationManager.GPS_PROVIDER, + minTimeMs, + minDistanceM, + listener, + looper + ) + } catch (e: SecurityException) { + close(e) + return@callbackFlow + } awaitClose { - removeUpdates(listener) + try { + removeUpdates(listener) + } catch (_: SecurityException) { + // Ignore if location permission was revoked while the flow was active. + } } } diff --git a/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt b/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt new file mode 100644 index 000000000..6aa220eb0 --- /dev/null +++ b/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt @@ -0,0 +1,144 @@ +/* + * 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.location + +import com.google.maps.android.model.LatLng +import kotlinx.coroutines.flow.Flow + +/** + * A platform-native geographic location fix emitted by device location providers. + * + * Why an `expect class` with extension properties instead of a wrapper data class? + * Existing Android code in this library ([coarseLocationEvents], [fineLocationEvents], and + * [fusedLocationEvents]) already returns `Flow`. By actualizing + * [Location] as a `typealias` to `android.location.Location` on Android and + * `platform.CoreLocation.CLLocation` on iOS: + * 1. Existing Android callers experience zero breaking changes and zero wrapper allocations. + * 2. Platform-specific code can still access every native method on `android.location.Location` + * or `CLLocation` directly. + * 3. Shared `commonMain` code can read coordinates and metadata uniformly through the + * extension properties below ([latLng], [latitude], [longitude], [accuracyMeters], etc.). + */ +public expect class Location + +/** + * The latitude of this location fix, in degrees. + */ +public expect val Location.latitude: Double + +/** + * The longitude of this location fix, in degrees. + */ +public expect val Location.longitude: Double + +/** + * The horizontal accuracy radius of this location fix in meters, or a negative value if + * horizontal accuracy is unavailable. + */ +public expect val Location.accuracyMeters: Float + +/** + * The altitude of this location fix in meters above the WGS 84 reference ellipsoid. + */ +public expect val Location.altitudeMeters: Double + +/** + * The direction of travel in degrees East of true North (`[0.0, 360.0)`), or a negative value + * if bearing/course is unavailable. + */ +public expect val Location.bearingDegrees: Float + +/** + * The instantaneous speed of the device in meters per second, or a negative value if speed + * is unavailable. + */ +public expect val Location.speedMetersPerSecond: Float + +/** + * The UTC timestamp of this location fix, in milliseconds since the Unix epoch + * (January 1, 1970 00:00:00 UTC). + */ +public expect val Location.timestampMillis: Long + +/** + * Converts this platform [Location] fix into a multiplatform [LatLng] coordinate for direct use + * with camera animations, markers, clustering, and spatial geometry utilities. + */ +public val Location.latLng: LatLng + get() = LatLng(latitude, longitude) + +/** + * Cross-platform quality-of-service priority for location update requests. + * + * Each platform maps these priorities to its native location engine: + * - On Android `FusedLocationProviderClient`, maps to Play Services `Priority` constants. + * - On Android `LocationManager`, selects between `GPS_PROVIDER` ([HIGH_ACCURACY]) and + * `NETWORK_PROVIDER` ([BALANCED_POWER_ACCURACY], [LOW_POWER]). + * - On iOS `CLLocationManager`, maps to `kCLLocationAccuracyBest`, + * `kCLLocationAccuracyNearestTenMeters`, and `kCLLocationAccuracyHundredMeters`. + */ +public enum class LocationPriority { + /** Requests the finest location accuracy available (typically GPS). */ + HIGH_ACCURACY, + + /** Requests block-level accuracy (~10-100m) balancing battery and precision. */ + BALANCED_POWER_ACCURACY, + + /** Requests coarse, city/neighborhood-level accuracy with minimal battery impact. */ + LOW_POWER +} + +/** + * A multiplatform source of reactive device [Location] updates exposed as a cold Kotlin [Flow]. + * + * Shared UI and presentation layers in `commonMain` can depend on [LocationSource] without + * coupling to Android's `FusedLocationProviderClient` / `LocationManager` or iOS's + * `CLLocationManager`. + */ +public fun interface LocationSource { + /** + * Returns a cold [Flow] that starts streaming [Location] updates when collected and + * automatically unregisters the underlying platform listener when the collector cancels. + * + * Without location permission, the Android sources fail with a `SecurityException`, while + * the iOS source completes without emitting. + * + * @param intervalMs Desired interval between updates in milliseconds (used by Android providers; + * iOS `CLLocationManager` filters primarily by [minUpdateDistanceM] and [priority]). + * @param minUpdateDistanceM Minimum displacement in meters before a new update is emitted. + * @param priority Desired accuracy and power trade-off. + */ + public fun locationEvents( + intervalMs: Long, + minUpdateDistanceM: Float, + priority: LocationPriority + ): Flow +} + +/** + * Convenience overload of [LocationSource.locationEvents] with sensible multiplatform defaults + * (`2,000 ms` interval, `1 meter` minimum distance, and [LocationPriority.HIGH_ACCURACY]). + */ +public fun LocationSource.locationEvents( + intervalMs: Long = 2_000L, + minUpdateDistanceM: Float = 1f, + priority: LocationPriority = LocationPriority.HIGH_ACCURACY +): Flow = locationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = priority +) diff --git a/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt new file mode 100644 index 000000000..d0c70a83d --- /dev/null +++ b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt @@ -0,0 +1,203 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package com.google.maps.android.location + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import platform.CoreLocation.CLLocation +import platform.CoreLocation.CLLocationAccuracy +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.CLLocationManagerDelegateProtocol +import platform.CoreLocation.kCLAuthorizationStatusDenied +import platform.CoreLocation.kCLAuthorizationStatusRestricted +import platform.CoreLocation.kCLErrorDenied +import platform.CoreLocation.kCLLocationAccuracyBest +import platform.CoreLocation.kCLLocationAccuracyHundredMeters +import platform.CoreLocation.kCLLocationAccuracyNearestTenMeters +import platform.Foundation.NSError +import platform.Foundation.timeIntervalSince1970 +import platform.darwin.NSObject + +/** + * On iOS, [Location] is a direct `typealias` for Apple's [CLLocation]. + * + * This avoids wrapper allocations on every GPS fix while allowing shared `commonMain` code to + * read coordinates and metadata via the multiplatform extension properties below. + */ +public actual typealias Location = CLLocation + +/** + * Extracts the latitude in degrees from the underlying `CLLocationCoordinate2D` C struct. + */ +public actual val Location.latitude: Double + get() = coordinate.useContents { latitude } + +/** + * Extracts the longitude in degrees from the underlying `CLLocationCoordinate2D` C struct. + */ +public actual val Location.longitude: Double + get() = coordinate.useContents { longitude } + +/** + * Returns `CLLocation.horizontalAccuracy` in meters (negative when horizontal accuracy is invalid). + */ +public actual val Location.accuracyMeters: Float + get() = horizontalAccuracy.toFloat() + +/** + * Returns `CLLocation.ellipsoidalAltitude` in meters above the WGS 84 reference ellipsoid, matching + * Android's `Location.getAltitude()`. (`CLLocation.altitude` is above mean sea level instead.) + */ +public actual val Location.altitudeMeters: Double + get() = ellipsoidalAltitude + +/** + * Returns `CLLocation.course` in degrees East of true North (negative when course is invalid). + */ +public actual val Location.bearingDegrees: Float + get() = course.toFloat() + +/** + * Returns `CLLocation.speed` in meters per second (negative when speed is invalid). + */ +public actual val Location.speedMetersPerSecond: Float + get() = speed.toFloat() + +/** + * Converts `CLLocation.timestamp` (`NSDate`) into UTC milliseconds since the Unix epoch. + */ +public actual val Location.timestampMillis: Long + get() = (timestamp.timeIntervalSince1970 * 1_000.0).toLong() + +/** + * Returns a cold [Flow] that emits device [Location] updates from this [CLLocationManager]. + * + * Updates begin streaming only when the flow is collected, and `stopUpdatingLocation()` is + * invoked automatically as soon as the collector cancels or completes. + * + * **Why `delegate` is captured inside `awaitClose`:** + * Objective-C declares `CLLocationManager.delegate` as an `assign` / `weak` reference. Capturing + * the Kotlin `delegate` instance inside `awaitClose` ensures Kotlin/Native's garbage collector + * keeps the delegate alive for the entire lifetime of the active flow subscription. + * + * **Warning**: [CLLocationManager] has a single delegate. While the flow is collected it replaces + * any delegate already set on this manager, which gets no callbacks until the collector cancels + * and the previous delegate is restored. Concurrently collecting several flows from the same + * manager is not supported, and cancelling any of them stops location updates. Share the flow + * (e.g. using [kotlinx.coroutines.flow.shareIn]) for multi-observer configurations. + * + * @param minDistanceM Minimum horizontal displacement in meters before a new fix is emitted. + * @param desiredAccuracy Desired CoreLocation accuracy constant (for example, + * [kCLLocationAccuracyBest] or [kCLLocationAccuracyHundredMeters]). + */ +public fun CLLocationManager.locationEvents( + minDistanceM: Float = 1f, + desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest +): Flow = callbackFlow { + val currentStatus = authorizationStatus + // TODO(https://github.com/googlemaps/android-maps-utils/issues/1818): Consider invoking + // requestWhenInUseAuthorization() automatically when currentStatus is + // kCLAuthorizationStatusNotDetermined so collecting the flow prompts for permission on iOS. + if (currentStatus == kCLAuthorizationStatusDenied || + currentStatus == kCLAuthorizationStatusRestricted + ) { + close() + return@callbackFlow + } + + val delegate = object : NSObject(), CLLocationManagerDelegateProtocol { + override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) { + for (item in didUpdateLocations) { + val location = item as? CLLocation ?: continue + trySend(location) + } + } + + override fun locationManager(manager: CLLocationManager, didFailWithError: NSError) { + if (didFailWithError.code == kCLErrorDenied) { + close() + } + } + + override fun locationManagerDidChangeAuthorization(manager: CLLocationManager) { + val status = manager.authorizationStatus + if (status == kCLAuthorizationStatusDenied || + status == kCLAuthorizationStatusRestricted + ) { + close() + } + } + } + + this@locationEvents.desiredAccuracy = desiredAccuracy + this@locationEvents.distanceFilter = minDistanceM.toDouble() + val previousDelegate = this@locationEvents.delegate + this@locationEvents.delegate = delegate + startUpdatingLocation() + + awaitClose { + stopUpdatingLocation() + if (this@locationEvents.delegate === delegate) { + this@locationEvents.delegate = previousDelegate + } + } +} + +/** + * Returns a cold [Flow] that emits coarse location updates on iOS using + * [kCLLocationAccuracyHundredMeters], mirroring Android's `LocationManager.coarseLocationEvents`. + */ +public fun CLLocationManager.coarseLocationEvents( + minDistanceM: Float = 1f +): Flow = locationEvents( + minDistanceM = minDistanceM, + desiredAccuracy = kCLLocationAccuracyHundredMeters +) + +/** + * Returns a cold [Flow] that emits fine (GPS-grade) location updates on iOS using + * [kCLLocationAccuracyBest], mirroring Android's `LocationManager.fineLocationEvents`. + */ +public fun CLLocationManager.fineLocationEvents( + minDistanceM: Float = 1f +): Flow = locationEvents( + minDistanceM = minDistanceM, + desiredAccuracy = kCLLocationAccuracyBest +) + +/** + * Adapts an iOS [CLLocationManager] into a multiplatform [LocationSource] that can be consumed + * from shared `commonMain` code alongside Android's `LocationManager` and + * `FusedLocationProviderClient`. + */ +public fun CLLocationManager.asLocationSource(): LocationSource = + LocationSource { _, minUpdateDistanceM, priority -> + val accuracy = when (priority) { + LocationPriority.HIGH_ACCURACY -> kCLLocationAccuracyBest + LocationPriority.BALANCED_POWER_ACCURACY -> kCLLocationAccuracyNearestTenMeters + LocationPriority.LOW_POWER -> kCLLocationAccuracyHundredMeters + } + locationEvents( + minDistanceM = minUpdateDistanceM, + desiredAccuracy = accuracy + ) + } diff --git a/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt b/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt new file mode 100644 index 000000000..5faab8240 --- /dev/null +++ b/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt @@ -0,0 +1,167 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalForeignApi::class, ExperimentalCoroutinesApi::class) + +package com.google.maps.android.location + +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import platform.CoreLocation.CLLocation +import platform.CoreLocation.CLLocationCoordinate2DMake +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.CLLocationManagerDelegateProtocol +import platform.CoreLocation.kCLErrorDenied +import platform.CoreLocation.kCLErrorDomain +import platform.CoreLocation.kCLLocationAccuracyBest +import platform.CoreLocation.kCLLocationAccuracyHundredMeters +import platform.Foundation.NSDate +import platform.Foundation.NSError +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.darwin.NSObject + +class CLLocationManagerTest { + + @Test + fun locationPropertiesMapFromCLLocation() { + val timestampSeconds = 1_728_500_000.0 + val clLocation = CLLocation( + coordinate = CLLocationCoordinate2DMake(51.5074, -0.1278), + altitude = 35.5, + horizontalAccuracy = 4.5, + verticalAccuracy = 8.0, + course = 180.0, + speed = 12.25, + timestamp = NSDate.dateWithTimeIntervalSince1970(timestampSeconds) + ) + + val location: Location = clLocation + + assertEquals(51.5074, location.latitude, 1e-6) + assertEquals(-0.1278, location.longitude, 1e-6) + assertEquals(LatLng(51.5074, -0.1278), location.latLng) + assertEquals(4.5f, location.accuracyMeters, 1e-4f) + // CLLocation has no initializer for ellipsoidalAltitude, so a constructed fix reports 0. + // altitudeMeters must still read it rather than the mean sea level `altitude` (35.5). + assertEquals(clLocation.ellipsoidalAltitude, location.altitudeMeters) + assertEquals(180.0f, location.bearingDegrees, 1e-4f) + assertEquals(12.25f, location.speedMetersPerSecond, 1e-4f) + assertEquals(1_728_500_000_000L, location.timestampMillis) + } + + @Test + fun fineLocationEventsEmitsUpdatesAndCleansUpDelegate() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val expectedFix = CLLocation(latitude = 37.4220, longitude = -122.0841) + + val deferred = async { + manager.fineLocationEvents(minDistanceM = 5f).first() + } + + val delegate = manager.delegate + assertNotNull(delegate, "Collecting fineLocationEvents should attach a CLLocationManagerDelegate") + assertEquals(kCLLocationAccuracyBest, manager.desiredAccuracy) + assertEquals(5.0, manager.distanceFilter) + + delegate.locationManager(manager, didUpdateLocations = listOf(expectedFix)) + + val emitted: Location = deferred.await() + assertEquals(37.4220, emitted.latLng.latitude, 1e-6) + assertEquals(-122.0841, emitted.latLng.longitude, 1e-6) + assertNull(manager.delegate, "Cancelling the flow collector should detach the delegate") + } + + @Test + fun collectingRestoresThePreviousDelegate() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val appDelegate = object : NSObject(), CLLocationManagerDelegateProtocol {} + manager.delegate = appDelegate + + val deferred = async { + manager.fineLocationEvents().first() + } + + val flowDelegate = assertNotNull(manager.delegate) + assertNotSame(appDelegate, flowDelegate) + flowDelegate.locationManager(manager, didUpdateLocations = listOf(CLLocation(latitude = 1.0, longitude = 2.0))) + deferred.await() + + assertSame(appDelegate, manager.delegate, "Cancelling the flow should restore the app's delegate") + } + + @Test + fun coarseLocationEventsCompletesOnPermissionDeniedError() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val firstFix = CLLocation(latitude = 40.7128, longitude = -74.0060) + + val deferred = async { + manager.coarseLocationEvents(minDistanceM = 10f).toList() + } + + val delegate = manager.delegate + assertNotNull(delegate) + assertEquals(kCLLocationAccuracyHundredMeters, manager.desiredAccuracy) + assertEquals(10.0, manager.distanceFilter) + + delegate.locationManager(manager, didUpdateLocations = listOf(firstFix)) + val deniedError = NSError.errorWithDomain( + domain = kCLErrorDomain, + code = kCLErrorDenied, + userInfo = null + ) + delegate.locationManager(manager, didFailWithError = deniedError) + + val results = deferred.await() + assertEquals(1, results.size) + assertEquals(LatLng(40.7128, -74.0060), results.single().latLng) + assertNull(manager.delegate) + } + + @Test + fun multiplatformLocationSourceStreamsThroughCommonAbstraction() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val source: LocationSource = manager.asLocationSource() + val expectedFix = CLLocation(latitude = 48.8566, longitude = 2.3522) + + val deferred = async { + source.locationEvents( + intervalMs = 1_000L, + minUpdateDistanceM = 2f, + priority = LocationPriority.HIGH_ACCURACY + ).first() + } + + val delegate = assertNotNull(manager.delegate) + delegate.locationManager(manager, didUpdateLocations = listOf(expectedFix)) + + val emitted = deferred.await() + assertEquals(LatLng(48.8566, 2.3522), emitted.latLng) + } +}