Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish-snapshot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,10 @@ jobs:
SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }}
SONATYPE_TOKEN_USERNAME: ${{ secrets.SONATYPE_TOKEN }}

# Branch CI runs the full test suite; this job only guards against publishing a
# snapshot whose API drifted from the committed api/*.api dumps.
- name: Check API compatibility
run: ./gradlew apiCheck --stacktrace

- name: Publish snapshot
run: ./gradlew publishToMavenCentral --stacktrace
Comment thread
kikoso marked this conversation as resolved.
13 changes: 7 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
22 changes: 21 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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/<module>.api` and the iOS ABI against `api/<module>.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)
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<img src="https://developers.google.com/maps/documentation/android-sdk/images/utility-markercluster.png" width="150" align=right>
Expand Down
4 changes: 4 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* 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/<module>.klib.api`). The Android API is checked here against the same
* `api/<module>.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<Project> {
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<KotlinMultiplatformExtension> {
explicitApi()
jvmToolchain(17)
iosArm64()
iosSimulatorArm64()
iosX64()
}

configure<Lint> {
sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile
}

configure<KoverProjectExtension> {
// 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<MavenPublishBaseExtension> {
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<KotlinApiBuildTask>("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<Copy>("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<KotlinApiCompareTask>("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)
}
}
}
69 changes: 69 additions & 0 deletions build-logic/convention/src/main/kotlin/MapsUtilsPublishing.kt
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,50 +66,7 @@ class PublishingConventionPlugin : Plugin<Project> {
),
)

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)
}
}

Expand Down
Loading
Loading