Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/publish-snapshot.yml
Original file line number Diff line number Diff line change
@@ -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
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,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/<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)
}
}
}
Loading
Loading