Skip to content
Open
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
72 changes: 72 additions & 0 deletions .github/workflows/multiplatform.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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.

# Builds and tests the iOS side of maps-compose-multiplatform and the iOS demo app. The Android
# side is covered by the other workflows; iOS can only be built on macOS.
name: Multiplatform (iOS)

on:
pull_request:
branches-ignore: ['gh-pages']
workflow_dispatch:

permissions:
contents: read

jobs:
ios:
runs-on: macos-latest
timeout-minutes: 60
steps:
- name: Checkout Repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up JDK 21
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0

# Compiles both iOS targets, runs the iOS simulator tests, checks the public API of every
# target against api/, and links the framework the iOS app embeds.
- name: Build and test the iOS targets
run: >-
./gradlew --stacktrace
:maps-compose-multiplatform:iosSimulatorArm64Test
:maps-compose-multiplatform:checkKotlinAbi
:maps-compose-multiplatform:linkPodDebugFrameworkIosSimulatorArm64
:maps-compose-multiplatform:linkPodDebugFrameworkIosArm64
:maps-compose-multiplatform-demo:compileKotlinIosArm64

# The app reads its key from secrets.properties at build time and falls back to a
# placeholder, which is enough to compile.
- name: Build the iOS demo app
working-directory: iosApp
run: |
(cd .. && ./gradlew :maps-compose-multiplatform-demo:generateDummyFramework)
ruby create_project.rb
pod install
xcodebuild build \
-workspace iosApp.xcworkspace \
-scheme iosApp \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-configuration Debug \
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO
13 changes: 7 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ permissions:

jobs:
publish:
runs-on: ubuntu-latest
# maps-compose-multiplatform publishes 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 @@ -45,12 +46,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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ secrets.properties

# This covers new IDEs, like Antigravity
.vscode/
.antigravitycli/
build-logic/**/bin/
32 changes: 32 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ libraries live in three modules; the rest of the repo supports them.
| `maps-compose` | Core library: `GoogleMap` composable, camera state, markers, shapes |
| `maps-compose-utils` | Utilities layer: clustering and other android-maps-utils integrations |
| `maps-compose-widgets` | Widget composables built on top of the core library |
| `maps-compose-multiplatform` | Experimental Compose Multiplatform map for Android and iOS, see below |
| `maps-compose-multiplatform-demo` | Demo screens shared by `maps-app` and `iosApp`; not published |
| `maps-app` | Demo app exercising the libraries |
| `iosApp` | iOS demo app for `maps-compose-multiplatform`, see `iosApp/README.md` |
| `docs` | Dokka documentation aggregation |

Shared Gradle conventions are in `build-logic/` (included build).
Expand All @@ -28,6 +31,35 @@ Shared Gradle conventions are in `build-logic/` (included build).
./gradlew :maps-app:validateDebugScreenshotTest # validate screenshot tests
```

### Kotlin Multiplatform (experimental)

`maps-compose-multiplatform` targets Android (wrapping `maps-compose`) and iOS (wrapping the
Google Maps SDK for iOS through CocoaPods). Its iOS targets only build on macOS, which is why
`.github/workflows/multiplatform.yml` and the publish workflow run on macOS runners.

```bash
./gradlew :maps-compose-multiplatform:allTests # commonTest on the JVM and the iOS simulator
./gradlew :maps-compose-multiplatform:checkKotlinAbi # public API of every target against api/
./gradlew :maps-compose-multiplatform:updateKotlinAbi # after an intended public API change
```

- Shared code goes in `commonMain`; only platform glue goes in `androidMain` and `iosMain`.
Tests go in `commonTest` so they run on both platforms.
- The public API is checked for each target: the iOS klibs in `api/*.klib.api` and the Android
target in `api/maps-compose-multiplatform.api`. Run `updateKotlinAbi` and commit the result
with any intended API change.
- Clustering and the shared `LatLng` come from the multiplatform modules of
android-maps-utils (`mapsutilsKmp` in the version catalog). While that is a `-SNAPSHOT`,
publishing a release of this module fails on purpose.
- There is no `iosX64` target: Compose Multiplatform no longer publishes it.
- Map content (`Marker`, `Polyline`, ...) does not emit UI. Each call registers a node in the
map's `MapNodeRegistry` while it is in the composition; `androidMain` draws the nodes with
maps-compose, `iosMain` keeps one `GMSOverlay` per node and updates it in place. A new kind of
content needs a node class in `MapContent.kt` and a case on both platforms.
- Demos go in `maps-compose-multiplatform-demo` so both apps show them. Open one directly with
`adb shell am start -n com.google.maps.android.compose/.KmpMapActivity --ei demo <index>` or
`xcrun simctl launch booted com.google.maps.android.compose.iosApp -demo <index>`.

Coverage is tracked over time. After every merge to `main`, the
`Record coverage history` workflow appends a row per module per suite to
`history.csv` and regenerates `COVERAGE.md`, both of which live on the
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,54 @@ Box(Modifier.fillMaxSize()) {

The colors of the text, line, and shadow are also all configurable (e.g., based on `isSystemInDarkTheme()` on a dark map). Similarly, the `DisappearingScaleBar` animations can be configured.

## Kotlin Multiplatform (experimental)

`maps-compose-multiplatform` brings the same `GoogleMap` composable to Compose Multiplatform apps
that target Android and iOS. On Android it is built on `maps-compose`; on iOS it shows the
[Google Maps SDK for iOS](https://developers.google.com/maps/documentation/ios-sdk). Its API
follows `maps-compose`, so code moves between the two with few changes.

It is experimental: the API may change in any release, and it covers a subset of `maps-compose`:

| Supported | Not yet |
| --- | --- |
| `GoogleMap` with `CameraPositionState`, `MapProperties` and `MapUiSettings` | Custom marker icons and info window content |
| Map clicks, long clicks and `onMapLoaded` | Ground and tile overlays, heatmaps |
| `Marker` (draggable, tappable, tinted pin), `Polyline`, `Polygon` with holes, `Circle` | Street View |
| `Clustering` that reclusters as the camera zooms | `MapEffect` and direct access to the native map |

```kotlin
// commonMain
val sydney = LatLng(-33.8688, 151.2093)
val cameraPositionState = rememberCameraPositionState {
position = cameraPosition(sydney, zoom = 12f)
}
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
onMapClick = { println("Tapped $it") },
) {
Marker(state = rememberMarkerState(sydney), title = "Sydney")
Circle(center = sydney, radius = 1_000.0, strokeColor = Color.Red)
Clustering(items = places)
}
```

`LatLng` and `CameraPosition` come from `com.google.maps.android.model`, shared with the multiplatform
modules of the [Maps SDK for Android Utility Library](https://github.com/googlemaps/android-maps-utils).
On Android they are the Maps SDK's own classes. Line widths are in density-independent pixels, so
shapes look the same on both platforms.

On iOS, add the `GoogleMaps` CocoaPod (10.14.0 or later, iOS 16 or later) and provide your API
key before showing a map:

```swift
GMSServices.provideAPIKey("YOUR_API_KEY")
```

The [`iosApp`](iosApp/README.md) and the "Multiplatform demos" entry of the Android sample app run
the same demos, from [`maps-compose-multiplatform-demo`](maps-compose-multiplatform-demo).

## Internal usage attribution ID

This library calls the `addInternalUsageAttributionId` method, which helps Google understand which libraries and samples are helpful to developers and is optional. Instructions for opting out of the identifier are provided below.
Expand Down
5 changes: 5 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
implementation(libs.dokka.plugin)
implementation(libs.kover.gradle.plugin)
implementation(libs.gradle.maven.publish.plugin)
implementation(libs.binary.compatibility.validator)

}

Expand All @@ -40,5 +41,9 @@ gradlePlugin {
id = "android.maps.compose.PublishingConventionPlugin"
implementationClass = "PublishingConventionPlugin"
}
register("kmpPublishingConventionPlugin") {
id = "android.maps.compose.KmpPublishingConventionPlugin"
implementationClass = "KmpPublishingConventionPlugin"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* 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.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation

/**
* Conventions for the Kotlin Multiplatform libraries in this repository: Android and iOS targets,
* explicit API mode, ABI validation of every target, lint, coverage and Maven Central publishing.
*
* iOS klibs can only be built on macOS, so publishing a module that applies this plugin needs a
* macOS runner.
*/
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()
// No iosX64 (Intel simulators): Compose Multiplatform dropped it in 1.11.
iosArm64()
iosSimulatorArm64()

// Records the public API of the iOS klibs in api/. The Android target is not
// supported yet, see configureAndroidApiValidation. Run updateKotlinAbi after an
// intended API change; checkKotlinAbi runs with check.
@OptIn(ExperimentalAbiValidation::class)
abiValidation()
}

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(),
)
)
configureMapsComposePublishing(
project,
pomDescription = "Compose Multiplatform components for the Maps SDKs for Android and iOS",
)
}

configureAndroidApiValidation()
}
}

/**
* Kotlin's ABI validation does not cover the Android target of a multiplatform library, so
* dump its public API from the compiled classes jar with the binary-compatibility-validator
* tasks, into api/<name>.api, and hook them into updateKotlinAbi and checkKotlinAbi.
*/
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)
dependsOn(bundleTask)
}

// Writes only its own file: a Copy into api/ would claim the directory that the klib
// dump also writes to.
val androidApiDump = tasks.register("androidApiDump") {
group = "verification"
description = "Syncs the public Android API declaration of $projectName to api/$projectName.api."
val generated = androidApiBuild.flatMap { it.outputApiFile }
inputs.file(generated)
outputs.file(apiFile)
doLast {
generated.get().asFile.copyTo(apiFile.asFile, overwrite = true)
}
}

val androidApiCheck = tasks.register<KotlinApiCompareTask>("androidApiCheck") {
group = "verification"
description = "Checks the public Android API of $projectName against api/$projectName.api."
projectApiFile.set(apiFile)
generatedApiFile.set(androidApiBuild.flatMap { it.outputApiFile })
dependsOn(androidApiBuild)
mustRunAfter(androidApiDump)
}

tasks.named("updateKotlinAbi") { dependsOn(androidApiDump) }
tasks.named("checkKotlinAbi") { dependsOn(androidApiCheck) }
}
}
}
Loading
Loading