diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 6914f71e3..6d23619d8 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -44,7 +44,7 @@ jobs: run: echo "MAPS_API_KEY=dummy" > secrets.properties - name: Run Android Lint - run: ./gradlew :library:lintDebug :demo:lintStandardDebug + run: ./gradlew :library:lint :demo:lintStandardDebug - name: Upload SARIF for library uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml new file mode 100644 index 000000000..6cd92a1e7 --- /dev/null +++ b/.github/workflows/publish-snapshot.yml @@ -0,0 +1,83 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Publishes a -SNAPSHOT build of every module to the Maven Central snapshot repository +# (https://central.sonatype.com/repository/maven-snapshots/). Snapshots are not signed +# releases and never go through release-please; nothing is tagged or committed. +name: Publish snapshot + +on: + workflow_dispatch: + inputs: + version: + description: 'Snapshot version, must end with -SNAPSHOT (e.g. 6.1.0-SNAPSHOT)' + required: true + +permissions: + contents: read + +jobs: + publish-snapshot: + # The Kotlin Multiplatform modules publish iOS klibs, which can only be built on macOS. + runs-on: macos-latest + steps: + - name: Check version + env: + VERSION: ${{ inputs.version }} + run: | + case "$VERSION" in + *-SNAPSHOT) ;; + *) echo "::error::Snapshot versions must end with -SNAPSHOT, got '$VERSION'"; exit 1 ;; + esac + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + - name: Set snapshot version + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak 's/version = "[^"]*"/version = "'"$VERSION"'"/' build.gradle.kts + grep -n 'version = ' build.gradle.kts + + - name: Configure Maven Central credentials and signing + run: | + echo $GPG_KEY_ARMOR | base64 --decode > ./release.asc + gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc + sed -i.bak -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') + sed -i.bak -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i.bak -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties + sed -i.bak -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties + sed -i.bak -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties + env: + GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} + GPG_KEY_ID: ${{ secrets.SYNCED_GPG_KEY_ID }} + GPG_PASSWORD: ${{ secrets.SYNCED_GPG_KEY_PASSWORD }} + SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }} + SONATYPE_TOKEN_USERNAME: ${{ secrets.SONATYPE_TOKEN }} + + - name: Publish snapshot + run: ./gradlew publishToMavenCentral --stacktrace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4f85c1054..2d3da0419 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,8 @@ permissions: jobs: publish: - runs-on: ubuntu-latest + # The Kotlin Multiplatform modules publish iOS klibs, which can only be built on macOS. + runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -46,12 +47,12 @@ jobs: gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc echo "Build and publish" - sed -i -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + sed -i.bak -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') - sed -i -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties - sed -i -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties - sed -i -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties - sed -i -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties + sed -i.bak -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i.bak -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties + sed -i.bak -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties + sed -i.bak -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties env: GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} diff --git a/AGENTS.md b/AGENTS.md index 7322ed840..2554c097f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ the 4.x to modular layout mapping. | Module | Purpose | | --- | --- | | `library` | Core utilities shared by the other modules | +| `maps-model` | Multiplatform `LatLng` and `CameraPosition` (typealiases to the Play Services types on Android) | | `clustering` | Marker clustering | | `heatmaps` | Heatmap tile overlays | | `data` | GeoJSON and KML parsing/rendering | @@ -24,11 +25,30 @@ the 4.x to modular layout mapping. Shared Gradle conventions are in `build-logic/` (included build). +### Kotlin Multiplatform modules (experimental) + +`maps-model`, `library`, `clustering` and `heatmaps` are Kotlin Multiplatform +modules targeting Android and iOS (`iosArm64`, `iosSimulatorArm64`, `iosX64`). +They use `KmpPublishingConventionPlugin`; `data`, `ui` and `maps-utils` stay +Android-only and use `PublishingConventionPlugin`. + +- Platform-independent code (geometry, clustering algorithms, heatmap model) + lives in `src/commonMain`. Anything touching the Maps SDK, Android or + `java.*` APIs goes in `src/androidMain`. Host tests are in + `src/androidHostTest`, shared tests in `src/commonTest`. +- The Android API must not change: `apiCheck` compares the Android API against + `api/.api` and the iOS ABI against `api/.klib.api`. +- Building or publishing the iOS targets needs macOS with Xcode. On Linux they + are skipped, which is why `publish.yml` and `publish-snapshot.yml` run on + `macos-latest`. +- Run the shared tests on iOS with `./gradlew :library:iosSimulatorArm64Test`. + ## Building and testing ```bash ./gradlew assembleDebug # build everything -./gradlew :clustering:testDebugUnitTest # unit tests for one module +./gradlew :data:testDebugUnitTest # unit tests for an Android-only module +./gradlew :clustering:testAndroidHostTest # unit tests for a multiplatform module ./gradlew test # all unit tests ./gradlew koverXmlReportDebug # all unit tests and coverage reports ./gradlew lint # Android Lint (includes lint-checks rules) diff --git a/README.md b/README.md index 2c1115b6b..f5c0cec58 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ configurations.all { } ``` +### Kotlin Multiplatform (experimental) + +`android-maps-utils-core`, `android-maps-utils-clustering` and `android-maps-utils-heatmaps` +are also published as Kotlin Multiplatform libraries for iOS (`iosArm64`, +`iosSimulatorArm64`, `iosX64`). The platform-independent parts are available from common +code: `PolyUtil`, `SphericalUtil` and `MathUtil`, the clustering algorithms, and the heatmap +`Gradient` and `WeightedLatLng`, on top of a common `LatLng` and `CameraPosition` from +`android-maps-utils-maps-model`. On Android those are the Maps SDK types, so Android apps +are not affected. Rendering (`ClusterManager`, `HeatmapTileProvider`, the data layers and UI +helpers) stays Android-only. + +Snapshot builds are published to the Maven Central snapshot repository: + +```kotlin +repositories { + maven("https://central.sonatype.com/repository/maven-snapshots/") +} +``` + ## Sample App diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0ae62850f..bbd68f240 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -39,6 +39,10 @@ gradlePlugin { id = "android.maps.utils.PublishingConventionPlugin" implementationClass = "PublishingConventionPlugin" } + register("kmpPublishingConventionPlugin") { + id = "android.maps.utils.KmpPublishingConventionPlugin" + implementationClass = "KmpPublishingConventionPlugin" + } register("bomPublishingConventionPlugin") { id = "android.maps.utils.BomPublishingConventionPlugin" implementationClass = "BomPublishingConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt new file mode 100644 index 000000000..8131115be --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt @@ -0,0 +1,145 @@ +/* + * 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. + */ + +import com.android.build.api.dsl.Lint +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SourcesJar +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension +import kotlinx.validation.KotlinApiBuildTask +import kotlinx.validation.KotlinApiCompareTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.Copy +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** + * Convention for the Kotlin Multiplatform library modules (Android plus iOS). + * + * Mirrors [PublishingConventionPlugin] for the Android-only modules: explicit API mode, Kover, + * Dokka and Maven Central publishing under the same artifactIds and POM. Each module still + * configures its own `androidLibrary { }` target (namespace, SDK levels) and source sets. + * + * The binary compatibility validator only checks the iOS klib ABI of these modules + * (`api/.klib.api`). The Android API is checked here against the same + * `api/.api` file the module had before it became multiplatform, so moving to KMP + * cannot change the Android API without the check failing. + */ +class KmpPublishingConventionPlugin : Plugin { + override fun apply(project: Project) { + project.run { + apply(plugin = "org.jetbrains.kotlin.multiplatform") + apply(plugin = "com.android.kotlin.multiplatform.library") + // Multiplatform Android libraries only get lint tasks from the standalone lint plugin. + apply(plugin = "com.android.lint") + apply(plugin = "org.jetbrains.kotlinx.kover") + apply(plugin = "org.jetbrains.dokka") + apply(plugin = "com.vanniktech.maven.publish") + + configure { + explicitApi() + jvmToolchain(17) + iosArm64() + iosSimulatorArm64() + iosX64() + } + + configure { + sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile + } + + configure { + // CI and the coverage history read koverXmlReportDebug / reportDebug.xml, which + // the Android-only modules produce. Expose the KMP Android coverage the same way. + currentProject { + createVariant("debug") { + add("android") + } + } + reports { + filters { + excludes { + androidGeneratedClasses() + } + } + } + } + + configure { + configure( + KotlinMultiplatform( + javadocJar = JavadocJar.Dokka("dokkaGeneratePublicationHtml"), + sourcesJar = SourcesJar.Sources(), + ) + ) + configureMapsUtilsPublishing(project) + } + + configureAndroidApiValidation() + } + } + + private fun Project.configureAndroidApiValidation() { + val projectName = name + val apiFile = layout.projectDirectory.file("api/$projectName.api") + val buildApiFile = layout.buildDirectory.file("api/android/$projectName.api") + + afterEvaluate { + val bundleTask = tasks.findByName("bundleAndroidMainClassesToCompileJar") ?: return@afterEvaluate + val classesJar = layout.buildDirectory.file( + "intermediates/compile_library_classes_jar/androidMain/bundleAndroidMainClassesToCompileJar/classes.jar" + ) + + val androidApiBuild = tasks.register("androidApiBuild") { + group = "verification" + description = "Builds the public Android API declaration for $projectName." + inputJar.set(classesJar) + outputApiFile.set(buildApiFile) + ignoredClasses.addAll( + "com.google.maps.android.R", + "com.google.maps.android.BuildConfig", + "com.google.maps.android.$projectName.R", + "com.google.maps.android.$projectName.BuildConfig", + ) + dependsOn(bundleTask) + } + + val androidApiDump = tasks.register("androidApiDump") { + group = "verification" + description = "Syncs the public Android API declaration of $projectName to api/$projectName.api." + from(androidApiBuild.flatMap { it.outputApiFile }) + into(apiFile.asFile.parentFile) + dependsOn(androidApiBuild) + } + + val androidApiCheck = tasks.register("androidApiCheck") { + group = "verification" + description = "Checks the public Android API of $projectName against the committed api/$projectName.api." + projectApiFile.set(apiFile) + generatedApiFile.set(androidApiBuild.flatMap { it.outputApiFile }) + dependsOn(androidApiBuild) + } + + tasks.named("apiDump") { dependsOn(androidApiDump) } + tasks.named("apiCheck") { dependsOn(androidApiCheck) } + tasks.findByName("check")?.dependsOn(androidApiCheck) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/MapsUtilsPublishing.kt b/build-logic/convention/src/main/kotlin/MapsUtilsPublishing.kt new file mode 100644 index 000000000..d36a69fac --- /dev/null +++ b/build-logic/convention/src/main/kotlin/MapsUtilsPublishing.kt @@ -0,0 +1,69 @@ +/* + * 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. + */ + +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import org.gradle.api.Project + +/** + * Publishing settings shared by the Android-only and the Kotlin Multiplatform modules: Maven + * Central, signing when keys are configured, the public artifactId scheme and the POM. + */ +internal fun MavenPublishBaseExtension.configureMapsUtilsPublishing(project: Project) { + publishToMavenCentral() + if (project.findProperty("signing.keyId")?.toString()?.isNotBlank() == true || + project.findProperty("signing.secretKeyRingFile")?.toString()?.isNotBlank() == true || + project.findProperty("signingInMemoryKey")?.toString()?.isNotBlank() == true + ) { + signAllPublications() + } + + val artifactIdName = when (project.name) { + "maps-utils" -> "android-maps-utils" + "library" -> "android-maps-utils-core" + else -> "android-maps-utils-${project.name}" + } + coordinates( + artifactId = artifactIdName, + ) + + pom { + name.set("android-maps-utils") + description.set("Handy extensions to the Google Maps Android API.") + url.set("https://github.com/googlemaps/android-maps-utils") + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + scm { + connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + url.set("https://github.com/googlemaps/android-maps-utils") + } + developers { + developer { + id.set("google") + name.set("Google LLC") + } + } + organization { + name.set("Google Inc") + url.set("http://developers.google.com/maps") + } + } +} diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index ebf8318ea..4d4cf3a9f 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -64,50 +64,7 @@ class PublishingConventionPlugin : Plugin { ) ) - publishToMavenCentral() - if (findProperty("signing.keyId")?.toString()?.isNotBlank() == true || - findProperty("signing.secretKeyRingFile")?.toString()?.isNotBlank() == true || - findProperty("signingInMemoryKey")?.toString()?.isNotBlank() == true - ) { - signAllPublications() - } - - val artifactIdName = when (project.name) { - "maps-utils" -> "android-maps-utils" - "library" -> "android-maps-utils-core" - else -> "android-maps-utils-${project.name}" - } - coordinates( - artifactId = artifactIdName, - ) - - pom { - name.set("android-maps-utils") - description.set("Handy extensions to the Google Maps Android API.") - url.set("https://github.com/googlemaps/android-maps-utils") - licenses { - license { - name.set("The Apache Software License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("repo") - } - } - scm { - connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") - developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") - url.set("https://github.com/googlemaps/android-maps-utils") - } - developers { - developer { - id.set("google") - name.set("Google LLC") - } - } - organization { - name.set("Google Inc") - url.set("http://developers.google.com/maps") - } - } + configureMapsUtilsPublishing(project) } } diff --git a/build.gradle.kts b/build.gradle.kts index 5f231f722..0e7071651 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,12 @@ plugins { apiValidation { ignoredProjects += listOf("demo", "visual-testing", "lint-checks", "maps-utils") + + // Validate the iOS klib ABI of the Kotlin Multiplatform modules (api/.klib.api). + @OptIn(kotlinx.validation.ExperimentalBCVApi::class) + klib { + enabled = true + } } val apiDump = tasks.register("apiDump") { diff --git a/clustering/api/clustering.klib.api b/clustering/api/clustering.klib.api new file mode 100644 index 000000000..328595cb0 --- /dev/null +++ b/clustering/api/clustering.klib.api @@ -0,0 +1,257 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract interface <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/Algorithm { // com.google.maps.android.clustering.algo/Algorithm|null[0] + abstract val items // com.google.maps.android.clustering.algo/Algorithm.items|{}items[0] + abstract fun (): kotlin.collections/Collection<#A> // com.google.maps.android.clustering.algo/Algorithm.items.|(){}[0] + + abstract var maxDistanceBetweenClusteredItems // com.google.maps.android.clustering.algo/Algorithm.maxDistanceBetweenClusteredItems|{}maxDistanceBetweenClusteredItems[0] + abstract fun (): kotlin/Int // com.google.maps.android.clustering.algo/Algorithm.maxDistanceBetweenClusteredItems.|(){}[0] + abstract fun (kotlin/Int) // com.google.maps.android.clustering.algo/Algorithm.maxDistanceBetweenClusteredItems.|(kotlin.Int){}[0] + + abstract fun addItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/Algorithm.addItem|addItem(1:0){}[0] + abstract fun addItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/Algorithm.addItems|addItems(kotlin.collections.Collection<1:0>){}[0] + abstract fun clearItems() // com.google.maps.android.clustering.algo/Algorithm.clearItems|clearItems(){}[0] + abstract fun getClusters(kotlin/Float): kotlin.collections/Set> // com.google.maps.android.clustering.algo/Algorithm.getClusters|getClusters(kotlin.Float){}[0] + abstract fun lock() // com.google.maps.android.clustering.algo/Algorithm.lock|lock(){}[0] + abstract fun removeItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/Algorithm.removeItem|removeItem(1:0){}[0] + abstract fun removeItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/Algorithm.removeItems|removeItems(kotlin.collections.Collection<1:0>){}[0] + abstract fun unlock() // com.google.maps.android.clustering.algo/Algorithm.unlock|unlock(){}[0] + abstract fun updateItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/Algorithm.updateItem|updateItem(1:0){}[0] +} + +abstract interface <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/ScreenBasedAlgorithm : com.google.maps.android.clustering.algo/Algorithm<#A> { // com.google.maps.android.clustering.algo/ScreenBasedAlgorithm|null[0] + abstract fun onCameraChange(com.google.maps.android.model/CameraPosition) // com.google.maps.android.clustering.algo/ScreenBasedAlgorithm.onCameraChange|onCameraChange(com.google.maps.android.model.CameraPosition){}[0] + abstract fun shouldReclusterOnMapMovement(): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithm.shouldReclusterOnMapMovement|shouldReclusterOnMapMovement(){}[0] +} + +abstract interface <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering/Cluster { // com.google.maps.android.clustering/Cluster|null[0] + abstract val items // com.google.maps.android.clustering/Cluster.items|{}items[0] + abstract fun (): kotlin.collections/Collection<#A> // com.google.maps.android.clustering/Cluster.items.|(){}[0] + abstract val position // com.google.maps.android.clustering/Cluster.position|{}position[0] + abstract fun (): com.google.maps.android.model/LatLng // com.google.maps.android.clustering/Cluster.position.|(){}[0] + abstract val size // com.google.maps.android.clustering/Cluster.size|{}size[0] + abstract fun (): kotlin/Int // com.google.maps.android.clustering/Cluster.size.|(){}[0] +} + +abstract interface com.google.maps.android.clustering/ClusterItem { // com.google.maps.android.clustering/ClusterItem|null[0] + abstract val position // com.google.maps.android.clustering/ClusterItem.position|{}position[0] + abstract fun (): com.google.maps.android.model/LatLng // com.google.maps.android.clustering/ClusterItem.position.|(){}[0] + abstract val snippet // com.google.maps.android.clustering/ClusterItem.snippet|{}snippet[0] + abstract fun (): kotlin/String? // com.google.maps.android.clustering/ClusterItem.snippet.|(){}[0] + abstract val title // com.google.maps.android.clustering/ClusterItem.title|{}title[0] + abstract fun (): kotlin/String? // com.google.maps.android.clustering/ClusterItem.title.|(){}[0] + abstract val zIndex // com.google.maps.android.clustering/ClusterItem.zIndex|{}zIndex[0] + abstract fun (): kotlin/Float? // com.google.maps.android.clustering/ClusterItem.zIndex.|(){}[0] +} + +abstract class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/AbstractAlgorithm : com.google.maps.android.clustering.algo/Algorithm<#A> { // com.google.maps.android.clustering.algo/AbstractAlgorithm|null[0] + constructor () // com.google.maps.android.clustering.algo/AbstractAlgorithm.|(){}[0] + + open fun lock() // com.google.maps.android.clustering.algo/AbstractAlgorithm.lock|lock(){}[0] + open fun unlock() // com.google.maps.android.clustering.algo/AbstractAlgorithm.unlock|unlock(){}[0] +} + +final class <#A: com.google.maps.android.quadtree/PointQuadTree.Item> com.google.maps.android.quadtree/PointQuadTree { // com.google.maps.android.quadtree/PointQuadTree|null[0] + constructor (com.google.maps.android.geometry/Bounds, kotlin/Int = ...) // com.google.maps.android.quadtree/PointQuadTree.|(com.google.maps.android.geometry.Bounds;kotlin.Int){}[0] + constructor (kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double) // com.google.maps.android.quadtree/PointQuadTree.|(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] + + final fun add(#A) // com.google.maps.android.quadtree/PointQuadTree.add|add(1:0){}[0] + final fun clear() // com.google.maps.android.quadtree/PointQuadTree.clear|clear(){}[0] + final fun remove(#A): kotlin/Boolean // com.google.maps.android.quadtree/PointQuadTree.remove|remove(1:0){}[0] + final fun search(com.google.maps.android.geometry/Bounds): kotlin.collections/Collection<#A> // com.google.maps.android.quadtree/PointQuadTree.search|search(com.google.maps.android.geometry.Bounds){}[0] + + abstract interface Item { // com.google.maps.android.quadtree/PointQuadTree.Item|null[0] + abstract val point // com.google.maps.android.quadtree/PointQuadTree.Item.point|{}point[0] + abstract fun (): com.google.maps.android.geometry/Point // com.google.maps.android.quadtree/PointQuadTree.Item.point.|(){}[0] + } + + final object Companion // com.google.maps.android.quadtree/PointQuadTree.Companion|null[0] +} + +final class com.google.maps.android.geometry/Bounds { // com.google.maps.android.geometry/Bounds|null[0] + constructor (kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double) // com.google.maps.android.geometry/Bounds.|(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] + + final val maxX // com.google.maps.android.geometry/Bounds.maxX|{}maxX[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Bounds.maxX.|(){}[0] + final val maxY // com.google.maps.android.geometry/Bounds.maxY|{}maxY[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Bounds.maxY.|(){}[0] + final val midX // com.google.maps.android.geometry/Bounds.midX|{}midX[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Bounds.midX.|(){}[0] + final val midY // com.google.maps.android.geometry/Bounds.midY|{}midY[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Bounds.midY.|(){}[0] + final val minX // com.google.maps.android.geometry/Bounds.minX|{}minX[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Bounds.minX.|(){}[0] + final val minY // com.google.maps.android.geometry/Bounds.minY|{}minY[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Bounds.minY.|(){}[0] + + final fun contains(com.google.maps.android.geometry/Bounds): kotlin/Boolean // com.google.maps.android.geometry/Bounds.contains|contains(com.google.maps.android.geometry.Bounds){}[0] + final fun contains(com.google.maps.android.geometry/Point): kotlin/Boolean // com.google.maps.android.geometry/Bounds.contains|contains(com.google.maps.android.geometry.Point){}[0] + final fun contains(kotlin/Double, kotlin/Double): kotlin/Boolean // com.google.maps.android.geometry/Bounds.contains|contains(kotlin.Double;kotlin.Double){}[0] + final fun intersects(com.google.maps.android.geometry/Bounds): kotlin/Boolean // com.google.maps.android.geometry/Bounds.intersects|intersects(com.google.maps.android.geometry.Bounds){}[0] + final fun intersects(kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Boolean // com.google.maps.android.geometry/Bounds.intersects|intersects(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] +} + +final class com.google.maps.android.projection/Point : com.google.maps.android.geometry/Point { // com.google.maps.android.projection/Point|null[0] + constructor (kotlin/Double, kotlin/Double) // com.google.maps.android.projection/Point.|(kotlin.Double;kotlin.Double){}[0] +} + +final class com.google.maps.android.projection/SphericalMercatorProjection { // com.google.maps.android.projection/SphericalMercatorProjection|null[0] + constructor (kotlin/Double) // com.google.maps.android.projection/SphericalMercatorProjection.|(kotlin.Double){}[0] + + final fun toLatLng(com.google.maps.android.geometry/Point): com.google.maps.android.model/LatLng // com.google.maps.android.projection/SphericalMercatorProjection.toLatLng|toLatLng(com.google.maps.android.geometry.Point){}[0] + final fun toPoint(com.google.maps.android.model/LatLng): com.google.maps.android.geometry/Point // com.google.maps.android.projection/SphericalMercatorProjection.toPoint|toPoint(com.google.maps.android.model.LatLng){}[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/CentroidNonHierarchicalDistanceBasedAlgorithm : com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm<#A> { // com.google.maps.android.clustering.algo/CentroidNonHierarchicalDistanceBasedAlgorithm|null[0] + constructor () // com.google.maps.android.clustering.algo/CentroidNonHierarchicalDistanceBasedAlgorithm.|(){}[0] + + final fun computeCentroid(kotlin.collections/Collection<#A>): com.google.maps.android.model/LatLng // com.google.maps.android.clustering.algo/CentroidNonHierarchicalDistanceBasedAlgorithm.computeCentroid|computeCentroid(kotlin.collections.Collection<1:0>){}[0] + open fun getClusters(kotlin/Float): kotlin.collections/Set> // com.google.maps.android.clustering.algo/CentroidNonHierarchicalDistanceBasedAlgorithm.getClusters|getClusters(kotlin.Float){}[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/ContinuousZoomEuclideanCentroidAlgorithm : com.google.maps.android.clustering.algo/CentroidNonHierarchicalDistanceBasedAlgorithm<#A> { // com.google.maps.android.clustering.algo/ContinuousZoomEuclideanCentroidAlgorithm|null[0] + constructor () // com.google.maps.android.clustering.algo/ContinuousZoomEuclideanCentroidAlgorithm.|(){}[0] + + open fun getClusters(kotlin/Float): kotlin.collections/Set> // com.google.maps.android.clustering.algo/ContinuousZoomEuclideanCentroidAlgorithm.getClusters|getClusters(kotlin.Float){}[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/GridBasedAlgorithm : com.google.maps.android.clustering.algo/AbstractAlgorithm<#A> { // com.google.maps.android.clustering.algo/GridBasedAlgorithm|null[0] + constructor () // com.google.maps.android.clustering.algo/GridBasedAlgorithm.|(){}[0] + + open val items // com.google.maps.android.clustering.algo/GridBasedAlgorithm.items|{}items[0] + open fun (): kotlin.collections/Collection<#A> // com.google.maps.android.clustering.algo/GridBasedAlgorithm.items.|(){}[0] + + open var maxDistanceBetweenClusteredItems // com.google.maps.android.clustering.algo/GridBasedAlgorithm.maxDistanceBetweenClusteredItems|{}maxDistanceBetweenClusteredItems[0] + open fun (): kotlin/Int // com.google.maps.android.clustering.algo/GridBasedAlgorithm.maxDistanceBetweenClusteredItems.|(){}[0] + open fun (kotlin/Int) // com.google.maps.android.clustering.algo/GridBasedAlgorithm.maxDistanceBetweenClusteredItems.|(kotlin.Int){}[0] + + open fun addItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/GridBasedAlgorithm.addItem|addItem(1:0){}[0] + open fun addItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/GridBasedAlgorithm.addItems|addItems(kotlin.collections.Collection<1:0>){}[0] + open fun clearItems() // com.google.maps.android.clustering.algo/GridBasedAlgorithm.clearItems|clearItems(){}[0] + open fun getClusters(kotlin/Float): kotlin.collections/Set> // com.google.maps.android.clustering.algo/GridBasedAlgorithm.getClusters|getClusters(kotlin.Float){}[0] + open fun removeItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/GridBasedAlgorithm.removeItem|removeItem(1:0){}[0] + open fun removeItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/GridBasedAlgorithm.removeItems|removeItems(kotlin.collections.Collection<1:0>){}[0] + open fun updateItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/GridBasedAlgorithm.updateItem|updateItem(1:0){}[0] + + final object Companion // com.google.maps.android.clustering.algo/GridBasedAlgorithm.Companion|null[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm : com.google.maps.android.clustering.algo/AbstractAlgorithm<#A> { // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm|null[0] + constructor () // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.|(){}[0] + + final val mItemMap // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.mItemMap|{}mItemMap[0] + final fun (): kotlin.collections/MutableMap<#A, com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem<#A>> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.mItemMap.|(){}[0] + final val mItems // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.mItems|{}mItems[0] + final fun (): kotlin.collections/MutableCollection> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.mItems.|(){}[0] + final val mQuadTree // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.mQuadTree|{}mQuadTree[0] + final fun (): com.google.maps.android.quadtree/PointQuadTree> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.mQuadTree.|(){}[0] + open val items // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.items|{}items[0] + open fun (): kotlin.collections/Collection<#A> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.items.|(){}[0] + + open var maxDistanceBetweenClusteredItems // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.maxDistanceBetweenClusteredItems|{}maxDistanceBetweenClusteredItems[0] + open fun (): kotlin/Int // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.maxDistanceBetweenClusteredItems.|(){}[0] + open fun (kotlin/Int) // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.maxDistanceBetweenClusteredItems.|(kotlin.Int){}[0] + + final fun createBoundsFromSpan(com.google.maps.android.geometry/Point, kotlin/Double): com.google.maps.android.geometry/Bounds // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.createBoundsFromSpan|createBoundsFromSpan(com.google.maps.android.geometry.Point;kotlin.Double){}[0] + final fun distanceSquared(com.google.maps.android.geometry/Point, com.google.maps.android.geometry/Point): kotlin/Double // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.distanceSquared|distanceSquared(com.google.maps.android.geometry.Point;com.google.maps.android.geometry.Point){}[0] + open fun addItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.addItem|addItem(1:0){}[0] + open fun addItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.addItems|addItems(kotlin.collections.Collection<1:0>){}[0] + open fun clearItems() // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.clearItems|clearItems(){}[0] + open fun getClusteringItems(com.google.maps.android.quadtree/PointQuadTree>, kotlin/Float): kotlin.collections/Collection> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.getClusteringItems|getClusteringItems(com.google.maps.android.quadtree.PointQuadTree>;kotlin.Float){}[0] + open fun getClusters(kotlin/Float): kotlin.collections/Set> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.getClusters|getClusters(kotlin.Float){}[0] + open fun removeItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.removeItem|removeItem(1:0){}[0] + open fun removeItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.removeItems|removeItems(kotlin.collections.Collection<1:0>){}[0] + open fun updateItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.updateItem|updateItem(1:0){}[0] + + final class <#A1: com.google.maps.android.clustering/ClusterItem> QuadItem : com.google.maps.android.clustering/Cluster<#A1>, com.google.maps.android.quadtree/PointQuadTree.Item { // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem|null[0] + constructor (#A1) // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.|(1:0){}[0] + + final val items // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.items|{}items[0] + final fun (): kotlin.collections/Collection<#A1> // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.items.|(){}[0] + final val mClusterItem // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.mClusterItem|{}mClusterItem[0] + final fun (): #A1 // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.mClusterItem.|(){}[0] + final val point // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.point|{}point[0] + final fun (): com.google.maps.android.geometry/Point // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.point.|(){}[0] + final val position // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.position|{}position[0] + final fun (): com.google.maps.android.model/LatLng // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.position.|(){}[0] + final val size // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.size|{}size[0] + final fun (): kotlin/Int // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.size.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.QuadItem.hashCode|hashCode(){}[0] + } + + final object Companion // com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm.Companion|null[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm : com.google.maps.android.clustering.algo/NonHierarchicalDistanceBasedAlgorithm<#A>, com.google.maps.android.clustering.algo/ScreenBasedAlgorithm<#A> { // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm|null[0] + constructor (kotlin/Int, kotlin/Int) // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm.|(kotlin.Int;kotlin.Int){}[0] + + final fun updateViewSize(kotlin/Int, kotlin/Int) // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm.updateViewSize|updateViewSize(kotlin.Int;kotlin.Int){}[0] + open fun getClusteringItems(com.google.maps.android.quadtree/PointQuadTree>, kotlin/Float): kotlin.collections/Collection> // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm.getClusteringItems|getClusteringItems(com.google.maps.android.quadtree.PointQuadTree>;kotlin.Float){}[0] + open fun onCameraChange(com.google.maps.android.model/CameraPosition) // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm.onCameraChange|onCameraChange(com.google.maps.android.model.CameraPosition){}[0] + open fun shouldReclusterOnMapMovement(): kotlin/Boolean // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm.shouldReclusterOnMapMovement|shouldReclusterOnMapMovement(){}[0] + + final object Companion // com.google.maps.android.clustering.algo/NonHierarchicalViewBasedAlgorithm.Companion|null[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter : com.google.maps.android.clustering.algo/AbstractAlgorithm<#A>, com.google.maps.android.clustering.algo/ScreenBasedAlgorithm<#A> { // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter|null[0] + constructor (com.google.maps.android.clustering.algo/Algorithm<#A>) // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.|(com.google.maps.android.clustering.algo.Algorithm<1:0>){}[0] + + open val items // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.items|{}items[0] + open fun (): kotlin.collections/Collection<#A> // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.items.|(){}[0] + + open var maxDistanceBetweenClusteredItems // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.maxDistanceBetweenClusteredItems|{}maxDistanceBetweenClusteredItems[0] + open fun (): kotlin/Int // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.maxDistanceBetweenClusteredItems.|(){}[0] + open fun (kotlin/Int) // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.maxDistanceBetweenClusteredItems.|(kotlin.Int){}[0] + + open fun addItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.addItem|addItem(1:0){}[0] + open fun addItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.addItems|addItems(kotlin.collections.Collection<1:0>){}[0] + open fun clearItems() // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.clearItems|clearItems(){}[0] + open fun getClusters(kotlin/Float): kotlin.collections/Set> // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.getClusters|getClusters(kotlin.Float){}[0] + open fun onCameraChange(com.google.maps.android.model/CameraPosition) // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.onCameraChange|onCameraChange(com.google.maps.android.model.CameraPosition){}[0] + open fun removeItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.removeItem|removeItem(1:0){}[0] + open fun removeItems(kotlin.collections/Collection<#A>): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.removeItems|removeItems(kotlin.collections.Collection<1:0>){}[0] + open fun shouldReclusterOnMapMovement(): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.shouldReclusterOnMapMovement|shouldReclusterOnMapMovement(){}[0] + open fun updateItem(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/ScreenBasedAlgorithmAdapter.updateItem|updateItem(1:0){}[0] +} + +open class <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.clustering.algo/StaticCluster : com.google.maps.android.clustering/Cluster<#A> { // com.google.maps.android.clustering.algo/StaticCluster|null[0] + constructor (com.google.maps.android.model/LatLng) // com.google.maps.android.clustering.algo/StaticCluster.|(com.google.maps.android.model.LatLng){}[0] + + open val items // com.google.maps.android.clustering.algo/StaticCluster.items|{}items[0] + open fun (): kotlin.collections/Collection<#A> // com.google.maps.android.clustering.algo/StaticCluster.items.|(){}[0] + open val position // com.google.maps.android.clustering.algo/StaticCluster.position|{}position[0] + open fun (): com.google.maps.android.model/LatLng // com.google.maps.android.clustering.algo/StaticCluster.position.|(){}[0] + open val size // com.google.maps.android.clustering.algo/StaticCluster.size|{}size[0] + open fun (): kotlin/Int // com.google.maps.android.clustering.algo/StaticCluster.size.|(){}[0] + + final fun add(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/StaticCluster.add|add(1:0){}[0] + final fun remove(#A): kotlin/Boolean // com.google.maps.android.clustering.algo/StaticCluster.remove|remove(1:0){}[0] + open fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.clustering.algo/StaticCluster.equals|equals(kotlin.Any?){}[0] + open fun hashCode(): kotlin/Int // com.google.maps.android.clustering.algo/StaticCluster.hashCode|hashCode(){}[0] + open fun toString(): kotlin/String // com.google.maps.android.clustering.algo/StaticCluster.toString|toString(){}[0] +} + +open class com.google.maps.android.geometry/Point { // com.google.maps.android.geometry/Point|null[0] + constructor (kotlin/Double, kotlin/Double) // com.google.maps.android.geometry/Point.|(kotlin.Double;kotlin.Double){}[0] + + final val x // com.google.maps.android.geometry/Point.x|{}x[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Point.x.|(){}[0] + final val y // com.google.maps.android.geometry/Point.y|{}y[0] + final fun (): kotlin/Double // com.google.maps.android.geometry/Point.y.|(){}[0] + + final fun copy(kotlin/Double = ..., kotlin/Double = ...): com.google.maps.android.geometry/Point // com.google.maps.android.geometry/Point.copy|copy(kotlin.Double;kotlin.Double){}[0] + open fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.geometry/Point.equals|equals(kotlin.Any?){}[0] + open fun hashCode(): kotlin/Int // com.google.maps.android.geometry/Point.hashCode|hashCode(){}[0] + open fun toString(): kotlin/String // com.google.maps.android.geometry/Point.toString|toString(){}[0] +} + +final fun (com.google.maps.android.geometry/Point).com.google.maps.android.geometry/component1(): kotlin/Double // com.google.maps.android.geometry/component1|component1@com.google.maps.android.geometry.Point(){}[0] +final fun (com.google.maps.android.geometry/Point).com.google.maps.android.geometry/component2(): kotlin/Double // com.google.maps.android.geometry/component2|component2@com.google.maps.android.geometry.Point(){}[0] diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index c0a30741b..53a5d4c6a 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,78 +14,54 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { - - id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") + id("android.maps.utils.KmpPublishingConventionPlugin") } -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { +kotlin { + 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" - ) - } - } - resourcePrefix = "amu_" - - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } + minSdk = libs.versions.minimumSdk.get().toInt() - kotlin { - explicitApi() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true } - jvmToolchain(17) } - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + // androidx.collection is multiplatform; LongSparseArray/LruCache work in common code + implementation(libs.androidx.collection) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(project(":ui")) + implementation(project(":library")) + implementation(project(":data")) + api(libs.play.services.maps) + api(libs.kotlinx.coroutines.core) + 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.kotlin) + } } - namespace = "com.google.maps.android.clustering" } dependencies { - implementation(project(":ui")) - implementation(project(":library")) - implementation(project(":data")) - api(libs.play.services.maps) - api(libs.kotlinx.coroutines.core) - 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.kotlinx.coroutines.test) - testImplementation(libs.mockito.kotlin) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") } diff --git a/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt 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/PointExtensionsTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/PointExtensionsTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/geometry/PointExtensionsTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/PointExtensionsTest.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/ktx/utils/clustering/ClusterManagerTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/ktx/utils/geometry/PointTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/geometry/PointTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/ktx/utils/geometry/PointTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/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/main/java/com/google/maps/android/clustering/ClusterManagerFlows.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/ClusterManagerFlows.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/ClusterManagerFlows.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/ClusterManagerFlows.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/ktx/utils/clustering/ClusterManager.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt diff --git a/clustering/src/main/java/com/google/maps/android/ktx/utils/geometry/Point.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/ktx/utils/geometry/Point.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/ktx/utils/geometry/Point.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/ktx/utils/geometry/Point.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 244292ae3..44462c6a1 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 ae2fc307a..08c8def28 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 639a1ed80..e6b775061 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. */ public 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 ca66c4fc7..b0ccc2897 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 b3c40c551..d6c96de73 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 @@ public open class ContinuousZoomEuclideanCentroidAlgorithm : Ce 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 ad0c7f370..8f4f3e8d9 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 */ public open 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 @@ public open 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 @@ public open class GridBasedAlgorithm : AbstractAlgorithm() { } override val items: Collection - get() = mItems + get() = mItemsLock.withLock { mItems.toSet() } public 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 948100062..710b271a8 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 */ public 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: MutableMap> = 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 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr 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 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr } override fun clearItems() { - synchronized(mQuadTree) { + quadTreeLock.withLock { mItems.clear() mItemMap.clear() mQuadTree.clear() @@ -90,7 +94,7 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr 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 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr 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 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr 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 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr 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 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr override val items: Collection get() { val items = LinkedHashSet() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (quadItem in mItems) { items.add(quadItem.mClusterItem) } @@ -235,7 +239,7 @@ public open class NonHierarchicalDistanceBasedAlgorithm : Abstr 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 2ed7f1a66..d17d61518 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 193d55fbe..992c7da79 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 dd04abc70..42620c4e9 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 a10d87160..d8b40faf8 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 c342a58bd..0cc24f7de 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 69b3fd549..6a6b3d697 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 + public open class Point( @JvmField public val x: Double, @JvmField public val y: Double, @@ -23,7 +25,7 @@ public 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/geometry/PointExtensions.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/PointExtensions.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/geometry/PointExtensions.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/geometry/PointExtensions.kt 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 377dfd533..d234a800d 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.* public class SphericalMercatorProjection public constructor( @@ -23,7 +25,7 @@ public class SphericalMercatorProjection public constructor( ) { public 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 @@ public class SphericalMercatorProjection public constructor( 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 e20677379..e92015600 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 6759b159e..17ffde268 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,7 @@ serialization = "1.0.2.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/api/heatmaps.klib.api b/heatmaps/api/heatmaps.klib.api new file mode 100644 index 000000000..44db65bbe --- /dev/null +++ b/heatmaps/api/heatmaps.klib.api @@ -0,0 +1,49 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class com.google.maps.android.heatmaps/Gradient { // com.google.maps.android.heatmaps/Gradient|null[0] + constructor (kotlin/IntArray, kotlin/FloatArray, kotlin/Int = ...) // com.google.maps.android.heatmaps/Gradient.|(kotlin.IntArray;kotlin.FloatArray;kotlin.Int){}[0] + + final val colorMapSize // com.google.maps.android.heatmaps/Gradient.colorMapSize|{}colorMapSize[0] + final fun (): kotlin/Int // com.google.maps.android.heatmaps/Gradient.colorMapSize.|(){}[0] + final val colors // com.google.maps.android.heatmaps/Gradient.colors|{}colors[0] + final fun (): kotlin/IntArray // com.google.maps.android.heatmaps/Gradient.colors.|(){}[0] + final val startPoints // com.google.maps.android.heatmaps/Gradient.startPoints|{}startPoints[0] + final fun (): kotlin/FloatArray // com.google.maps.android.heatmaps/Gradient.startPoints.|(){}[0] + + final fun generateColorMap(kotlin/Double = ...): kotlin/IntArray // com.google.maps.android.heatmaps/Gradient.generateColorMap|generateColorMap(kotlin.Double){}[0] + + final object Companion { // com.google.maps.android.heatmaps/Gradient.Companion|null[0] + final fun interpolateColor(kotlin/Int, kotlin/Int, kotlin/Float): kotlin/Int // com.google.maps.android.heatmaps/Gradient.Companion.interpolateColor|interpolateColor(kotlin.Int;kotlin.Int;kotlin.Float){}[0] + } +} + +final class com.google.maps.android.heatmaps/WeightedLatLng : com.google.maps.android.quadtree/PointQuadTree.Item { // com.google.maps.android.heatmaps/WeightedLatLng|null[0] + constructor (com.google.maps.android.model/LatLng, com.google.maps.android.geometry/Point, kotlin/Double) // com.google.maps.android.heatmaps/WeightedLatLng.|(com.google.maps.android.model.LatLng;com.google.maps.android.geometry.Point;kotlin.Double){}[0] + constructor (com.google.maps.android.model/LatLng, kotlin/Double = ...) // com.google.maps.android.heatmaps/WeightedLatLng.|(com.google.maps.android.model.LatLng;kotlin.Double){}[0] + + final val intensity // com.google.maps.android.heatmaps/WeightedLatLng.intensity|{}intensity[0] + final fun (): kotlin/Double // com.google.maps.android.heatmaps/WeightedLatLng.intensity.|(){}[0] + final val latLng // com.google.maps.android.heatmaps/WeightedLatLng.latLng|{}latLng[0] + final fun (): com.google.maps.android.model/LatLng // com.google.maps.android.heatmaps/WeightedLatLng.latLng.|(){}[0] + final val point // com.google.maps.android.heatmaps/WeightedLatLng.point|{}point[0] + final fun (): com.google.maps.android.geometry/Point // com.google.maps.android.heatmaps/WeightedLatLng.point.|(){}[0] + + final fun component1(): com.google.maps.android.model/LatLng // com.google.maps.android.heatmaps/WeightedLatLng.component1|component1(){}[0] + final fun component2(): com.google.maps.android.geometry/Point // com.google.maps.android.heatmaps/WeightedLatLng.component2|component2(){}[0] + final fun component3(): kotlin/Double // com.google.maps.android.heatmaps/WeightedLatLng.component3|component3(){}[0] + final fun copy(com.google.maps.android.model/LatLng = ..., com.google.maps.android.geometry/Point = ..., kotlin/Double = ...): com.google.maps.android.heatmaps/WeightedLatLng // com.google.maps.android.heatmaps/WeightedLatLng.copy|copy(com.google.maps.android.model.LatLng;com.google.maps.android.geometry.Point;kotlin.Double){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.heatmaps/WeightedLatLng.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.heatmaps/WeightedLatLng.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.google.maps.android.heatmaps/WeightedLatLng.toString|toString(){}[0] + + final object Companion { // com.google.maps.android.heatmaps/WeightedLatLng.Companion|null[0] + final const val DEFAULT_INTENSITY // com.google.maps.android.heatmaps/WeightedLatLng.Companion.DEFAULT_INTENSITY|{}DEFAULT_INTENSITY[0] + final fun (): kotlin/Double // com.google.maps.android.heatmaps/WeightedLatLng.Companion.DEFAULT_INTENSITY.|(){}[0] + } +} diff --git a/heatmaps/build.gradle.kts b/heatmaps/build.gradle.kts index 7a928a85a..bf69d0c24 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,76 +14,51 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { - - id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") + id("android.maps.utils.KmpPublishingConventionPlugin") } -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { +kotlin { + 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" - ) - } - } - resourcePrefix = "amu_" - - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } + minSdk = libs.versions.minimumSdk.get().toInt() - kotlin { - explicitApi() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true } - jvmToolchain(17) } - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true + 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.kotlin.test) + implementation(libs.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockito.kotlin) + } } - 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.kotlinx.coroutines.test) - testImplementation(libs.mockito.kotlin) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") } 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/HeatmapExtensionsTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/HeatmapExtensionsTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapExtensionsTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/HeatmapExtensionsTest.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/test/java/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.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/Heatmap.kt b/heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/Heatmap.kt similarity index 100% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/Heatmap.kt rename to heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/Heatmap.kt 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 d3434a473..5ba4dc475 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 @@ -304,13 +304,13 @@ public class HeatmapTileProvider private constructor( public companion object { public const val DEFAULT_RADIUS: Int = 20 - public const val DEFAULT_OPACITY: Double = 0.7 + public const val DEFAULT_OPACITY: Double = 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 public val DEFAULT_GRADIENT: 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/main/java/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt b/heatmaps/src/androidMain/kotlin/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt similarity index 100% rename from heatmaps/src/main/java/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt rename to heatmaps/src/androidMain/kotlin/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt 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 136932451..4fe744064 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 @@ public 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 @@ public 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 - public fun generateColorMap(opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY): IntArray { + public fun generateColorMap(opacity: Double = HeatmapConstants.DEFAULT_OPACITY): IntArray { val colorIntervals = generateColorIntervals() val colorMap = IntArray(colorMapSize) var interval = colorIntervals[0] @@ -114,11 +114,11 @@ public 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 @@ public 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 @@ public 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..c2f591479 --- /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. + */ +internal object HeatmapConstants { + /** Default opacity of heatmap overlay. */ + internal 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 25831cf2e..fe91af57e 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 @@ public data class WeightedLatLng( public companion object { public const val DEFAULT_INTENSITY: Double = 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/heatmaps/src/test/resources/robolectric.properties b/heatmaps/src/test/resources/robolectric.properties deleted file mode 100644 index 89a6c8b4c..000000000 --- a/heatmaps/src/test/resources/robolectric.properties +++ /dev/null @@ -1 +0,0 @@ -sdk=28 \ No newline at end of file diff --git a/library/api/library.klib.api b/library/api/library.klib.api new file mode 100644 index 000000000..a6ac7adaf --- /dev/null +++ b/library/api/library.klib.api @@ -0,0 +1,56 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final object com.google.maps.android/MathUtil { // com.google.maps.android/MathUtil|null[0] + final const val EARTH_RADIUS // com.google.maps.android/MathUtil.EARTH_RADIUS|{}EARTH_RADIUS[0] + final fun (): kotlin/Double // com.google.maps.android/MathUtil.EARTH_RADIUS.|(){}[0] + + final fun arcHav(kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.arcHav|arcHav(kotlin.Double){}[0] + final fun clamp(kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.clamp|clamp(kotlin.Double;kotlin.Double;kotlin.Double){}[0] + final fun hav(kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.hav|hav(kotlin.Double){}[0] + final fun havDistance(kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.havDistance|havDistance(kotlin.Double;kotlin.Double;kotlin.Double){}[0] + final fun havFromSin(kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.havFromSin|havFromSin(kotlin.Double){}[0] + final fun inverseMercator(kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.inverseMercator|inverseMercator(kotlin.Double){}[0] + final fun mercator(kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.mercator|mercator(kotlin.Double){}[0] + final fun mod(kotlin/Double, kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.mod|mod(kotlin.Double;kotlin.Double){}[0] + final fun sinFromHav(kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.sinFromHav|sinFromHav(kotlin.Double){}[0] + final fun sinSumFromHav(kotlin/Double, kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.sinSumFromHav|sinSumFromHav(kotlin.Double;kotlin.Double){}[0] + final fun wrap(kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // com.google.maps.android/MathUtil.wrap|wrap(kotlin.Double;kotlin.Double;kotlin.Double){}[0] +} + +final object com.google.maps.android/PolyUtil { // com.google.maps.android/PolyUtil|null[0] + final const val DEFAULT_TOLERANCE // com.google.maps.android/PolyUtil.DEFAULT_TOLERANCE|{}DEFAULT_TOLERANCE[0] + final fun (): kotlin/Double // com.google.maps.android/PolyUtil.DEFAULT_TOLERANCE.|(){}[0] + + final fun containsLocation(com.google.maps.android.model/LatLng, kotlin.collections/List, kotlin/Boolean): kotlin/Boolean // com.google.maps.android/PolyUtil.containsLocation|containsLocation(com.google.maps.android.model.LatLng;kotlin.collections.List;kotlin.Boolean){}[0] + final fun containsLocation(kotlin/Double, kotlin/Double, kotlin.collections/List, kotlin/Boolean): kotlin/Boolean // com.google.maps.android/PolyUtil.containsLocation|containsLocation(kotlin.Double;kotlin.Double;kotlin.collections.List;kotlin.Boolean){}[0] + final fun decode(kotlin/String): kotlin.collections/List // com.google.maps.android/PolyUtil.decode|decode(kotlin.String){}[0] + final fun distanceToLine(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng): kotlin/Double // com.google.maps.android/PolyUtil.distanceToLine|distanceToLine(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng){}[0] + final fun encode(kotlin.collections/List): kotlin/String // com.google.maps.android/PolyUtil.encode|encode(kotlin.collections.List){}[0] + final fun isClosedPolygon(kotlin.collections/List): kotlin/Boolean // com.google.maps.android/PolyUtil.isClosedPolygon|isClosedPolygon(kotlin.collections.List){}[0] + final fun isLocationOnEdge(com.google.maps.android.model/LatLng, kotlin.collections/List, kotlin/Boolean, kotlin/Double = ...): kotlin/Boolean // com.google.maps.android/PolyUtil.isLocationOnEdge|isLocationOnEdge(com.google.maps.android.model.LatLng;kotlin.collections.List;kotlin.Boolean;kotlin.Double){}[0] + final fun isLocationOnPath(com.google.maps.android.model/LatLng, kotlin.collections/List, kotlin/Boolean, kotlin/Double = ...): kotlin/Boolean // com.google.maps.android/PolyUtil.isLocationOnPath|isLocationOnPath(com.google.maps.android.model.LatLng;kotlin.collections.List;kotlin.Boolean;kotlin.Double){}[0] + final fun locationIndexOnEdgeOrPath(com.google.maps.android.model/LatLng, kotlin.collections/List, kotlin/Boolean, kotlin/Boolean, kotlin/Double): kotlin/Int // com.google.maps.android/PolyUtil.locationIndexOnEdgeOrPath|locationIndexOnEdgeOrPath(com.google.maps.android.model.LatLng;kotlin.collections.List;kotlin.Boolean;kotlin.Boolean;kotlin.Double){}[0] + final fun locationIndexOnPath(com.google.maps.android.model/LatLng, kotlin.collections/List, kotlin/Boolean, kotlin/Double = ...): kotlin/Int // com.google.maps.android/PolyUtil.locationIndexOnPath|locationIndexOnPath(com.google.maps.android.model.LatLng;kotlin.collections.List;kotlin.Boolean;kotlin.Double){}[0] + final fun simplify(kotlin.collections/List, kotlin/Double): kotlin.collections/List // com.google.maps.android/PolyUtil.simplify|simplify(kotlin.collections.List;kotlin.Double){}[0] +} + +final object com.google.maps.android/SphericalUtil { // com.google.maps.android/SphericalUtil|null[0] + final fun computeAngleBetween(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng): kotlin/Double // com.google.maps.android/SphericalUtil.computeAngleBetween|computeAngleBetween(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng){}[0] + final fun computeArea(kotlin.collections/List): kotlin/Double // com.google.maps.android/SphericalUtil.computeArea|computeArea(kotlin.collections.List){}[0] + final fun computeDistanceBetween(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng): kotlin/Double // com.google.maps.android/SphericalUtil.computeDistanceBetween|computeDistanceBetween(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng){}[0] + final fun computeHeading(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng): kotlin/Double // com.google.maps.android/SphericalUtil.computeHeading|computeHeading(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng){}[0] + final fun computeLength(kotlin.collections/List): kotlin/Double // com.google.maps.android/SphericalUtil.computeLength|computeLength(kotlin.collections.List){}[0] + final fun computeOffset(com.google.maps.android.model/LatLng, kotlin/Double, kotlin/Double): com.google.maps.android.model/LatLng // com.google.maps.android/SphericalUtil.computeOffset|computeOffset(com.google.maps.android.model.LatLng;kotlin.Double;kotlin.Double){}[0] + final fun computeOffsetOrigin(com.google.maps.android.model/LatLng, kotlin/Double, kotlin/Double): com.google.maps.android.model/LatLng? // com.google.maps.android/SphericalUtil.computeOffsetOrigin|computeOffsetOrigin(com.google.maps.android.model.LatLng;kotlin.Double;kotlin.Double){}[0] + final fun computeSignedArea(kotlin.collections/List): kotlin/Double // com.google.maps.android/SphericalUtil.computeSignedArea|computeSignedArea(kotlin.collections.List){}[0] + final fun computeSignedArea(kotlin.collections/List, kotlin/Double): kotlin/Double // com.google.maps.android/SphericalUtil.computeSignedArea|computeSignedArea(kotlin.collections.List;kotlin.Double){}[0] + final fun getPointOnPolyline(kotlin.collections/List, kotlin/Double): com.google.maps.android.model/LatLng? // com.google.maps.android/SphericalUtil.getPointOnPolyline|getPointOnPolyline(kotlin.collections.List;kotlin.Double){}[0] + final fun getPolylinePrefix(kotlin.collections/List, kotlin/Double): kotlin.collections/List // com.google.maps.android/SphericalUtil.getPolylinePrefix|getPolylinePrefix(kotlin.collections.List;kotlin.Double){}[0] + final fun interpolate(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng, kotlin/Double): com.google.maps.android.model/LatLng // com.google.maps.android/SphericalUtil.interpolate|interpolate(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng;kotlin.Double){}[0] +} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index a6631a17b..6d6dbe579 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 * @@ -15,81 +13,9 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * See the License for the specific language governing permissions and * limitations under the License. */ -plugins { - 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 { - explicitApi() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true - } - namespace = "com.google.maps.android" -} - -dependencies { - api(libs.play.services.maps) - compileOnly(libs.play.services.location) - api(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - implementation(libs.startup.runtime) - lintPublish(project(":lint-checks")) - testImplementation(libs.play.services.location) - 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.kotlinx.coroutines.test) - testImplementation(libs.mockito.kotlin) -} -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") +plugins { + id("android.maps.utils.KmpPublishingConventionPlugin") } abstract class GenerateArtifactIdTask : DefaultTask() { @@ -128,13 +54,60 @@ val generateArtifactIdFile = tasks.register("generateArt version.set(project.version.toString()) } -androidComponents { - onVariants { variant -> - variant.sources.java?.addGeneratedSourceDirectory( - generateArtifactIdFile, - GenerateArtifactIdTask::outputDir - ) +kotlin { + androidLibrary { + namespace = "com.google.maps.android" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minimumSdk.get().toInt() + + optimization { + consumerKeepRules.publish = true + consumerKeepRules.file("consumer-rules.pro") + } + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } } + + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain { + kotlin.srcDir(generateArtifactIdFile) + } + androidMain.dependencies { + api(libs.play.services.maps) + compileOnly(libs.play.services.location) + api(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + implementation(libs.startup.runtime) + } + getByName("androidHostTest").dependencies { + implementation(libs.play.services.location) + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.kotlin.test) + implementation(libs.androidx.test.core) + implementation(libs.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockito.kotlin) + } + } +} + +dependencies { + lintPublish(project(":lint-checks")) } tasks.named("dokkaGeneratePublicationHtml") { diff --git a/library/src/test/java/com/google/maps/android/AdversarialResilienceTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/AdversarialResilienceTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/AdversarialResilienceTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/AdversarialResilienceTest.kt diff --git a/library/src/test/java/com/google/maps/android/FlexiblePolylineUtilTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/FlexiblePolylineUtilTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/FlexiblePolylineUtilTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/FlexiblePolylineUtilTest.kt diff --git a/library/src/test/java/com/google/maps/android/GoogleMapTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/GoogleMapTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/GoogleMapTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/GoogleMapTest.kt diff --git a/library/src/test/java/com/google/maps/android/LatLngTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/LatLngTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/LatLngTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/LatLngTest.kt diff --git a/library/src/test/java/com/google/maps/android/MapFragmentExtensionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/MapFragmentExtensionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/MapFragmentExtensionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/MapFragmentExtensionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/MapsInitializerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/MapsInitializerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/MapsInitializerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/MapsInitializerTest.kt diff --git a/library/src/test/java/com/google/maps/android/PolygonTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/PolygonTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/PolygonTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/PolygonTest.kt diff --git a/library/src/test/java/com/google/maps/android/PolylineTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/PolylineTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/PolylineTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/PolylineTest.kt 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/CollectionManagersTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/CollectionManagersTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/CollectionManagersTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/CollectionManagersTest.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/ktx/GoogleMapTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/GoogleMapTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/GoogleMapTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/GoogleMapTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/MapsInitializerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/MapsInitializerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/MapsInitializerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/MapsInitializerTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/CameraPositionTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/CameraPositionTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/CameraPositionTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/CameraPositionTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/CircleOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/CircleOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/CircleOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/CircleOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/MarkerOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/MarkerOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/MarkerOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/MarkerOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/PolygonOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/PolygonOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/PolygonOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/PolygonOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/PolylineOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/PolylineOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/PolylineOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/PolylineOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/LatLngTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/LatLngTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/utils/LatLngTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/LatLngTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/PolygonTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/PolygonTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/utils/PolygonTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/PolygonTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/PolylineTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/PolylineTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/utils/PolylineTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/PolylineTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/location/FusedLocationProviderTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/location/FusedLocationProviderTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt diff --git a/library/src/test/java/com/google/maps/android/location/LocationManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/location/LocationManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/CameraPositionTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/CameraPositionTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/CameraPositionTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/CameraPositionTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/CircleOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/CircleOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/CircleOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/CircleOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/GroundOverlayOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/GroundOverlayOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/GroundOverlayOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/GroundOverlayOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/MarkerOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/MarkerOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/MarkerOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/MarkerOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/PolygonOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/PolygonOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/PolygonOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/PolygonOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/PolylineOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/PolylineOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/PolylineOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/PolylineOptionsTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaOrientationTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/StreetViewPanoramaOrientationTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/StreetViewPanoramaOrientationTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/StreetViewPanoramaOrientationTest.kt diff --git a/library/src/test/java/com/google/maps/android/model/TileOverlayOptionsTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/model/TileOverlayOptionsTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/model/TileOverlayOptionsTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/model/TileOverlayOptionsTest.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/FlexiblePolyline.kt b/library/src/androidMain/kotlin/com/google/maps/android/FlexiblePolyline.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/FlexiblePolyline.kt rename to library/src/androidMain/kotlin/com/google/maps/android/FlexiblePolyline.kt diff --git a/library/src/main/java/com/google/maps/android/GoogleMap.kt b/library/src/androidMain/kotlin/com/google/maps/android/GoogleMap.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/GoogleMap.kt rename to library/src/androidMain/kotlin/com/google/maps/android/GoogleMap.kt diff --git a/library/src/main/java/com/google/maps/android/LatLng.kt b/library/src/androidMain/kotlin/com/google/maps/android/LatLng.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/LatLng.kt rename to library/src/androidMain/kotlin/com/google/maps/android/LatLng.kt diff --git a/library/src/main/java/com/google/maps/android/MapFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/MapFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/MapFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/MapFragment.kt diff --git a/library/src/main/java/com/google/maps/android/MapView.kt b/library/src/androidMain/kotlin/com/google/maps/android/MapView.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/MapView.kt rename to library/src/androidMain/kotlin/com/google/maps/android/MapView.kt diff --git a/library/src/main/java/com/google/maps/android/MapsExperimentalFeature.kt b/library/src/androidMain/kotlin/com/google/maps/android/MapsExperimentalFeature.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/MapsExperimentalFeature.kt rename to library/src/androidMain/kotlin/com/google/maps/android/MapsExperimentalFeature.kt diff --git a/library/src/main/java/com/google/maps/android/MapsInitializer.kt b/library/src/androidMain/kotlin/com/google/maps/android/MapsInitializer.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/MapsInitializer.kt rename to library/src/androidMain/kotlin/com/google/maps/android/MapsInitializer.kt diff --git a/library/src/main/java/com/google/maps/android/Polygon.kt b/library/src/androidMain/kotlin/com/google/maps/android/Polygon.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/Polygon.kt rename to library/src/androidMain/kotlin/com/google/maps/android/Polygon.kt diff --git a/library/src/main/java/com/google/maps/android/Polyline.kt b/library/src/androidMain/kotlin/com/google/maps/android/Polyline.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/Polyline.kt rename to library/src/androidMain/kotlin/com/google/maps/android/Polyline.kt 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/StreetViewPanoramaFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/StreetViewPanoramaFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/StreetViewPanoramaFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/StreetViewPanoramaFragment.kt diff --git a/library/src/main/java/com/google/maps/android/StreetViewPanoramaView.kt b/library/src/androidMain/kotlin/com/google/maps/android/StreetViewPanoramaView.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/StreetViewPanoramaView.kt rename to library/src/androidMain/kotlin/com/google/maps/android/StreetViewPanoramaView.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/SupportMapFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/SupportMapFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/SupportMapFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/SupportMapFragment.kt diff --git a/library/src/main/java/com/google/maps/android/SupportStreetViewPanoramaFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/SupportStreetViewPanoramaFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/SupportStreetViewPanoramaFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/SupportStreetViewPanoramaFragment.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/CircleManagerFlows.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/CircleManagerFlows.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/CircleManagerFlows.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/CircleManagerFlows.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/GroundOverlayManagerFlows.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/GroundOverlayManagerFlows.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/GroundOverlayManagerFlows.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/GroundOverlayManagerFlows.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/MarkerManagerFlows.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/MarkerManagerFlows.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/MarkerManagerFlows.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/MarkerManagerFlows.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/PolygonManagerFlows.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/PolygonManagerFlows.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/PolygonManagerFlows.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/PolygonManagerFlows.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/collections/PolylineManagerFlows.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/PolylineManagerFlows.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/PolylineManagerFlows.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/PolylineManagerFlows.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/GoogleMap.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/GoogleMap.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/GoogleMap.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/GoogleMap.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/MapFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/MapFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/MapFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/MapFragment.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/MapView.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/MapView.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/MapView.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/MapView.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/MapsExperimentalFeature.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/MapsExperimentalFeature.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/MapsExperimentalFeature.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/MapsExperimentalFeature.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/MapsInitializer.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/MapsInitializer.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/MapsInitializer.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/MapsInitializer.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaView.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/StreetViewPanoramaView.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaView.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/StreetViewPanoramaView.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/SupportMapFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/SupportMapFragment.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/CameraPosition.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/CameraPosition.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/CameraPosition.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/CameraPosition.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/CircleOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/CircleOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/CircleOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/CircleOptions.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/GroundOverlayOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/GroundOverlayOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/GroundOverlayOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/GroundOverlayOptions.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/MarkerOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/MarkerOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/MarkerOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/MarkerOptions.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/PolygonOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/PolygonOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/PolygonOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/PolygonOptions.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/PolylineOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/PolylineOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/PolylineOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/PolylineOptions.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/model/TileOverlayOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/model/TileOverlayOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/model/TileOverlayOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/model/TileOverlayOptions.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/LatLng.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/LatLng.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/LatLng.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/LatLng.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/Polygon.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/Polygon.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/Polygon.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/Polygon.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/Polyline.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/Polyline.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/Polyline.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/Polyline.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/CircleManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/CircleManager.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/MarkerManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/MarkerManager.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/PolygonManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/PolygonManager.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/PolylineManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/collection/PolylineManager.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/location/LocationManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/ktx/utils/location/LocationManager.kt diff --git a/library/src/main/java/com/google/maps/android/location/FusedLocationProvider.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/location/FusedLocationProvider.kt rename to library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt diff --git a/library/src/main/java/com/google/maps/android/location/LocationManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/location/LocationManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt diff --git a/library/src/main/java/com/google/maps/android/model/CameraPosition.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/CameraPosition.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/CameraPosition.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/CameraPosition.kt diff --git a/library/src/main/java/com/google/maps/android/model/CircleOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/CircleOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/CircleOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/CircleOptions.kt diff --git a/library/src/main/java/com/google/maps/android/model/GroundOverlayOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/GroundOverlayOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/GroundOverlayOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/GroundOverlayOptions.kt diff --git a/library/src/main/java/com/google/maps/android/model/MarkerOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/MarkerOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/MarkerOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/MarkerOptions.kt diff --git a/library/src/main/java/com/google/maps/android/model/PolygonOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/PolygonOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/PolygonOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/PolygonOptions.kt diff --git a/library/src/main/java/com/google/maps/android/model/PolylineOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/PolylineOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/PolylineOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/PolylineOptions.kt diff --git a/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaCamera.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/StreetViewPanoramaCamera.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/StreetViewPanoramaCamera.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/StreetViewPanoramaCamera.kt diff --git a/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaOrientation.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/StreetViewPanoramaOrientation.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/StreetViewPanoramaOrientation.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/StreetViewPanoramaOrientation.kt diff --git a/library/src/main/java/com/google/maps/android/model/TileOverlayOptions.kt b/library/src/androidMain/kotlin/com/google/maps/android/model/TileOverlayOptions.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/model/TileOverlayOptions.kt rename to library/src/androidMain/kotlin/com/google/maps/android/model/TileOverlayOptions.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 5566cdc07..8e2e05095 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 @@ public object MathUtil { */ @JvmStatic public 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 77f5be345..0b292d54f 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 2603605ab..399dac3a1 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 public object SphericalUtil { /** @@ -42,17 +45,17 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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 @@ public 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/library/src/test/resources/robolectric.properties b/library/src/test/resources/robolectric.properties deleted file mode 100644 index 89a6c8b4c..000000000 --- a/library/src/test/resources/robolectric.properties +++ /dev/null @@ -1 +0,0 @@ -sdk=28 \ No newline at end of file diff --git a/maps-model/api/maps-model.api b/maps-model/api/maps-model.api new file mode 100644 index 000000000..78c3644e2 --- /dev/null +++ b/maps-model/api/maps-model.api @@ -0,0 +1,9 @@ +public final class com/google/maps/android/model/Model_androidKt { + public static final fun getBearing (Lcom/google/android/gms/maps/model/CameraPosition;)F + public static final fun getLatitude (Lcom/google/android/gms/maps/model/LatLng;)D + public static final fun getLongitude (Lcom/google/android/gms/maps/model/LatLng;)D + public static final fun getTarget (Lcom/google/android/gms/maps/model/CameraPosition;)Lcom/google/android/gms/maps/model/LatLng; + public static final fun getTilt (Lcom/google/android/gms/maps/model/CameraPosition;)F + public static final fun getZoom (Lcom/google/android/gms/maps/model/CameraPosition;)F +} + diff --git a/maps-model/api/maps-model.klib.api b/maps-model/api/maps-model.klib.api new file mode 100644 index 000000000..b6733b8b5 --- /dev/null +++ b/maps-model/api/maps-model.klib.api @@ -0,0 +1,46 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class com.google.maps.android.model/CameraPosition { // com.google.maps.android.model/CameraPosition|null[0] + constructor (com.google.maps.android.model/LatLng, kotlin/Float, kotlin/Float, kotlin/Float) // com.google.maps.android.model/CameraPosition.|(com.google.maps.android.model.LatLng;kotlin.Float;kotlin.Float;kotlin.Float){}[0] + + final val bearing // com.google.maps.android.model/CameraPosition.bearing|{}bearing[0] + final fun (): kotlin/Float // com.google.maps.android.model/CameraPosition.bearing.|(){}[0] + final val target // com.google.maps.android.model/CameraPosition.target|{}target[0] + final fun (): com.google.maps.android.model/LatLng // com.google.maps.android.model/CameraPosition.target.|(){}[0] + final val tilt // com.google.maps.android.model/CameraPosition.tilt|{}tilt[0] + final fun (): kotlin/Float // com.google.maps.android.model/CameraPosition.tilt.|(){}[0] + final val zoom // com.google.maps.android.model/CameraPosition.zoom|{}zoom[0] + final fun (): kotlin/Float // com.google.maps.android.model/CameraPosition.zoom.|(){}[0] +} + +final class com.google.maps.android.model/LatLng { // com.google.maps.android.model/LatLng|null[0] + constructor (kotlin/Double, kotlin/Double) // com.google.maps.android.model/LatLng.|(kotlin.Double;kotlin.Double){}[0] + + final val latitude // com.google.maps.android.model/LatLng.latitude|{}latitude[0] + final fun (): kotlin/Double // com.google.maps.android.model/LatLng.latitude.|(){}[0] + final val longitude // com.google.maps.android.model/LatLng.longitude|{}longitude[0] + final fun (): kotlin/Double // com.google.maps.android.model/LatLng.longitude.|(){}[0] + + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.model/LatLng.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.model/LatLng.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.google.maps.android.model/LatLng.toString|toString(){}[0] +} + +final val com.google.maps.android.model/bearing // com.google.maps.android.model/bearing|@com.google.maps.android.model.CameraPosition{}bearing[0] + final fun (com.google.maps.android.model/CameraPosition).(): kotlin/Float // com.google.maps.android.model/bearing.|@com.google.maps.android.model.CameraPosition(){}[0] +final val com.google.maps.android.model/latitude // com.google.maps.android.model/latitude|@com.google.maps.android.model.LatLng{}latitude[0] + final fun (com.google.maps.android.model/LatLng).(): kotlin/Double // com.google.maps.android.model/latitude.|@com.google.maps.android.model.LatLng(){}[0] +final val com.google.maps.android.model/longitude // com.google.maps.android.model/longitude|@com.google.maps.android.model.LatLng{}longitude[0] + final fun (com.google.maps.android.model/LatLng).(): kotlin/Double // com.google.maps.android.model/longitude.|@com.google.maps.android.model.LatLng(){}[0] +final val com.google.maps.android.model/target // com.google.maps.android.model/target|@com.google.maps.android.model.CameraPosition{}target[0] + final fun (com.google.maps.android.model/CameraPosition).(): com.google.maps.android.model/LatLng // com.google.maps.android.model/target.|@com.google.maps.android.model.CameraPosition(){}[0] +final val com.google.maps.android.model/tilt // com.google.maps.android.model/tilt|@com.google.maps.android.model.CameraPosition{}tilt[0] + final fun (com.google.maps.android.model/CameraPosition).(): kotlin/Float // com.google.maps.android.model/tilt.|@com.google.maps.android.model.CameraPosition(){}[0] +final val com.google.maps.android.model/zoom // com.google.maps.android.model/zoom|@com.google.maps.android.model.CameraPosition{}zoom[0] + final fun (com.google.maps.android.model/CameraPosition).(): kotlin/Float // com.google.maps.android.model/zoom.|@com.google.maps.android.model.CameraPosition(){}[0] diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts new file mode 100644 index 000000000..e54944a08 --- /dev/null +++ b/maps-model/build.gradle.kts @@ -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. + */ +plugins { + id("android.maps.utils.KmpPublishingConventionPlugin") +} + +kotlin { + androidLibrary { + namespace = "com.google.maps.android.model" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minimumSdk.get().toInt() + } + + sourceSets { + androidMain.dependencies { + // The Android actuals are typealiases to the Play Services types, so + // this must be api(): consumers see GMS LatLng in our public API. + api(libs.play.services.maps) + } + } +} diff --git a/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt b/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt new file mode 100644 index 000000000..c4daf5f99 --- /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 + +public 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). +public actual val LatLng.latitude: Double get() = this.latitude + +public actual val LatLng.longitude: Double get() = this.longitude + +public actual typealias CameraPosition = com.google.android.gms.maps.model.CameraPosition + +public actual val CameraPosition.target: LatLng get() = this.target + +public actual val CameraPosition.zoom: Float get() = this.zoom + +public actual val CameraPosition.tilt: Float get() = this.tilt + +public 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..c92a72121 --- /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]. + */ +public expect class CameraPosition(target: LatLng, zoom: Float, tilt: Float, bearing: Float) + +public expect val CameraPosition.target: LatLng + +public expect val CameraPosition.zoom: Float + +public expect val CameraPosition.tilt: Float + +public 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..e1301db63 --- /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. + */ +public expect class LatLng(latitude: Double, longitude: Double) + +public expect val LatLng.latitude: Double + +public 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..9432cd111 --- /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. + */ +public actual class LatLng actual constructor(latitude: Double, longitude: Double) { + public val latitude: Double = latitude.coerceIn(-90.0, 90.0) + public 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)" +} + +public actual val LatLng.latitude: Double get() = this.latitude + +public actual val LatLng.longitude: Double get() = this.longitude + +public actual class CameraPosition actual constructor( + public val target: LatLng, + public val zoom: Float, + public val tilt: Float, + public val bearing: Float +) + +public actual val CameraPosition.target: LatLng get() = this.target + +public actual val CameraPosition.zoom: Float get() = this.zoom + +public actual val CameraPosition.tilt: Float get() = this.tilt + +public 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")