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 diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index a17bcc2f1..3c5cf7845 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,72 @@ 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): 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") +// 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/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..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" @@ -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/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)); - } -} diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts new file mode 100644 index 000000000..b5b7ccc92 --- /dev/null +++ b/maps-model/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +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 { + 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) + } + } +} + +// 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/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")