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
67 changes: 63 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ upgrading from 4.x, see the [migration guide](MIGRATION.md).
- **Spherical geometry** — for example: computeDistance, computeHeading,
computeArea
- **Street View metadata** — checks if a Street View panorama exists at a given location
- **Reactive Kotlin Extensions & Builders** — coroutine suspensions (`awaitMap()`), reactive `Flow` observers (`mapClickEvents()`), and option builder DSLs (`addMarker { ... }`) consolidated directly into `com.google.maps.android.*`.

You can also find Kotlin extensions for this library in [Maps Android KTX][android-maps-ktx].
> [!IMPORTANT]
> **KTX Consolidation Notice (`v6.0.0+`)**: All Kotlin extensions (`maps-ktx` and `maps-utils-ktx` from `android-maps-ktx`) are now built directly into `android-maps-utils` under the canonical `com.google.maps.android.*` packages. Separate dependencies on `android-maps-ktx` or `maps-utils-ktx` are no longer needed and should be removed. Legacy calls to `com.google.maps.android.ktx.*` packages remain supported via `@Deprecated(level = DeprecationLevel.WARNING)` bridges that forward directly to canonical implementations.

<p align="center"><img width="90%" vspace="20" src="https://cloud.githubusercontent.com/assets/1950036/6629704/f57bc6d8-c908-11e4-815a-0d909fe02f99.gif"></p>

Expand All @@ -46,9 +48,7 @@ You can also find Kotlin extensions for this library in [Maps Android KTX][andro

```kotlin
dependencies {
// Utilities for Maps SDK for Android (requires Google Play Services)
// You do not need to add a separate dependency for the Maps SDK for Android
// since this library builds in the compatible version of the Maps SDK.
// Utilities and consolidated Kotlin Extensions for Maps SDK for Android
// The aggregator artifact transitively pulls in all submodules below.
implementation("com.google.maps.android:android-maps-utils:5.2.0") // x-release-please-version

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one still says 5.2.0 while every other marker got bumped to rc04. Another reason to let release-please handle all of them rather than bumping by hand.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85cb2fbf — reverted README.md to match origin/main so release-please updates all version markers together.

}
Expand Down Expand Up @@ -167,6 +167,65 @@ Full guides for using the utilities are published in

</details>

<details>
<summary>Reactive Kotlin Extensions & Builders (Consolidated in v6.0.0)</summary>

### Reactive Kotlin Extensions & Builders

All Kotlin extensions formerly provided by `android-maps-ktx` (`maps-ktx` and `maps-utils-ktx`) are now integrated into `android-maps-utils` (`v6.0.0+`) under canonical packages (`com.google.maps.android.*`, `com.google.maps.android.clustering.*`, etc.).

#### 1. Coroutine Suspensions (`awaitMapsSdkInitialized()`, `awaitMap()`, `awaitAnimateCamera()`)
```kotlin
import com.google.android.gms.maps.MapsInitializer
import com.google.maps.android.awaitMapsSdkInitialized
import com.google.maps.android.awaitMap
import com.google.maps.android.awaitAnimateCamera

// Suspend until Maps SDK is initialized
val renderer: MapsInitializer.Renderer = context.awaitMapsSdkInitialized(MapsInitializer.Renderer.LATEST)

// Suspend until GoogleMap is ready on MapView / MapFragment
val googleMap: GoogleMap = mapView.awaitMap()

// Suspend until camera animation completes
googleMap.awaitAnimateCamera(CameraUpdateFactory.newLatLngZoom(sydney, 12f))
```

#### 2. Option Builders DSL (`addMarker`, `addPolyline`, `addPolygon`)
```kotlin
import com.google.maps.android.addMarker
import com.google.maps.android.addCircle

googleMap.addMarker {
position(LatLng(-33.852, 151.211))
title("Sydney Opera House")
}

googleMap.addCircle {
center(LatLng(-33.870, 151.200))
radius(500.0)
strokeWidth(2f)
}
```

#### 3. Reactive `Flow` Observers (`mapClickEvents`, `cameraMoveEvents`)
```kotlin
import com.google.maps.android.mapClickEvents

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
googleMap.mapClickEvents().collect { latLng ->
Log.d("MapClick", "Clicked: $latLng")
}
}
}
```

#### Backward Compatibility & Deprecation
Existing references to `com.google.maps.android.ktx.*` continue to work through `@Deprecated(level = DeprecationLevel.WARNING)` forwarding wrappers. You can safely migrate your code incrementally to `com.google.maps.android.*`.

</details>

<details>
<summary>Street View metadata utility</summary>

Expand Down
26 changes: 26 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
* limitations under the License.
*/

import java.nio.file.Files
import java.util.Properties

plugins {
id("com.vanniktech.maven.publish") version libs.versions.gradleMavenPublishPlugin.get() apply false
}
Expand Down Expand Up @@ -59,4 +62,27 @@ allprojects {
}
}
}

tasks.withType<Test>().configureEach {
val testHome = rootProject.layout.buildDirectory.dir("test-home").get().asFile
testHome.mkdirs()
val m2Link = File(testHome, ".m2")
if (!m2Link.exists()) {
val realM2 = File(System.getProperty("user.home"), ".m2")
if (realM2.exists()) {
try {
Files.createSymbolicLink(m2Link.toPath(), realM2.toPath())
} catch (_: Exception) {}
}
}
systemProperty("user.home", testHome.absolutePath)
val androidSdkDir = System.getenv("ANDROID_HOME")
?: System.getenv("ANDROID_SDK_ROOT")
?: rootProject.file("local.properties").takeIf { it.isFile }?.let { localPropsFile ->
Properties().apply { localPropsFile.inputStream().use(::load) }.getProperty("sdk.dir")
}
if (androidSdkDir != null) {
environment("ANDROID_HOME", androidSdkDir)
}
}
}
6 changes: 2 additions & 4 deletions clustering/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ dependencies {
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)
Expand All @@ -78,11 +79,8 @@ dependencies {
testImplementation(libs.kotlin.test)
testImplementation(libs.truth)
implementation(libs.kotlin.stdlib.jdk8)

testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
}

tasks.register("instrumentTest") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* 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.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.ClusterManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow

/**
* Returns a flow that emits when a cluster is clicked. Using this to observe cluster clicks
* will override an existing listener (if any) to [ClusterManager.setOnClusterClickListener].
*
* **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing
* multiple collectors will result in listener hijacking, and cancelling any observer will unregister
* the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn])
* for multi-observer configurations.
*
* **Note on event consumption**: The underlying SDK listener returns the result of `trySend().isSuccess`.
* When an emission is accepted by the flow buffer, the click event is considered consumed (`true`),
* suppressing default SDK behavior (such as zooming). Under backpressure if the buffer is full,
* `trySend` returns `false`, allowing default SDK click handling to proceed.
*/
public fun <T : ClusterItem> ClusterManager<T>.clusterClickEvents(): Flow<Cluster<T>> =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These six functions are public and return Flow<...>, but clustering/build.gradle.kts still has implementation(libs.kotlinx.coroutines.android). The api(coroutines) you added to :library doesn't help either, since clustering depends on it with implementation.

So the published POM for android-maps-utils-clustering puts coroutines at runtime scope, and an app calling clusterClickEvents() gets "cannot access class kotlinx.coroutines.flow.Flow". Our unit tests won't catch it because they compile inside the module.

Can you move it to api(libs.kotlinx.coroutines.core) and check the other modules while you're there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85cb2fbf — changed kotlinx-coroutines-core to api(libs.kotlinx.coroutines.core) and kept kotlinx-coroutines-android as implementation(libs.kotlinx.coroutines.android) in clustering/build.gradle.kts.

callbackFlow {
setOnClusterClickListener {
trySend(it).isSuccess
Comment thread
dkhawk marked this conversation as resolved.
}
awaitClose {
setOnClusterClickListener(null)
}
}

/**
* Returns a flow that emits when a cluster item is clicked. Using this to observe cluster item clicks
* will override an existing listener (if any) to [ClusterManager.setOnClusterItemClickListener].
*
* **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing
* multiple collectors will result in listener hijacking, and cancelling any observer will unregister
* the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn])
* for multi-observer configurations.
*
* **Note on event consumption**: The underlying SDK listener returns the result of `trySend().isSuccess`.
* When an emission is accepted by the flow buffer, the click event is considered consumed (`true`),
* suppressing default SDK behavior. Under backpressure if the buffer is full, `trySend` returns `false`,
* allowing default SDK click handling to proceed.
*/
public fun <T : ClusterItem> ClusterManager<T>.clusterItemClickEvents(): Flow<T> =
callbackFlow {
setOnClusterItemClickListener {
trySend(it).isSuccess
}
awaitClose {
setOnClusterItemClickListener(null)
}
}

/**
* Returns a flow that emits when a cluster's info window is clicked. Using this to observe cluster info window clicks
* will override an existing listener (if any) to [ClusterManager.setOnClusterInfoWindowClickListener].
*
* **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing
* multiple collectors will result in listener hijacking, and cancelling any observer will unregister
* the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn])
* for multi-observer configurations.
*/
public fun <T : ClusterItem> ClusterManager<T>.clusterInfoWindowClickEvents(): Flow<Cluster<T>> =
callbackFlow {
setOnClusterInfoWindowClickListener {
trySend(it).isSuccess
}
awaitClose {
setOnClusterInfoWindowClickListener(null)
}
}

/**
* Returns a flow that emits when a cluster's info window is long clicked. Using this to observe cluster info window long clicks
* will override an existing listener (if any) to [ClusterManager.setOnClusterInfoWindowLongClickListener].
*
* **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing
* multiple collectors will result in listener hijacking, and cancelling any observer will unregister
* the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn])
* for multi-observer configurations.
*/
public fun <T : ClusterItem> ClusterManager<T>.clusterInfoWindowLongClickEvents(): Flow<Cluster<T>> =
callbackFlow {
setOnClusterInfoWindowLongClickListener {
trySend(it).isSuccess
}
awaitClose {
setOnClusterInfoWindowLongClickListener(null)
}
}

/**
* Returns a flow that emits when a cluster item's info window is clicked. Using this to observe cluster item info window clicks
* will override an existing listener (if any) to [ClusterManager.setOnClusterItemInfoWindowClickListener].
*
* **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing
* multiple collectors will result in listener hijacking, and cancelling any observer will unregister
* the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn])
* for multi-observer configurations.
*/
public fun <T : ClusterItem> ClusterManager<T>.clusterItemInfoWindowClickEvents(): Flow<T> =
callbackFlow {
setOnClusterItemInfoWindowClickListener {
trySend(it).isSuccess
}
awaitClose {
setOnClusterItemInfoWindowClickListener(null)
}
}

/**
* Returns a flow that emits when a cluster item's info window is long clicked. Using this to observe cluster item info window long clicks
* will override an existing listener (if any) to [ClusterManager.setOnClusterItemInfoWindowLongClickListener].
*
* **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing
* multiple collectors will result in listener hijacking, and cancelling any observer will unregister
* the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn])
* for multi-observer configurations.
*/
public fun <T : ClusterItem> ClusterManager<T>.clusterItemInfoWindowLongClickEvents(): Flow<T> =
callbackFlow {
setOnClusterItemInfoWindowLongClickListener {
trySend(it).isSuccess
}
awaitClose {
setOnClusterItemInfoWindowLongClickListener(null)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.geometry

import com.google.maps.android.geometry.Point

/**
* Returns the x value of this Point.
*
* e.g.
*
* ```
* val (x, _) = point
* ```
*/
public operator fun Point.component1(): Double = this.x

/**
* Returns the y value of this Point.
*
* e.g.
*
* ```
* val (_, y) = point
*/
public operator fun Point.component2(): Double = this.y
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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.ktx.utils.clustering

import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.ClusterManager
import kotlinx.coroutines.flow.Flow
import com.google.maps.android.clustering.clusterClickEvents as canonicalClusterClickEvents
import com.google.maps.android.clustering.clusterItemClickEvents as canonicalClusterItemClickEvents
import com.google.maps.android.clustering.clusterInfoWindowClickEvents as canonicalClusterInfoWindowClickEvents
import com.google.maps.android.clustering.clusterInfoWindowLongClickEvents as canonicalClusterInfoWindowLongClickEvents
import com.google.maps.android.clustering.clusterItemInfoWindowClickEvents as canonicalClusterItemInfoWindowClickEvents
import com.google.maps.android.clustering.clusterItemInfoWindowLongClickEvents as canonicalClusterItemInfoWindowLongClickEvents

@Deprecated("Moved to com.google.maps.android.clustering.clusterClickEvents", ReplaceWith("clusterClickEvents()", "com.google.maps.android.clustering.clusterClickEvents"))
public fun <T : ClusterItem> ClusterManager<T>.clusterClickEvents(): Flow<Cluster<T>> = this.canonicalClusterClickEvents()

@Deprecated("Moved to com.google.maps.android.clustering.clusterItemClickEvents", ReplaceWith("clusterItemClickEvents()", "com.google.maps.android.clustering.clusterItemClickEvents"))
public fun <T : ClusterItem> ClusterManager<T>.clusterItemClickEvents(): Flow<T> = this.canonicalClusterItemClickEvents()

@Deprecated("Moved to com.google.maps.android.clustering.clusterInfoWindowClickEvents", ReplaceWith("clusterInfoWindowClickEvents()", "com.google.maps.android.clustering.clusterInfoWindowClickEvents"))
public fun <T : ClusterItem> ClusterManager<T>.clusterInfoWindowClickEvents(): Flow<Cluster<T>> = this.canonicalClusterInfoWindowClickEvents()

@Deprecated("Moved to com.google.maps.android.clustering.clusterInfoWindowLongClickEvents", ReplaceWith("clusterInfoWindowLongClickEvents()", "com.google.maps.android.clustering.clusterInfoWindowLongClickEvents"))
public fun <T : ClusterItem> ClusterManager<T>.clusterInfoWindowLongClickEvents(): Flow<Cluster<T>> = this.canonicalClusterInfoWindowLongClickEvents()

@Deprecated("Moved to com.google.maps.android.clustering.clusterItemInfoWindowClickEvents", ReplaceWith("clusterItemInfoWindowClickEvents()", "com.google.maps.android.clustering.clusterItemInfoWindowClickEvents"))
public fun <T : ClusterItem> ClusterManager<T>.clusterItemInfoWindowClickEvents(): Flow<T> = this.canonicalClusterItemInfoWindowClickEvents()

@Deprecated("Moved to com.google.maps.android.clustering.clusterItemInfoWindowLongClickEvents", ReplaceWith("clusterItemInfoWindowLongClickEvents()", "com.google.maps.android.clustering.clusterItemInfoWindowLongClickEvents"))
public fun <T : ClusterItem> ClusterManager<T>.clusterItemInfoWindowLongClickEvents(): Flow<T> = this.canonicalClusterItemInfoWindowLongClickEvents()
Loading
Loading