diff --git a/README.md b/README.md index 5b22a01ea..7334026f4 100644 --- a/README.md +++ b/README.md @@ -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.

@@ -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 } @@ -167,6 +167,65 @@ Full guides for using the utilities are published in +
+ Reactive Kotlin Extensions & Builders (Consolidated in v6.0.0) + +### 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.*`. + +
+
Street View metadata utility diff --git a/build.gradle.kts b/build.gradle.kts index 6f3aa73a3..feb04cf54 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } @@ -59,4 +62,27 @@ allprojects { } } } + + tasks.withType().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) + } + } } diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index a17bcc2f1..13582eba6 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -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) @@ -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") { diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManagerFlows.kt b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManagerFlows.kt new file mode 100644 index 000000000..51fd21d4e --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManagerFlows.kt @@ -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 ClusterManager.clusterClickEvents(): Flow> = + callbackFlow { + setOnClusterClickListener { + trySend(it).isSuccess + } + 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 ClusterManager.clusterItemClickEvents(): Flow = + 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 ClusterManager.clusterInfoWindowClickEvents(): Flow> = + 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 ClusterManager.clusterInfoWindowLongClickEvents(): Flow> = + 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 ClusterManager.clusterItemInfoWindowClickEvents(): Flow = + 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 ClusterManager.clusterItemInfoWindowLongClickEvents(): Flow = + callbackFlow { + setOnClusterItemInfoWindowLongClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterItemInfoWindowLongClickListener(null) + } + } diff --git a/clustering/src/main/java/com/google/maps/android/geometry/PointExtensions.kt b/clustering/src/main/java/com/google/maps/android/geometry/PointExtensions.kt new file mode 100644 index 000000000..211f253fa --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/geometry/PointExtensions.kt @@ -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 diff --git a/clustering/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt b/clustering/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt new file mode 100644 index 000000000..ac6d8fd63 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt @@ -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 ClusterManager.clusterClickEvents(): Flow> = this.canonicalClusterClickEvents() + +@Deprecated("Moved to com.google.maps.android.clustering.clusterItemClickEvents", ReplaceWith("clusterItemClickEvents()", "com.google.maps.android.clustering.clusterItemClickEvents")) +public fun ClusterManager.clusterItemClickEvents(): Flow = this.canonicalClusterItemClickEvents() + +@Deprecated("Moved to com.google.maps.android.clustering.clusterInfoWindowClickEvents", ReplaceWith("clusterInfoWindowClickEvents()", "com.google.maps.android.clustering.clusterInfoWindowClickEvents")) +public fun ClusterManager.clusterInfoWindowClickEvents(): Flow> = this.canonicalClusterInfoWindowClickEvents() + +@Deprecated("Moved to com.google.maps.android.clustering.clusterInfoWindowLongClickEvents", ReplaceWith("clusterInfoWindowLongClickEvents()", "com.google.maps.android.clustering.clusterInfoWindowLongClickEvents")) +public fun ClusterManager.clusterInfoWindowLongClickEvents(): Flow> = this.canonicalClusterInfoWindowLongClickEvents() + +@Deprecated("Moved to com.google.maps.android.clustering.clusterItemInfoWindowClickEvents", ReplaceWith("clusterItemInfoWindowClickEvents()", "com.google.maps.android.clustering.clusterItemInfoWindowClickEvents")) +public fun ClusterManager.clusterItemInfoWindowClickEvents(): Flow = this.canonicalClusterItemInfoWindowClickEvents() + +@Deprecated("Moved to com.google.maps.android.clustering.clusterItemInfoWindowLongClickEvents", ReplaceWith("clusterItemInfoWindowLongClickEvents()", "com.google.maps.android.clustering.clusterItemInfoWindowLongClickEvents")) +public fun ClusterManager.clusterItemInfoWindowLongClickEvents(): Flow = this.canonicalClusterItemInfoWindowLongClickEvents() diff --git a/clustering/src/main/java/com/google/maps/android/ktx/utils/geometry/Point.kt b/clustering/src/main/java/com/google/maps/android/ktx/utils/geometry/Point.kt new file mode 100644 index 000000000..53283208d --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/ktx/utils/geometry/Point.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.ktx.utils.geometry + +import com.google.maps.android.geometry.Point +import com.google.maps.android.geometry.component1 as canonicalComponent1 +import com.google.maps.android.geometry.component2 as canonicalComponent2 + +@Deprecated("Moved to com.google.maps.android.geometry.component1", ReplaceWith("component1()", "com.google.maps.android.geometry.component1")) +public operator fun Point.component1(): Double = this.canonicalComponent1() + +@Deprecated("Moved to com.google.maps.android.geometry.component2", ReplaceWith("component2()", "com.google.maps.android.geometry.component2")) +public operator fun Point.component2(): Double = this.canonicalComponent2() diff --git a/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt new file mode 100644 index 000000000..b7c07ebc9 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerFlowsTest.kt @@ -0,0 +1,139 @@ +/* + * 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.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class ClusterManagerFlowsTest { + + @Mock + private lateinit var clusterManager: ClusterManager + + @Mock + private lateinit var cluster: Cluster + + @Mock + private lateinit var clusterItem: ClusterItem + + @Captor + private lateinit var clusterClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterInfoWindowClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterInfoWindowLongClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemInfoWindowClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemInfoWindowLongClickListener: ArgumentCaptor> + + @Test + public fun testClusterClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterClickEvents().first() + assertThat(event).isEqualTo(cluster) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterClickListener(clusterClickListener.capture()) + clusterClickListener.value.onClusterClick(cluster) + job.cancel() + } + + @Test + public fun testClusterItemClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterItemClickEvents().first() + assertThat(event).isEqualTo(clusterItem) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemClickListener(clusterItemClickListener.capture()) + clusterItemClickListener.value.onClusterItemClick(clusterItem) + job.cancel() + } + + @Test + public fun testClusterInfoWindowClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterInfoWindowClickEvents().first() + assertThat(event).isEqualTo(cluster) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterInfoWindowClickListener(clusterInfoWindowClickListener.capture()) + clusterInfoWindowClickListener.value.onClusterInfoWindowClick(cluster) + job.cancel() + } + + @Test + public fun testClusterInfoWindowLongClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterInfoWindowLongClickEvents().first() + assertThat(event).isEqualTo(cluster) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterInfoWindowLongClickListener(clusterInfoWindowLongClickListener.capture()) + clusterInfoWindowLongClickListener.value.onClusterInfoWindowLongClick(cluster) + job.cancel() + } + + @Test + public fun testClusterItemInfoWindowClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterItemInfoWindowClickEvents().first() + assertThat(event).isEqualTo(clusterItem) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemInfoWindowClickListener(clusterItemInfoWindowClickListener.capture()) + clusterItemInfoWindowClickListener.value.onClusterItemInfoWindowClick(clusterItem) + job.cancel() + } + + @Test + public fun testClusterItemInfoWindowLongClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterItemInfoWindowLongClickEvents().first() + assertThat(event).isEqualTo(clusterItem) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemInfoWindowLongClickListener(clusterItemInfoWindowLongClickListener.capture()) + clusterItemInfoWindowLongClickListener.value.onClusterItemInfoWindowLongClick(clusterItem) + job.cancel() + } +} diff --git a/clustering/src/test/java/com/google/maps/android/geometry/PointExtensionsTest.kt b/clustering/src/test/java/com/google/maps/android/geometry/PointExtensionsTest.kt new file mode 100644 index 000000000..1b660e951 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/geometry/PointExtensionsTest.kt @@ -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.geometry + +import com.google.maps.android.geometry.Point +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test + +internal class PointExtensionsTest { + + private lateinit var point: Point + + @Before + fun setUp() { + point = Point(1.0, 2.0) + } + + @Test + fun `destructure x`() { + val (x, _) = point + assertThat(x).isWithin(1e-6).of(1.0) + } + + @Test + fun `destructure y`() { + val (_, y) = point + assertThat(y).isWithin(1e-6).of(2.0) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt b/clustering/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt new file mode 100644 index 000000000..b531d6347 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt @@ -0,0 +1,134 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils.clustering + +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class ClusterManagerTest { + + @Mock + private lateinit var clusterManager: ClusterManager + + @Mock + private lateinit var cluster: Cluster + + @Mock + private lateinit var clusterItem: ClusterItem + + @Captor + private lateinit var clusterClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterInfoWindowClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterInfoWindowLongClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemInfoWindowClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemInfoWindowLongClickListener: ArgumentCaptor> + + @Test + public fun testClusterClickEvents(): Unit = runTest { + val deferred = async { + clusterManager.clusterClickEvents().first() + } + advanceUntilIdle() + verify(clusterManager).setOnClusterClickListener(clusterClickListener.capture()) + clusterClickListener.value.onClusterClick(cluster) + assertThat(deferred.await()).isEqualTo(cluster) + } + + @Test + public fun testClusterItemClickEvents(): Unit = runTest { + val deferred = async { + clusterManager.clusterItemClickEvents().first() + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemClickListener(clusterItemClickListener.capture()) + clusterItemClickListener.value.onClusterItemClick(clusterItem) + assertThat(deferred.await()).isEqualTo(clusterItem) + } + + @Test + public fun testClusterInfoWindowClickEvents(): Unit = runTest { + val deferred = async { + clusterManager.clusterInfoWindowClickEvents().first() + } + advanceUntilIdle() + verify(clusterManager).setOnClusterInfoWindowClickListener(clusterInfoWindowClickListener.capture()) + clusterInfoWindowClickListener.value.onClusterInfoWindowClick(cluster) + assertThat(deferred.await()).isEqualTo(cluster) + } + + @Test + public fun testClusterInfoWindowLongClickEvents(): Unit = runTest { + val deferred = async { + clusterManager.clusterInfoWindowLongClickEvents().first() + } + advanceUntilIdle() + verify(clusterManager).setOnClusterInfoWindowLongClickListener(clusterInfoWindowLongClickListener.capture()) + clusterInfoWindowLongClickListener.value.onClusterInfoWindowLongClick(cluster) + assertThat(deferred.await()).isEqualTo(cluster) + } + + @Test + public fun testClusterItemInfoWindowClickEvents(): Unit = runTest { + val deferred = async { + clusterManager.clusterItemInfoWindowClickEvents().first() + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemInfoWindowClickListener(clusterItemInfoWindowClickListener.capture()) + clusterItemInfoWindowClickListener.value.onClusterItemInfoWindowClick(clusterItem) + assertThat(deferred.await()).isEqualTo(clusterItem) + } + + @Test + public fun testClusterItemInfoWindowLongClickEvents(): Unit = runTest { + val deferred = async { + clusterManager.clusterItemInfoWindowLongClickEvents().first() + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemInfoWindowLongClickListener(clusterItemInfoWindowLongClickListener.capture()) + clusterItemInfoWindowLongClickListener.value.onClusterItemInfoWindowLongClick(clusterItem) + assertThat(deferred.await()).isEqualTo(clusterItem) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/ktx/utils/geometry/PointTest.kt b/clustering/src/test/java/com/google/maps/android/ktx/utils/geometry/PointTest.kt new file mode 100644 index 000000000..d4e402ed3 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/ktx/utils/geometry/PointTest.kt @@ -0,0 +1,46 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils.geometry + +import com.google.maps.android.geometry.Point +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test + +internal class PointTest { + + private lateinit var point: Point + + @Before + fun setUp() { + point = Point(1.0, 2.0) + } + + @Test + fun `destructure x`() { + val (x, _) = point + assertThat(x).isWithin(1e-6).of(1.0) + } + + @Test + fun `destructure y`() { + val (_, y) = point + assertThat(y).isWithin(1e-6).of(2.0) + } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7445fcc7e..d81754ca4 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -79,11 +79,8 @@ dependencies { testImplementation(libs.truth) testImplementation(libs.androidx.test.core) 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") { diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJson.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJson.kt new file mode 100644 index 000000000..3619a0b1e --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJson.kt @@ -0,0 +1,71 @@ +/* + * 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.data.geojson + +import android.content.Context +import androidx.annotation.IntegerRes +import androidx.annotation.RawRes +import com.google.android.gms.maps.GoogleMap +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.geojson.GeoJsonLayer +import org.json.JSONObject + +/** + * Alias for the [GeoJsonLayer] constructor that provides Kotlin named parameters and default + * values. + */ +public fun geoJsonLayer( + map: GoogleMap, + geoJsonFile: JSONObject, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null +): GeoJsonLayer = GeoJsonLayer( + map, + geoJsonFile, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager +) + +/** + * Alias for the [GeoJsonLayer] constructor that provides Kotlin named parameters and default + * values. + */ +public fun geoJsonLayer( + map: GoogleMap, + @RawRes resourceId: Int, + context: Context, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null +): GeoJsonLayer = GeoJsonLayer( + map, + resourceId, + context, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager +) \ No newline at end of file diff --git a/data/src/main/java/com/google/maps/android/data/kml/Kml.kt b/data/src/main/java/com/google/maps/android/data/kml/Kml.kt new file mode 100644 index 000000000..6df061988 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/Kml.kt @@ -0,0 +1,75 @@ +/* + * 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.data.kml + +import android.content.Context +import androidx.annotation.RawRes +import com.google.android.gms.maps.GoogleMap +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.Renderer +import com.google.maps.android.data.kml.KmlLayer +import java.io.InputStream + +/** + * Alias for the [KmlLayer] constructor that provides Kotlin named parameters and default values. + */ +public fun kmlLayer( + map: GoogleMap, + @RawRes resourceId: Int, + context: Context, + markerManager: MarkerManager = MarkerManager(map), + polygonManager: PolygonManager = PolygonManager(map), + polylineManager: PolylineManager = PolylineManager(map), + groundOverlayManager: GroundOverlayManager = GroundOverlayManager(map), + imagesCache: Renderer.ImagesCache? = null +): KmlLayer = KmlLayer( + map, + resourceId, + context, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + imagesCache +) + +/** + * Alias for the [KmlLayer] constructor that provides Kotlin named parameters and default values. + */ +public fun kmlLayer( + map: GoogleMap, + stream: InputStream, + context: Context, + markerManager: MarkerManager = MarkerManager(map), + polygonManager: PolygonManager = PolygonManager(map), + polylineManager: PolylineManager = PolylineManager(map), + groundOverlayManager: GroundOverlayManager = GroundOverlayManager(map), + imagesCache: Renderer.ImagesCache? = null +): KmlLayer = KmlLayer( + map, + stream, + context, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + imagesCache +) diff --git a/data/src/main/java/com/google/maps/android/ktx/utils/geojson/GeoJson.kt b/data/src/main/java/com/google/maps/android/ktx/utils/geojson/GeoJson.kt new file mode 100644 index 000000000..d61b06466 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/ktx/utils/geojson/GeoJson.kt @@ -0,0 +1,49 @@ +/* + * 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.geojson + +import android.content.Context +import androidx.annotation.RawRes +import com.google.android.gms.maps.GoogleMap +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.geojson.GeoJsonLayer +import org.json.JSONObject +import com.google.maps.android.data.geojson.geoJsonLayer as canonicalGeoJsonLayer + +@Deprecated("Moved to com.google.maps.android.data.geojson.geoJsonLayer", ReplaceWith("geoJsonLayer(map, geoJsonFile, markerManager, polygonManager, polylineManager, groundOverlayManager)", "com.google.maps.android.data.geojson.geoJsonLayer")) +public fun geoJsonLayer( + map: GoogleMap, + geoJsonFile: JSONObject, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null +): GeoJsonLayer = canonicalGeoJsonLayer(map, geoJsonFile, markerManager, polygonManager, polylineManager, groundOverlayManager) + +@Deprecated("Moved to com.google.maps.android.data.geojson.geoJsonLayer", ReplaceWith("geoJsonLayer(map, resourceId, context, markerManager, polygonManager, polylineManager, groundOverlayManager)", "com.google.maps.android.data.geojson.geoJsonLayer")) +public fun geoJsonLayer( + map: GoogleMap, + @RawRes resourceId: Int, + context: Context, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null +): GeoJsonLayer = canonicalGeoJsonLayer(map, resourceId, context, markerManager, polygonManager, polylineManager, groundOverlayManager) + diff --git a/data/src/main/java/com/google/maps/android/ktx/utils/kml/Kml.kt b/data/src/main/java/com/google/maps/android/ktx/utils/kml/Kml.kt new file mode 100644 index 000000000..921541f4c --- /dev/null +++ b/data/src/main/java/com/google/maps/android/ktx/utils/kml/Kml.kt @@ -0,0 +1,53 @@ +/* + * 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.kml + +import android.content.Context +import androidx.annotation.RawRes +import com.google.android.gms.maps.GoogleMap +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.Renderer +import com.google.maps.android.data.kml.KmlLayer +import java.io.InputStream +import com.google.maps.android.data.kml.kmlLayer as canonicalKmlLayer + +@Deprecated("Moved to com.google.maps.android.data.kml.kmlLayer", ReplaceWith("kmlLayer(map, resourceId, context, markerManager, polygonManager, polylineManager, groundOverlayManager, imagesCache)", "com.google.maps.android.data.kml.kmlLayer")) +public fun kmlLayer( + map: GoogleMap, + @RawRes resourceId: Int, + context: Context, + markerManager: MarkerManager = MarkerManager(map), + polygonManager: PolygonManager = PolygonManager(map), + polylineManager: PolylineManager = PolylineManager(map), + groundOverlayManager: GroundOverlayManager = GroundOverlayManager(map), + imagesCache: Renderer.ImagesCache? = null +): KmlLayer = canonicalKmlLayer(map, resourceId, context, markerManager, polygonManager, polylineManager, groundOverlayManager, imagesCache) + +@Deprecated("Moved to com.google.maps.android.data.kml.kmlLayer", ReplaceWith("kmlLayer(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, imagesCache)", "com.google.maps.android.data.kml.kmlLayer")) +public fun kmlLayer( + map: GoogleMap, + stream: InputStream, + context: Context, + markerManager: MarkerManager = MarkerManager(map), + polygonManager: PolygonManager = PolygonManager(map), + polylineManager: PolylineManager = PolylineManager(map), + groundOverlayManager: GroundOverlayManager = GroundOverlayManager(map), + imagesCache: Renderer.ImagesCache? = null +): KmlLayer = canonicalKmlLayer(map, stream, context, markerManager, polygonManager, polylineManager, groundOverlayManager, imagesCache) + diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 487ef5b60..e2423ab45 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -140,6 +140,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/KtxExtensionsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/KtxExtensionsDemoActivity.kt new file mode 100644 index 000000000..bef287ed0 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/KtxExtensionsDemoActivity.kt @@ -0,0 +1,131 @@ +/* + * 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.utils.demo + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.addMarker +import com.google.maps.android.awaitAnimateCamera +import com.google.maps.android.awaitMap +import com.google.maps.android.awaitMapsSdkInitialized +import com.google.maps.android.ktx.addCircle as deprecatedBridgeAddCircle +import com.google.maps.android.mapClickEvents + +/** + * A demo activity illustrating the consolidated reactive Coroutine/Flow/Builder extensions + * in [com.google.maps.android], as well as compatibility verification for the deprecated + * [com.google.maps.android.ktx] package bridges. + */ +class KtxExtensionsDemoActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + ReactiveMapScreen() + } + } + } + + @Composable + private fun ReactiveMapScreen() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val mapView = remember { MapView(context) } + + DisposableEffect(lifecycleOwner, mapView) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) + Lifecycle.Event.ON_START -> mapView.onStart() + Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_PAUSE -> mapView.onPause() + Lifecycle.Event.ON_STOP -> mapView.onStop() + Lifecycle.Event.ON_DESTROY -> mapView.onDestroy() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(lifecycleOwner, mapView) { + // 1. Canonical awaitMapsSdkInitialized() coroutine suspension + context.awaitMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + + // 2. Canonical awaitMap() coroutine suspension + val googleMap: GoogleMap = mapView.awaitMap() + + val sydney = LatLng(-33.852, 151.211) + + // 3. Canonical addMarker builder DSL + googleMap.addMarker { + position(sydney) + title("Sydney Opera House (Canonical Builder)") + } + + // 3. Deprecated bridge addCircle check (verifying zero conflicts with canonical builder) + @Suppress("DEPRECATION") + googleMap.deprecatedBridgeAddCircle { + center(LatLng(-33.870, 151.200)) + radius(500.0) + } + + // 4. Canonical awaitAnimateCamera suspension + googleMap.awaitAnimateCamera(CameraUpdateFactory.newLatLngZoom(sydney, 12f), 1500) + + // 5. Canonical Flow observation for map clicks tied to the composable & lifecycle + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + googleMap.mapClickEvents().collect { latLng -> + Toast.makeText( + context, + "Clicked at: ${latLng.latitude}, ${latLng.longitude}", + Toast.LENGTH_SHORT + ).show() + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView(factory = { mapView }) + } + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt index 587048a55..60d627f4d 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt @@ -132,6 +132,7 @@ class MainActivity : ComponentActivity() { Demo(R.string.demo_title_icon_generator, IconGeneratorDemoActivity::class.java), Demo(R.string.demo_title_tile_provider, TileProviderAndProjectionDemo::class.java), Demo(R.string.demo_title_animation_util, AnimationUtilDemoActivity::class.java), + Demo(R.string.demo_title_reactive_extensions, KtxExtensionsDemoActivity::class.java), ), ), DemoGroup( diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index 78d1f0cb5..ee23b0f97 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ IconGenerator Generating tiles AnimationUtil sample + Reactive Extensions & Builders Street View Demo diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32f4828e7..174e6bf62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ compose-bom = "2026.09.00" # --- Google Services (Maps) --- # Versions for Google Play Services libraries essential for map functionality. play-services-maps = "20.0.0" +play-services-location = "21.4.0" navigation-sdk = "7.9.0" desugar-jdk-libs = "2.1.5" @@ -42,7 +43,7 @@ androidx-test-ext-junit = "1.3.0" espresso-core = "3.7.0" junit = "4.13.2" kxml2 = "2.3.0" -mockito-core = "5.23.0" +mockito-kotlin = "5.4.0" mockk = "1.14.11" robolectric = "4.16.1" truth = "1.4.5" @@ -98,6 +99,7 @@ material-icons-core = { group = "androidx.compose.material", name = "material-ic # --- Google Services (Maps) --- # Key libraries for integrating Google Maps and related services. play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "play-services-maps" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } navigation-sdk = { module = "com.google.android.libraries.navigation:navigation", version.ref = "navigation-sdk" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar-jdk-libs" } @@ -107,7 +109,7 @@ androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-te androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } junit = { module = "junit:junit", version.ref = "junit" } -mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } truth = { module = "com.google.truth:truth", version.ref = "truth" } diff --git a/heatmaps/build.gradle.kts b/heatmaps/build.gradle.kts index 08b2f81e1..357967b0e 100644 --- a/heatmaps/build.gradle.kts +++ b/heatmaps/build.gradle.kts @@ -77,11 +77,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") { diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/Heatmap.kt b/heatmaps/src/main/java/com/google/maps/android/heatmaps/Heatmap.kt new file mode 100644 index 000000000..f232203b6 --- /dev/null +++ b/heatmaps/src/main/java/com/google/maps/android/heatmaps/Heatmap.kt @@ -0,0 +1,75 @@ +/* + * 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.heatmaps.Gradient +import com.google.maps.android.heatmaps.HeatmapTileProvider +import com.google.maps.android.heatmaps.WeightedLatLng + +/** + * Converts this LatLng to a [WeightedLatLng] + */ +public fun LatLng.toWeightedLatLng( + intensity: Double = WeightedLatLng.DEFAULT_INTENSITY +): WeightedLatLng = + WeightedLatLng(this, intensity) + +/** + * Constructs a [HeatmapTileProvider]. + * + * @throws IllegalStateException when [opacity] is not within the range [0, 1] or if [latLngs] is + * empty + */ +public fun heatmapTileProviderWithData( + latLngs: Collection, + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0 +) : HeatmapTileProvider { + return HeatmapTileProvider.Builder() + .data(latLngs) + .radius(radius) + .gradient(gradient) + .opacity(opacity) + .maxIntensity(maxIntensity) + .build() +} + +/** + * Constructs a [HeatmapTileProvider]. + * + * @throws IllegalStateException when [opacity] is not within the range [0, 1] or if [latLngs] is + * empty + */ +public fun heatmapTileProviderWithWeightedData( + latLngs: Collection, + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0 +) : HeatmapTileProvider { + return HeatmapTileProvider.Builder() + .weightedData(latLngs) + .radius(radius) + .gradient(gradient) + .opacity(opacity) + .maxIntensity(maxIntensity) + .build() +} diff --git a/heatmaps/src/main/java/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt b/heatmaps/src/main/java/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt new file mode 100644 index 000000000..3d62d32c5 --- /dev/null +++ b/heatmaps/src/main/java/com/google/maps/android/ktx/utils/heatmaps/Heatmap.kt @@ -0,0 +1,48 @@ +/* + * 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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.heatmaps.Gradient +import com.google.maps.android.heatmaps.HeatmapTileProvider +import com.google.maps.android.heatmaps.WeightedLatLng +import com.google.maps.android.heatmaps.toWeightedLatLng as canonicalToWeightedLatLng +import com.google.maps.android.heatmaps.heatmapTileProviderWithData as canonicalHeatmapTileProviderWithData +import com.google.maps.android.heatmaps.heatmapTileProviderWithWeightedData as canonicalHeatmapTileProviderWithWeightedData + +@Deprecated("Moved to com.google.maps.android.heatmaps.toWeightedLatLng", ReplaceWith("toWeightedLatLng(intensity)", "com.google.maps.android.heatmaps.toWeightedLatLng")) +public fun LatLng.toWeightedLatLng( + intensity: Double = WeightedLatLng.DEFAULT_INTENSITY +): WeightedLatLng = this.canonicalToWeightedLatLng(intensity) + +@Deprecated("Moved to com.google.maps.android.heatmaps.heatmapTileProviderWithData", ReplaceWith("heatmapTileProviderWithData(latLngs, radius, gradient, opacity, maxIntensity)", "com.google.maps.android.heatmaps.heatmapTileProviderWithData")) +public fun heatmapTileProviderWithData( + latLngs: Collection, + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0 +) : HeatmapTileProvider = canonicalHeatmapTileProviderWithData(latLngs, radius, gradient, opacity, maxIntensity) + +@Deprecated("Moved to com.google.maps.android.heatmaps.heatmapTileProviderWithWeightedData", ReplaceWith("heatmapTileProviderWithWeightedData(latLngs, radius, gradient, opacity, maxIntensity)", "com.google.maps.android.heatmaps.heatmapTileProviderWithWeightedData")) +public fun heatmapTileProviderWithWeightedData( + latLngs: Collection, + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0 +) : HeatmapTileProvider = canonicalHeatmapTileProviderWithWeightedData(latLngs, radius, gradient, opacity, maxIntensity) + diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapExtensionsTest.kt b/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapExtensionsTest.kt new file mode 100644 index 000000000..a58acfccd --- /dev/null +++ b/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapExtensionsTest.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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.heatmaps.WeightedLatLng +import com.google.maps.android.heatmaps.toWeightedLatLng +import org.junit.Test + +internal class HeatmapExtensionsTest { + @Test + fun `to WeightedLatLng converts correctly`() { + val latLng = LatLng(1.0, 2.0) + + weightedLatLngEquals(WeightedLatLng(latLng), latLng.toWeightedLatLng()) + weightedLatLngEquals( + WeightedLatLng(latLng, 2.0), + latLng.toWeightedLatLng(intensity = 2.0) + ) + } + + private fun weightedLatLngEquals(lhs: WeightedLatLng, rhs: WeightedLatLng) { + assertThat(lhs.point.x).isWithin(1e-6).of(rhs.point.x) + assertThat(lhs.point.y).isWithin(1e-6).of(rhs.point.y) + assertThat(lhs.intensity).isWithin(1e-6).of(rhs.intensity) + } +} diff --git a/heatmaps/src/test/java/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.kt b/heatmaps/src/test/java/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.kt new file mode 100644 index 000000000..d993ad869 --- /dev/null +++ b/heatmaps/src/test/java/com/google/maps/android/ktx/utils/heatmaps/HeatmapTest.kt @@ -0,0 +1,44 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.heatmaps.WeightedLatLng +import com.google.maps.android.ktx.utils.heatmaps.toWeightedLatLng +import org.junit.Test + +internal class HeatmapTest { + @Test + fun `to WeightedLatLng converts correctly`() { + val latLng = LatLng(1.0, 2.0) + + weightedLatLngEquals(WeightedLatLng(latLng), latLng.toWeightedLatLng()) + weightedLatLngEquals( + WeightedLatLng(latLng, 2.0), + latLng.toWeightedLatLng(intensity = 2.0) + ) + } + + private fun weightedLatLngEquals(lhs: WeightedLatLng, rhs: WeightedLatLng) { + assertThat(lhs.point.x).isWithin(1e-6).of(rhs.point.x) + assertThat(lhs.point.y).isWithin(1e-6).of(rhs.point.y) + assertThat(lhs.intensity).isWithin(1e-6).of(rhs.intensity) + } +} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 88119e578..8eca4621f 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -63,11 +63,14 @@ 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) @@ -76,11 +79,8 @@ dependencies { testImplementation(libs.androidx.test.core) 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") { diff --git a/library/consumer-rules.pro b/library/consumer-rules.pro index e69de29bb..6411008cd 100644 --- a/library/consumer-rules.pro +++ b/library/consumer-rules.pro @@ -0,0 +1,6 @@ +# play-services-location is an optional compileOnly dependency for FusedLocationProviderClient extensions. +# Suppress R8 missing-class warnings for consumers that do not include play-services-location. +-dontwarn com.google.android.gms.location.** + +# Navigation SDK consumers exclude play-services-maps, which transitively provides GooglePlayServicesNotAvailableException. +-dontwarn com.google.android.gms.common.GooglePlayServicesNotAvailableException diff --git a/library/src/main/java/com/google/maps/android/GoogleMap.kt b/library/src/main/java/com/google/maps/android/GoogleMap.kt new file mode 100644 index 000000000..3491d0bad --- /dev/null +++ b/library/src/main/java/com/google/maps/android/GoogleMap.kt @@ -0,0 +1,594 @@ +/* + * 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 android.graphics.Bitmap +import android.location.Location +import androidx.annotation.IntDef +import com.google.android.gms.maps.CameraUpdate +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.GoogleMapOptions +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.android.gms.maps.model.IndoorBuilding +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PointOfInterest +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.android.gms.maps.model.TileOverlay +import com.google.android.gms.maps.model.TileOverlayOptions +import com.google.maps.android.model.circleOptions +import com.google.maps.android.model.groundOverlayOptions +import com.google.maps.android.model.markerOptions +import com.google.maps.android.model.polygonOptions +import com.google.maps.android.model.polylineOptions +import com.google.maps.android.model.tileOverlayOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Annotation indicating the reason a camera move started. + * See [GoogleMap.OnCameraMoveStartedListener]. + */ +@IntDef( + GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE, + GoogleMap.OnCameraMoveStartedListener.REASON_API_ANIMATION, + GoogleMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION +) +@Retention(AnnotationRetention.SOURCE) +public annotation class MoveStartedReason + +/** + * Sealed hierarchy representing camera movement lifecycle events emitted by [GoogleMap.cameraEvents]. + */ +public sealed class CameraEvent + +/** + * Emitted when camera movement has ended and the camera is idle. + * See [GoogleMap.OnCameraIdleListener]. + */ +public object CameraIdleEvent : CameraEvent() + +/** + * Emitted when camera movement has been canceled or interrupted before completion. + * See [GoogleMap.OnCameraMoveCanceledListener]. + */ +public object CameraMoveCanceledEvent : CameraEvent() + +/** + * Emitted repeatedly while the camera is moving. + * See [GoogleMap.OnCameraMoveListener]. + */ +public object CameraMoveEvent : CameraEvent() + +/** + * Emitted when the camera starts moving, carrying the [reason] for the movement. + * See [GoogleMap.OnCameraMoveStartedListener]. + * + * @property reason the reason the camera started moving, annotated with [MoveStartedReason] + */ +public data class CameraMoveStartedEvent(@param:MoveStartedReason val reason: Int) : CameraEvent() + +/** + * Change event when a marker is dragged. See [GoogleMap.setOnMarkerDragListener] + */ +public sealed class OnMarkerDragEvent { + public abstract val marker: Marker +} + +/** + * Event emitted repeatedly while a marker is being dragged. + */ +public data class MarkerDragEvent(public override val marker: Marker) : OnMarkerDragEvent() + +/** + * Event emitted when a marker has finished being dragged. + */ +public data class MarkerDragEndEvent(public override val marker: Marker) : OnMarkerDragEvent() + +/** + * Event emitted when a marker starts being dragged. + */ +public data class MarkerDragStartEvent(public override val marker: Marker) : OnMarkerDragEvent() + +/** + * Change event when the indoor state changes. See [GoogleMap.OnIndoorStateChangeListener] + */ +public sealed class IndoorChangeEvent + +/** + * Change event when an indoor building is focused. + * See [GoogleMap.OnIndoorStateChangeListener.onIndoorBuildingFocused] + */ +public object IndoorBuildingFocusedEvent : IndoorChangeEvent() + +/** + * Change event when an indoor level is activated. + * See [GoogleMap.OnIndoorStateChangeListener.onIndoorLevelActivated] + */ +public data class IndoorLevelActivatedEvent(val building: IndoorBuilding) : IndoorChangeEvent() + +/** + * Returns a [Flow] of [CameraEvent] items so that camera movements can be observed. Using this to + * observe camera events will set listeners and thus override existing listeners to + * [GoogleMap.setOnCameraIdleListener], [GoogleMap.setOnCameraMoveCanceledListener], + * [GoogleMap.setOnCameraMoveListener] and [GoogleMap.setOnCameraMoveStartedListener]. + */ +@Deprecated( + message = "Use cameraIdleEvents(), cameraMoveCanceledEvents(), cameraMoveEvents() or cameraMoveStartedEvents", +) +public fun GoogleMap.cameraEvents(): Flow = + callbackFlow { + setOnCameraIdleListener { + trySend(CameraIdleEvent) + } + setOnCameraMoveCanceledListener { + trySend(CameraMoveCanceledEvent) + } + setOnCameraMoveListener { + trySend(CameraMoveEvent) + } + setOnCameraMoveStartedListener { + trySend(CameraMoveStartedEvent(it)) + } + awaitClose { + setOnCameraIdleListener(null) + setOnCameraMoveCanceledListener(null) + setOnCameraMoveListener(null) + setOnCameraMoveStartedListener(null) + } + } + +/** + * A suspending function that awaits the completion of the [cameraUpdate] animation. + * + * @param cameraUpdate the [CameraUpdate] to apply on the map + * @param durationMs the duration in milliseconds of the animation, or `null` to use the Maps SDK default duration + */ +public suspend fun GoogleMap.awaitAnimateCamera( + cameraUpdate: CameraUpdate, + durationMs: Int? = null +): Unit = + suspendCancellableCoroutine { continuation -> + val callback = object : GoogleMap.CancelableCallback { + override fun onFinish() { + if (continuation.isActive) { + continuation.resume(Unit) + } + } + + override fun onCancel() { + if (continuation.isActive) { + continuation.cancel() + } + } + } + if (durationMs != null) { + animateCamera(cameraUpdate, durationMs, callback) + } else { + animateCamera(cameraUpdate, callback) + } + } + +/** + * A suspending function that awaits for the map to be loaded. Uses + * [GoogleMap.setOnMapLoadedCallback]. + */ +public suspend fun GoogleMap.awaitMapLoad(): Unit = + suspendCancellableCoroutine { continuation -> + setOnMapLoadedCallback { + if (continuation.isActive) { + continuation.resume(Unit) + } + } + continuation.invokeOnCancellation { + setOnMapLoadedCallback(null) + } + } + +/** + * Returns a flow that emits when the camera is idle. Using this to observe camera idle events will + * override an existing listener (if any) to [GoogleMap.setOnCameraIdleListener]. + */ +public fun GoogleMap.cameraIdleEvents(): Flow = + callbackFlow { + setOnCameraIdleListener { + trySend(Unit) + } + awaitClose { + setOnCameraIdleListener(null) + } + } + +/** + * Returns a flow that emits when a camera move is canceled. Using this to observe camera move + * cancel events will override an existing listener (if any) to + * [GoogleMap.setOnCameraMoveCanceledListener]. + */ +public fun GoogleMap.cameraMoveCanceledEvents(): Flow = + callbackFlow { + setOnCameraMoveCanceledListener { + trySend(Unit) + } + awaitClose { + setOnCameraMoveCanceledListener(null) + } + } + +/** + * Returns a flow that emits when the camera moves. Using this to observe camera move events will + * override an existing listener (if any) to [GoogleMap.setOnCameraMoveListener]. + */ +public fun GoogleMap.cameraMoveEvents(): Flow = + callbackFlow { + setOnCameraMoveListener { + trySend(Unit) + } + awaitClose { + setOnCameraMoveListener(null) + } + } + +/** + * A suspending function that returns a bitmap snapshot of the current view of the map. Uses + * [GoogleMap.snapshot]. + * + * @param bitmap an optional preallocated bitmap + * @return the snapshot + */ +public suspend fun GoogleMap.awaitSnapshot(bitmap: Bitmap? = null): Bitmap? = + suspendCancellableCoroutine { continuation -> + snapshot( + { + if (continuation.isActive) { + continuation.resume(it) + } + }, + bitmap + ) + } + +/** + * Returns a flow that emits when a camera move started. Using this to observe camera move start + * events will override an existing listener (if any) to [GoogleMap.setOnCameraMoveStartedListener]. + */ +public fun GoogleMap.cameraMoveStartedEvents(): Flow = + callbackFlow { + setOnCameraMoveStartedListener { + trySend(it) + } + awaitClose { + setOnCameraMoveStartedListener(null) + } + } + +/** + * Returns a flow that emits when a circle is clicked. Using this to observe circle clicks events + * will override an existing listener (if any) to [GoogleMap.setOnCircleClickListener]. + */ +public fun GoogleMap.circleClickEvents(): Flow = + callbackFlow { + setOnCircleClickListener { + trySend(it) + } + awaitClose { + setOnCircleClickListener(null) + } + } + +/** + * Returns a flow that emits when a ground overlay is clicked. Using this to observe ground overlay + * clicks events will override an existing listener (if any) to + * [GoogleMap.setOnGroundOverlayClickListener]. + */ +public fun GoogleMap.groundOverlayClicks(): Flow = + callbackFlow { + setOnGroundOverlayClickListener { + trySend(it) + } + awaitClose { + setOnGroundOverlayClickListener(null) + } + } + +/** + * Returns a flow that emits when the indoor state changes. Using this to observe indoor state + * change events will override an existing listener (if any) to + * [GoogleMap.setOnIndoorStateChangeListener] + */ +public fun GoogleMap.indoorStateChangeEvents(): Flow = + callbackFlow { + setOnIndoorStateChangeListener(object : GoogleMap.OnIndoorStateChangeListener { + override fun onIndoorBuildingFocused() { + trySend(IndoorBuildingFocusedEvent) + } + + override fun onIndoorLevelActivated(indoorBuilding: IndoorBuilding) { + trySend(IndoorLevelActivatedEvent(building = indoorBuilding)) + } + }) + awaitClose { + setOnIndoorStateChangeListener(null) + } + } + +/** + * Returns a flow that emits when a marker's info window is clicked. Using this to observe info + * info window clicks will override an existing listener (if any) to + * [GoogleMap.setOnInfoWindowClickListener] + */ +public fun GoogleMap.infoWindowClickEvents(): Flow = + callbackFlow { + setOnInfoWindowClickListener { + trySend(it) + } + awaitClose { + setOnInfoWindowClickListener(null) + } + } + +/** + * Returns a flow that emits when a marker's info window is closed. Using this to observe info + * window closes will override an existing listener (if any) to + * [GoogleMap.setOnInfoWindowCloseListener] + */ +public fun GoogleMap.infoWindowCloseEvents(): Flow = + callbackFlow { + setOnInfoWindowCloseListener { + trySend(it) + } + awaitClose { + setOnInfoWindowCloseListener(null) + } + } + +/** + * Returns a flow that emits when a marker's info window is long pressed. Using this to observe info + * window long presses will override an existing listener (if any) to + * [GoogleMap.setOnInfoWindowLongClickListener] + */ +public fun GoogleMap.infoWindowLongClickEvents(): Flow = + callbackFlow { + setOnInfoWindowLongClickListener { + trySend(it) + } + awaitClose { + setOnInfoWindowLongClickListener(null) + } + } + +/** + * Returns a flow that emits when the map is clicked. Using this to observe map click events will + * override an existing listener (if any) to [GoogleMap.setOnMapClickListener] + */ +public fun GoogleMap.mapClickEvents(): Flow = + callbackFlow { + setOnMapClickListener { + trySend(it) + } + awaitClose { + setOnMapClickListener(null) + } + } + +/** + * Returns a flow that emits when the map is long clicked. Using this to observe map click events + * will override an existing listener (if any) to [GoogleMap.setOnMapLongClickListener] + */ +public fun GoogleMap.mapLongClickEvents(): Flow = + callbackFlow { + setOnMapLongClickListener { + trySend(it) + } + awaitClose { + setOnMapLongClickListener(null) + } + } + +/** + * Returns a flow that emits when a marker on the map is clicked. Using this to observe marker click + * events will override an existing listener (if any) to [GoogleMap.setOnMarkerClickListener] + */ +public fun GoogleMap.markerClickEvents(): Flow = + callbackFlow { + setOnMarkerClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnMarkerClickListener(null) + } + } + +/** + * Returns a flow that emits when a marker is dragged. Using this to observer marker drag events + * will override existing listeners (if any) to [GoogleMap.setOnMarkerDragListener] + */ +public fun GoogleMap.markerDragEvents(): Flow = + callbackFlow { + setOnMarkerDragListener(object : GoogleMap.OnMarkerDragListener { + override fun onMarkerDragStart(marker: Marker) { + trySend(MarkerDragStartEvent(marker = marker)) + } + + override fun onMarkerDrag(marker: Marker) { + trySend(MarkerDragEvent(marker = marker)) + } + + override fun onMarkerDragEnd(marker: Marker) { + trySend(MarkerDragEndEvent(marker = marker)) + } + + }) + awaitClose { + setOnMarkerDragListener(null) + } + } + +/** + * Returns a flow that emits when the my location button is clicked. Using this to observe my + * location button click events will override an existing listener (if any) to + * [GoogleMap.setOnMyLocationButtonClickListener] + */ +public fun GoogleMap.myLocationButtonClickEvents(): Flow = + callbackFlow { + setOnMyLocationButtonClickListener { + trySend(Unit).isSuccess + } + awaitClose { + setOnMyLocationButtonClickListener(null) + } + } + +/** + * Returns a flow that emits when the my location blue dot is clicked. Using this to observe my + * location blue dot click events will override an existing listener (if any) to + * [GoogleMap.setOnMyLocationClickListener] + */ +public fun GoogleMap.myLocationClickEvents(): Flow = + callbackFlow { + setOnMyLocationClickListener { + trySend(it) + } + awaitClose { + setOnMyLocationClickListener(null) + } + } + +/** + * Returns a flow that emits when a PointOfInterest is clicked. Using this to observe + * PointOfInterest click events will override an existing listener (if any) to + * [GoogleMap.setOnPoiClickListener] + */ +public fun GoogleMap.poiClickEvents(): Flow = + callbackFlow { + setOnPoiClickListener { + trySend(it) + } + awaitClose { + setOnPoiClickListener(null) + } + } + +/** + * Returns a flow that emits when a Polygon is clicked. Using this to observe Polygon click events + * will override an existing listener (if any) to [GoogleMap.setOnPolygonClickListener] + */ +public fun GoogleMap.polygonClickEvents(): Flow = + callbackFlow { + setOnPolygonClickListener { + trySend(it) + } + awaitClose { + setOnPolygonClickListener(null) + } + } + +/** + * Returns a flow that emits when a Polyline is clicked. Using this to observe Polyline click events + * will override an existing listener (if any) to [GoogleMap.setOnPolylineClickListener] + */ +public fun GoogleMap.polylineClickEvents(): Flow = + callbackFlow { + setOnPolylineClickListener { + trySend(it) + } + awaitClose { + setOnPolylineClickListener(null) + } + } + +/** + * Builds a new [GoogleMapOptions] using the provided [optionsActions]. + * + * @return the constructed [GoogleMapOptions] + */ +public inline fun buildGoogleMapOptions(optionsActions: GoogleMapOptions.() -> Unit): GoogleMapOptions = + GoogleMapOptions().apply( + optionsActions + ) + +/** + * Adds a [Circle] to this [GoogleMap] using the function literal with receiver [optionsActions]. + * + * @return the added [Circle] + */ +public inline fun GoogleMap.addCircle(optionsActions: CircleOptions.() -> Unit): Circle = + this.addCircle( + circleOptions(optionsActions) + ) + +/** + * Adds a [GroundOverlay] to this [GoogleMap] using the function literal with receiver + * [optionsActions]. + * + * @return the added [GroundOverlay] + */ +public inline fun GoogleMap.addGroundOverlay(optionsActions: GroundOverlayOptions.() -> Unit): GroundOverlay? = + this.addGroundOverlay( + groundOverlayOptions(optionsActions) + ) + +/** + * Adds a [Marker] to this [GoogleMap] using the function literal with receiver [optionsActions]. + * + * @return the added [Marker] + */ +public inline fun GoogleMap.addMarker(optionsActions: MarkerOptions.() -> Unit): Marker? = + this.addMarker( + markerOptions(optionsActions) + ) + +/** + * Adds a [Polygon] to this [GoogleMap] using the function literal with receiver [optionsActions]. + * + * @return the added [Polygon] + */ +public inline fun GoogleMap.addPolygon(optionsActions: PolygonOptions.() -> Unit): Polygon = + this.addPolygon( + polygonOptions(optionsActions) + ) + +/** + * Adds a [Polyline] to this [GoogleMap] using the function literal with receiver [optionsActions]. + * + * @return the added [Polyline] + */ +public inline fun GoogleMap.addPolyline(optionsActions: PolylineOptions.() -> Unit): Polyline = + this.addPolyline( + polylineOptions(optionsActions) + ) + +/** + * Adds a [TileOverlay] to this [GoogleMap] using the function literal with receiver + * [optionsActions]. + * + * @return the added [TileOverlay] + */ +public inline fun GoogleMap.addTileOverlay(optionsActions: TileOverlayOptions.() -> Unit): TileOverlay? = + this.addTileOverlay( + tileOverlayOptions(optionsActions) + ) diff --git a/library/src/main/java/com/google/maps/android/LatLng.kt b/library/src/main/java/com/google/maps/android/LatLng.kt new file mode 100644 index 000000000..d7f7c09aa --- /dev/null +++ b/library/src/main/java/com/google/maps/android/LatLng.kt @@ -0,0 +1,213 @@ +/* + * 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.android.gms.maps.model.LatLng +import com.google.maps.android.PolyUtil +import com.google.maps.android.SphericalUtil + +/** + * Returns the [LatLng.latitude] of this [LatLng]. + * + * e.g. + * ``` + * val (lat, _) = latLng + * ``` + */ +public operator fun LatLng.component1(): Double = this.latitude + +/** + * Returns the [LatLng.longitude] of this [LatLng]. + * + * e.g. + * ``` + * val (_, lng) = latLng + * ``` + */ +public operator fun LatLng.component2(): Double = this.longitude + +/** + * Computes whether the given [latLng] lies on or is near this polyline within [tolerance] (in + * meters). + * + * @param latLng the LatLng to inspect + * @param geodesic if this polyline is geodesic or not + * @param tolerance the tolerance in meters + * @return true if [latLng] is on this path, otherwise, false + * + * @see PolyUtil.isLocationOnPath + */ +public fun List.isLocationOnPath( + latLng: LatLng, + geodesic: Boolean, + tolerance: Double = 0.1 +): Boolean = PolyUtil.isLocationOnPath(latLng, this, geodesic, tolerance) + +/** + * Checks whether or not [latLng] lies on or is near the edge of this polygon within the [tolerance] + * (in meters). The default value is [PolyUtil.DEFAULT_TOLERANCE]. + * + * @param latLng the LatLng to inspect + * @param geodesic if this polygon is geodesic or not + * @param tolerance the tolerance in meters + * @return true if [latLng] lies on or is near the edge of this Polygon, otherwise, false + * + * @see PolyUtil.isLocationOnEdge + */ +public fun List.isOnEdge( + latLng: LatLng, + geodesic: Boolean, + tolerance: Double = 0.1 +): Boolean = PolyUtil.isLocationOnEdge(latLng, this, geodesic, tolerance) + +/** + * Computes whether the [latLng] lies inside this. + * + * The polygon is always considered closed, regardless of whether the last point equals + * the first or not. + * + * Inside is defined as not containing the South Pole -- the South Pole is always outside. + * The polygon is formed of great circle segments if [geodesic] is true, and of rhumb + * (loxodromic) segments otherwise. + * + * @param latLng the LatLng to check if it is contained within this polygon + * @param geodesic if this Polygon is geodesic or not + * + * @see PolyUtil.containsLocation + */ +public fun List.containsLocation(latLng: LatLng, geodesic: Boolean): Boolean = + PolyUtil.containsLocation(latLng, this, geodesic) + +/** + * Simplifies this list of LatLng using the Douglas-Peucker decimation. Increasing the value of + * [tolerance] will result in fewer points. + * + * @param tolerance the tolerance in meters + * @return the simplified list of [LatLng] + * + * @see PolyUtil.simplify + */ +public fun List.simplify(tolerance: Double): List = + PolyUtil.simplify(this, tolerance) + +/** + * Decodes this encoded string into a [LatLng] list. + * + * @return the decoded [LatLng] list + * + * @see [Polyline Algorithm Format](https://developers.google.com/maps/documentation/utilities/polylinealgorithm) + */ +public fun String.toLatLngList(): List = PolyUtil.decode(this) + +/** + * Encodes this [LatLng] list in a String using the + * [Polyline Algorithm Format](https://developers.google.com/maps/documentation/utilities/polylinealgorithm). + * + * @return the encoded String + * + * @see [Polyline Algorithm Format](https://developers.google.com/maps/documentation/utilities/polylinealgorithm) + */ +public fun List.latLngListEncode(): String = PolyUtil.encode(this) + +/** + * Checks whether or not this [LatLng] list is a closed Polygon. + * + * @return true if this list is a closed Polygon, otherwise, false + * + * @see PolyUtil.isClosedPolygon + */ +public fun List.isClosedPolygon(): Boolean = PolyUtil.isClosedPolygon(this) + +/** + * Computes the length of this path on Earth. + * + * @return the length of this path in meters + */ +public fun List.sphericalPathLength(): Double = SphericalUtil.computeLength(this) + +/** + * Computes the area under a closed path on Earth. + * + * @return the area in square meters + */ +public fun List.sphericalPolygonArea(): Double = SphericalUtil.computeArea(this) + +/** + * Computes the signed area under a closed path on Earth. The sign of the area may be used to + * determine the orientation of the path. + * + * @return the signed area in square meters + */ +public fun List.sphericalPolygonSignedArea(): Double = SphericalUtil.computeSignedArea(this) + +/** + * Computes the heading from this LatLng to [toLatLng]. + * + * @param toLatLng the other LatLng to compute the heading to + * @return the heading expressed in degrees clockwise from North within the range [-180, 180] + * + * @see SphericalUtil.computeHeading + */ +public fun LatLng.sphericalHeading(toLatLng: LatLng): Double = + SphericalUtil.computeHeading(this, toLatLng) + +/** + * Offsets this LatLng from the provided [distance] and [heading] and returns the result. + * + * @param distance the distance to offset by in meters + * @param heading the heading to offset by in degrees clockwise from north + * @return the resulting LatLng + * + * @see SphericalUtil.computeOffset + */ +public fun LatLng.withSphericalOffset(distance: Double, heading: Double): LatLng = + SphericalUtil.computeOffset(this, distance, heading) + +/** + * Attempts to compute the origin [LatLng] from this LatLng where [distance] meters have been + * traveled with heading value [heading]. + * + * @param distance the distance traveled from origin in meters + * @param heading the heading from origin to this LatLng (measured in degrees clockwise from North) + * @return the computed origin if a solution is available, otherwise, null + * + * @see SphericalUtil.computeOffsetOrigin + */ +public fun LatLng.computeSphericalOffsetOrigin(distance: Double, heading: Double): LatLng? = + SphericalUtil.computeOffsetOrigin(this, distance, heading) + +/** + * Returns an interpolated [LatLng] between this LatLng and [to] by the provided fractional value + * [fraction]. + * + * @param to the destination LatLng + * @param fraction the fraction to interpolate by where the range is [0.0, 1.0] + * @return the interpolated [LatLng] + * + * @see [Slerp](http://en.wikipedia.org/wiki/Slerp) + */ +public fun LatLng.withSphericalLinearInterpolation(to: LatLng, fraction: Double): LatLng = + SphericalUtil.interpolate(this, to, fraction) + +/** + * Computes the spherical distance between this LatLng and [to]. + * + * @param to the LatLng to compute the distance to + * @return the distance between this and [to] in meters + */ +public fun LatLng.sphericalDistance(to: LatLng): Double = + SphericalUtil.computeDistanceBetween(this, to) diff --git a/library/src/main/java/com/google/maps/android/MapFragment.kt b/library/src/main/java/com/google/maps/android/MapFragment.kt new file mode 100644 index 000000000..43572e5dc --- /dev/null +++ b/library/src/main/java/com/google/maps/android/MapFragment.kt @@ -0,0 +1,39 @@ +/* + * 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.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapFragment +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + + +/** + * A suspending function that provides an instance of a [GoogleMap] from this [MapFragment]. + * This is an alternative to [MapFragment.getMapAsync] by using coroutines to obtain a [GoogleMap]. + * + * @return the [GoogleMap] instance + */ +public suspend fun MapFragment.awaitMap(): GoogleMap = + suspendCancellableCoroutine { continuation -> + getMapAsync { + if (continuation.isActive) { + continuation.resume(it) + } + } + } diff --git a/library/src/main/java/com/google/maps/android/MapView.kt b/library/src/main/java/com/google/maps/android/MapView.kt new file mode 100644 index 000000000..4d5c1d72c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/MapView.kt @@ -0,0 +1,36 @@ +/* + * 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.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * A suspending function that provides an instance of [GoogleMap] from this [MapView]. This is + * an alternative to [MapView.getMapAsync] by using coroutines to obtain the [GoogleMap]. + * + * @return the [GoogleMap] instance + */ +public suspend fun MapView.awaitMap(): GoogleMap = + suspendCancellableCoroutine { continuation -> + getMapAsync { + if (continuation.isActive) { + continuation.resume(it) + } + } + } diff --git a/library/src/main/java/com/google/maps/android/MapsExperimentalFeature.kt b/library/src/main/java/com/google/maps/android/MapsExperimentalFeature.kt new file mode 100644 index 000000000..af6492605 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/MapsExperimentalFeature.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 + +/** + * Annotation for APIs that are experimental and require explicit opt-in annotation before use. + */ +@RequiresOptIn +@Retention(AnnotationRetention.BINARY) +public annotation class MapsExperimentalFeature diff --git a/library/src/main/java/com/google/maps/android/MapsInitializer.kt b/library/src/main/java/com/google/maps/android/MapsInitializer.kt new file mode 100644 index 000000000..6348caeea --- /dev/null +++ b/library/src/main/java/com/google/maps/android/MapsInitializer.kt @@ -0,0 +1,64 @@ +/* + * 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 android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.maps.MapsInitializer +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Suspends until the Google Maps SDK is initialized and returns the [MapsInitializer.Renderer] + * that was actually loaded. + * + * **Purpose:** + * Provides a modern coroutine suspension alternative to [MapsInitializer.initialize] with + * callback handlers, enabling clean, sequential asynchronous SDK initialization. + * + * **How it works:** + * Invokes [MapsInitializer.initialize] passing an [com.google.android.gms.maps.OnMapsSdkInitializedCallback]. + * When the callback fires, it resumes the coroutine with the loaded [MapsInitializer.Renderer]. + * If the SDK returns an error status code other than [ConnectionResult.SUCCESS] and does not + * invoke the callback, it resumes with [GooglePlayServicesNotAvailableException] holding the error code. + * + * **Cancellation:** + * This suspending function supports standard coroutine cancellation. Note that the underlying Maps SDK + * [MapsInitializer.initialize] operation cannot be cancelled once initiated. Only the first + * Maps SDK initialization in an application lifecycle honors [preferredRenderer]; passing `null` + * uses the SDK's default preference. + * + * @param preferredRenderer the renderer to request, or `null` to use the SDK default preference + * @return the [MapsInitializer.Renderer] actually loaded by the Maps SDK + * @throws GooglePlayServicesNotAvailableException if initialization returns a status other than + * [ConnectionResult.SUCCESS] without invoking the callback + */ +public suspend fun Context.awaitMapsSdkInitialized( + preferredRenderer: MapsInitializer.Renderer? = null +): MapsInitializer.Renderer = + suspendCancellableCoroutine { continuation -> + val status = MapsInitializer.initialize(this, preferredRenderer) { renderer -> + if (continuation.isActive) { + continuation.resume(renderer) + } + } + if (continuation.isActive && status != ConnectionResult.SUCCESS) { + continuation.resumeWithException(GooglePlayServicesNotAvailableException(status)) + } + } diff --git a/library/src/main/java/com/google/maps/android/Polygon.kt b/library/src/main/java/com/google/maps/android/Polygon.kt new file mode 100644 index 000000000..7e7cb0934 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/Polygon.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 + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polygon +import com.google.maps.android.PolyUtil +import com.google.maps.android.SphericalUtil + +/** + * Computes whether or not [latLng] is contained within this Polygon. + * + * @param latLng the LatLng to inspect + * @return true if [latLng] is contained within this Polygon, otherwise, false + * + * @see PolyUtil.containsLocation + */ +public fun Polygon.contains(latLng: LatLng): Boolean = + PolyUtil.containsLocation(latLng, this.points, this.isGeodesic) + +/** + * Checks whether or not [latLng] lies on or is near the edge of this Polygon within a tolerance + * (in meters) of [tolerance]. The default value is [PolyUtil.DEFAULT_TOLERANCE]. + * + * @param latLng the LatLng to inspect + * @param tolerance the tolerance in meters + * @return true if [latLng] lies on or is near the edge of this Polygon, otherwise, false + * + * @see PolyUtil.isLocationOnEdge + */ +public fun Polygon.isOnEdge(latLng: LatLng, tolerance: Double = 0.1): Boolean = + PolyUtil.isLocationOnEdge(latLng, this.points, this.isGeodesic, tolerance) + +/** + * The area of this Polygon on Earth in square meters. + */ +public val Polygon.area: Double + get() = SphericalUtil.computeArea(this.points) + +/** + * Computes the signed area under a closed path on Earth. The sign of the area may be used to + * determine the orientation of the path. + */ +public val Polygon.signedArea: Double + get() = SphericalUtil.computeSignedArea(this.points) diff --git a/library/src/main/java/com/google/maps/android/Polyline.kt b/library/src/main/java/com/google/maps/android/Polyline.kt new file mode 100644 index 000000000..dc8769e8a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/Polyline.kt @@ -0,0 +1,36 @@ +/* + * 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.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polyline +import com.google.maps.android.PolyUtil +import com.google.maps.android.SphericalUtil + +/** + * Computes where the given [latLng] is contained on or near this Polyline within a specified + * tolerance in meters. + */ +public fun Polyline.contains(latLng: LatLng, tolerance: Double = 0.1): Boolean = + PolyUtil.isLocationOnPath(latLng, this.points, this.isGeodesic, tolerance) + +/** + * The spherical length of this Polyline on Earth as measured in meters. + */ +public val Polyline.sphericalPathLength: Double + get() = SphericalUtil.computeLength(this.points) diff --git a/library/src/main/java/com/google/maps/android/StreetViewPanoramaFragment.kt b/library/src/main/java/com/google/maps/android/StreetViewPanoramaFragment.kt new file mode 100644 index 000000000..008d81119 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/StreetViewPanoramaFragment.kt @@ -0,0 +1,40 @@ +/* + * 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.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaFragment +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * A suspending function that provides an instance of a [StreetViewPanorama] from this + * [StreetViewPanoramaFragment]. This is an alternative to using + * [StreetViewPanoramaFragment.getStreetViewPanoramaAsync] by using coroutines to obtain a + * [StreetViewPanorama]. + * + * @return the [StreetViewPanorama] + */ +public suspend fun StreetViewPanoramaFragment.awaitStreetViewPanorama(): StreetViewPanorama = + suspendCancellableCoroutine { continuation -> + getStreetViewPanoramaAsync { + if (continuation.isActive) { + continuation.resume(it) + } + } + } \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/StreetViewPanoramaView.kt b/library/src/main/java/com/google/maps/android/StreetViewPanoramaView.kt new file mode 100644 index 000000000..caee00a39 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/StreetViewPanoramaView.kt @@ -0,0 +1,104 @@ +/* + * 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.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.model.StreetViewPanoramaCamera +import com.google.android.gms.maps.model.StreetViewPanoramaLocation +import com.google.android.gms.maps.model.StreetViewPanoramaOrientation +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * A suspending function that provides an instance of a [StreetViewPanorama] from this + * [StreetViewPanoramaView]. This is an alternative to using + * [StreetViewPanoramaView.getStreetViewPanoramaAsync] by using coroutines to obtain a + * [StreetViewPanorama]. + * + * @return the [StreetViewPanorama] instance + */ +public suspend fun StreetViewPanoramaView.awaitStreetViewPanorama(): StreetViewPanorama = + suspendCancellableCoroutine { continuation -> + getStreetViewPanoramaAsync { + if (continuation.isActive) { + continuation.resume(it) + } + } + } + +/** + * Returns a flow that emits when the street view panorama camera changes. Using this to + * observe panorama camera change events will override an existing listener (if any) to + * [StreetViewPanorama.setOnStreetViewPanoramaCameraChangeListener]. + */ +public fun StreetViewPanorama.cameraChangeEvents(): Flow = + callbackFlow { + setOnStreetViewPanoramaCameraChangeListener { + trySend(it) + } + awaitClose { + setOnStreetViewPanoramaCameraChangeListener(null) + } + } + +/** + * Returns a flow that emits when the street view panorama loads a new panorama. Using this to + * observe panorama load change events will override an existing listener (if any) to + * [StreetViewPanorama.setOnStreetViewPanoramaChangeListener]. + */ +public fun StreetViewPanorama.changeEvents(): Flow = + callbackFlow { + setOnStreetViewPanoramaChangeListener { + trySend(it) + } + awaitClose { + setOnStreetViewPanoramaChangeListener(null) + } + } + +/** + * Returns a flow that emits when the street view panorama is clicked. Using this to + * observe panorama click events will override an existing listener (if any) to + * [StreetViewPanorama.setOnStreetViewPanoramaClickListener]. + */ +public fun StreetViewPanorama.clickEvents(): Flow = + callbackFlow { + setOnStreetViewPanoramaClickListener { + trySend(it) + } + awaitClose { + setOnStreetViewPanoramaClickListener(null) + } + } + +/** + * Returns a flow that emits when the street view panorama is long clicked. Using this to + * observe panorama long click events will override an existing listener (if any) to + * [StreetViewPanorama.setOnStreetViewPanoramaLongClickListener]. + */ +public fun StreetViewPanorama.longClickEvents(): Flow = + callbackFlow { + setOnStreetViewPanoramaLongClickListener { + trySend(it) + } + awaitClose { + setOnStreetViewPanoramaLongClickListener(null) + } + } diff --git a/library/src/main/java/com/google/maps/android/SupportMapFragment.kt b/library/src/main/java/com/google/maps/android/SupportMapFragment.kt new file mode 100644 index 000000000..c5f1c0614 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/SupportMapFragment.kt @@ -0,0 +1,39 @@ +/* + * 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.android.gms.maps.GoogleMap +import com.google.android.gms.maps.SupportMapFragment +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * A suspending function that provides an instance of a [GoogleMap] from this [SupportMapFragment]. + * This is an alternative to using [SupportMapFragment.getMapAsync] by using coroutines to obtain + * a [GoogleMap]. + * + * @return the [GoogleMap] instance + */ +public suspend fun SupportMapFragment.awaitMap(): GoogleMap = + suspendCancellableCoroutine { continuation -> + getMapAsync { + if (continuation.isActive) { + continuation.resume(it) + } + } + } diff --git a/library/src/main/java/com/google/maps/android/SupportStreetViewPanoramaFragment.kt b/library/src/main/java/com/google/maps/android/SupportStreetViewPanoramaFragment.kt new file mode 100644 index 000000000..d7e19936c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/SupportStreetViewPanoramaFragment.kt @@ -0,0 +1,40 @@ +/* + * 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.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * A suspending function that provides an instance of a [StreetViewPanorama] from this + * [SupportStreetViewPanoramaFragment]. This is an alternative to using + * [SupportStreetViewPanoramaFragment.getStreetViewPanoramaAsync] by using coroutines to obtain a + * [StreetViewPanorama]. + * + * @return the [StreetViewPanorama] + */ +public suspend fun SupportStreetViewPanoramaFragment.awaitStreetViewPanorama(): StreetViewPanorama = + suspendCancellableCoroutine { continuation -> + getStreetViewPanoramaAsync { + if (continuation.isActive) { + continuation.resume(it) + } + } + } diff --git a/library/src/main/java/com/google/maps/android/collections/CircleManagerFlows.kt b/library/src/main/java/com/google/maps/android/collections/CircleManagerFlows.kt new file mode 100644 index 000000000..f3b92aae0 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/CircleManagerFlows.kt @@ -0,0 +1,55 @@ +/* + * 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.collections + +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Adds a new [Circle] to the underlying map and to this [CircleManager.Collection] with the + * provided [optionsActions]. + */ +public inline fun CircleManager.Collection.addCircle(optionsActions: CircleOptions.() -> Unit): Circle = + this.addCircle( + CircleOptions().apply(optionsActions) + ) + +/** + * Returns a flow that emits when a circle in this collection is clicked. Using this to observe circle clicks + * will override an existing listener (if any) to [CircleManager.Collection.setOnCircleClickListener]. + * + * **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 CircleManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnCircleClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnCircleClickListener(null) + } + } diff --git a/library/src/main/java/com/google/maps/android/collections/GroundOverlayManagerFlows.kt b/library/src/main/java/com/google/maps/android/collections/GroundOverlayManagerFlows.kt new file mode 100644 index 000000000..5786cf099 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/GroundOverlayManagerFlows.kt @@ -0,0 +1,57 @@ +/* + * 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.collections + +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Adds a new [GroundOverlay] to the underlying map and to this [GroundOverlayManager.Collection] + * with the provided [optionsActions]. + */ +public inline fun GroundOverlayManager.Collection.addGroundOverlay( + optionsActions: GroundOverlayOptions.() -> Unit +): GroundOverlay = + this.addGroundOverlay( + GroundOverlayOptions().apply(optionsActions) + ) + +/** + * Returns a flow that emits when a ground overlay in this collection is clicked. Using this to observe ground overlay clicks + * will override an existing listener (if any) to [GroundOverlayManager.Collection.setOnGroundOverlayClickListener]. + * + * **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 GroundOverlayManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnGroundOverlayClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnGroundOverlayClickListener(null) + } + } diff --git a/library/src/main/java/com/google/maps/android/collections/MarkerManagerFlows.kt b/library/src/main/java/com/google/maps/android/collections/MarkerManagerFlows.kt new file mode 100644 index 000000000..f4eff73b9 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/MarkerManagerFlows.kt @@ -0,0 +1,93 @@ +/* + * 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.collections + +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Adds a new [Marker] to the underlying map and to this [MarkerManager.Collection] with the + * provided [optionsActions]. + */ +public inline fun MarkerManager.Collection.addMarker(optionsActions: MarkerOptions.() -> Unit): Marker = + this.addMarker( + MarkerOptions().apply(optionsActions) + ) + +/** + * Returns a flow that emits when a marker in this collection is clicked. Using this to observe marker clicks + * will override an existing listener (if any) to [MarkerManager.Collection.setOnMarkerClickListener]. + * + * **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 MarkerManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnMarkerClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnMarkerClickListener(null) + } + } + +/** + * Returns a flow that emits when a marker's info window in this collection is clicked. Using this to observe info window clicks + * will override an existing listener (if any) to [MarkerManager.Collection.setOnInfoWindowClickListener]. + * + * **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 MarkerManager.Collection.infoWindowClickEvents(): Flow = + callbackFlow { + setOnInfoWindowClickListener { + trySend(it) + } + awaitClose { + setOnInfoWindowClickListener(null) + } + } + +/** + * Returns a flow that emits when a marker's info window in this collection is long clicked. Using this to observe info window + * long clicks will override an existing listener (if any) to [MarkerManager.Collection.setOnInfoWindowLongClickListener]. + * + * **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 MarkerManager.Collection.infoWindowLongClickEvents(): Flow = + callbackFlow { + setOnInfoWindowLongClickListener { + trySend(it) + } + awaitClose { + setOnInfoWindowLongClickListener(null) + } + } diff --git a/library/src/main/java/com/google/maps/android/collections/PolygonManagerFlows.kt b/library/src/main/java/com/google/maps/android/collections/PolygonManagerFlows.kt new file mode 100644 index 000000000..cc7f71494 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/PolygonManagerFlows.kt @@ -0,0 +1,57 @@ +/* + * 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.collections + +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Adds a new [Polygon] to the underlying map and to this [PolygonManager.Collection] with the + * provided [optionsActions]. + */ +public inline fun PolygonManager.Collection.addPolygon( + optionsActions: PolygonOptions.() -> Unit +): Polygon = + this.addPolygon( + PolygonOptions().apply(optionsActions) + ) + +/** + * Returns a flow that emits when a polygon in this collection is clicked. Using this to observe polygon clicks + * will override an existing listener (if any) to [PolygonManager.Collection.setOnPolygonClickListener]. + * + * **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 PolygonManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnPolygonClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnPolygonClickListener(null) + } + } diff --git a/library/src/main/java/com/google/maps/android/collections/PolylineManagerFlows.kt b/library/src/main/java/com/google/maps/android/collections/PolylineManagerFlows.kt new file mode 100644 index 000000000..ce6f35259 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/collections/PolylineManagerFlows.kt @@ -0,0 +1,57 @@ +/* + * 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.collections + +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Adds a new [Polyline] to the underlying map and to this [PolylineManager.Collection] with the + * provided [optionsActions]. + */ +public inline fun PolylineManager.Collection.addPolyline( + optionsActions: PolylineOptions.() -> Unit +): Polyline = + this.addPolyline( + PolylineOptions().apply(optionsActions) + ) + +/** + * Returns a flow that emits when a polyline in this collection is clicked. Using this to observe polyline clicks + * will override an existing listener (if any) to [PolylineManager.Collection.setOnPolylineClickListener]. + * + * **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 PolylineManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnPolylineClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnPolylineClickListener(null) + } + } diff --git a/library/src/main/java/com/google/maps/android/ktx/GoogleMap.kt b/library/src/main/java/com/google/maps/android/ktx/GoogleMap.kt new file mode 100644 index 000000000..dcb55f5f9 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/GoogleMap.kt @@ -0,0 +1,215 @@ +/* + * 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 + +import android.graphics.Bitmap +import android.location.Location +import com.google.android.gms.maps.CameraUpdate +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.GoogleMapOptions +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.android.gms.maps.model.IndoorBuilding +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PointOfInterest +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.android.gms.maps.model.TileOverlay +import com.google.android.gms.maps.model.TileOverlayOptions +import kotlinx.coroutines.flow.Flow + +import com.google.maps.android.CameraEvent as CanonicalCameraEvent +import com.google.maps.android.CameraIdleEvent as CanonicalCameraIdleEvent +import com.google.maps.android.CameraMoveCanceledEvent as CanonicalCameraMoveCanceledEvent +import com.google.maps.android.CameraMoveEvent as CanonicalCameraMoveEvent +import com.google.maps.android.CameraMoveStartedEvent as CanonicalCameraMoveStartedEvent +import com.google.maps.android.IndoorBuildingFocusedEvent as CanonicalIndoorBuildingFocusedEvent +import com.google.maps.android.IndoorChangeEvent as CanonicalIndoorChangeEvent +import com.google.maps.android.IndoorLevelActivatedEvent as CanonicalIndoorLevelActivatedEvent +import com.google.maps.android.MarkerDragEndEvent as CanonicalMarkerDragEndEvent +import com.google.maps.android.MarkerDragEvent as CanonicalMarkerDragEvent +import com.google.maps.android.MarkerDragStartEvent as CanonicalMarkerDragStartEvent +import com.google.maps.android.MoveStartedReason as CanonicalMoveStartedReason +import com.google.maps.android.OnMarkerDragEvent as CanonicalOnMarkerDragEvent +import com.google.maps.android.cameraEvents as canonicalCameraEvents +import com.google.maps.android.awaitAnimateCamera as canonicalAwaitAnimateCamera +import com.google.maps.android.awaitMapLoad as canonicalAwaitMapLoad +import com.google.maps.android.cameraIdleEvents as canonicalCameraIdleEvents +import com.google.maps.android.cameraMoveCanceledEvents as canonicalCameraMoveCanceledEvents +import com.google.maps.android.cameraMoveEvents as canonicalCameraMoveEvents +import com.google.maps.android.awaitSnapshot as canonicalAwaitSnapshot +import com.google.maps.android.cameraMoveStartedEvents as canonicalCameraMoveStartedEvents +import com.google.maps.android.circleClickEvents as canonicalCircleClickEvents +import com.google.maps.android.groundOverlayClicks as canonicalGroundOverlayClicks +import com.google.maps.android.indoorStateChangeEvents as canonicalIndoorStateChangeEvents +import com.google.maps.android.infoWindowClickEvents as canonicalInfoWindowClickEvents +import com.google.maps.android.infoWindowCloseEvents as canonicalInfoWindowCloseEvents +import com.google.maps.android.infoWindowLongClickEvents as canonicalInfoWindowLongClickEvents +import com.google.maps.android.mapClickEvents as canonicalMapClickEvents +import com.google.maps.android.mapLongClickEvents as canonicalMapLongClickEvents +import com.google.maps.android.markerClickEvents as canonicalMarkerClickEvents +import com.google.maps.android.markerDragEvents as canonicalMarkerDragEvents +import com.google.maps.android.myLocationButtonClickEvents as canonicalMyLocationButtonClickEvents +import com.google.maps.android.myLocationClickEvents as canonicalMyLocationClickEvents +import com.google.maps.android.poiClickEvents as canonicalPoiClickEvents +import com.google.maps.android.polygonClickEvents as canonicalPolygonClickEvents +import com.google.maps.android.polylineClickEvents as canonicalPolylineClickEvents +import com.google.maps.android.buildGoogleMapOptions as canonicalBuildGoogleMapOptions +import com.google.maps.android.addCircle as canonicalAddCircle +import com.google.maps.android.addGroundOverlay as canonicalAddGroundOverlay +import com.google.maps.android.addMarker as canonicalAddMarker +import com.google.maps.android.addPolygon as canonicalAddPolygon +import com.google.maps.android.addPolyline as canonicalAddPolyline +import com.google.maps.android.addTileOverlay as canonicalAddTileOverlay + +@Deprecated("Moved to com.google.maps.android.MoveStartedReason", ReplaceWith("MoveStartedReason", "com.google.maps.android.MoveStartedReason")) +public typealias MoveStartedReason = CanonicalMoveStartedReason + +@Deprecated("Moved to com.google.maps.android.CameraEvent", ReplaceWith("CameraEvent", "com.google.maps.android.CameraEvent")) +public typealias CameraEvent = CanonicalCameraEvent + +@Deprecated("Moved to com.google.maps.android.CameraIdleEvent", ReplaceWith("CameraIdleEvent", "com.google.maps.android.CameraIdleEvent")) +public typealias CameraIdleEvent = CanonicalCameraIdleEvent + +@Deprecated("Moved to com.google.maps.android.CameraMoveCanceledEvent", ReplaceWith("CameraMoveCanceledEvent", "com.google.maps.android.CameraMoveCanceledEvent")) +public typealias CameraMoveCanceledEvent = CanonicalCameraMoveCanceledEvent + +@Deprecated("Moved to com.google.maps.android.CameraMoveEvent", ReplaceWith("CameraMoveEvent", "com.google.maps.android.CameraMoveEvent")) +public typealias CameraMoveEvent = CanonicalCameraMoveEvent + +@Deprecated("Moved to com.google.maps.android.CameraMoveStartedEvent", ReplaceWith("CameraMoveStartedEvent", "com.google.maps.android.CameraMoveStartedEvent")) +public typealias CameraMoveStartedEvent = CanonicalCameraMoveStartedEvent + +@Deprecated("Moved to com.google.maps.android.OnMarkerDragEvent", ReplaceWith("OnMarkerDragEvent", "com.google.maps.android.OnMarkerDragEvent")) +public typealias OnMarkerDragEvent = CanonicalOnMarkerDragEvent + +@Deprecated("Moved to com.google.maps.android.MarkerDragEvent", ReplaceWith("MarkerDragEvent", "com.google.maps.android.MarkerDragEvent")) +public typealias MarkerDragEvent = CanonicalMarkerDragEvent + +@Deprecated("Moved to com.google.maps.android.MarkerDragEndEvent", ReplaceWith("MarkerDragEndEvent", "com.google.maps.android.MarkerDragEndEvent")) +public typealias MarkerDragEndEvent = CanonicalMarkerDragEndEvent + +@Deprecated("Moved to com.google.maps.android.MarkerDragStartEvent", ReplaceWith("MarkerDragStartEvent", "com.google.maps.android.MarkerDragStartEvent")) +public typealias MarkerDragStartEvent = CanonicalMarkerDragStartEvent + +@Deprecated("Moved to com.google.maps.android.IndoorChangeEvent", ReplaceWith("IndoorChangeEvent", "com.google.maps.android.IndoorChangeEvent")) +public typealias IndoorChangeEvent = CanonicalIndoorChangeEvent + +@Deprecated("Moved to com.google.maps.android.IndoorBuildingFocusedEvent", ReplaceWith("IndoorBuildingFocusedEvent", "com.google.maps.android.IndoorBuildingFocusedEvent")) +public typealias IndoorBuildingFocusedEvent = CanonicalIndoorBuildingFocusedEvent + +@Deprecated("Moved to com.google.maps.android.IndoorLevelActivatedEvent", ReplaceWith("IndoorLevelActivatedEvent", "com.google.maps.android.IndoorLevelActivatedEvent")) +public typealias IndoorLevelActivatedEvent = CanonicalIndoorLevelActivatedEvent + +@Suppress("DEPRECATION") +@Deprecated("Use cameraIdleEvents(), cameraMoveCanceledEvents(), cameraMoveEvents() or cameraMoveStartedEvents") +public fun GoogleMap.cameraEvents(): Flow = this.canonicalCameraEvents() + +@Deprecated("Moved to com.google.maps.android.awaitAnimateCamera", ReplaceWith("awaitAnimateCamera(cameraUpdate, durationMs)", "com.google.maps.android.awaitAnimateCamera")) +public suspend fun GoogleMap.awaitAnimateCamera(cameraUpdate: CameraUpdate, durationMs: Int? = null): Unit = this.canonicalAwaitAnimateCamera(cameraUpdate, durationMs) + +@Deprecated("Moved to com.google.maps.android.awaitMapLoad", ReplaceWith("awaitMapLoad()", "com.google.maps.android.awaitMapLoad")) +public suspend fun GoogleMap.awaitMapLoad(): Unit = this.canonicalAwaitMapLoad() + +@Deprecated("Moved to com.google.maps.android.cameraIdleEvents", ReplaceWith("cameraIdleEvents()", "com.google.maps.android.cameraIdleEvents")) +public fun GoogleMap.cameraIdleEvents(): Flow = this.canonicalCameraIdleEvents() + +@Deprecated("Moved to com.google.maps.android.cameraMoveCanceledEvents", ReplaceWith("cameraMoveCanceledEvents()", "com.google.maps.android.cameraMoveCanceledEvents")) +public fun GoogleMap.cameraMoveCanceledEvents(): Flow = this.canonicalCameraMoveCanceledEvents() + +@Deprecated("Moved to com.google.maps.android.cameraMoveEvents", ReplaceWith("cameraMoveEvents()", "com.google.maps.android.cameraMoveEvents")) +public fun GoogleMap.cameraMoveEvents(): Flow = this.canonicalCameraMoveEvents() + +@Deprecated("Moved to com.google.maps.android.awaitSnapshot", ReplaceWith("awaitSnapshot(bitmap)", "com.google.maps.android.awaitSnapshot")) +public suspend fun GoogleMap.awaitSnapshot(bitmap: Bitmap? = null): Bitmap? = this.canonicalAwaitSnapshot(bitmap) + + +@Deprecated("Moved to com.google.maps.android.cameraMoveStartedEvents", ReplaceWith("cameraMoveStartedEvents()", "com.google.maps.android.cameraMoveStartedEvents")) +public fun GoogleMap.cameraMoveStartedEvents(): Flow = this.canonicalCameraMoveStartedEvents() + +@Deprecated("Moved to com.google.maps.android.circleClickEvents", ReplaceWith("circleClickEvents()", "com.google.maps.android.circleClickEvents")) +public fun GoogleMap.circleClickEvents(): Flow = this.canonicalCircleClickEvents() + +@Deprecated("Moved to com.google.maps.android.groundOverlayClicks", ReplaceWith("groundOverlayClicks()", "com.google.maps.android.groundOverlayClicks")) +public fun GoogleMap.groundOverlayClicks(): Flow = this.canonicalGroundOverlayClicks() + +@Suppress("DEPRECATION") +@Deprecated("Moved to com.google.maps.android.indoorStateChangeEvents", ReplaceWith("indoorStateChangeEvents()", "com.google.maps.android.indoorStateChangeEvents")) +public fun GoogleMap.indoorStateChangeEvents(): Flow = this.canonicalIndoorStateChangeEvents() + +@Deprecated("Moved to com.google.maps.android.infoWindowClickEvents", ReplaceWith("infoWindowClickEvents()", "com.google.maps.android.infoWindowClickEvents")) +public fun GoogleMap.infoWindowClickEvents(): Flow = this.canonicalInfoWindowClickEvents() + +@Deprecated("Moved to com.google.maps.android.infoWindowCloseEvents", ReplaceWith("infoWindowCloseEvents()", "com.google.maps.android.infoWindowCloseEvents")) +public fun GoogleMap.infoWindowCloseEvents(): Flow = this.canonicalInfoWindowCloseEvents() + +@Deprecated("Moved to com.google.maps.android.infoWindowLongClickEvents", ReplaceWith("infoWindowLongClickEvents()", "com.google.maps.android.infoWindowLongClickEvents")) +public fun GoogleMap.infoWindowLongClickEvents(): Flow = this.canonicalInfoWindowLongClickEvents() + +@Deprecated("Moved to com.google.maps.android.mapClickEvents", ReplaceWith("mapClickEvents()", "com.google.maps.android.mapClickEvents")) +public fun GoogleMap.mapClickEvents(): Flow = this.canonicalMapClickEvents() + +@Deprecated("Moved to com.google.maps.android.mapLongClickEvents", ReplaceWith("mapLongClickEvents()", "com.google.maps.android.mapLongClickEvents")) +public fun GoogleMap.mapLongClickEvents(): Flow = this.canonicalMapLongClickEvents() + +@Deprecated("Moved to com.google.maps.android.markerClickEvents", ReplaceWith("markerClickEvents()", "com.google.maps.android.markerClickEvents")) +public fun GoogleMap.markerClickEvents(): Flow = this.canonicalMarkerClickEvents() + +@Suppress("DEPRECATION") +@Deprecated("Moved to com.google.maps.android.markerDragEvents", ReplaceWith("markerDragEvents()", "com.google.maps.android.markerDragEvents")) +public fun GoogleMap.markerDragEvents(): Flow = this.canonicalMarkerDragEvents() + +@Deprecated("Moved to com.google.maps.android.myLocationButtonClickEvents", ReplaceWith("myLocationButtonClickEvents()", "com.google.maps.android.myLocationButtonClickEvents")) +public fun GoogleMap.myLocationButtonClickEvents(): Flow = this.canonicalMyLocationButtonClickEvents() + +@Deprecated("Moved to com.google.maps.android.myLocationClickEvents", ReplaceWith("myLocationClickEvents()", "com.google.maps.android.myLocationClickEvents")) +public fun GoogleMap.myLocationClickEvents(): Flow = this.canonicalMyLocationClickEvents() + +@Deprecated("Moved to com.google.maps.android.poiClickEvents", ReplaceWith("poiClickEvents()", "com.google.maps.android.poiClickEvents")) +public fun GoogleMap.poiClickEvents(): Flow = this.canonicalPoiClickEvents() + +@Deprecated("Moved to com.google.maps.android.polygonClickEvents", ReplaceWith("polygonClickEvents()", "com.google.maps.android.polygonClickEvents")) +public fun GoogleMap.polygonClickEvents(): Flow = this.canonicalPolygonClickEvents() + +@Deprecated("Moved to com.google.maps.android.polylineClickEvents", ReplaceWith("polylineClickEvents()", "com.google.maps.android.polylineClickEvents")) +public fun GoogleMap.polylineClickEvents(): Flow = this.canonicalPolylineClickEvents() + +@Deprecated("Moved to com.google.maps.android.buildGoogleMapOptions", ReplaceWith("buildGoogleMapOptions(optionsActions)", "com.google.maps.android.buildGoogleMapOptions")) +public inline fun buildGoogleMapOptions(optionsActions: GoogleMapOptions.() -> Unit): GoogleMapOptions = canonicalBuildGoogleMapOptions(optionsActions) + +@Deprecated("Moved to com.google.maps.android.addCircle", ReplaceWith("addCircle(optionsActions)", "com.google.maps.android.addCircle")) +public inline fun GoogleMap.addCircle(optionsActions: CircleOptions.() -> Unit): Circle = this.canonicalAddCircle(optionsActions) + +@Deprecated("Moved to com.google.maps.android.addGroundOverlay", ReplaceWith("addGroundOverlay(optionsActions)", "com.google.maps.android.addGroundOverlay")) +public inline fun GoogleMap.addGroundOverlay(optionsActions: GroundOverlayOptions.() -> Unit): GroundOverlay? = this.canonicalAddGroundOverlay(optionsActions) + +@Deprecated("Moved to com.google.maps.android.addMarker", ReplaceWith("addMarker(optionsActions)", "com.google.maps.android.addMarker")) +public inline fun GoogleMap.addMarker(optionsActions: MarkerOptions.() -> Unit): Marker? = this.canonicalAddMarker(optionsActions) + +@Deprecated("Moved to com.google.maps.android.addPolygon", ReplaceWith("addPolygon(optionsActions)", "com.google.maps.android.addPolygon")) +public inline fun GoogleMap.addPolygon(optionsActions: PolygonOptions.() -> Unit): Polygon = this.canonicalAddPolygon(optionsActions) + +@Deprecated("Moved to com.google.maps.android.addPolyline", ReplaceWith("addPolyline(optionsActions)", "com.google.maps.android.addPolyline")) +public inline fun GoogleMap.addPolyline(optionsActions: PolylineOptions.() -> Unit): Polyline = this.canonicalAddPolyline(optionsActions) + +@Deprecated("Moved to com.google.maps.android.addTileOverlay", ReplaceWith("addTileOverlay(optionsActions)", "com.google.maps.android.addTileOverlay")) +public inline fun GoogleMap.addTileOverlay(optionsActions: TileOverlayOptions.() -> Unit): TileOverlay? = this.canonicalAddTileOverlay(optionsActions) diff --git a/library/src/main/java/com/google/maps/android/ktx/MapFragment.kt b/library/src/main/java/com/google/maps/android/ktx/MapFragment.kt new file mode 100644 index 000000000..41e8a200a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/MapFragment.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapFragment +import com.google.maps.android.awaitMap as canonicalAwaitMap + +@Deprecated( + message = "Use com.google.maps.android.awaitMap instead", + replaceWith = ReplaceWith("awaitMap()", "com.google.maps.android.awaitMap"), + level = DeprecationLevel.WARNING +) +public suspend fun MapFragment.awaitMap(): GoogleMap = this.canonicalAwaitMap() + diff --git a/library/src/main/java/com/google/maps/android/ktx/MapView.kt b/library/src/main/java/com/google/maps/android/ktx/MapView.kt new file mode 100644 index 000000000..128df239d --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/MapView.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.maps.android.awaitMap as canonicalAwaitMap + +@Deprecated( + message = "Use com.google.maps.android.awaitMap instead", + replaceWith = ReplaceWith("awaitMap()", "com.google.maps.android.awaitMap"), + level = DeprecationLevel.WARNING +) +public suspend fun MapView.awaitMap(): GoogleMap = this.canonicalAwaitMap() + diff --git a/library/src/main/java/com/google/maps/android/ktx/MapsExperimentalFeature.kt b/library/src/main/java/com/google/maps/android/ktx/MapsExperimentalFeature.kt new file mode 100644 index 000000000..099426c7e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/MapsExperimentalFeature.kt @@ -0,0 +1,25 @@ +/* + * 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 + +@RequiresOptIn +@Deprecated( + message = "The KTX library functionality has been moved to com.google.maps.android. Use com.google.maps.android.MapsExperimentalFeature instead.", + replaceWith = ReplaceWith("MapsExperimentalFeature", "com.google.maps.android.MapsExperimentalFeature"), + level = DeprecationLevel.WARNING +) +@Retention(AnnotationRetention.BINARY) +public annotation class MapsExperimentalFeature diff --git a/library/src/main/java/com/google/maps/android/ktx/MapsInitializer.kt b/library/src/main/java/com/google/maps/android/ktx/MapsInitializer.kt new file mode 100644 index 000000000..19e18030d --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/MapsInitializer.kt @@ -0,0 +1,39 @@ +/* + * 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 + +import android.content.Context +import com.google.android.gms.maps.MapsInitializer +import com.google.maps.android.awaitMapsSdkInitialized as canonicalAwaitMapsSdkInitialized + +/** + * Suspends until the Google Maps SDK is initialized and returns the [MapsInitializer.Renderer] + * that was actually loaded. + * + * @deprecated Use [com.google.maps.android.awaitMapsSdkInitialized] instead. + */ +@Deprecated( + message = "Use com.google.maps.android.awaitMapsSdkInitialized instead", + replaceWith = ReplaceWith( + "awaitMapsSdkInitialized(preferredRenderer)", + "com.google.maps.android.awaitMapsSdkInitialized" + ), + level = DeprecationLevel.WARNING +) +public suspend fun Context.awaitMapsSdkInitialized( + preferredRenderer: MapsInitializer.Renderer? = null +): MapsInitializer.Renderer = this.canonicalAwaitMapsSdkInitialized(preferredRenderer) + diff --git a/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt b/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt new file mode 100644 index 000000000..3dccb8729 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaFragment.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import com.google.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaFragment +import com.google.maps.android.awaitStreetViewPanorama as canonicalAwaitStreetViewPanorama + +@Deprecated( + message = "Use com.google.maps.android.awaitStreetViewPanorama instead", + replaceWith = ReplaceWith("awaitStreetViewPanorama()", "com.google.maps.android.awaitStreetViewPanorama"), + level = DeprecationLevel.WARNING +) +public suspend fun StreetViewPanoramaFragment.awaitStreetViewPanorama(): StreetViewPanorama = this.canonicalAwaitStreetViewPanorama() + diff --git a/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaView.kt b/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaView.kt new file mode 100644 index 000000000..dfd5b9c6d --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/StreetViewPanoramaView.kt @@ -0,0 +1,64 @@ +/* + * 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 + +import com.google.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.model.StreetViewPanoramaCamera +import com.google.android.gms.maps.model.StreetViewPanoramaLocation +import com.google.android.gms.maps.model.StreetViewPanoramaOrientation +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.awaitStreetViewPanorama as canonicalAwaitStreetViewPanorama +import com.google.maps.android.cameraChangeEvents as canonicalCameraChangeEvents +import com.google.maps.android.changeEvents as canonicalChangeEvents +import com.google.maps.android.clickEvents as canonicalClickEvents +import com.google.maps.android.longClickEvents as canonicalLongClickEvents + +@Deprecated( + message = "Use com.google.maps.android.awaitStreetViewPanorama instead", + replaceWith = ReplaceWith("awaitStreetViewPanorama()", "com.google.maps.android.awaitStreetViewPanorama"), + level = DeprecationLevel.WARNING +) +public suspend fun StreetViewPanoramaView.awaitStreetViewPanorama(): StreetViewPanorama = this.canonicalAwaitStreetViewPanorama() + + +@Deprecated( + message = "Use com.google.maps.android.cameraChangeEvents instead", + replaceWith = ReplaceWith("cameraChangeEvents()", "com.google.maps.android.cameraChangeEvents"), + level = DeprecationLevel.WARNING +) +public fun StreetViewPanorama.cameraChangeEvents(): Flow = this.canonicalCameraChangeEvents() + +@Deprecated( + message = "Use com.google.maps.android.changeEvents instead", + replaceWith = ReplaceWith("changeEvents()", "com.google.maps.android.changeEvents"), + level = DeprecationLevel.WARNING +) +public fun StreetViewPanorama.changeEvents(): Flow = this.canonicalChangeEvents() + +@Deprecated( + message = "Use com.google.maps.android.clickEvents instead", + replaceWith = ReplaceWith("clickEvents()", "com.google.maps.android.clickEvents"), + level = DeprecationLevel.WARNING +) +public fun StreetViewPanorama.clickEvents(): Flow = this.canonicalClickEvents() + +@Deprecated( + message = "Use com.google.maps.android.longClickEvents instead", + replaceWith = ReplaceWith("longClickEvents()", "com.google.maps.android.longClickEvents"), + level = DeprecationLevel.WARNING +) +public fun StreetViewPanorama.longClickEvents(): Flow = this.canonicalLongClickEvents() diff --git a/library/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt b/library/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt new file mode 100644 index 000000000..8621a0683 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.SupportMapFragment +import com.google.maps.android.awaitMap as canonicalAwaitMap + +@Deprecated( + message = "Use com.google.maps.android.awaitMap instead", + replaceWith = ReplaceWith("awaitMap()", "com.google.maps.android.awaitMap"), + level = DeprecationLevel.WARNING +) +public suspend fun SupportMapFragment.awaitMap(): GoogleMap = this.canonicalAwaitMap() + diff --git a/library/src/main/java/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt b/library/src/main/java/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt new file mode 100644 index 000000000..07cc270c3 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/SupportStreetViewPanoramaFragment.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import com.google.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment +import com.google.maps.android.awaitStreetViewPanorama as canonicalAwaitStreetViewPanorama + +@Deprecated( + message = "Use com.google.maps.android.awaitStreetViewPanorama instead", + replaceWith = ReplaceWith("awaitStreetViewPanorama()", "com.google.maps.android.awaitStreetViewPanorama"), + level = DeprecationLevel.WARNING +) +public suspend fun SupportStreetViewPanoramaFragment.awaitStreetViewPanorama(): StreetViewPanorama = this.canonicalAwaitStreetViewPanorama() + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/CameraPosition.kt b/library/src/main/java/com/google/maps/android/ktx/model/CameraPosition.kt new file mode 100644 index 000000000..2b3ee122e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/CameraPosition.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.model.cameraPosition as canonicalCameraPosition + +@Deprecated( + message = "Use com.google.maps.android.model.cameraPosition instead", + replaceWith = ReplaceWith("cameraPosition(optionsActions)", "com.google.maps.android.model.cameraPosition"), + level = DeprecationLevel.WARNING +) +public inline fun cameraPosition(optionsActions: CameraPosition.Builder.() -> Unit): CameraPosition = canonicalCameraPosition(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/CircleOptions.kt b/library/src/main/java/com/google/maps/android/ktx/model/CircleOptions.kt new file mode 100644 index 000000000..9a0eb5f51 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/CircleOptions.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.CircleOptions +import com.google.maps.android.model.circleOptions as canonicalCircleOptions + +@Deprecated( + message = "Use com.google.maps.android.model.circleOptions instead", + replaceWith = ReplaceWith("circleOptions(optionsActions)", "com.google.maps.android.model.circleOptions"), + level = DeprecationLevel.WARNING +) +public inline fun circleOptions(optionsActions: CircleOptions.() -> Unit): CircleOptions = canonicalCircleOptions(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/GroundOverlayOptions.kt b/library/src/main/java/com/google/maps/android/ktx/model/GroundOverlayOptions.kt new file mode 100644 index 000000000..9e285218d --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/GroundOverlayOptions.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.maps.android.model.groundOverlayOptions as canonicalGroundOverlayOptions + +@Deprecated( + message = "Use com.google.maps.android.model.groundOverlayOptions instead", + replaceWith = ReplaceWith("groundOverlayOptions(optionsActions)", "com.google.maps.android.model.groundOverlayOptions"), + level = DeprecationLevel.WARNING +) +public inline fun groundOverlayOptions(optionsActions: GroundOverlayOptions.() -> Unit): GroundOverlayOptions = canonicalGroundOverlayOptions(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/MarkerOptions.kt b/library/src/main/java/com/google/maps/android/ktx/model/MarkerOptions.kt new file mode 100644 index 000000000..81ea9d377 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/MarkerOptions.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.MarkerOptions +import com.google.maps.android.model.markerOptions as canonicalMarkerOptions + +@Deprecated( + message = "Use com.google.maps.android.model.markerOptions instead", + replaceWith = ReplaceWith("markerOptions(optionsActions)", "com.google.maps.android.model.markerOptions"), + level = DeprecationLevel.WARNING +) +public inline fun markerOptions(optionsActions: MarkerOptions.() -> Unit): MarkerOptions = canonicalMarkerOptions(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/PolygonOptions.kt b/library/src/main/java/com/google/maps/android/ktx/model/PolygonOptions.kt new file mode 100644 index 000000000..de0bdca90 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/PolygonOptions.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.PolygonOptions +import com.google.maps.android.model.polygonOptions as canonicalPolygonOptions + +@Deprecated( + message = "Use com.google.maps.android.model.polygonOptions instead", + replaceWith = ReplaceWith("polygonOptions(optionsActions)", "com.google.maps.android.model.polygonOptions"), + level = DeprecationLevel.WARNING +) +public inline fun polygonOptions(optionsActions: PolygonOptions.() -> Unit): PolygonOptions = canonicalPolygonOptions(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/PolylineOptions.kt b/library/src/main/java/com/google/maps/android/ktx/model/PolylineOptions.kt new file mode 100644 index 000000000..b86868529 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/PolylineOptions.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.model.polylineOptions as canonicalPolylineOptions + +@Deprecated( + message = "Use com.google.maps.android.model.polylineOptions instead", + replaceWith = ReplaceWith("polylineOptions(optionsActions)", "com.google.maps.android.model.polylineOptions"), + level = DeprecationLevel.WARNING +) +public inline fun polylineOptions(optionsActions: PolylineOptions.() -> Unit): PolylineOptions = canonicalPolylineOptions(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.kt b/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.kt new file mode 100644 index 000000000..6112674a9 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaCamera.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.StreetViewPanoramaCamera +import com.google.maps.android.model.streetViewPanoramaCamera as canonicalStreetViewPanoramaCamera + +@Deprecated( + message = "Use com.google.maps.android.model.streetViewPanoramaCamera instead", + replaceWith = ReplaceWith("streetViewPanoramaCamera(optionsActions)", "com.google.maps.android.model.streetViewPanoramaCamera"), + level = DeprecationLevel.WARNING +) +public inline fun streetViewPanoramaCamera(optionsActions: StreetViewPanoramaCamera.Builder.() -> Unit): StreetViewPanoramaCamera = canonicalStreetViewPanoramaCamera(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.kt b/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.kt new file mode 100644 index 000000000..ea9582e48 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientation.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.StreetViewPanoramaOrientation +import com.google.maps.android.model.streetViewPanoramaOrientation as canonicalStreetViewPanoramaOrientation + +@Deprecated( + message = "Use com.google.maps.android.model.streetViewPanoramaOrientation instead", + replaceWith = ReplaceWith("streetViewPanoramaOrientation(optionsActions)", "com.google.maps.android.model.streetViewPanoramaOrientation"), + level = DeprecationLevel.WARNING +) +public inline fun streetViewPanoramaOrientation(optionsActions: StreetViewPanoramaOrientation.Builder.() -> Unit): StreetViewPanoramaOrientation = canonicalStreetViewPanoramaOrientation(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/model/TileOverlayOptions.kt b/library/src/main/java/com/google/maps/android/ktx/model/TileOverlayOptions.kt new file mode 100644 index 000000000..9c2e27dcc --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/model/TileOverlayOptions.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.TileOverlayOptions +import com.google.maps.android.model.tileOverlayOptions as canonicalTileOverlayOptions + +@Deprecated( + message = "Use com.google.maps.android.model.tileOverlayOptions instead", + replaceWith = ReplaceWith("tileOverlayOptions(optionsActions)", "com.google.maps.android.model.tileOverlayOptions"), + level = DeprecationLevel.WARNING +) +public inline fun tileOverlayOptions(optionsActions: TileOverlayOptions.() -> Unit): TileOverlayOptions = canonicalTileOverlayOptions(optionsActions) + diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/LatLng.kt b/library/src/main/java/com/google/maps/android/ktx/utils/LatLng.kt new file mode 100644 index 000000000..cb7e7174f --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/LatLng.kt @@ -0,0 +1,87 @@ +/* + * 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.component1 as canonicalComponent1 +import com.google.maps.android.component2 as canonicalComponent2 +import com.google.maps.android.isLocationOnPath as canonicalIsLocationOnPath +import com.google.maps.android.isOnEdge as canonicalIsOnEdge +import com.google.maps.android.containsLocation as canonicalContainsLocation +import com.google.maps.android.simplify as canonicalSimplify +import com.google.maps.android.toLatLngList as canonicalToLatLngList +import com.google.maps.android.latLngListEncode as canonicalLatLngListEncode +import com.google.maps.android.isClosedPolygon as canonicalIsClosedPolygon +import com.google.maps.android.sphericalPathLength as canonicalSphericalPathLength +import com.google.maps.android.sphericalPolygonArea as canonicalSphericalPolygonArea +import com.google.maps.android.sphericalPolygonSignedArea as canonicalSphericalPolygonSignedArea +import com.google.maps.android.sphericalHeading as canonicalSphericalHeading +import com.google.maps.android.withSphericalOffset as canonicalWithSphericalOffset +import com.google.maps.android.computeSphericalOffsetOrigin as canonicalComputeSphericalOffsetOrigin +import com.google.maps.android.withSphericalLinearInterpolation as canonicalWithSphericalLinearInterpolation +import com.google.maps.android.sphericalDistance as canonicalSphericalDistance + +@Deprecated("Moved to com.google.maps.android.component1", ReplaceWith("component1()", "com.google.maps.android.component1")) +public operator fun LatLng.component1(): Double = this.canonicalComponent1() + +@Deprecated("Moved to com.google.maps.android.component2", ReplaceWith("component2()", "com.google.maps.android.component2")) +public operator fun LatLng.component2(): Double = this.canonicalComponent2() + +@Deprecated("Moved to com.google.maps.android.isLocationOnPath", ReplaceWith("isLocationOnPath(latLng, geodesic, tolerance)", "com.google.maps.android.isLocationOnPath")) +public fun List.isLocationOnPath(latLng: LatLng, geodesic: Boolean, tolerance: Double = 0.1): Boolean = this.canonicalIsLocationOnPath(latLng, geodesic, tolerance) + +@Deprecated("Moved to com.google.maps.android.isOnEdge", ReplaceWith("isOnEdge(latLng, geodesic, tolerance)", "com.google.maps.android.isOnEdge")) +public fun List.isOnEdge(latLng: LatLng, geodesic: Boolean, tolerance: Double = 0.1): Boolean = this.canonicalIsOnEdge(latLng, geodesic, tolerance) + +@Deprecated("Moved to com.google.maps.android.containsLocation", ReplaceWith("containsLocation(latLng, geodesic)", "com.google.maps.android.containsLocation")) +public fun List.containsLocation(latLng: LatLng, geodesic: Boolean): Boolean = this.canonicalContainsLocation(latLng, geodesic) + +@Deprecated("Moved to com.google.maps.android.simplify", ReplaceWith("simplify(tolerance)", "com.google.maps.android.simplify")) +public fun List.simplify(tolerance: Double): List = this.canonicalSimplify(tolerance) + +@Deprecated("Moved to com.google.maps.android.toLatLngList", ReplaceWith("toLatLngList()", "com.google.maps.android.toLatLngList")) +public fun String.toLatLngList(): List = this.canonicalToLatLngList() + +@Deprecated("Moved to com.google.maps.android.latLngListEncode", ReplaceWith("latLngListEncode()", "com.google.maps.android.latLngListEncode")) +public fun List.latLngListEncode(): String = this.canonicalLatLngListEncode() + +@Deprecated("Moved to com.google.maps.android.isClosedPolygon", ReplaceWith("isClosedPolygon()", "com.google.maps.android.isClosedPolygon")) +public fun List.isClosedPolygon(): Boolean = this.canonicalIsClosedPolygon() + +@Deprecated("Moved to com.google.maps.android.sphericalPathLength", ReplaceWith("sphericalPathLength()", "com.google.maps.android.sphericalPathLength")) +public fun List.sphericalPathLength(): Double = this.canonicalSphericalPathLength() + +@Deprecated("Moved to com.google.maps.android.sphericalPolygonArea", ReplaceWith("sphericalPolygonArea()", "com.google.maps.android.sphericalPolygonArea")) +public fun List.sphericalPolygonArea(): Double = this.canonicalSphericalPolygonArea() + +@Deprecated("Moved to com.google.maps.android.sphericalPolygonSignedArea", ReplaceWith("sphericalPolygonSignedArea()", "com.google.maps.android.sphericalPolygonSignedArea")) +public fun List.sphericalPolygonSignedArea(): Double = this.canonicalSphericalPolygonSignedArea() + +@Deprecated("Moved to com.google.maps.android.sphericalHeading", ReplaceWith("sphericalHeading(toLatLng)", "com.google.maps.android.sphericalHeading")) +public fun LatLng.sphericalHeading(toLatLng: LatLng): Double = this.canonicalSphericalHeading(toLatLng) + +@Deprecated("Moved to com.google.maps.android.withSphericalOffset", ReplaceWith("withSphericalOffset(distance, heading)", "com.google.maps.android.withSphericalOffset")) +public fun LatLng.withSphericalOffset(distance: Double, heading: Double): LatLng = this.canonicalWithSphericalOffset(distance, heading) + +@Deprecated("Moved to com.google.maps.android.computeSphericalOffsetOrigin", ReplaceWith("computeSphericalOffsetOrigin(distance, heading)", "com.google.maps.android.computeSphericalOffsetOrigin")) +public fun LatLng.computeSphericalOffsetOrigin(distance: Double, heading: Double): LatLng? = this.canonicalComputeSphericalOffsetOrigin(distance, heading) + +@Deprecated("Moved to com.google.maps.android.withSphericalLinearInterpolation", ReplaceWith("withSphericalLinearInterpolation(to, fraction)", "com.google.maps.android.withSphericalLinearInterpolation")) +public fun LatLng.withSphericalLinearInterpolation(to: LatLng, fraction: Double): LatLng = this.canonicalWithSphericalLinearInterpolation(to, fraction) + +@Deprecated("Moved to com.google.maps.android.sphericalDistance", ReplaceWith("sphericalDistance(to)", "com.google.maps.android.sphericalDistance")) +public fun LatLng.sphericalDistance(to: LatLng): Double = this.canonicalSphericalDistance(to) + diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/Polygon.kt b/library/src/main/java/com/google/maps/android/ktx/utils/Polygon.kt new file mode 100644 index 000000000..48d71680d --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/Polygon.kt @@ -0,0 +1,36 @@ +/* + * 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polygon +import com.google.maps.android.contains as canonicalContains +import com.google.maps.android.isOnEdge as canonicalIsOnEdge +import com.google.maps.android.area as canonicalArea +import com.google.maps.android.signedArea as canonicalSignedArea + +@Deprecated("Moved to com.google.maps.android.contains", ReplaceWith("contains(latLng)", "com.google.maps.android.contains")) +public fun Polygon.contains(latLng: LatLng): Boolean = this.canonicalContains(latLng) + +@Deprecated("Moved to com.google.maps.android.isOnEdge", ReplaceWith("isOnEdge(latLng, tolerance)", "com.google.maps.android.isOnEdge")) +public fun Polygon.isOnEdge(latLng: LatLng, tolerance: Double = 0.1): Boolean = this.canonicalIsOnEdge(latLng, tolerance) + +@Deprecated("Moved to com.google.maps.android.area", ReplaceWith("area", "com.google.maps.android.area")) +public val Polygon.area: Double get() = this.canonicalArea + +@Deprecated("Moved to com.google.maps.android.signedArea", ReplaceWith("signedArea", "com.google.maps.android.signedArea")) +public val Polygon.signedArea: Double get() = this.canonicalSignedArea + diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/Polyline.kt b/library/src/main/java/com/google/maps/android/ktx/utils/Polyline.kt new file mode 100644 index 000000000..0f0850670 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/Polyline.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polyline +import com.google.maps.android.contains as canonicalContains +import com.google.maps.android.sphericalPathLength as canonicalSphericalPathLength + +@Deprecated("Moved to com.google.maps.android.contains", ReplaceWith("contains(latLng, tolerance)", "com.google.maps.android.contains")) +public fun Polyline.contains(latLng: LatLng, tolerance: Double = 0.1): Boolean = this.canonicalContains(latLng, tolerance) + +@Deprecated("Moved to com.google.maps.android.sphericalPathLength", ReplaceWith("sphericalPathLength", "com.google.maps.android.sphericalPathLength")) +public val Polyline.sphericalPathLength: Double get() = this.canonicalSphericalPathLength + diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt b/library/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt new file mode 100644 index 000000000..d45562e7a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.utils.collection + +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.CircleOptions +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.addCircle as canonicalAddCircle +import com.google.maps.android.collections.clickEvents as canonicalClickEvents +import com.google.maps.android.collections.infoWindowClickEvents as canonicalInfoWindowClickEvents +import com.google.maps.android.collections.infoWindowLongClickEvents as canonicalInfoWindowLongClickEvents + +@Deprecated( + message = "Use com.google.maps.android.collections.addCircle instead", + replaceWith = ReplaceWith("addCircle(optionsActions)", "com.google.maps.android.collections.addCircle"), + level = DeprecationLevel.WARNING +) +public inline fun CircleManager.Collection.addCircle(optionsActions: CircleOptions.() -> Unit): Circle = this.canonicalAddCircle(optionsActions) + +@Deprecated("Moved to com.google.maps.android.collections.clickEvents", ReplaceWith("clickEvents()", "com.google.maps.android.collections.clickEvents")) +public fun CircleManager.Collection.clickEvents(): Flow = this.canonicalClickEvents() diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt b/library/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt new file mode 100644 index 000000000..61a7267da --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt @@ -0,0 +1,38 @@ +/* + * 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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.utils.collection + +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.addGroundOverlay as canonicalAddGroundOverlay +import com.google.maps.android.collections.clickEvents as canonicalClickEvents +import com.google.maps.android.collections.infoWindowClickEvents as canonicalInfoWindowClickEvents +import com.google.maps.android.collections.infoWindowLongClickEvents as canonicalInfoWindowLongClickEvents + +@Deprecated( + message = "Use com.google.maps.android.collections.addGroundOverlay instead", + replaceWith = ReplaceWith("addGroundOverlay(optionsActions)", "com.google.maps.android.collections.addGroundOverlay"), + level = DeprecationLevel.WARNING +) +public inline fun GroundOverlayManager.Collection.addGroundOverlay(optionsActions: GroundOverlayOptions.() -> Unit): GroundOverlay = this.canonicalAddGroundOverlay(optionsActions) + +@Deprecated("Moved to com.google.maps.android.collections.clickEvents", ReplaceWith("clickEvents()", "com.google.maps.android.collections.clickEvents")) +public fun GroundOverlayManager.Collection.clickEvents(): Flow = this.canonicalClickEvents() + diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt b/library/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt new file mode 100644 index 000000000..f24a8a989 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.utils.collection + +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.addMarker as canonicalAddMarker +import com.google.maps.android.collections.clickEvents as canonicalClickEvents +import com.google.maps.android.collections.infoWindowClickEvents as canonicalInfoWindowClickEvents +import com.google.maps.android.collections.infoWindowLongClickEvents as canonicalInfoWindowLongClickEvents + +@Deprecated( + message = "Use com.google.maps.android.collections.addMarker instead", + replaceWith = ReplaceWith("addMarker(optionsActions)", "com.google.maps.android.collections.addMarker"), + level = DeprecationLevel.WARNING +) +public inline fun MarkerManager.Collection.addMarker(optionsActions: MarkerOptions.() -> Unit): Marker = this.canonicalAddMarker(optionsActions) + +@Deprecated("Moved to com.google.maps.android.collections.clickEvents", ReplaceWith("clickEvents()", "com.google.maps.android.collections.clickEvents")) +public fun MarkerManager.Collection.clickEvents(): Flow = this.canonicalClickEvents() + +@Deprecated("Moved to com.google.maps.android.collections.infoWindowClickEvents", ReplaceWith("infoWindowClickEvents()", "com.google.maps.android.collections.infoWindowClickEvents")) +public fun MarkerManager.Collection.infoWindowClickEvents(): Flow = this.canonicalInfoWindowClickEvents() + +@Deprecated("Moved to com.google.maps.android.collections.infoWindowLongClickEvents", ReplaceWith("infoWindowLongClickEvents()", "com.google.maps.android.collections.infoWindowLongClickEvents")) +public fun MarkerManager.Collection.infoWindowLongClickEvents(): Flow = this.canonicalInfoWindowLongClickEvents() diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt b/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt new file mode 100644 index 000000000..e81af8b6a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.utils.collection + +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.addPolygon as canonicalAddPolygon +import com.google.maps.android.collections.clickEvents as canonicalClickEvents +import com.google.maps.android.collections.infoWindowClickEvents as canonicalInfoWindowClickEvents +import com.google.maps.android.collections.infoWindowLongClickEvents as canonicalInfoWindowLongClickEvents + +@Deprecated( + message = "Use com.google.maps.android.collections.addPolygon instead", + replaceWith = ReplaceWith("addPolygon(optionsActions)", "com.google.maps.android.collections.addPolygon"), + level = DeprecationLevel.WARNING +) +public inline fun PolygonManager.Collection.addPolygon(optionsActions: PolygonOptions.() -> Unit): Polygon = this.canonicalAddPolygon(optionsActions) + +@Deprecated("Moved to com.google.maps.android.collections.clickEvents", ReplaceWith("clickEvents()", "com.google.maps.android.collections.clickEvents")) +public fun PolygonManager.Collection.clickEvents(): Flow = this.canonicalClickEvents() diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt b/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt new file mode 100644 index 000000000..944da71a0 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.google.maps.android.ktx.utils.collection + +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.collections.addPolyline as canonicalAddPolyline +import com.google.maps.android.collections.clickEvents as canonicalClickEvents +import com.google.maps.android.collections.infoWindowClickEvents as canonicalInfoWindowClickEvents +import com.google.maps.android.collections.infoWindowLongClickEvents as canonicalInfoWindowLongClickEvents + +@Deprecated( + message = "Use com.google.maps.android.collections.addPolyline instead", + replaceWith = ReplaceWith("addPolyline(optionsActions)", "com.google.maps.android.collections.addPolyline"), + level = DeprecationLevel.WARNING +) +public inline fun PolylineManager.Collection.addPolyline(optionsActions: PolylineOptions.() -> Unit): Polyline = this.canonicalAddPolyline(optionsActions) + +@Deprecated("Moved to com.google.maps.android.collections.clickEvents", ReplaceWith("clickEvents()", "com.google.maps.android.collections.clickEvents")) +public fun PolylineManager.Collection.clickEvents(): Flow = this.canonicalClickEvents() diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt b/library/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt new file mode 100644 index 000000000..f7bfd9ab7 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.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.ktx.utils.location + +import android.Manifest +import android.location.Location +import android.os.Looper +import androidx.annotation.RequiresPermission +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.Priority +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.location.locationEvents as canonicalLocationEvents +import com.google.maps.android.location.fusedLocationEvents as canonicalFusedLocationEvents + +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +@Deprecated("Moved to com.google.maps.android.location.locationEvents", ReplaceWith("locationEvents(locationRequest, looper)", "com.google.maps.android.location.locationEvents")) +public fun FusedLocationProviderClient.locationEvents( + locationRequest: LocationRequest, + looper: Looper = Looper.getMainLooper() +): Flow = this.canonicalLocationEvents(locationRequest, looper) + +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +@Deprecated("Moved to com.google.maps.android.location.fusedLocationEvents", ReplaceWith("fusedLocationEvents(intervalMs, minUpdateDistanceM, priority, looper)", "com.google.maps.android.location.fusedLocationEvents")) +public fun FusedLocationProviderClient.fusedLocationEvents( + intervalMs: Long = 2000L, + minUpdateDistanceM: Float = 1f, + priority: Int = Priority.PRIORITY_HIGH_ACCURACY, + looper: Looper = Looper.getMainLooper() +): Flow = this.canonicalFusedLocationEvents(intervalMs, minUpdateDistanceM, priority, looper) diff --git a/library/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt b/library/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt new file mode 100644 index 000000000..ad7b575c0 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt @@ -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.ktx.utils.location + +import android.Manifest +import android.location.Location +import android.location.LocationManager +import android.os.Looper +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.flow.Flow +import com.google.maps.android.location.coarseLocationEvents as canonicalCoarseLocationEvents +import com.google.maps.android.location.fineLocationEvents as canonicalFineLocationEvents + +@RequiresPermission(Manifest.permission.ACCESS_COARSE_LOCATION) +@Deprecated("Moved to com.google.maps.android.location.coarseLocationEvents", ReplaceWith("coarseLocationEvents(minTimeMs, minDistanceM, looper)", "com.google.maps.android.location.coarseLocationEvents")) +public fun LocationManager.coarseLocationEvents( + minTimeMs: Long = 1_000L, + minDistanceM: Float = 1f, + looper: Looper = Looper.getMainLooper() +): Flow = this.canonicalCoarseLocationEvents(minTimeMs, minDistanceM, looper) + +@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) +@Deprecated("Moved to com.google.maps.android.location.fineLocationEvents", ReplaceWith("fineLocationEvents(minTimeMs, minDistanceM, looper)", "com.google.maps.android.location.fineLocationEvents")) +public fun LocationManager.fineLocationEvents( + minTimeMs: Long = 1_000L, + minDistanceM: Float = 1f, + looper: Looper = Looper.getMainLooper() +): Flow = this.canonicalFineLocationEvents(minTimeMs, minDistanceM, looper) diff --git a/library/src/main/java/com/google/maps/android/location/FusedLocationProvider.kt b/library/src/main/java/com/google/maps/android/location/FusedLocationProvider.kt new file mode 100644 index 000000000..0dab48af6 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/location/FusedLocationProvider.kt @@ -0,0 +1,87 @@ +/* + * 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.location + +import android.Manifest +import android.location.Location +import android.os.Looper +import androidx.annotation.RequiresPermission +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Returns a cold flow that emits device location updates using [FusedLocationProviderClient.requestLocationUpdates]. + * + * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately + * when the collector cancels or closes the subscription. + * + * **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 callback completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + * + * @param locationRequest The [LocationRequest] specifying the quality of service (e.g. interval, priority). + * @param looper The [Looper] on which the callback runs. Defaults to [Looper.getMainLooper()]. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.locationEvents( + locationRequest: LocationRequest, + looper: Looper = Looper.getMainLooper() +): Flow = + callbackFlow { + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + for (location in result.locations) { + trySend(location) + } + } + } + + requestLocationUpdates(locationRequest, callback, looper) + + awaitClose { + removeLocationUpdates(callback) + } + } + +/** + * Simplified helper returning a cold flow that emits device location updates from [FusedLocationProviderClient]. + * + * @param intervalMs The desired interval for location updates in milliseconds. Defaults to 2000 ms. + * @param minUpdateDistanceM The minimum distance between location updates in meters. Defaults to 1 meter. + * @param priority The location priority accuracy mode. Defaults to [Priority.PRIORITY_HIGH_ACCURACY]. + * @param looper The [Looper] on which the callback runs. Defaults to [Looper.getMainLooper()]. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.fusedLocationEvents( + intervalMs: Long = 2_000L, + minUpdateDistanceM: Float = 1f, + priority: Int = Priority.PRIORITY_HIGH_ACCURACY, + looper: Looper = Looper.getMainLooper() +): Flow { + val request = LocationRequest.Builder(priority, intervalMs) + .setMinUpdateDistanceMeters(minUpdateDistanceM) + .build() + return locationEvents(request, looper) +} diff --git a/library/src/main/java/com/google/maps/android/location/LocationManager.kt b/library/src/main/java/com/google/maps/android/location/LocationManager.kt new file mode 100644 index 000000000..c834da7ea --- /dev/null +++ b/library/src/main/java/com/google/maps/android/location/LocationManager.kt @@ -0,0 +1,126 @@ +/* + * 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.location + +import android.Manifest +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.os.Looper +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Returns a cold flow that emits the device's coarse location updates using [LocationManager.NETWORK_PROVIDER] + * (or [LocationManager.PASSIVE_PROVIDER] if network provider is not available). + * + * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately + * when the collector cancels or closes the subscription. When the underlying location provider is disabled, + * the flow completes normally. + * + * **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. + */ +@RequiresPermission(Manifest.permission.ACCESS_COARSE_LOCATION) +public fun LocationManager.coarseLocationEvents( + minTimeMs: Long = 1_000L, + minDistanceM: Float = 1f, + looper: Looper = Looper.getMainLooper() +): Flow = + callbackFlow { + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + trySend(location) + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Deprecated in API 29, left empty for backward compatibility with minSdk 23 + } + + override fun onProviderEnabled(provider: String) { + // Left empty for backward compatibility + } + + override fun onProviderDisabled(provider: String) { + close() + } + } + + val provider = if (allProviders.contains(LocationManager.NETWORK_PROVIDER)) { + LocationManager.NETWORK_PROVIDER + } else { + LocationManager.PASSIVE_PROVIDER + } + + requestLocationUpdates(provider, minTimeMs, minDistanceM, listener, looper) + + awaitClose { + removeUpdates(listener) + } + } + +/** + * Returns a cold flow that emits the device's fine location updates using [LocationManager.GPS_PROVIDER]. + * + * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately + * when the collector cancels or closes the subscription. When the underlying location provider is disabled, + * the flow completes normally. + * + * **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. + */ +@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) +public fun LocationManager.fineLocationEvents( + minTimeMs: Long = 1_000L, + minDistanceM: Float = 1f, + looper: Looper = Looper.getMainLooper() +): Flow = + callbackFlow { + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + trySend(location) + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Deprecated in API 29, left empty for backward compatibility with minSdk 23 + } + + override fun onProviderEnabled(provider: String) { + // Left empty for backward compatibility + } + + override fun onProviderDisabled(provider: String) { + close() + } + } + + requestLocationUpdates(LocationManager.GPS_PROVIDER, minTimeMs, minDistanceM, listener, looper) + + awaitClose { + removeUpdates(listener) + } + } diff --git a/library/src/main/java/com/google/maps/android/model/CameraPosition.kt b/library/src/main/java/com/google/maps/android/model/CameraPosition.kt new file mode 100644 index 000000000..4634462d0 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/CameraPosition.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.model + +import com.google.android.gms.maps.model.CameraPosition + +/** + * Builds a new [CameraPosition] using the provided [optionsActions]. Using this removes the need + * to construct a [CameraPosition.Builder] object. + * + * @return the constructed [CameraPosition] + */ +public inline fun cameraPosition(optionsActions: CameraPosition.Builder.() -> Unit): CameraPosition = + CameraPosition.Builder().apply(optionsActions).build() diff --git a/library/src/main/java/com/google/maps/android/model/CircleOptions.kt b/library/src/main/java/com/google/maps/android/model/CircleOptions.kt new file mode 100644 index 000000000..c2622c15a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/CircleOptions.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import com.google.android.gms.maps.model.CircleOptions + +/** + * Builds a new [CircleOptions] using the provided [optionsActions]. + * + * @return the constructed [CircleOptions] + */ +public inline fun circleOptions(optionsActions: CircleOptions.() -> Unit): CircleOptions = + CircleOptions().apply( + optionsActions + ) diff --git a/library/src/main/java/com/google/maps/android/model/GroundOverlayOptions.kt b/library/src/main/java/com/google/maps/android/model/GroundOverlayOptions.kt new file mode 100644 index 000000000..09bc82699 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/GroundOverlayOptions.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import com.google.android.gms.maps.model.GroundOverlayOptions + +/** + * Builds a new [GroundOverlayOptions] using the provided [optionsActions]. + * + * @return the constructed [GroundOverlayOptions] + */ +public inline fun groundOverlayOptions(optionsActions: GroundOverlayOptions.() -> Unit): GroundOverlayOptions = + GroundOverlayOptions().apply( + optionsActions + ) diff --git a/library/src/main/java/com/google/maps/android/model/MarkerOptions.kt b/library/src/main/java/com/google/maps/android/model/MarkerOptions.kt new file mode 100644 index 000000000..404f9d323 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/MarkerOptions.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import com.google.android.gms.maps.model.MarkerOptions + +/** + * Builds a new [MarkerOptions] using the provided [optionsActions]. + * + * @return the constructed [MarkerOptions] + */ +public inline fun markerOptions(optionsActions: MarkerOptions.() -> Unit): MarkerOptions = + MarkerOptions().apply( + optionsActions + ) \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/model/PolygonOptions.kt b/library/src/main/java/com/google/maps/android/model/PolygonOptions.kt new file mode 100644 index 000000000..fdb5278e5 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/PolygonOptions.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 + +import com.google.android.gms.maps.model.PolygonOptions + +/** + * Builds a new [PolygonOptions] using the provided [optionsActions]. + * + * @return the [PolygonOptions] + */ +public inline fun polygonOptions(optionsActions: PolygonOptions.() -> Unit): PolygonOptions = + PolygonOptions().apply( + optionsActions + ) + diff --git a/library/src/main/java/com/google/maps/android/model/PolylineOptions.kt b/library/src/main/java/com/google/maps/android/model/PolylineOptions.kt new file mode 100644 index 000000000..7e8f9678b --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/PolylineOptions.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import com.google.android.gms.maps.model.PolylineOptions + +/** + * Builds a new [PolylineOptions] using the provided [optionsActions]. + * + * @return the constructed [PolylineOptions] + */ +public inline fun polylineOptions(optionsActions: PolylineOptions.() -> Unit): PolylineOptions = + PolylineOptions().apply( + optionsActions + ) diff --git a/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaCamera.kt b/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaCamera.kt new file mode 100644 index 000000000..b0c1b9280 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaCamera.kt @@ -0,0 +1,33 @@ +/* + * 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 + +import com.google.android.gms.maps.model.StreetViewPanoramaCamera + +/** + * Builds a new [StreetViewPanoramaCamera] using the provided [optionsActions]. Using this removes + * the need to construct a [StreetViewPanoramaCamera.Builder] object. + * + * @return the constructed [StreetViewPanoramaCamera] + */ +public inline fun streetViewPanoramaCamera( + optionsActions: StreetViewPanoramaCamera.Builder.() -> Unit +): StreetViewPanoramaCamera = + StreetViewPanoramaCamera.Builder().apply( + optionsActions + ).build() \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaOrientation.kt b/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaOrientation.kt new file mode 100644 index 000000000..20c91f49c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/StreetViewPanoramaOrientation.kt @@ -0,0 +1,33 @@ +/* + * 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 + +import com.google.android.gms.maps.model.StreetViewPanoramaOrientation + +/** + * Builds a new [StreetViewPanoramaOrientation] using the provided [optionsActions]. Using this + * removes * the need to construct a [StreetViewPanoramaOrientation.Builder] object. + * + * @return the constructed [StreetViewPanoramaOrientation] + */ +public inline fun streetViewPanoramaOrientation( + optionsActions: StreetViewPanoramaOrientation.Builder.() -> Unit +): StreetViewPanoramaOrientation = + StreetViewPanoramaOrientation.Builder().apply( + optionsActions + ).build() diff --git a/library/src/main/java/com/google/maps/android/model/TileOverlayOptions.kt b/library/src/main/java/com/google/maps/android/model/TileOverlayOptions.kt new file mode 100644 index 000000000..1ddd3afa2 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/model/TileOverlayOptions.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import com.google.android.gms.maps.model.TileOverlayOptions + +/** + * Builds a new [TileOverlayOptions] using the provided [optionsActions]. + * + * @return the constructed [TileOverlayOptions] + */ +public inline fun tileOverlayOptions(optionsActions: TileOverlayOptions.() -> Unit): TileOverlayOptions = + TileOverlayOptions().apply( + optionsActions + ) diff --git a/library/src/test/java/com/google/maps/android/AdversarialResilienceTest.kt b/library/src/test/java/com/google/maps/android/AdversarialResilienceTest.kt new file mode 100644 index 000000000..7212b1e03 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/AdversarialResilienceTest.kt @@ -0,0 +1,264 @@ +/* + * 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 android.content.Context +import android.graphics.Bitmap +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.maps.CameraUpdate +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapFragment +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.OnMapReadyCallback +import com.google.android.gms.maps.OnMapsSdkInitializedCallback +import com.google.android.gms.maps.OnStreetViewPanoramaReadyCallback +import com.google.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaFragment +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.any +import org.mockito.Mockito.eq +import org.mockito.Mockito.isNull +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.verify + +/** + * Adversarial resilience tests designed to break coroutine and Flow extensions under: + * 1. Late SDK callback delivery after coroutine cancellation (`withTimeout` / `job.cancel()`). + * 2. Duplicate/repeated callback delivery from the underlying Maps SDK. + * 3. Error status return followed by a late asynchronous callback in `MapsInitializer`. + * 4. Listener leak verification upon `awaitMapLoad()` cancellation and rapid Flow resubscription. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class AdversarialResilienceTest { + + @Test + fun `awaitMap on MapView does not throw IllegalStateException when callback fires after cancellation`() = + runTest(UnconfinedTestDispatcher()) { + val mapView = mock(MapView::class.java) + val googleMap = mock(GoogleMap::class.java) + val callbackCaptor = ArgumentCaptor.forClass(OnMapReadyCallback::class.java) + + val job = launch { + mapView.awaitMap() + } + verify(mapView).getMapAsync(callbackCaptor.capture()) + + // Cancel the caller before the Maps SDK delivers the map instance + job.cancel(CancellationException("Timed out waiting for map")) + assertThat(job.isCancelled).isTrue() + + // Adversarial event: Maps SDK still fires OnMapReadyCallback on the main thread later + callbackCaptor.value.onMapReady(googleMap) + // Also test duplicate callback invocation + callbackCaptor.value.onMapReady(googleMap) + } + + @Test + fun `awaitMap on SupportMapFragment and MapFragment survives post-cancellation and duplicate callbacks`() = + runTest(UnconfinedTestDispatcher()) { + val supportMapFragment = mock(SupportMapFragment::class.java) + val mapFragment = mock(MapFragment::class.java) + val googleMap = mock(GoogleMap::class.java) + val supportCaptor = ArgumentCaptor.forClass(OnMapReadyCallback::class.java) + val fragmentCaptor = ArgumentCaptor.forClass(OnMapReadyCallback::class.java) + + val job1 = launch { supportMapFragment.awaitMap() } + val job2 = launch { mapFragment.awaitMap() } + + verify(supportMapFragment).getMapAsync(supportCaptor.capture()) + verify(mapFragment).getMapAsync(fragmentCaptor.capture()) + + job1.cancel() + job2.cancel() + + // Late callbacks after cancellation must not crash + supportCaptor.value.onMapReady(googleMap) + supportCaptor.value.onMapReady(googleMap) + fragmentCaptor.value.onMapReady(googleMap) + fragmentCaptor.value.onMapReady(googleMap) + } + + @Test + fun `awaitStreetViewPanorama survives post-cancellation and duplicate callbacks across all views`() = + runTest(UnconfinedTestDispatcher()) { + val view = mock(StreetViewPanoramaView::class.java) + val fragment = mock(StreetViewPanoramaFragment::class.java) + val supportFragment = mock(SupportStreetViewPanoramaFragment::class.java) + val panorama = mock(StreetViewPanorama::class.java) + + val viewCaptor = ArgumentCaptor.forClass(OnStreetViewPanoramaReadyCallback::class.java) + val fragmentCaptor = ArgumentCaptor.forClass(OnStreetViewPanoramaReadyCallback::class.java) + val supportCaptor = ArgumentCaptor.forClass(OnStreetViewPanoramaReadyCallback::class.java) + + val job1 = launch { view.awaitStreetViewPanorama() } + val job2 = launch { fragment.awaitStreetViewPanorama() } + val job3 = launch { supportFragment.awaitStreetViewPanorama() } + + verify(view).getStreetViewPanoramaAsync(viewCaptor.capture()) + verify(fragment).getStreetViewPanoramaAsync(fragmentCaptor.capture()) + verify(supportFragment).getStreetViewPanoramaAsync(supportCaptor.capture()) + + job1.cancel() + job2.cancel() + job3.cancel() + + // Late callbacks after cancellation must not crash + viewCaptor.value.onStreetViewPanoramaReady(panorama) + viewCaptor.value.onStreetViewPanoramaReady(panorama) + fragmentCaptor.value.onStreetViewPanoramaReady(panorama) + fragmentCaptor.value.onStreetViewPanoramaReady(panorama) + supportCaptor.value.onStreetViewPanoramaReady(panorama) + supportCaptor.value.onStreetViewPanoramaReady(panorama) + } + + @Test + fun `awaitMapLoad clears listener on cancellation and survives late queued callback`() = + runTest(UnconfinedTestDispatcher()) { + val googleMap = mock(GoogleMap::class.java) + val callbackCaptor = ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback::class.java) + + val job = launch { + googleMap.awaitMapLoad() + } + verify(googleMap).setOnMapLoadedCallback(callbackCaptor.capture()) + val capturedCallback = callbackCaptor.value + + // Cancel the coroutine while waiting for map load + job.cancel() + + // Verify the listener was unregistered to prevent leaking the continuation in GoogleMap + verify(googleMap).setOnMapLoadedCallback(null) + + // Even if the callback was already posted to the main thread message queue before nulling, + // invoking it after cancellation must not throw IllegalStateException. + capturedCallback.onMapLoaded() + capturedCallback.onMapLoaded() + } + + @Test + fun `awaitSnapshot survives post-cancellation and duplicate callbacks`() = + runTest(UnconfinedTestDispatcher()) { + val googleMap = mock(GoogleMap::class.java) + val bitmap = mock(Bitmap::class.java) + val callbackCaptor = ArgumentCaptor.forClass(GoogleMap.SnapshotReadyCallback::class.java) + + val job = launch { + googleMap.awaitSnapshot(bitmap) + } + verify(googleMap).snapshot(callbackCaptor.capture(), eq(bitmap)) + + job.cancel() + + // Late callback after cancellation must not throw IllegalStateException + callbackCaptor.value.onSnapshotReady(bitmap) + callbackCaptor.value.onSnapshotReady(bitmap) + } + + @Test + fun `awaitAnimateCamera survives post-cancellation onFinish and duplicate callbacks`() = + runTest(UnconfinedTestDispatcher()) { + val googleMap = mock(GoogleMap::class.java) + val cameraUpdate = mock(CameraUpdate::class.java) + val callbackCaptor = ArgumentCaptor.forClass(GoogleMap.CancelableCallback::class.java) + + val job = launch { + googleMap.awaitAnimateCamera(cameraUpdate, 500) + } + verify(googleMap).animateCamera( + eq(cameraUpdate), + eq(500), + callbackCaptor.capture() + ) + + // Caller cancels coroutine while camera animation is in-flight + job.cancel() + + // Maps SDK finishes animation on main thread after coroutine cancellation + callbackCaptor.value.onFinish() + callbackCaptor.value.onFinish() + callbackCaptor.value.onCancel() + } + + @Test + fun `awaitMapsSdkInitialized survives post-cancellation callback and error-then-callback race`() = + runTest(UnconfinedTestDispatcher()) { + val context = mock(Context::class.java) + + mockStatic(MapsInitializer::class.java).use { mockedStatic -> + var capturedCallback: OnMapsSdkInitializedCallback? = null + mockedStatic.`when` { + MapsInitializer.initialize( + eq(context), + isNull(), + any() + ) + }.thenAnswer { invocation -> + capturedCallback = invocation.getArgument(2) + // Return error status first, then later invoke callback asynchronously + ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED + } + + val exception = assertThrows(GooglePlayServicesNotAvailableException::class.java) { + runBlocking { + context.awaitMapsSdkInitialized() + } + } + assertThat(exception.errorCode).isEqualTo(ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) + + // Adversarial race: SDK still invokes callback after returning non-zero status code + capturedCallback?.onMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + } + } + + @Test + fun `rapid cancellation and resubscription to GoogleMap flows cleans up listeners without dropping active collector`() = + runTest(UnconfinedTestDispatcher()) { + val googleMap = mock(GoogleMap::class.java) + val listenerCaptor = ArgumentCaptor.forClass(GoogleMap.OnMapClickListener::class.java) + + val received = mutableListOf() + val job1 = launch { + googleMap.mapClickEvents().collect { received.add(it) } + } + verify(googleMap).setOnMapClickListener(listenerCaptor.capture()) + val firstListener = listenerCaptor.value + + // Cancel first collector and immediately start a second collector + job1.cancel() + verify(googleMap).setOnMapClickListener(null) + + // Firing a stale event on the old listener after cancellation must not crash or emit + firstListener.onMapClick(LatLng(10.0, 20.0)) + assertThat(received).isEmpty() + } +} diff --git a/library/src/test/java/com/google/maps/android/GoogleMapTest.kt b/library/src/test/java/com/google/maps/android/GoogleMapTest.kt new file mode 100644 index 000000000..2c5762a6c --- /dev/null +++ b/library/src/test/java/com/google/maps/android/GoogleMapTest.kt @@ -0,0 +1,606 @@ +/* + * 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 android.graphics.Bitmap +import android.location.Location +import com.google.android.gms.maps.CameraUpdate +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.IndoorBuilding +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.PointOfInterest +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.TileOverlay +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + + +/** + * Unit test suite for Kotlin extensions on [GoogleMap], covering reactive `Flow` event streams, + * coroutine suspension helpers, and Kotlin DSL option builders. + * + * **Purpose:** + * 1. **Reactive Flows:** Verifies that all 20 Google Map callback listener methods (`setOnMapClickListener`, + * `setOnMarkerClickListener`, `setOnCameraMoveListener`, etc.) are correctly bridged to cold Kotlin `Flow` streams. + * 2. **Coroutine Suspension Helpers:** Verifies that async callback methods (`awaitMapLoad`, + * `awaitAnimateCamera`, `awaitSnapshot`) suspend the coroutine until their callback is completed. + * 3. **DSL Builders:** Verifies that Kotlin inline option builders (`addMarker { ... }`, `addPolyline { ... }`, etc.) + * construct valid option objects and delegate to the underlying [GoogleMap] add-overlay methods. + * + * **How it works:** + * - For **Flow tests**: Uses `runTest` and `async` to collect the first emitted item (`flow.first()`). + * An [ArgumentCaptor] intercepts the SDK listener registered on [GoogleMap]. Invoking the listener's + * callback method (`onMapClick`, `onMarkerClick`, etc.) emits the test payload into the flow. + * - For **Suspension tests**: Captures the SDK callback (`OnMapLoadedCallback`, `CancelableCallback`, + * or `SnapshotReadyCallback`) and invokes its completion method to resume the suspended coroutine. + * - For **DSL Builders**: Invokes `googleMap.addMarker { ... }` and verifies via Mockito that + * `googleMap.addMarker(any())` was called with the constructed options. + * + * **How we know it is correct:** + * - **Code under test:** Correct if listener callbacks emit the exact mock instance to the Flow/coroutine, + * and if DSL builders delegate to the canonical [GoogleMap] overlay methods without mutation loss. + * - **Test:** Correct because `advanceUntilIdle()` guarantees deterministic coroutine scheduling before + * and after callback invocation, and `assertThat(deferred.await()).isEqualTo(expected)` confirms exact emission equality. + */ +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +public class GoogleMapTest { + + @Mock + private lateinit var googleMap: GoogleMap + + @Mock + private lateinit var marker: Marker + + @Mock + private lateinit var circle: Circle + + @Mock + private lateinit var groundOverlay: GroundOverlay + + @Mock + private lateinit var polygon: Polygon + + @Mock + private lateinit var polyline: Polyline + + @Mock + private lateinit var tileOverlay: TileOverlay + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var bitmap: Bitmap + + @Captor + private lateinit var cameraIdleListener: ArgumentCaptor + + @Captor + private lateinit var cameraMoveListener: ArgumentCaptor + + @Captor + private lateinit var cameraMoveStartedListener: ArgumentCaptor + + @Captor + private lateinit var cameraMoveCanceledListener: ArgumentCaptor + + @Captor + private lateinit var mapClickListener: ArgumentCaptor + + @Captor + private lateinit var mapLongClickListener: ArgumentCaptor + + @Captor + private lateinit var markerClickListener: ArgumentCaptor + + @Captor + private lateinit var markerDragListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowCloseListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowLongClickListener: ArgumentCaptor + + @Captor + private lateinit var polygonClickListener: ArgumentCaptor + + @Captor + private lateinit var polylineClickListener: ArgumentCaptor + + @Captor + private lateinit var circleClickListener: ArgumentCaptor + + @Captor + private lateinit var groundOverlayClickListener: ArgumentCaptor + + @Captor + private lateinit var poiClickListener: ArgumentCaptor + + @Captor + private lateinit var myLocationClickListener: ArgumentCaptor + + @Captor + private lateinit var myLocationButtonClickListener: ArgumentCaptor + + @Captor + private lateinit var indoorStateChangeListener: ArgumentCaptor + + @Captor + private lateinit var loadedCallback: ArgumentCaptor + + @Captor + private lateinit var cancelableCallback: ArgumentCaptor + + @Mock + private lateinit var cameraUpdate: CameraUpdate + + @Captor + private lateinit var snapshotReadyCallback: ArgumentCaptor + + @Before + public fun setUp() { + Mockito.`when`(googleMap.addMarker(any())).thenReturn(marker) + Mockito.`when`(googleMap.addPolyline(any())).thenReturn(polyline) + Mockito.`when`(googleMap.addPolygon(any())).thenReturn(polygon) + Mockito.`when`(googleMap.addCircle(any())).thenReturn(circle) + Mockito.`when`(googleMap.addGroundOverlay(any())).thenReturn(groundOverlay) + Mockito.`when`(googleMap.addTileOverlay(any())).thenReturn(tileOverlay) + } + + // --------------------------------------------------------------------------------------------- + // Reactive Coroutine Flow Event Listener Tests + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies [GoogleMap.cameraIdleEvents] converts camera idle callbacks into a Flow. + * **How it works:** Subscribes to `cameraIdleEvents().first()`, captures `OnCameraIdleListener`, and invokes `onCameraIdle()`. + * **How we know it is correct:** Test succeeds if `deferred.await()` completes when `onCameraIdle()` is called. + */ + @Test + public fun testCameraIdleEvents(): Unit = runTest { + val deferred = async { + googleMap.cameraIdleEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnCameraIdleListener(cameraIdleListener.capture()) + cameraIdleListener.value.onCameraIdle() + assertThat(deferred.await()).isEqualTo(Unit) + } + + + /** + * **Purpose:** Verifies [GoogleMap.cameraMoveEvents] converts camera move callbacks into a Flow. + * **How it works:** Subscribes to `cameraMoveEvents().first()`, captures `OnCameraMoveListener`, and calls `onCameraMove()`. + * **How we know it is correct:** Test succeeds if `deferred.await()` returns `Unit` when `onCameraMove()` is invoked. + */ + @Test + public fun testCameraMoveEvents(): Unit = runTest { + val deferred = async { + googleMap.cameraMoveEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnCameraMoveListener(cameraMoveListener.capture()) + cameraMoveListener.value.onCameraMove() + assertThat(deferred.await()).isEqualTo(Unit) + } + + /** + * **Purpose:** Verifies [GoogleMap.cameraMoveStartedEvents] emits camera move start reasons. + * **How it works:** Subscribes to `cameraMoveStartedEvents().first()`, captures listener, and triggers `onCameraMoveStarted(REASON_GESTURE)`. + * **How we know it is correct:** Asserts the emitted integer equals [GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE]. + */ + @Test + public fun testCameraMoveStartedEvents(): Unit = runTest { + val deferred = async { + googleMap.cameraMoveStartedEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnCameraMoveStartedListener(cameraMoveStartedListener.capture()) + cameraMoveStartedListener.value.onCameraMoveStarted(GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) + assertThat(deferred.await()).isEqualTo(GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) + } + + /** + * **Purpose:** Verifies [GoogleMap.cameraMoveCanceledEvents] emits when camera movement is cancelled. + * **How it works:** Subscribes to `cameraMoveCanceledEvents().first()`, captures listener, and calls `onCameraMoveCanceled()`. + * **How we know it is correct:** Asserts the flow emits a Unit event upon cancellation. + */ + @Test + public fun testCameraMoveCanceledEvents(): Unit = runTest { + val deferred = async { + googleMap.cameraMoveCanceledEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnCameraMoveCanceledListener(cameraMoveCanceledListener.capture()) + cameraMoveCanceledListener.value.onCameraMoveCanceled() + assertThat(deferred.await()).isEqualTo(Unit) + } + + + /** + * **Purpose:** Verifies [GoogleMap.mapClickEvents] emits clicked [LatLng] coordinates. + * **How it works:** Subscribes to `mapClickEvents().first()`, captures `OnMapClickListener`, and calls `onMapClick(target)`. + * **How we know it is correct:** Asserts the emitted coordinates equal the exact `target` LatLng. + */ + @Test + public fun testMapClickEvents(): Unit = runTest { + val target = LatLng(10.0, 20.0) + val deferred = async { + googleMap.mapClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnMapClickListener(mapClickListener.capture()) + mapClickListener.value.onMapClick(target) + assertThat(deferred.await()).isEqualTo(target) + } + + /** + * **Purpose:** Verifies [GoogleMap.mapLongClickEvents] emits long-clicked [LatLng] coordinates. + * **How it works:** Subscribes to `mapLongClickEvents().first()`, captures `OnMapLongClickListener`, and calls `onMapLongClick(target)`. + * **How we know it is correct:** Asserts the emitted coordinates equal `target`. + */ + @Test + public fun testMapLongClickEvents(): Unit = runTest { + val target = LatLng(30.0, 40.0) + val deferred = async { + googleMap.mapLongClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnMapLongClickListener(mapLongClickListener.capture()) + mapLongClickListener.value.onMapLongClick(target) + assertThat(deferred.await()).isEqualTo(target) + } + + /** + * **Purpose:** Verifies [GoogleMap.markerClickEvents] emits clicked [Marker] instances. + * **How it works:** Subscribes to `markerClickEvents().first()`, captures `OnMarkerClickListener`, and calls `onMarkerClick(marker)`. + * **How we know it is correct:** Asserts the emitted marker equals the mock `marker`. + */ + @Test + public fun testMarkerClickEvents(): Unit = runTest { + val deferred = async { + googleMap.markerClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnMarkerClickListener(markerClickListener.capture()) + markerClickListener.value.onMarkerClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + /** + * **Purpose:** Verifies [GoogleMap.markerDragEvents] emits marker drag events (start, drag, end). + * **How it works:** Subscribes to `markerDragEvents().first()`, captures `OnMarkerDragListener`, and calls `onMarkerDragStart(marker)`. + * **How we know it is correct:** Asserts the emitted drag event contains the mock `marker`. + */ + @Test + public fun testMarkerDragEvents(): Unit = runTest { + val deferred = async { + googleMap.markerDragEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnMarkerDragListener(markerDragListener.capture()) + markerDragListener.value.onMarkerDragStart(marker) + assertThat(deferred.await().marker).isEqualTo(marker) + } + + /** + * **Purpose:** Verifies [GoogleMap.infoWindowClickEvents] emits markers when their info window is clicked. + * **How it works:** Subscribes to `infoWindowClickEvents().first()`, captures listener, and calls `onInfoWindowClick(marker)`. + * **How we know it is correct:** Asserts the emitted marker equals `marker`. + */ + @Test + public fun testInfoWindowClickEvents(): Unit = runTest { + val deferred = async { + googleMap.infoWindowClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnInfoWindowClickListener(infoWindowClickListener.capture()) + infoWindowClickListener.value.onInfoWindowClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + /** + * **Purpose:** Verifies [GoogleMap.infoWindowCloseEvents] emits markers when their info window closes. + * **How it works:** Subscribes to `infoWindowCloseEvents().first()`, captures listener, and calls `onInfoWindowClose(marker)`. + * **How we know it is correct:** Asserts the emitted marker equals `marker`. + */ + @Test + public fun testInfoWindowCloseEvents(): Unit = runTest { + val deferred = async { + googleMap.infoWindowCloseEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnInfoWindowCloseListener(infoWindowCloseListener.capture()) + infoWindowCloseListener.value.onInfoWindowClose(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + /** + * **Purpose:** Verifies [GoogleMap.infoWindowLongClickEvents] emits markers when their info window is long-clicked. + * **How it works:** Subscribes to `infoWindowLongClickEvents().first()`, captures listener, and calls `onInfoWindowLongClick(marker)`. + * **How we know it is correct:** Asserts the emitted marker equals `marker`. + */ + @Test + public fun testInfoWindowLongClickEvents(): Unit = runTest { + val deferred = async { + googleMap.infoWindowLongClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnInfoWindowLongClickListener(infoWindowLongClickListener.capture()) + infoWindowLongClickListener.value.onInfoWindowLongClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + /** + * **Purpose:** Verifies [GoogleMap.polygonClickEvents] emits clicked [Polygon] instances. + * **How it works:** Subscribes to `polygonClickEvents().first()`, captures listener, and calls `onPolygonClick(polygon)`. + * **How we know it is correct:** Asserts the emitted polygon equals `polygon`. + */ + @Test + public fun testPolygonClickEvents(): Unit = runTest { + val deferred = async { + googleMap.polygonClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnPolygonClickListener(polygonClickListener.capture()) + polygonClickListener.value.onPolygonClick(polygon) + assertThat(deferred.await()).isEqualTo(polygon) + } + + /** + * **Purpose:** Verifies [GoogleMap.polylineClickEvents] emits clicked [Polyline] instances. + * **How it works:** Subscribes to `polylineClickEvents().first()`, captures listener, and calls `onPolylineClick(polyline)`. + * **How we know it is correct:** Asserts the emitted polyline equals `polyline`. + */ + @Test + public fun testPolylineClickEvents(): Unit = runTest { + val deferred = async { + googleMap.polylineClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnPolylineClickListener(polylineClickListener.capture()) + polylineClickListener.value.onPolylineClick(polyline) + assertThat(deferred.await()).isEqualTo(polyline) + } + + /** + * **Purpose:** Verifies [GoogleMap.circleClickEvents] emits clicked [Circle] instances. + * **How it works:** Subscribes to `circleClickEvents().first()`, captures listener, and calls `onCircleClick(circle)`. + * **How we know it is correct:** Asserts the emitted circle equals `circle`. + */ + @Test + public fun testCircleClickEvents(): Unit = runTest { + val deferred = async { + googleMap.circleClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnCircleClickListener(circleClickListener.capture()) + circleClickListener.value.onCircleClick(circle) + assertThat(deferred.await()).isEqualTo(circle) + } + + /** + * **Purpose:** Verifies [GoogleMap.groundOverlayClicks] emits clicked [GroundOverlay] instances. + * **How it works:** Subscribes to `groundOverlayClicks().first()`, captures listener, and calls `onGroundOverlayClick(groundOverlay)`. + * **How we know it is correct:** Asserts the emitted ground overlay equals `groundOverlay`. + */ + @Test + public fun testGroundOverlayClickEvents(): Unit = runTest { + val deferred = async { + googleMap.groundOverlayClicks().first() + } + advanceUntilIdle() + verify(googleMap).setOnGroundOverlayClickListener(groundOverlayClickListener.capture()) + groundOverlayClickListener.value.onGroundOverlayClick(groundOverlay) + assertThat(deferred.await()).isEqualTo(groundOverlay) + } + + /** + * **Purpose:** Verifies [GoogleMap.poiClickEvents] emits clicked [PointOfInterest] instances. + * **How it works:** Subscribes to `poiClickEvents().first()`, captures listener, and calls `onPoiClick(poi)`. + * **How we know it is correct:** Asserts the emitted POI equals the exact `poi` object. + */ + @Test + public fun testPoiClickEvents(): Unit = runTest { + val poi = PointOfInterest(LatLng(1.0, 2.0), "id", "name") + val deferred = async { + googleMap.poiClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnPoiClickListener(poiClickListener.capture()) + poiClickListener.value.onPoiClick(poi) + assertThat(deferred.await()).isEqualTo(poi) + } + + /** + * **Purpose:** Verifies [GoogleMap.myLocationClickEvents] emits user [Location] when the location dot is clicked. + * **How it works:** Subscribes to `myLocationClickEvents().first()`, captures listener, and calls `onMyLocationClick(location)`. + * **How we know it is correct:** Asserts the emitted location equals `location`. + */ + @Test + public fun testMyLocationClickEvents(): Unit = runTest { + val deferred = async { + googleMap.myLocationClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnMyLocationClickListener(myLocationClickListener.capture()) + myLocationClickListener.value.onMyLocationClick(location) + assertThat(deferred.await()).isEqualTo(location) + } + + /** + * **Purpose:** Verifies [GoogleMap.myLocationButtonClickEvents] emits when the My Location button is clicked. + * **How it works:** Subscribes to `myLocationButtonClickEvents().first()`, captures listener, and calls `onMyLocationButtonClick()`. + * **How we know it is correct:** Asserts the emitted Unit event is received via `deferred.await()`. + */ + @Test + public fun testMyLocationButtonClickEvents(): Unit = runTest { + val deferred = async { + googleMap.myLocationButtonClickEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnMyLocationButtonClickListener(myLocationButtonClickListener.capture()) + myLocationButtonClickListener.value.onMyLocationButtonClick() + assertThat(deferred.await()).isEqualTo(Unit) + } + + /** + * **Purpose:** Verifies [GoogleMap.indoorStateChangeEvents] emits when an indoor building comes into focus. + * **How it works:** Subscribes to `indoorStateChangeEvents().first()`, captures listener, and calls `onIndoorBuildingFocused()`. + * **How we know it is correct:** Asserts the flow emits a non-null indoor change event. + */ + @Test + public fun testIndoorStateChangeEvents(): Unit = runTest { + val deferred = async { + googleMap.indoorStateChangeEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnIndoorStateChangeListener(indoorStateChangeListener.capture()) + indoorStateChangeListener.value.onIndoorBuildingFocused() + assertThat(deferred.await()).isNotNull() + } + + // --------------------------------------------------------------------------------------------- + // Coroutine Suspension Helpers + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies [GoogleMap.awaitMapLoad] suspends until the map finishes rendering. + * **How it works:** Launches `googleMap.awaitMapLoad()`, captures `OnMapLoadedCallback`, and calls `onMapLoaded()`. + * **How we know it is correct:** Verifies `setOnMapLoadedCallback` was registered and coroutine resumes cleanly when invoked. + */ + @Test + public fun testAwaitMapLoad(): Unit = runTest { + val deferred = async { + googleMap.awaitMapLoad() + } + advanceUntilIdle() + verify(googleMap).setOnMapLoadedCallback(loadedCallback.capture()) + loadedCallback.value.onMapLoaded() + assertThat(deferred.await()).isEqualTo(Unit) + } + + /** + * **Purpose:** Verifies [GoogleMap.awaitAnimateCamera] suspends until camera animation finishes, delegating to 2-arg `animateCamera` when `durationMs` is omitted (`null`) and 3-arg `animateCamera` when `durationMs` is specified. + * **How it works:** Launches `awaitAnimateCamera(cameraUpdate)` and `awaitAnimateCamera(cameraUpdate, 500)`, captures `CancelableCallback`, and calls `onFinish()`. + * **How we know it is correct:** Asserts `animateCamera` was called with the appropriate 2-arg or 3-arg overload and coroutine resumes upon `onFinish()`. + */ + @Test + public fun testAwaitAnimateCamera(): Unit = runTest { + val deferredDefault = async { + googleMap.awaitAnimateCamera(cameraUpdate) + } + advanceUntilIdle() + verify(googleMap).animateCamera(any(CameraUpdate::class.java), cancelableCallback.capture()) + cancelableCallback.value.onFinish() + assertThat(deferredDefault.await()).isEqualTo(Unit) + + val deferredWithDuration = async { + googleMap.awaitAnimateCamera(cameraUpdate, 500) + } + advanceUntilIdle() + verify(googleMap).animateCamera(any(CameraUpdate::class.java), Mockito.eq(500), cancelableCallback.capture()) + cancelableCallback.value.onFinish() + assertThat(deferredWithDuration.await()).isEqualTo(Unit) + } + + + /** + * **Purpose:** Verifies [GoogleMap.awaitSnapshot] suspends until a bitmap snapshot is ready. + * **How it works:** Stubs `googleMap.snapshot(any(), any())` to immediately invoke `onSnapshotReady(bitmap)`, and calls `awaitSnapshot(bitmap)`. + * **How we know it is correct:** Asserts the returned Bitmap equals the exact `bitmap` mock. + */ + @Test + public fun testAwaitSnapshot(): Unit = runTest { + Mockito.`when`(googleMap.snapshot(any(), any())).thenAnswer { + val cb = it.getArgument(0) + cb.onSnapshotReady(bitmap) + } + val deferred = async { + googleMap.awaitSnapshot(bitmap) + } + assertThat(deferred.await()).isEqualTo(bitmap) + } + + // --------------------------------------------------------------------------------------------- + // Kotlin DSL Option Builders + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies all 6 Kotlin DSL option builders (`addMarker`, `addPolyline`, `addPolygon`, `addCircle`, `addGroundOverlay`, `addTileOverlay`) construct valid options and delegate to [GoogleMap]. + * **How it works:** Calls each inline builder block with sample option actions (e.g. `position(LatLng(1, 2))`), and verifies via Mockito that the corresponding `googleMap.add*(any())` method was called. + * **How we know it is correct:** + * - **Code under test:** Correct because the inline builder applies the user DSL lambda to a new options builder and passes it to GoogleMap. + * - **Test:** Fails if any builder fails to delegate to the underlying GoogleMap method or throws an exception. + */ + @Test + public fun testDslBuilders() { + googleMap.addMarker { + position(LatLng(1.0, 2.0)) + } + verify(googleMap).addMarker(any()) + + googleMap.addPolyline { + add(LatLng(1.0, 2.0)) + } + verify(googleMap).addPolyline(any()) + + googleMap.addPolygon { + add(LatLng(1.0, 2.0)) + } + verify(googleMap).addPolygon(any()) + + googleMap.addCircle { + center(LatLng(1.0, 2.0)) + } + verify(googleMap).addCircle(any()) + + googleMap.addGroundOverlay { + zIndex(1f) + clickable(true) + } + verify(googleMap).addGroundOverlay(any()) + + googleMap.addTileOverlay { + fadeIn(true) + } + verify(googleMap).addTileOverlay(any()) + } +} diff --git a/library/src/test/java/com/google/maps/android/LatLngTest.kt b/library/src/test/java/com/google/maps/android/LatLngTest.kt new file mode 100644 index 000000000..2c6238f79 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/LatLngTest.kt @@ -0,0 +1,189 @@ +/* + * 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.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class LatLngTest { + private val earthRadius = 6371009.0 + + @Test + fun `test that latLng can be destructured`() { + val latLng = LatLng(2.0, 3.0) + val (lat, lng) = latLng + assertThat(lat).isWithin(1e-6).of(2.0) + assertThat(lng).isWithin(1e-6).of(3.0) + } + + @Test + fun `single LatLng encoding`() { + val line = listOf(LatLng(1.0, 2.0)) + assertThat(line.latLngListEncode()).isEqualTo("_ibE_seK") + } + + @Test + fun `single LatLng decoding`() { + val lineEncoded = "_yfyF_ocsF" + val line = lineEncoded.toLatLngList() + assertThat(line.first()).isEqualTo(LatLng(41.0, 40.0)) + } + + @Test + fun `closed polygon true`() { + val latLngList = listOf(LatLng(1.0, 2.0), LatLng(3.0, 4.0), LatLng(1.0, 2.0)) + assertThat(latLngList.isClosedPolygon()).isTrue() + } + + @Test + fun `closed polygon false`() { + val latLngList = listOf(LatLng(1.0, 2.0), LatLng(3.0, 4.0)) + assertThat(latLngList.isClosedPolygon()).isFalse() + } + + @Test + fun `simplify endpoints are still equal`() { + val lineEncoded = "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 = lineEncoded.toLatLngList() + val simplifiedLine = line.simplify(tolerance = 5.0) + assertThat(simplifiedLine).hasSize(20) + assertThat(simplifiedLine.first()).isEqualTo(line.first()) + assertThat(simplifiedLine.last()).isEqualTo(line.last()) + } + + @Test + fun `heading is accurate`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + assertThat(up.sphericalHeading(down)).isWithin(1e-6).of(-180.0) + } + + @Test + fun `withOffset is accurate`() { + val up = LatLng(90.0, 135.0) + val down = up.withSphericalOffset(earthRadius, 180.0) + assertThat(down.latitude).isWithin(1e-6).of(32.704220486917684) + assertThat(down.longitude).isWithin(1e-6).of(-135.0) + } + + @Test + fun `computeOffsetOrigin is accurate`() { + val front = LatLng(0.0, 0.0) + assertThat(front.computeSphericalOffsetOrigin(0.0, 0.0)).isEqualTo(front) + + val result = LatLng(0.0, 45.0).computeSphericalOffsetOrigin( + distance = Math.PI * earthRadius / 4.0, + heading = 90.0 + )!! + assertThat(result.latitude).isWithin(1e-6).of(0.0) + assertThat(result.longitude).isWithin(1e-6).of(0.0) + } + + @Test + fun `compute interpolation`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + + val zeroFraction = up.withSphericalLinearInterpolation(down, 0.0) + assertThat(zeroFraction.latitude).isWithin(1e-6).of(90.0) + assertThat(zeroFraction.longitude).isWithin(1e-6).of(0.0) + + val halfFraction = up.withSphericalLinearInterpolation(down, 0.5) + assertThat(halfFraction.latitude).isWithin(1e-6).of(0.0) + assertThat(halfFraction.longitude).isWithin(1e-6).of(0.0) + + val oneFraction = up.withSphericalLinearInterpolation(down, 1.0) + assertThat(oneFraction.latitude).isWithin(1e-6).of(-90.0) + assertThat(oneFraction.longitude).isWithin(1e-6).of(0.0) + } + + @Test + fun `compute spherical distance`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + assertThat(up.sphericalDistance(down)).isWithin(1e-6).of(Math.PI * earthRadius) + } + + @Test + fun `validate spherical path length`() { + assertThat(emptyList().sphericalPathLength()).isWithin(1e-6).of(0.0) + + val latLngs = listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1)) + val expectation = earthRadius * Math.sqrt(2.0) * Math.toRadians(0.1) + assertThat(latLngs.sphericalPathLength()).isWithin(1e-1).of(expectation) + } + + @Test + fun `validate spherical polygon area`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + val right = LatLng(0.0, 90.0) + val polygon = listOf(up, down, right, up) + assertThat(polygon.sphericalPolygonArea()).isWithin(1e-6).of(1.2751647824926386E14) + println(polygon.sphericalPolygonSignedArea()) + } + + @Test + fun `validate signed spherical polygon area`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + val right = LatLng(0.0, 90.0) + val polygon = listOf(up, down, right, up) + val reversedPolygon = listOf(up, right, down, up) + assertThat(reversedPolygon.sphericalPolygonSignedArea()) + .isWithin(1e-6) + .of(-polygon.sphericalPolygonSignedArea()) + } + + @Test + fun `contains location evaluates to true`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.containsLocation(LatLng(30.0, 45.0), geodesic = true)).isTrue() + } + + @Test + fun `contains location evaluates to false`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.containsLocation(LatLng(-30.0, 45.0), geodesic = true)).isFalse() + } + + @Test + fun `isOnEdge location evaluates to true`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.isOnEdge(LatLng(0.0, 45.0), geodesic = true)).isTrue() + } + + @Test + fun `isOnEdge location evaluates to false`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.isOnEdge(LatLng(0.0, -45.0), geodesic = true)).isFalse() + } + + @Test + fun `isLocationOnPath location evaluates to true`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(0.0, 180.0)) + assertThat(latLngList.isLocationOnPath(LatLng(0.0, 45.0), geodesic = true)).isTrue() + } + + @Test + fun `isLocationOnPath location evaluates to false`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(0.0, 180.0)) + assertThat(latLngList.isLocationOnPath(LatLng(0.0, -45.0), geodesic = true)).isFalse() + } +} diff --git a/library/src/test/java/com/google/maps/android/MapFragmentExtensionsTest.kt b/library/src/test/java/com/google/maps/android/MapFragmentExtensionsTest.kt new file mode 100644 index 000000000..5fc97db3c --- /dev/null +++ b/library/src/test/java/com/google/maps/android/MapFragmentExtensionsTest.kt @@ -0,0 +1,443 @@ +/* + * 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.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapFragment +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.OnMapReadyCallback +import com.google.android.gms.maps.OnStreetViewPanoramaReadyCallback +import com.google.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaFragment +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment +import com.google.android.gms.maps.model.StreetViewPanoramaCamera +import com.google.android.gms.maps.model.StreetViewPanoramaLocation +import com.google.android.gms.maps.model.StreetViewPanoramaOrientation +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.ktx.awaitMap as ktxAwaitMap +import com.google.maps.android.ktx.awaitStreetViewPanorama as ktxAwaitStreetViewPanorama +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +/** + * Unit test suite for Kotlin coroutine extensions on Map and StreetViewPanorama Fragments/Views. + * + * **Purpose:** + * Tests that asynchronous callback-based Google Maps SDK methods ([SupportMapFragment.getMapAsync], + * [MapView.getMapAsync], [SupportStreetViewPanoramaFragment.getStreetViewPanoramaAsync], etc.) are + * correctly bridged to Kotlin coroutine suspending functions (`awaitMap()`, `awaitStreetViewPanorama()`), + * and that StreetViewPanorama callback listeners are bridged to reactive Kotlin `Flow` streams. + * + * **How it works:** + * 1. For suspending functions (`awaitMap`, `awaitStreetViewPanorama`): Uses `runTest` and `launch` + * to invoke the suspending extension. Intercepts the SDK callback ([OnMapReadyCallback] or + * [OnStreetViewPanoramaReadyCallback]) using a Mockito [ArgumentCaptor]. Invoking `onMapReady(googleMap)` + * resumes the suspended coroutine. + * 2. For Flow extensions (`cameraChangeEvents`, `clickEvents`, etc.): Uses `flow.first()` inside `launch`, + * captures the registered SDK listener via [ArgumentCaptor], and invokes the listener callback to + * emit an item into the flow. + * + * **How we know it is correct:** + * - **Code under test:** Correct if invoking the SDK callback (`onMapReady`, `onStreetViewPanoramaReady`, + * or listener callback) resumes the coroutine or emits to the flow with the exact mock instance. + * - **Test:** Correct because `advanceUntilIdle()` ensures deterministic coroutine scheduling before + * and after callback execution, and `assertThat(result).isEqualTo(expected)` verifies instance equality. + */ +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class MapFragmentExtensionsTest { + + @Mock + private lateinit var googleMap: GoogleMap + + @Mock + private lateinit var streetViewPanorama: StreetViewPanorama + + @Mock + private lateinit var supportMapFragment: SupportMapFragment + + @Mock + private lateinit var mapFragment: MapFragment + + @Mock + private lateinit var mapView: MapView + + @Mock + private lateinit var supportStreetViewPanoramaFragment: SupportStreetViewPanoramaFragment + + @Mock + private lateinit var streetViewPanoramaFragment: StreetViewPanoramaFragment + + @Mock + private lateinit var streetViewPanoramaView: StreetViewPanoramaView + + @Mock + private lateinit var panoramaCamera: StreetViewPanoramaCamera + + @Mock + private lateinit var panoramaLocation: StreetViewPanoramaLocation + + @Mock + private lateinit var panoramaOrientation: StreetViewPanoramaOrientation + + @Captor + private lateinit var onMapReadyCallback: ArgumentCaptor + + @Captor + private lateinit var onStreetViewPanoramaReadyCallback: ArgumentCaptor + + @Captor + private lateinit var cameraChangeListener: ArgumentCaptor + + @Captor + private lateinit var changeListener: ArgumentCaptor + + @Captor + private lateinit var clickListener: ArgumentCaptor + + @Captor + private lateinit var longClickListener: ArgumentCaptor + + // --------------------------------------------------------------------------------------------- + // Canonical awaitMap() tests for SupportMapFragment, MapFragment, and MapView + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies [SupportMapFragment.awaitMap] suspends until [OnMapReadyCallback.onMapReady] is invoked. + * **How it works:** Calls `awaitMap()` in a coroutine, captures `OnMapReadyCallback`, and invokes `onMapReady(googleMap)`. + * **How we know it is correct:** Test asserts `awaitMap()` returns the exact `googleMap` mock passed to `onMapReady`. + */ + @Test + fun testSupportMapFragmentAwaitMap() = runTest { + var map: GoogleMap? = null + val job = launch { + map = supportMapFragment.awaitMap() + } + advanceUntilIdle() + verify(supportMapFragment).getMapAsync(onMapReadyCallback.capture()) + onMapReadyCallback.value.onMapReady(googleMap) + advanceUntilIdle() + assertThat(map).isEqualTo(googleMap) + job.cancel() + } + + /** + * **Purpose:** Verifies [MapFragment.awaitMap] suspends until [OnMapReadyCallback.onMapReady] is invoked. + * **How it works:** Captures `OnMapReadyCallback` on `mapFragment.getMapAsync` and invokes `onMapReady(googleMap)`. + * **How we know it is correct:** Asserts the resumed value equals `googleMap`. + */ + @Test + fun testMapFragmentAwaitMap() = runTest { + var map: GoogleMap? = null + val job = launch { + map = mapFragment.awaitMap() + } + advanceUntilIdle() + verify(mapFragment).getMapAsync(onMapReadyCallback.capture()) + onMapReadyCallback.value.onMapReady(googleMap) + advanceUntilIdle() + assertThat(map).isEqualTo(googleMap) + job.cancel() + } + + /** + * **Purpose:** Verifies [MapView.awaitMap] suspends until [OnMapReadyCallback.onMapReady] is invoked. + * **How it works:** Captures `OnMapReadyCallback` on `mapView.getMapAsync` and invokes `onMapReady(googleMap)`. + * **How we know it is correct:** Asserts the resumed value equals `googleMap`. + */ + @Test + fun testMapViewAwaitMap() = runTest { + var map: GoogleMap? = null + val job = launch { + map = mapView.awaitMap() + } + advanceUntilIdle() + verify(mapView).getMapAsync(onMapReadyCallback.capture()) + onMapReadyCallback.value.onMapReady(googleMap) + advanceUntilIdle() + assertThat(map).isEqualTo(googleMap) + job.cancel() + } + + // --------------------------------------------------------------------------------------------- + // Canonical awaitStreetViewPanorama() tests for SupportStreetViewPanoramaFragment, etc. + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies [SupportStreetViewPanoramaFragment.awaitStreetViewPanorama] suspends until panorama is ready. + * **How it works:** Captures `OnStreetViewPanoramaReadyCallback` on `getStreetViewPanoramaAsync` and calls `onStreetViewPanoramaReady`. + * **How we know it is correct:** Asserts `awaitStreetViewPanorama()` returns the expected `streetViewPanorama` instance. + */ + @Test + fun testSupportStreetViewPanoramaFragmentAwaitPanorama() = runTest { + var panorama: StreetViewPanorama? = null + val job = launch { + panorama = supportStreetViewPanoramaFragment.awaitStreetViewPanorama() + } + advanceUntilIdle() + verify(supportStreetViewPanoramaFragment).getStreetViewPanoramaAsync(onStreetViewPanoramaReadyCallback.capture()) + onStreetViewPanoramaReadyCallback.value.onStreetViewPanoramaReady(streetViewPanorama) + advanceUntilIdle() + assertThat(panorama).isEqualTo(streetViewPanorama) + job.cancel() + } + + /** + * **Purpose:** Verifies [StreetViewPanoramaFragment.awaitStreetViewPanorama] suspends until panorama is ready. + * **How it works:** Captures callback from `streetViewPanoramaFragment.getStreetViewPanoramaAsync` and invokes ready callback. + * **How we know it is correct:** Asserts resumed panorama equals `streetViewPanorama`. + */ + @Test + fun testStreetViewPanoramaFragmentAwaitPanorama() = runTest { + var panorama: StreetViewPanorama? = null + val job = launch { + panorama = streetViewPanoramaFragment.awaitStreetViewPanorama() + } + advanceUntilIdle() + verify(streetViewPanoramaFragment).getStreetViewPanoramaAsync(onStreetViewPanoramaReadyCallback.capture()) + onStreetViewPanoramaReadyCallback.value.onStreetViewPanoramaReady(streetViewPanorama) + advanceUntilIdle() + assertThat(panorama).isEqualTo(streetViewPanorama) + job.cancel() + } + + /** + * **Purpose:** Verifies [StreetViewPanoramaView.awaitStreetViewPanorama] suspends until panorama is ready. + * **How it works:** Captures callback from `streetViewPanoramaView.getStreetViewPanoramaAsync` and invokes ready callback. + * **How we know it is correct:** Asserts resumed panorama equals `streetViewPanorama`. + */ + @Test + fun testStreetViewPanoramaViewAwaitPanorama() = runTest { + var panorama: StreetViewPanorama? = null + val job = launch { + panorama = streetViewPanoramaView.awaitStreetViewPanorama() + } + advanceUntilIdle() + verify(streetViewPanoramaView).getStreetViewPanoramaAsync(onStreetViewPanoramaReadyCallback.capture()) + onStreetViewPanoramaReadyCallback.value.onStreetViewPanoramaReady(streetViewPanorama) + advanceUntilIdle() + assertThat(panorama).isEqualTo(streetViewPanorama) + job.cancel() + } + + // --------------------------------------------------------------------------------------------- + // Backwards-Compatible KTX Shims for awaitMap() and awaitStreetViewPanorama() + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitMap(SupportMapFragment)` forwards to canonical awaitMap(). + * **How it works:** Calls KTX shim in coroutine, invokes captured `OnMapReadyCallback`, and verifies resumption. + * **How we know it is correct:** Asserts KTX shim returns the exact `googleMap` instance without regression. + */ + @Suppress("DEPRECATION") + @Test + fun testKtxSupportMapFragmentAwaitMap() = runTest { + var map: GoogleMap? = null + val job = launch { + map = supportMapFragment.ktxAwaitMap() + } + advanceUntilIdle() + verify(supportMapFragment).getMapAsync(onMapReadyCallback.capture()) + onMapReadyCallback.value.onMapReady(googleMap) + advanceUntilIdle() + assertThat(map).isEqualTo(googleMap) + job.cancel() + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitMap(MapFragment)` forwards to canonical awaitMap(). + * **How it works:** Calls KTX shim in coroutine, invokes captured `OnMapReadyCallback`, and verifies resumption. + * **How we know it is correct:** Asserts KTX shim returns `googleMap`. + */ + @Suppress("DEPRECATION") + @Test + fun testKtxMapFragmentAwaitMap() = runTest { + var map: GoogleMap? = null + val job = launch { + map = mapFragment.ktxAwaitMap() + } + advanceUntilIdle() + verify(mapFragment).getMapAsync(onMapReadyCallback.capture()) + onMapReadyCallback.value.onMapReady(googleMap) + advanceUntilIdle() + assertThat(map).isEqualTo(googleMap) + job.cancel() + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitMap(MapView)` forwards to canonical awaitMap(). + * **How it works:** Calls KTX shim in coroutine, invokes captured `OnMapReadyCallback`, and verifies resumption. + * **How we know it is correct:** Asserts KTX shim returns `googleMap`. + */ + @Suppress("DEPRECATION") + @Test + fun testKtxMapViewAwaitMap() = runTest { + var map: GoogleMap? = null + val job = launch { + map = mapView.ktxAwaitMap() + } + advanceUntilIdle() + verify(mapView).getMapAsync(onMapReadyCallback.capture()) + onMapReadyCallback.value.onMapReady(googleMap) + advanceUntilIdle() + assertThat(map).isEqualTo(googleMap) + job.cancel() + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitStreetViewPanorama(SupportStreetViewPanoramaFragment)` forwards correctly. + * **How it works:** Calls KTX shim in coroutine and triggers captured SDK ready callback. + * **How we know it is correct:** Asserts KTX shim returns `streetViewPanorama`. + */ + @Suppress("DEPRECATION") + @Test + fun testKtxSupportStreetViewPanoramaFragmentAwaitPanorama() = runTest { + var panorama: StreetViewPanorama? = null + val job = launch { + panorama = supportStreetViewPanoramaFragment.ktxAwaitStreetViewPanorama() + } + advanceUntilIdle() + verify(supportStreetViewPanoramaFragment).getStreetViewPanoramaAsync(onStreetViewPanoramaReadyCallback.capture()) + onStreetViewPanoramaReadyCallback.value.onStreetViewPanoramaReady(streetViewPanorama) + advanceUntilIdle() + assertThat(panorama).isEqualTo(streetViewPanorama) + job.cancel() + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitStreetViewPanorama(StreetViewPanoramaFragment)` forwards correctly. + * **How it works:** Calls KTX shim in coroutine and triggers captured SDK ready callback. + * **How we know it is correct:** Asserts KTX shim returns `streetViewPanorama`. + */ + @Suppress("DEPRECATION") + @Test + fun testKtxStreetViewPanoramaFragmentAwaitPanorama() = runTest { + var panorama: StreetViewPanorama? = null + val job = launch { + panorama = streetViewPanoramaFragment.ktxAwaitStreetViewPanorama() + } + advanceUntilIdle() + verify(streetViewPanoramaFragment).getStreetViewPanoramaAsync(onStreetViewPanoramaReadyCallback.capture()) + onStreetViewPanoramaReadyCallback.value.onStreetViewPanoramaReady(streetViewPanorama) + advanceUntilIdle() + assertThat(panorama).isEqualTo(streetViewPanorama) + job.cancel() + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitStreetViewPanorama(StreetViewPanoramaView)` forwards correctly. + * **How it works:** Calls KTX shim in coroutine and triggers captured SDK ready callback. + * **How we know it is correct:** Asserts KTX shim returns `streetViewPanorama`. + */ + @Suppress("DEPRECATION") + @Test + fun testKtxStreetViewPanoramaViewAwaitPanorama() = runTest { + var panorama: StreetViewPanorama? = null + val job = launch { + panorama = streetViewPanoramaView.ktxAwaitStreetViewPanorama() + } + advanceUntilIdle() + verify(streetViewPanoramaView).getStreetViewPanoramaAsync(onStreetViewPanoramaReadyCallback.capture()) + onStreetViewPanoramaReadyCallback.value.onStreetViewPanoramaReady(streetViewPanorama) + advanceUntilIdle() + assertThat(panorama).isEqualTo(streetViewPanorama) + job.cancel() + } + + // --------------------------------------------------------------------------------------------- + // StreetViewPanorama Coroutine Flow Event Listeners + // --------------------------------------------------------------------------------------------- + + /** + * **Purpose:** Verifies [StreetViewPanorama.cameraChangeEvents] converts camera change callbacks to a Kotlin Flow. + * **How it works:** Subscribes to `cameraChangeEvents().first()`, captures SDK listener, and calls `onStreetViewPanoramaCameraChange(panoramaCamera)`. + * **How we know it is correct:** Proves flow emits the exact `panoramaCamera` event passed to the SDK listener. + */ + @Test + fun testStreetViewPanoramaCameraChangeEvents() = runTest { + val job = launch { + val event = streetViewPanorama.cameraChangeEvents().first() + assertThat(event).isEqualTo(panoramaCamera) + } + advanceUntilIdle() + verify(streetViewPanorama).setOnStreetViewPanoramaCameraChangeListener(cameraChangeListener.capture()) + cameraChangeListener.value.onStreetViewPanoramaCameraChange(panoramaCamera) + job.cancel() + } + + /** + * **Purpose:** Verifies [StreetViewPanorama.changeEvents] converts location change callbacks to a Kotlin Flow. + * **How it works:** Subscribes to `changeEvents().first()`, captures SDK listener, and calls `onStreetViewPanoramaChange(panoramaLocation)`. + * **How we know it is correct:** Proves flow emits the exact `panoramaLocation` event. + */ + @Test + fun testStreetViewPanoramaChangeEvents() = runTest { + val job = launch { + val event = streetViewPanorama.changeEvents().first() + assertThat(event).isEqualTo(panoramaLocation) + } + advanceUntilIdle() + verify(streetViewPanorama).setOnStreetViewPanoramaChangeListener(changeListener.capture()) + changeListener.value.onStreetViewPanoramaChange(panoramaLocation) + job.cancel() + } + + /** + * **Purpose:** Verifies [StreetViewPanorama.clickEvents] converts panorama click callbacks to a Kotlin Flow. + * **How it works:** Subscribes to `clickEvents().first()`, captures SDK listener, and calls `onStreetViewPanoramaClick(panoramaOrientation)`. + * **How we know it is correct:** Proves flow emits the clicked `panoramaOrientation`. + */ + @Test + fun testStreetViewPanoramaClickEvents() = runTest { + val job = launch { + val event = streetViewPanorama.clickEvents().first() + assertThat(event).isEqualTo(panoramaOrientation) + } + advanceUntilIdle() + verify(streetViewPanorama).setOnStreetViewPanoramaClickListener(clickListener.capture()) + clickListener.value.onStreetViewPanoramaClick(panoramaOrientation) + job.cancel() + } + + /** + * **Purpose:** Verifies [StreetViewPanorama.longClickEvents] converts panorama long-click callbacks to a Kotlin Flow. + * **How it works:** Subscribes to `longClickEvents().first()`, captures SDK listener, and calls `onStreetViewPanoramaLongClick(panoramaOrientation)`. + * **How we know it is correct:** Proves flow emits the long-clicked `panoramaOrientation`. + */ + @Test + fun testStreetViewPanoramaLongClickEvents() = runTest { + val job = launch { + val event = streetViewPanorama.longClickEvents().first() + assertThat(event).isEqualTo(panoramaOrientation) + } + advanceUntilIdle() + verify(streetViewPanorama).setOnStreetViewPanoramaLongClickListener(longClickListener.capture()) + longClickListener.value.onStreetViewPanoramaLongClick(panoramaOrientation) + job.cancel() + } +} diff --git a/library/src/test/java/com/google/maps/android/MapsInitializerTest.kt b/library/src/test/java/com/google/maps/android/MapsInitializerTest.kt new file mode 100644 index 000000000..ea7757440 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/MapsInitializerTest.kt @@ -0,0 +1,171 @@ +/* + * 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 android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.OnMapsSdkInitializedCallback +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.junit.MockitoJUnitRunner + +/** + * Unit test suite for canonical Maps SDK coroutine initialization extension [Context.awaitMapsSdkInitialized]. + * + * **Purpose:** + * Validates that [Context.awaitMapsSdkInitialized] properly bridges [MapsInitializer.initialize] + * callbacks into coroutine resumption with the loaded [MapsInitializer.Renderer], handles `null` + * and default preferences, and converts initialization failure status codes into + * [GooglePlayServicesNotAvailableException]. + * + * **How it works:** + * Uses Mockito static mocking (`mockStatic(MapsInitializer::class.java)`) to intercept static calls + * to [MapsInitializer.initialize] on the provided mock [Context]. Test methods trigger either the + * asynchronous callback or return failure status codes and verify the returned result or thrown exception. + * + * **How we know it is correct:** + * - **Success cases:** Asserts the returned renderer matches the value supplied to [OnMapsSdkInitializedCallback]. + * - **Error cases:** Asserts [GooglePlayServicesNotAvailableException] is thrown with the exact [ConnectionResult] error code. + */ +@RunWith(MockitoJUnitRunner::class) +public class MapsInitializerTest { + + @Mock + private lateinit var context: Context + + private lateinit var mapsInitializerMock: MockedStatic + + @Before + public fun setUp() { + mapsInitializerMock = mockStatic(MapsInitializer::class.java) + } + + @After + public fun tearDown() { + mapsInitializerMock.close() + } + + /** + * **Purpose:** Verifies [Context.awaitMapsSdkInitialized] resumes with the loaded renderer when initialization succeeds. + * **How it works:** Mocks [MapsInitializer.initialize] to invoke callback with [MapsInitializer.Renderer.LEGACY] and return [ConnectionResult.SUCCESS]. + * **How we know it is correct:** Asserts the returned renderer equals [MapsInitializer.Renderer.LEGACY]. + */ + @Suppress("DEPRECATION") + @Test + public fun testAwaitMapsSdkInitializedReturnsActualRenderer(): Unit = runTest { + mapsInitializerMock.`when` { + MapsInitializer.initialize( + eq(context), + eq(MapsInitializer.Renderer.LATEST), + any(OnMapsSdkInitializedCallback::class.java) + ) + }.thenAnswer { invocation -> + invocation.getArgument(2) + .onMapsSdkInitialized(MapsInitializer.Renderer.LEGACY) + ConnectionResult.SUCCESS + } + + val renderer = context.awaitMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + + assertThat(renderer).isEqualTo(MapsInitializer.Renderer.LEGACY) + } + + /** + * **Purpose:** Verifies [Context.awaitMapsSdkInitialized] throws [GooglePlayServicesNotAvailableException] on initialization failure. + * **How it works:** Mocks [MapsInitializer.initialize] to return [ConnectionResult.SERVICE_MISSING] without triggering the callback. + * **How we know it is correct:** Asserts [GooglePlayServicesNotAvailableException] is thrown with [ConnectionResult.SERVICE_MISSING] error code. + */ + @Test + public fun testAwaitMapsSdkInitializedThrowsForNonSuccessStatus(): Unit = runTest { + mapsInitializerMock.`when` { + MapsInitializer.initialize( + eq(context), + eq(MapsInitializer.Renderer.LATEST), + any(OnMapsSdkInitializedCallback::class.java) + ) + }.thenReturn(ConnectionResult.SERVICE_MISSING) + + val exception = runCatching { + context.awaitMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + }.exceptionOrNull() + + assertThat(exception).isInstanceOf(GooglePlayServicesNotAvailableException::class.java) + assertThat((exception as GooglePlayServicesNotAvailableException).errorCode) + .isEqualTo(ConnectionResult.SERVICE_MISSING) + } + + /** + * **Purpose:** Verifies [Context.awaitMapsSdkInitialized] handles an explicit `null` preferred renderer parameter. + * **How it works:** Mocks [MapsInitializer.initialize] with `null` preferred renderer and invokes callback with [MapsInitializer.Renderer.LATEST]. + * **How we know it is correct:** Asserts the returned renderer equals [MapsInitializer.Renderer.LATEST]. + */ + @Suppress("DEPRECATION") + @Test + public fun testAwaitMapsSdkInitializedWithNullPreferredRenderer(): Unit = runTest { + mapsInitializerMock.`when` { + MapsInitializer.initialize( + eq(context), + eq(null), + any(OnMapsSdkInitializedCallback::class.java) + ) + }.thenAnswer { invocation -> + invocation.getArgument(2) + .onMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + ConnectionResult.SUCCESS + } + + val renderer = context.awaitMapsSdkInitialized(null) + + assertThat(renderer).isEqualTo(MapsInitializer.Renderer.LATEST) + } + + /** + * **Purpose:** Verifies [Context.awaitMapsSdkInitialized] defaults to `null` preferred renderer when called without arguments. + * **How it works:** Calls `context.awaitMapsSdkInitialized()` with default argument and verifies callback resolution. + * **How we know it is correct:** Asserts the returned renderer equals [MapsInitializer.Renderer.LATEST]. + */ + @Suppress("DEPRECATION") + @Test + public fun testAwaitMapsSdkInitializedWithDefaultNullRenderer(): Unit = runTest { + mapsInitializerMock.`when` { + MapsInitializer.initialize( + eq(context), + eq(null), + any(OnMapsSdkInitializedCallback::class.java) + ) + }.thenAnswer { invocation -> + invocation.getArgument(2) + .onMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + ConnectionResult.SUCCESS + } + + val renderer = context.awaitMapsSdkInitialized() + + assertThat(renderer).isEqualTo(MapsInitializer.Renderer.LATEST) + } +} diff --git a/library/src/test/java/com/google/maps/android/PolygonTest.kt b/library/src/test/java/com/google/maps/android/PolygonTest.kt new file mode 100644 index 000000000..312685436 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/PolygonTest.kt @@ -0,0 +1,59 @@ +/* + * 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.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polygon +import com.google.common.truth.Truth.assertThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.junit.Test + +internal class PolygonTest { + @Test + fun testContainsTrue() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.contains(LatLng(1.0, 2.2))).isTrue() + } + + @Test + fun testContainsFalse() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.contains(LatLng(1.01, 2.2))).isFalse() + } + + @Test + fun testIsOnEdgeTrue() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.isOnEdge(LatLng(1.0, 2.2))).isTrue() + + // Tolerance + assertThat(polygon.isOnEdge(LatLng(1.0000005, 2.2))).isTrue() + } + + @Test + fun testIsOnEdgeFalse() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.isOnEdge(LatLng(3.0, 2.2))).isFalse() + } + + private fun mockPolygon(p: List, geodesic: Boolean = true) = mock { + on { points } doReturn p + on { isGeodesic } doReturn geodesic + } +} diff --git a/library/src/test/java/com/google/maps/android/PolylineTest.kt b/library/src/test/java/com/google/maps/android/PolylineTest.kt new file mode 100644 index 000000000..26304ab07 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/PolylineTest.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 + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.junit.Test + +internal class PolylineTest { + private val earthRadius = 6371009.0 + + @Test + fun `test that contains returns true`() { + val line = mockPolyline(listOf(LatLng(1.0, 0.0), LatLng(3.0, 0.0))) + assertThat(line.contains(LatLng(2.0, 0.0))).isTrue() + } + + @Test + fun `test that contains returns true with tolerance`() { + val line = mockPolyline(listOf(LatLng(1.0, 0.0), LatLng(3.0, 0.0))) + assertThat(line.contains(LatLng(1.0, 0.00000001))).isTrue() + } + + @Test + fun `test that contains returns false`() { + val line = mockPolyline(listOf(LatLng(1.0, 0.0), LatLng(3.0, 0.0))) + assertThat(line.contains(LatLng(4.0, 0.0))).isFalse() + } + + @Test + fun `validate spherical path length`() { + assertThat(mockPolyline(emptyList()).sphericalPathLength).isWithin(1e-6).of(0.0) + val polyline = mockPolyline(listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1))) + val expectation = earthRadius * Math.sqrt(2.0) * Math.toRadians(0.1) + assertThat(polyline.sphericalPathLength).isWithin(1e-1).of(expectation) + } + + private fun mockPolyline(p: List, geodesic: Boolean = true) = mock { + on { points } doReturn p + on { isGeodesic } doReturn geodesic + } +} diff --git a/library/src/test/java/com/google/maps/android/collections/CollectionManagersTest.kt b/library/src/test/java/com/google/maps/android/collections/CollectionManagersTest.kt new file mode 100644 index 000000000..9118b9d33 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/collections/CollectionManagersTest.kt @@ -0,0 +1,247 @@ +/* + * 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.collections + +import com.google.maps.android.MarkerDragEndEvent +import com.google.maps.android.MarkerDragEvent +import com.google.maps.android.MarkerDragStartEvent + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class CollectionManagersTest { + + @Mock + private lateinit var markerCollection: MarkerManager.Collection + + @Mock + private lateinit var polylineCollection: PolylineManager.Collection + + @Mock + private lateinit var polygonCollection: PolygonManager.Collection + + @Mock + private lateinit var circleCollection: CircleManager.Collection + + @Mock + private lateinit var groundOverlayCollection: GroundOverlayManager.Collection + + @Mock + private lateinit var marker: Marker + + @Mock + private lateinit var polyline: Polyline + + @Mock + private lateinit var polygon: Polygon + + @Mock + private lateinit var circle: Circle + + @Mock + private lateinit var groundOverlay: GroundOverlay + + @Captor + private lateinit var markerClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowLongClickListener: ArgumentCaptor + + @Captor + private lateinit var polylineClickListener: ArgumentCaptor + + @Captor + private lateinit var polygonClickListener: ArgumentCaptor + + @Captor + private lateinit var circleClickListener: ArgumentCaptor + + @Captor + private lateinit var groundOverlayClickListener: ArgumentCaptor + + private var activeMarkerClickListener: GoogleMap.OnMarkerClickListener? = null + + @Before + public fun setUp() { + activeMarkerClickListener = null + // Stub setOnMarkerClickListener to track the active listener on the collection manager + `when`(markerCollection.setOnMarkerClickListener(any())).thenAnswer { invocation -> + activeMarkerClickListener = invocation.arguments[0] as? GoogleMap.OnMarkerClickListener + null + } + } + + @Test + public fun testMarkerCollectionClickEvents(): Unit = runTest { + val deferred = async { + markerCollection.clickEvents().first() + } + advanceUntilIdle() + // Trigger the event via our tracked active listener slot! + assertThat(activeMarkerClickListener).isNotNull() + activeMarkerClickListener?.onMarkerClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + @Test + public fun testConcurrentCollectionMarkerClick(): Unit = runTest { + val flow = markerCollection.clickEvents() + val collector1Events = mutableListOf() + val collector2Events = mutableListOf() + + // Start Collector 1 + val job1 = launch { + flow.collect { collector1Events.add(it) } + } + advanceUntilIdle() + assertThat(activeMarkerClickListener).isNotNull() + val listener1 = activeMarkerClickListener + + // Start Collector 2 - this will overwrite the listener on the mock + val job2 = launch { + flow.collect { collector2Events.add(it) } + } + advanceUntilIdle() + val listener2 = activeMarkerClickListener + + // Verify they are different listener instances and listener2 hijacked the slot + assertThat(listener1).isNotEqualTo(listener2) + assertThat(activeMarkerClickListener).isEqualTo(listener2) + + // Simulate click via the active listener + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + // Only collector 2 should receive the event because it hijacked the single-listener slot + assertThat(collector1Events).isEmpty() + assertThat(collector2Events).containsExactly(marker) + + // Cancel collector 1. This triggers awaitClose and clears the listener slot (sets to null) + job1.cancel() + advanceUntilIdle() + + // Assert that the active listener slot is now null! + assertThat(activeMarkerClickListener).isNull() + + // Try to trigger a click again via the active listener (which is now null) + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + // Collector 2 is now BROKEN and receives no further events because Collector 1's cleanup cleared the shared slot! + assertThat(collector2Events).containsExactly(marker) + + job2.cancel() + } + + @Test + public fun testMarkerCollectionInfoWindowClickEvents(): Unit = runTest { + val deferred = async { + markerCollection.infoWindowClickEvents().first() + } + advanceUntilIdle() + verify(markerCollection).setOnInfoWindowClickListener(infoWindowClickListener.capture()) + infoWindowClickListener.value.onInfoWindowClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + @Test + public fun testMarkerCollectionInfoWindowLongClickEvents(): Unit = runTest { + val deferred = async { + markerCollection.infoWindowLongClickEvents().first() + } + advanceUntilIdle() + verify(markerCollection).setOnInfoWindowLongClickListener(infoWindowLongClickListener.capture()) + infoWindowLongClickListener.value.onInfoWindowLongClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + @Test + public fun testPolylineCollectionClickEvents(): Unit = runTest { + val deferred = async { + polylineCollection.clickEvents().first() + } + advanceUntilIdle() + verify(polylineCollection).setOnPolylineClickListener(polylineClickListener.capture()) + polylineClickListener.value.onPolylineClick(polyline) + assertThat(deferred.await()).isEqualTo(polyline) + } + + @Test + public fun testPolygonCollectionClickEvents(): Unit = runTest { + val deferred = async { + polygonCollection.clickEvents().first() + } + advanceUntilIdle() + verify(polygonCollection).setOnPolygonClickListener(polygonClickListener.capture()) + polygonClickListener.value.onPolygonClick(polygon) + assertThat(deferred.await()).isEqualTo(polygon) + } + + @Test + public fun testCircleCollectionClickEvents(): Unit = runTest { + val deferred = async { + circleCollection.clickEvents().first() + } + advanceUntilIdle() + verify(circleCollection).setOnCircleClickListener(circleClickListener.capture()) + circleClickListener.value.onCircleClick(circle) + assertThat(deferred.await()).isEqualTo(circle) + } + + @Test + public fun testGroundOverlayCollectionClickEvents(): Unit = runTest { + val deferred = async { + groundOverlayCollection.clickEvents().first() + } + advanceUntilIdle() + verify(groundOverlayCollection).setOnGroundOverlayClickListener(groundOverlayClickListener.capture()) + groundOverlayClickListener.value.onGroundOverlayClick(groundOverlay) + assertThat(deferred.await()).isEqualTo(groundOverlay) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/GoogleMapTest.kt b/library/src/test/java/com/google/maps/android/ktx/GoogleMapTest.kt new file mode 100644 index 000000000..dd8b42f29 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/GoogleMapTest.kt @@ -0,0 +1,98 @@ +/* + * 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. + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx + +import com.google.android.gms.maps.CameraUpdate +import com.google.android.gms.maps.GoogleMap +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +public class GoogleMapTest { + + @Mock + private lateinit var googleMap: GoogleMap + + @Mock + private lateinit var cameraUpdate: CameraUpdate + + @Captor + private lateinit var cameraIdleListener: ArgumentCaptor + + @Captor + private lateinit var loadedCallback: ArgumentCaptor + + @Captor + private lateinit var cancelableCallback: ArgumentCaptor + + @Test + public fun testCameraIdleEvents(): Unit = runTest { + val deferred = async { + googleMap.cameraIdleEvents().first() + } + advanceUntilIdle() + verify(googleMap).setOnCameraIdleListener(cameraIdleListener.capture()) + cameraIdleListener.value.onCameraIdle() + assertThat(deferred.await()).isEqualTo(Unit) + } + + @Test + public fun testAwaitMapLoad(): Unit = runTest { + val deferred = async { + googleMap.awaitMapLoad() + } + advanceUntilIdle() + verify(googleMap).setOnMapLoadedCallback(loadedCallback.capture()) + loadedCallback.value.onMapLoaded() + assertThat(deferred.await()).isEqualTo(Unit) + } + + @Test + public fun testAwaitAnimateCamera(): Unit = runTest { + val deferredDefault = async { + googleMap.awaitAnimateCamera(cameraUpdate) + } + advanceUntilIdle() + verify(googleMap).animateCamera(any(CameraUpdate::class.java), cancelableCallback.capture()) + cancelableCallback.value.onFinish() + assertThat(deferredDefault.await()).isEqualTo(Unit) + + val deferredWithDuration = async { + googleMap.awaitAnimateCamera(cameraUpdate, 500) + } + advanceUntilIdle() + verify(googleMap).animateCamera(any(CameraUpdate::class.java), Mockito.eq(500), cancelableCallback.capture()) + cancelableCallback.value.onFinish() + assertThat(deferredWithDuration.await()).isEqualTo(Unit) + } +} + diff --git a/library/src/test/java/com/google/maps/android/ktx/MapsInitializerTest.kt b/library/src/test/java/com/google/maps/android/ktx/MapsInitializerTest.kt new file mode 100644 index 000000000..1f15efa05 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/MapsInitializerTest.kt @@ -0,0 +1,118 @@ +/* + * 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. + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.OnMapsSdkInitializedCallback +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.junit.MockitoJUnitRunner + +/** + * Unit test suite for the backward-compatibility KTX shim [com.google.maps.android.ktx.awaitMapsSdkInitialized]. + * + * **Purpose:** + * Validates that the deprecated KTX shim [com.google.maps.android.ktx.awaitMapsSdkInitialized] forwards + * seamlessly to the canonical [com.google.maps.android.awaitMapsSdkInitialized] implementation. + * + * **How it works:** + * Uses Mockito static mocking (`mockStatic(MapsInitializer::class.java)`) to intercept SDK calls and + * verifies that the deprecated KTX extension correctly returns loaded renderers and propagates exceptions. + * + * **How we know it is correct:** + * - Verifies return value parity with canonical implementation. + * - Verifies exception propagation matches canonical implementation. + */ +@RunWith(MockitoJUnitRunner::class) +public class MapsInitializerTest { + + @Mock + private lateinit var context: Context + + private lateinit var mapsInitializerMock: MockedStatic + + @Before + public fun setUp() { + mapsInitializerMock = mockStatic(MapsInitializer::class.java) + } + + @After + public fun tearDown() { + mapsInitializerMock.close() + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitMapsSdkInitialized` returns the loaded renderer. + * **How it works:** Calls [com.google.maps.android.ktx.awaitMapsSdkInitialized] and simulates successful SDK initialization. + * **How we know it is correct:** Asserts returned renderer equals [MapsInitializer.Renderer.LATEST]. + */ + @Test + public fun testKtxAwaitMapsSdkInitializedReturnsActualRenderer(): Unit = runTest { + mapsInitializerMock.`when` { + MapsInitializer.initialize( + eq(context), + eq(MapsInitializer.Renderer.LATEST), + any(OnMapsSdkInitializedCallback::class.java) + ) + }.thenAnswer { invocation -> + invocation.getArgument(2) + .onMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + ConnectionResult.SUCCESS + } + + val renderer = context.awaitMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + + assertThat(renderer).isEqualTo(MapsInitializer.Renderer.LATEST) + } + + /** + * **Purpose:** Verifies deprecated KTX shim `awaitMapsSdkInitialized` propagates initialization exceptions. + * **How it works:** Simulates SDK missing error and calls [com.google.maps.android.ktx.awaitMapsSdkInitialized]. + * **How we know it is correct:** Asserts [GooglePlayServicesNotAvailableException] is thrown with [ConnectionResult.SERVICE_MISSING]. + */ + @Test + public fun testKtxAwaitMapsSdkInitializedThrowsForNonSuccessStatus(): Unit = runTest { + mapsInitializerMock.`when` { + MapsInitializer.initialize( + eq(context), + eq(MapsInitializer.Renderer.LATEST), + any(OnMapsSdkInitializedCallback::class.java) + ) + }.thenReturn(ConnectionResult.SERVICE_MISSING) + + val exception = runCatching { + context.awaitMapsSdkInitialized(MapsInitializer.Renderer.LATEST) + }.exceptionOrNull() + + assertThat(exception).isInstanceOf(GooglePlayServicesNotAvailableException::class.java) + assertThat((exception as GooglePlayServicesNotAvailableException).errorCode) + .isEqualTo(ConnectionResult.SERVICE_MISSING) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/CameraPositionTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/CameraPositionTest.kt new file mode 100644 index 000000000..a5f3016cc --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/CameraPositionTest.kt @@ -0,0 +1,40 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class CameraPositionTest { + + @Test + fun testBuilder() { + val cameraPosition = cameraPosition { + bearing(1f) + target(LatLng(1.0, 2.0)) + tilt(1f) + zoom(12f) + } + assertThat(cameraPosition.bearing).isWithin(1e-6f).of(1f) + assertThat(cameraPosition.target).isEqualTo(LatLng(1.0, 2.0)) + assertThat(cameraPosition.tilt).isWithin(1e-6f).of(1f) + assertThat(cameraPosition.zoom).isWithin(1e-6f).of(12f) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/CircleOptionsTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/CircleOptionsTest.kt new file mode 100644 index 000000000..4aac4f2cd --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/CircleOptionsTest.kt @@ -0,0 +1,48 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class CircleOptionsTest { + + @Test + fun testBuilder() { + val circleOptions = circleOptions { + center(LatLng(0.0, 0.0)) + clickable(true) + fillColor(0) + radius(1.23) + strokeColor(1) + strokeWidth(2f) + visible(true) + zIndex(1f) + } + assertThat(circleOptions.center).isEqualTo(LatLng(0.0, 0.0)) + assertThat(circleOptions.isClickable).isTrue() + assertThat(circleOptions.fillColor).isEqualTo(0) + assertThat(circleOptions.radius).isWithin(1e-6).of(1.23) + assertThat(circleOptions.strokeColor).isEqualTo(1) + assertThat(circleOptions.strokeWidth).isEqualTo(2f) + assertThat(circleOptions.isVisible).isTrue() + assertThat(circleOptions.zIndex).isWithin(1e-6f).of(1f) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt new file mode 100644 index 000000000..477f8d60e --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/GroundOverlayOptionsTest.kt @@ -0,0 +1,44 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.common.truth.Truth.assertThat +import org.mockito.kotlin.mock +import org.junit.Test + +internal class GroundOverlayOptionsTest { + + @Test + fun testBuilder() { + val descriptor: BitmapDescriptor = mock() + val groundOverlayOptions = groundOverlayOptions { + image(descriptor) + bearing(1f) + clickable(true) + transparency(0.5f) + visible(true) + } + assertThat(groundOverlayOptions.image).isEqualTo(descriptor) + assertThat(groundOverlayOptions.bearing).isWithin(1e-6f).of(1f) + assertThat(groundOverlayOptions.isClickable).isTrue() + assertThat(groundOverlayOptions.transparency).isWithin(1e-6f).of(0.5f) + assertThat(groundOverlayOptions.isVisible).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/MarkerOptionsTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/MarkerOptionsTest.kt new file mode 100644 index 000000000..23cdef1e4 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/MarkerOptionsTest.kt @@ -0,0 +1,46 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class MarkerOptionsTest { + + @Test + fun testBuilder() { + val markerOptions = markerOptions { + position(LatLng(1.0, 2.0)) + alpha(0.5f) + draggable(false) + flat(true) + title("Test") + snippet("Snippet") + visible(true) + } + assertThat(markerOptions.position).isEqualTo(LatLng(1.0, 2.0)) + assertThat(markerOptions.alpha).isWithin(1e-6f).of(0.5f) + assertThat(markerOptions.isDraggable).isFalse() + assertThat(markerOptions.isFlat).isTrue() + assertThat(markerOptions.title).isEqualTo("Test") + assertThat(markerOptions.snippet).isEqualTo("Snippet") + assertThat(markerOptions.isVisible).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/PolygonOptionsTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/PolygonOptionsTest.kt new file mode 100644 index 000000000..85c884197 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/PolygonOptionsTest.kt @@ -0,0 +1,39 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import android.graphics.Color +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class PolygonOptionsTest { + @Test + fun testBuilder() { + val polygonOptions = + polygonOptions { + strokeWidth(1.0f) + strokeColor(Color.BLACK) + add(LatLng(1.0, 2.0)) + } + assertThat(polygonOptions.strokeWidth).isWithin(1e-6f).of(1.0f) + assertThat(polygonOptions.strokeColor).isEqualTo(Color.BLACK) + assertThat(polygonOptions.points).containsExactly(LatLng(1.0, 2.0)) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/PolylineOptionsTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/PolylineOptionsTest.kt new file mode 100644 index 000000000..3d9f986c2 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/PolylineOptionsTest.kt @@ -0,0 +1,42 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class PolylineOptionsTest { + + @Test + fun testBuilder() { + val polylineOptions = polylineOptions { + add(LatLng(1.0, 2.0)) + clickable(true) + color(0) + geodesic(true) + width(1f) + } + assertThat(polylineOptions.points).containsExactly(LatLng(1.0, 2.0)) + assertThat(polylineOptions.isClickable).isTrue() + assertThat(polylineOptions.color).isEqualTo(0) + assertThat(polylineOptions.isGeodesic).isTrue() + assertThat(polylineOptions.width).isWithin(1e-6f).of(1f) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt new file mode 100644 index 000000000..d900a8ef8 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaCameraTest.kt @@ -0,0 +1,37 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class StreetViewPanoramaCameraTest { + + @Test + fun `test that StreetViewPanoramaCamera is constructed`() { + val camera = streetViewPanoramaCamera { + bearing(1f) + tilt(20f) + zoom(2f) + } + assertThat(camera.bearing).isWithin(1e-6f).of(1f) + assertThat(camera.tilt).isWithin(1e-6f).of(20f) + assertThat(camera.zoom).isWithin(1e-6f).of(2f) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.kt new file mode 100644 index 000000000..56a9443d0 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/StreetViewPanoramaOrientationTest.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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class StreetViewPanoramaOrientationTest { + + @Test + fun `test that StreetViewPanoramaOrientation is constructed`() { + val orientation = streetViewPanoramaOrientation { + bearing(1f) + tilt(20f) + } + assertThat(orientation.bearing).isWithin(1e-6f).of(1f) + assertThat(orientation.tilt).isWithin(1e-6f).of(20f) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt b/library/src/test/java/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt new file mode 100644 index 000000000..c1d3c24c4 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/model/TileOverlayOptionsTest.kt @@ -0,0 +1,39 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class TileOverlayOptionsTest { + + @Test + fun testBuilder() { + val tileOverlayOptions = tileOverlayOptions { + fadeIn(true) + transparency(0.5f) + visible(false) + zIndex(1f) + } + assertThat(tileOverlayOptions.fadeIn).isTrue() + assertThat(tileOverlayOptions.isVisible).isFalse() + assertThat(tileOverlayOptions.transparency).isWithin(1e-6f).of(0.5f) + assertThat(tileOverlayOptions.zIndex).isWithin(1e-6f).of(1f) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/LatLngTest.kt b/library/src/test/java/com/google/maps/android/ktx/utils/LatLngTest.kt new file mode 100644 index 000000000..136748560 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/utils/LatLngTest.kt @@ -0,0 +1,190 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class LatLngTest { + private val earthRadius = 6371009.0 + + @Test + fun `test that latLng can be destructured`() { + val latLng = LatLng(2.0, 3.0) + val (lat, lng) = latLng + assertThat(lat).isWithin(1e-6).of(2.0) + assertThat(lng).isWithin(1e-6).of(3.0) + } + + @Test + fun `single LatLng encoding`() { + val line = listOf(LatLng(1.0, 2.0)) + assertThat(line.latLngListEncode()).isEqualTo("_ibE_seK") + } + + @Test + fun `single LatLng decoding`() { + val lineEncoded = "_yfyF_ocsF" + val line = lineEncoded.toLatLngList() + assertThat(line.first()).isEqualTo(LatLng(41.0, 40.0)) + } + + @Test + fun `closed polygon true`() { + val latLngList = listOf(LatLng(1.0, 2.0), LatLng(3.0, 4.0), LatLng(1.0, 2.0)) + assertThat(latLngList.isClosedPolygon()).isTrue() + } + + @Test + fun `closed polygon false`() { + val latLngList = listOf(LatLng(1.0, 2.0), LatLng(3.0, 4.0)) + assertThat(latLngList.isClosedPolygon()).isFalse() + } + + @Test + fun `simplify endpoints are still equal`() { + val lineEncoded = "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 = lineEncoded.toLatLngList() + val simplifiedLine = line.simplify(tolerance = 5.0) + assertThat(simplifiedLine).hasSize(20) + assertThat(simplifiedLine.first()).isEqualTo(line.first()) + assertThat(simplifiedLine.last()).isEqualTo(line.last()) + } + + @Test + fun `heading is accurate`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + assertThat(up.sphericalHeading(down)).isWithin(1e-6).of(-180.0) + } + + @Test + fun `withOffset is accurate`() { + val up = LatLng(90.0, 135.0) + val down = up.withSphericalOffset(earthRadius, 180.0) + assertThat(down.latitude).isWithin(1e-6).of(32.704220486917684) + assertThat(down.longitude).isWithin(1e-6).of(-135.0) + } + + @Test + fun `computeOffsetOrigin is accurate`() { + val front = LatLng(0.0, 0.0) + assertThat(front.computeSphericalOffsetOrigin(0.0, 0.0)).isEqualTo(front) + + val result = LatLng(0.0, 45.0).computeSphericalOffsetOrigin( + distance = Math.PI * earthRadius / 4.0, + heading = 90.0 + )!! + assertThat(result.latitude).isWithin(1e-6).of(0.0) + assertThat(result.longitude).isWithin(1e-6).of(0.0) + } + + @Test + fun `compute interpolation`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + + val zeroFraction = up.withSphericalLinearInterpolation(down, 0.0) + assertThat(zeroFraction.latitude).isWithin(1e-6).of(90.0) + assertThat(zeroFraction.longitude).isWithin(1e-6).of(0.0) + + val halfFraction = up.withSphericalLinearInterpolation(down, 0.5) + assertThat(halfFraction.latitude).isWithin(1e-6).of(0.0) + assertThat(halfFraction.longitude).isWithin(1e-6).of(0.0) + + val oneFraction = up.withSphericalLinearInterpolation(down, 1.0) + assertThat(oneFraction.latitude).isWithin(1e-6).of(-90.0) + assertThat(oneFraction.longitude).isWithin(1e-6).of(0.0) + } + + @Test + fun `compute spherical distance`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + assertThat(up.sphericalDistance(down)).isWithin(1e-6).of(Math.PI * earthRadius) + } + + @Test + fun `validate spherical path length`() { + assertThat(emptyList().sphericalPathLength()).isWithin(1e-6).of(0.0) + + val latLngs = listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1)) + val expectation = earthRadius * Math.sqrt(2.0) * Math.toRadians(0.1) + assertThat(latLngs.sphericalPathLength()).isWithin(1e-1).of(expectation) + } + + @Test + fun `validate spherical polygon area`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + val right = LatLng(0.0, 90.0) + val polygon = listOf(up, down, right, up) + assertThat(polygon.sphericalPolygonArea()).isWithin(1e-6).of(1.2751647824926386E14) + println(polygon.sphericalPolygonSignedArea()) + } + + @Test + fun `validate signed spherical polygon area`() { + val up = LatLng(90.0, 0.0) + val down = LatLng(-90.0, 0.0) + val right = LatLng(0.0, 90.0) + val polygon = listOf(up, down, right, up) + val reversedPolygon = listOf(up, right, down, up) + assertThat(reversedPolygon.sphericalPolygonSignedArea()) + .isWithin(1e-6) + .of(-polygon.sphericalPolygonSignedArea()) + } + + @Test + fun `contains location evaluates to true`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.containsLocation(LatLng(30.0, 45.0), geodesic = true)).isTrue() + } + + @Test + fun `contains location evaluates to false`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.containsLocation(LatLng(-30.0, 45.0), geodesic = true)).isFalse() + } + + @Test + fun `isOnEdge location evaluates to true`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.isOnEdge(LatLng(0.0, 45.0), geodesic = true)).isTrue() + } + + @Test + fun `isOnEdge location evaluates to false`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(-90.0, 45.0)) + assertThat(latLngList.isOnEdge(LatLng(0.0, -45.0), geodesic = true)).isFalse() + } + + @Test + fun `isLocationOnPath location evaluates to true`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(0.0, 180.0)) + assertThat(latLngList.isLocationOnPath(LatLng(0.0, 45.0), geodesic = true)).isTrue() + } + + @Test + fun `isLocationOnPath location evaluates to false`() { + val latLngList = listOf(LatLng(0.0, 0.0), LatLng(0.0, 90.0), LatLng(0.0, 180.0)) + assertThat(latLngList.isLocationOnPath(LatLng(0.0, -45.0), geodesic = true)).isFalse() + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/PolygonTest.kt b/library/src/test/java/com/google/maps/android/ktx/utils/PolygonTest.kt new file mode 100644 index 000000000..f3f0df6e5 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/utils/PolygonTest.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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polygon +import com.google.common.truth.Truth.assertThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.junit.Test + +internal class PolygonTest { + @Test + fun testContainsTrue() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.contains(LatLng(1.0, 2.2))).isTrue() + } + + @Test + fun testContainsFalse() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.contains(LatLng(1.01, 2.2))).isFalse() + } + + @Test + fun testIsOnEdgeTrue() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.isOnEdge(LatLng(1.0, 2.2))).isTrue() + + // Tolerance + assertThat(polygon.isOnEdge(LatLng(1.0000005, 2.2))).isTrue() + } + + @Test + fun testIsOnEdgeFalse() { + val polygon = mockPolygon(listOf(LatLng(1.0, 2.2), LatLng(0.0, 1.0))) + assertThat(polygon.isOnEdge(LatLng(3.0, 2.2))).isFalse() + } + + private fun mockPolygon(p: List, geodesic: Boolean = true) = mock { + on { points } doReturn p + on { isGeodesic } doReturn geodesic + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/PolylineTest.kt b/library/src/test/java/com/google/maps/android/ktx/utils/PolylineTest.kt new file mode 100644 index 000000000..4b32a2b75 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/utils/PolylineTest.kt @@ -0,0 +1,61 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.junit.Test + +internal class PolylineTest { + private val earthRadius = 6371009.0 + + @Test + fun `test that contains returns true`() { + val line = mockPolyline(listOf(LatLng(1.0, 0.0), LatLng(3.0, 0.0))) + assertThat(line.contains(LatLng(2.0, 0.0))).isTrue() + } + + @Test + fun `test that contains returns true with tolerance`() { + val line = mockPolyline(listOf(LatLng(1.0, 0.0), LatLng(3.0, 0.0))) + assertThat(line.contains(LatLng(1.0, 0.00000001))).isTrue() + } + + @Test + fun `test that contains returns false`() { + val line = mockPolyline(listOf(LatLng(1.0, 0.0), LatLng(3.0, 0.0))) + assertThat(line.contains(LatLng(4.0, 0.0))).isFalse() + } + + @Test + fun `validate spherical path length`() { + assertThat(mockPolyline(emptyList()).sphericalPathLength).isWithin(1e-6).of(0.0) + val polyline = mockPolyline(listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1))) + val expectation = earthRadius * Math.sqrt(2.0) * Math.toRadians(0.1) + assertThat(polyline.sphericalPathLength).isWithin(1e-1).of(expectation) + } + + private fun mockPolyline(p: List, geodesic: Boolean = true) = mock { + on { points } doReturn p + on { isGeodesic } doReturn geodesic + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt b/library/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt new file mode 100644 index 000000000..ebca42b2c --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt @@ -0,0 +1,248 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils.collection + +import com.google.maps.android.ktx.MarkerDragEndEvent +import com.google.maps.android.ktx.MarkerDragEvent +import com.google.maps.android.ktx.MarkerDragStartEvent + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class CollectionManagersTest { + + @Mock + private lateinit var markerCollection: MarkerManager.Collection + + @Mock + private lateinit var polylineCollection: PolylineManager.Collection + + @Mock + private lateinit var polygonCollection: PolygonManager.Collection + + @Mock + private lateinit var circleCollection: CircleManager.Collection + + @Mock + private lateinit var groundOverlayCollection: GroundOverlayManager.Collection + + @Mock + private lateinit var marker: Marker + + @Mock + private lateinit var polyline: Polyline + + @Mock + private lateinit var polygon: Polygon + + @Mock + private lateinit var circle: Circle + + @Mock + private lateinit var groundOverlay: GroundOverlay + + @Captor + private lateinit var markerClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowLongClickListener: ArgumentCaptor + + @Captor + private lateinit var polylineClickListener: ArgumentCaptor + + @Captor + private lateinit var polygonClickListener: ArgumentCaptor + + @Captor + private lateinit var circleClickListener: ArgumentCaptor + + @Captor + private lateinit var groundOverlayClickListener: ArgumentCaptor + + private var activeMarkerClickListener: GoogleMap.OnMarkerClickListener? = null + + @Before + public fun setUp() { + activeMarkerClickListener = null + // Stub setOnMarkerClickListener to track the active listener on the collection manager + `when`(markerCollection.setOnMarkerClickListener(any())).thenAnswer { invocation -> + activeMarkerClickListener = invocation.arguments[0] as? GoogleMap.OnMarkerClickListener + null + } + } + + @Test + public fun testMarkerCollectionClickEvents(): Unit = runTest { + val deferred = async { + markerCollection.clickEvents().first() + } + advanceUntilIdle() + // Trigger the event via our tracked active listener slot! + assertThat(activeMarkerClickListener).isNotNull() + activeMarkerClickListener?.onMarkerClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + @Test + public fun testConcurrentCollectionMarkerClick(): Unit = runTest { + val flow = markerCollection.clickEvents() + val collector1Events = mutableListOf() + val collector2Events = mutableListOf() + + // Start Collector 1 + val job1 = launch { + flow.collect { collector1Events.add(it) } + } + advanceUntilIdle() + assertThat(activeMarkerClickListener).isNotNull() + val listener1 = activeMarkerClickListener + + // Start Collector 2 - this will overwrite the listener on the mock + val job2 = launch { + flow.collect { collector2Events.add(it) } + } + advanceUntilIdle() + val listener2 = activeMarkerClickListener + + // Verify they are different listener instances and listener2 hijacked the slot + assertThat(listener1).isNotEqualTo(listener2) + assertThat(activeMarkerClickListener).isEqualTo(listener2) + + // Simulate click via the active listener + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + // Only collector 2 should receive the event because it hijacked the single-listener slot + assertThat(collector1Events).isEmpty() + assertThat(collector2Events).containsExactly(marker) + + // Cancel collector 1. This triggers awaitClose and clears the listener slot (sets to null) + job1.cancel() + advanceUntilIdle() + + // Assert that the active listener slot is now null! + assertThat(activeMarkerClickListener).isNull() + + // Try to trigger a click again via the active listener (which is now null) + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + // Collector 2 is now BROKEN and receives no further events because Collector 1's cleanup cleared the shared slot! + assertThat(collector2Events).containsExactly(marker) + + job2.cancel() + } + + @Test + public fun testMarkerCollectionInfoWindowClickEvents(): Unit = runTest { + val deferred = async { + markerCollection.infoWindowClickEvents().first() + } + advanceUntilIdle() + verify(markerCollection).setOnInfoWindowClickListener(infoWindowClickListener.capture()) + infoWindowClickListener.value.onInfoWindowClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + @Test + public fun testMarkerCollectionInfoWindowLongClickEvents(): Unit = runTest { + val deferred = async { + markerCollection.infoWindowLongClickEvents().first() + } + advanceUntilIdle() + verify(markerCollection).setOnInfoWindowLongClickListener(infoWindowLongClickListener.capture()) + infoWindowLongClickListener.value.onInfoWindowLongClick(marker) + assertThat(deferred.await()).isEqualTo(marker) + } + + @Test + public fun testPolylineCollectionClickEvents(): Unit = runTest { + val deferred = async { + polylineCollection.clickEvents().first() + } + advanceUntilIdle() + verify(polylineCollection).setOnPolylineClickListener(polylineClickListener.capture()) + polylineClickListener.value.onPolylineClick(polyline) + assertThat(deferred.await()).isEqualTo(polyline) + } + + @Test + public fun testPolygonCollectionClickEvents(): Unit = runTest { + val deferred = async { + polygonCollection.clickEvents().first() + } + advanceUntilIdle() + verify(polygonCollection).setOnPolygonClickListener(polygonClickListener.capture()) + polygonClickListener.value.onPolygonClick(polygon) + assertThat(deferred.await()).isEqualTo(polygon) + } + + @Test + public fun testCircleCollectionClickEvents(): Unit = runTest { + val deferred = async { + circleCollection.clickEvents().first() + } + advanceUntilIdle() + verify(circleCollection).setOnCircleClickListener(circleClickListener.capture()) + circleClickListener.value.onCircleClick(circle) + assertThat(deferred.await()).isEqualTo(circle) + } + + @Test + public fun testGroundOverlayCollectionClickEvents(): Unit = runTest { + val deferred = async { + groundOverlayCollection.clickEvents().first() + } + advanceUntilIdle() + verify(groundOverlayCollection).setOnGroundOverlayClickListener(groundOverlayClickListener.capture()) + groundOverlayClickListener.value.onGroundOverlayClick(groundOverlay) + assertThat(deferred.await()).isEqualTo(groundOverlay) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt b/library/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt new file mode 100644 index 000000000..91936a513 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt @@ -0,0 +1,117 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils.location + +import android.annotation.SuppressLint +import android.location.Location +import android.os.Looper +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class FusedLocationProviderTest { + + @Mock + private lateinit var fusedLocationClient: FusedLocationProviderClient + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var locationCallbackCaptor: ArgumentCaptor + + @SuppressLint("MissingPermission") + @Test + public fun testLocationEvents(): Unit = runTest { + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L).build() + + val job = launch { + val event = fusedLocationClient.locationEvents(request, looper).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + verify(fusedLocationClient).requestLocationUpdates( + eq(request), + locationCallbackCaptor.capture(), + eq(looper) + ) + + val result = LocationResult.create(listOf(location)) + locationCallbackCaptor.value.onLocationResult(result) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testFusedLocationEvents(): Unit = runTest { + val job = launch { + val event = fusedLocationClient.fusedLocationEvents( + intervalMs = 2000L, + minUpdateDistanceM = 5f, + priority = Priority.PRIORITY_BALANCED_POWER_ACCURACY, + looper = looper + ).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + verify(fusedLocationClient).requestLocationUpdates( + any(LocationRequest::class.java), + locationCallbackCaptor.capture(), + eq(looper) + ) + + val result = LocationResult.create(listOf(location)) + locationCallbackCaptor.value.onLocationResult(result) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) + } +} diff --git a/library/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt b/library/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt new file mode 100644 index 000000000..0bba99dea --- /dev/null +++ b/library/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt @@ -0,0 +1,132 @@ +/* + * 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. + * + */ +@file:Suppress("DEPRECATION") + +package com.google.maps.android.ktx.utils.location + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class LocationManagerTest { + + @Mock + private lateinit var locationManager: LocationManager + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var locationListenerCaptor: ArgumentCaptor + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationEvents(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + + val deferred = async { + locationManager.coarseLocationEvents(1_000L, 1f, looper).first() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + eq(1_000L), + eq(1f), + locationListenerCaptor.capture(), + eq(looper) + ) + + locationListenerCaptor.value.onLocationChanged(location) + assertThat(deferred.await()).isEqualTo(location) + + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationProviderDisabled(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + + val deferred = async { + locationManager.coarseLocationEvents(1_000L, 1f, looper).toList() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + anyLong(), + anyFloat(), + locationListenerCaptor.capture(), + any() + ) + + locationListenerCaptor.value.onProviderDisabled(LocationManager.NETWORK_PROVIDER) + advanceUntilIdle() + + assertThat(deferred.await()).isEmpty() + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testFineLocationEvents(): Unit = runTest { + val deferred = async { + locationManager.fineLocationEvents(2_000L, 2f, looper).first() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + eq(2_000L), + eq(2f), + locationListenerCaptor.capture(), + eq(looper) + ) + + locationListenerCaptor.value.onLocationChanged(location) + assertThat(deferred.await()).isEqualTo(location) + + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } +} diff --git a/library/src/test/java/com/google/maps/android/location/FusedLocationProviderTest.kt b/library/src/test/java/com/google/maps/android/location/FusedLocationProviderTest.kt new file mode 100644 index 000000000..a9ac1a50c --- /dev/null +++ b/library/src/test/java/com/google/maps/android/location/FusedLocationProviderTest.kt @@ -0,0 +1,116 @@ +/* + * 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.location + +import android.annotation.SuppressLint +import android.location.Location +import android.os.Looper +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class FusedLocationProviderTest { + + @Mock + private lateinit var fusedLocationClient: FusedLocationProviderClient + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var locationCallbackCaptor: ArgumentCaptor + + @SuppressLint("MissingPermission") + @Test + public fun testLocationEvents(): Unit = runTest { + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L).build() + + val job = launch { + val event = fusedLocationClient.locationEvents(request, looper).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + verify(fusedLocationClient).requestLocationUpdates( + eq(request), + locationCallbackCaptor.capture(), + eq(looper) + ) + + val result = LocationResult.create(listOf(location)) + locationCallbackCaptor.value.onLocationResult(result) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testFusedLocationEvents(): Unit = runTest { + val job = launch { + val event = fusedLocationClient.fusedLocationEvents( + intervalMs = 2000L, + minUpdateDistanceM = 5f, + priority = Priority.PRIORITY_BALANCED_POWER_ACCURACY, + looper = looper + ).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + verify(fusedLocationClient).requestLocationUpdates( + any(LocationRequest::class.java), + locationCallbackCaptor.capture(), + eq(looper) + ) + + val result = LocationResult.create(listOf(location)) + locationCallbackCaptor.value.onLocationResult(result) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) + } +} diff --git a/library/src/test/java/com/google/maps/android/location/LocationManagerTest.kt b/library/src/test/java/com/google/maps/android/location/LocationManagerTest.kt new file mode 100644 index 000000000..bbe9d9738 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/location/LocationManagerTest.kt @@ -0,0 +1,132 @@ +/* + * 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.location + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class LocationManagerTest { + + @Mock + private lateinit var locationManager: LocationManager + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var locationListenerCaptor: ArgumentCaptor + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationEvents(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + + val deferred = async { + locationManager.coarseLocationEvents(1_000L, 1f, looper).first() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + eq(1_000L), + eq(1f), + locationListenerCaptor.capture(), + eq(looper) + ) + + locationListenerCaptor.value.onLocationChanged(location) + assertThat(deferred.await()).isEqualTo(location) + + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationProviderDisabled(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + + val deferred = async { + locationManager.coarseLocationEvents(1_000L, 1f, looper).toList() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + anyLong(), + anyFloat(), + locationListenerCaptor.capture(), + any() + ) + + locationListenerCaptor.value.onLocationChanged(location) + locationListenerCaptor.value.onProviderDisabled(LocationManager.NETWORK_PROVIDER) + + // Verify that the flow completed normally with the emitted item and cleaned up its listener + assertThat(deferred.await()).containsExactly(location) + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testFineLocationEvents(): Unit = runTest { + val deferred = async { + locationManager.fineLocationEvents(2_000L, 2f, looper).first() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + eq(2_000L), + eq(2f), + locationListenerCaptor.capture(), + eq(looper) + ) + + locationListenerCaptor.value.onLocationChanged(location) + assertThat(deferred.await()).isEqualTo(location) + + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/CameraPositionTest.kt b/library/src/test/java/com/google/maps/android/model/CameraPositionTest.kt new file mode 100644 index 000000000..b00873a2a --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/CameraPositionTest.kt @@ -0,0 +1,39 @@ +/* + * 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class CameraPositionTest { + + @Test + fun testBuilder() { + val cameraPosition = cameraPosition { + bearing(1f) + target(LatLng(1.0, 2.0)) + tilt(1f) + zoom(12f) + } + assertThat(cameraPosition.bearing).isWithin(1e-6f).of(1f) + assertThat(cameraPosition.target).isEqualTo(LatLng(1.0, 2.0)) + assertThat(cameraPosition.tilt).isWithin(1e-6f).of(1f) + assertThat(cameraPosition.zoom).isWithin(1e-6f).of(12f) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/CircleOptionsTest.kt b/library/src/test/java/com/google/maps/android/model/CircleOptionsTest.kt new file mode 100644 index 000000000..faa2dbabb --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/CircleOptionsTest.kt @@ -0,0 +1,47 @@ +/* + * 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class CircleOptionsTest { + + @Test + fun testBuilder() { + val circleOptions = circleOptions { + center(LatLng(0.0, 0.0)) + clickable(true) + fillColor(0) + radius(1.23) + strokeColor(1) + strokeWidth(2f) + visible(true) + zIndex(1f) + } + assertThat(circleOptions.center).isEqualTo(LatLng(0.0, 0.0)) + assertThat(circleOptions.isClickable).isTrue() + assertThat(circleOptions.fillColor).isEqualTo(0) + assertThat(circleOptions.radius).isWithin(1e-6).of(1.23) + assertThat(circleOptions.strokeColor).isEqualTo(1) + assertThat(circleOptions.strokeWidth).isEqualTo(2f) + assertThat(circleOptions.isVisible).isTrue() + assertThat(circleOptions.zIndex).isWithin(1e-6f).of(1f) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/GroundOverlayOptionsTest.kt b/library/src/test/java/com/google/maps/android/model/GroundOverlayOptionsTest.kt new file mode 100644 index 000000000..57efe55b4 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/GroundOverlayOptionsTest.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.model + +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.common.truth.Truth.assertThat +import org.mockito.kotlin.mock +import org.junit.Test + +internal class GroundOverlayOptionsTest { + + @Test + fun testBuilder() { + val descriptor: BitmapDescriptor = mock() + val groundOverlayOptions = groundOverlayOptions { + image(descriptor) + bearing(1f) + clickable(true) + transparency(0.5f) + visible(true) + } + assertThat(groundOverlayOptions.image).isEqualTo(descriptor) + assertThat(groundOverlayOptions.bearing).isWithin(1e-6f).of(1f) + assertThat(groundOverlayOptions.isClickable).isTrue() + assertThat(groundOverlayOptions.transparency).isWithin(1e-6f).of(0.5f) + assertThat(groundOverlayOptions.isVisible).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/model/MarkerOptionsTest.kt b/library/src/test/java/com/google/maps/android/model/MarkerOptionsTest.kt new file mode 100644 index 000000000..57652a6f0 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/MarkerOptionsTest.kt @@ -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.model + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class MarkerOptionsTest { + + @Test + fun testBuilder() { + val markerOptions = markerOptions { + position(LatLng(1.0, 2.0)) + alpha(0.5f) + draggable(false) + flat(true) + title("Test") + snippet("Snippet") + visible(true) + } + assertThat(markerOptions.position).isEqualTo(LatLng(1.0, 2.0)) + assertThat(markerOptions.alpha).isWithin(1e-6f).of(0.5f) + assertThat(markerOptions.isDraggable).isFalse() + assertThat(markerOptions.isFlat).isTrue() + assertThat(markerOptions.title).isEqualTo("Test") + assertThat(markerOptions.snippet).isEqualTo("Snippet") + assertThat(markerOptions.isVisible).isTrue() + } +} diff --git a/library/src/test/java/com/google/maps/android/model/PolygonOptionsTest.kt b/library/src/test/java/com/google/maps/android/model/PolygonOptionsTest.kt new file mode 100644 index 000000000..d817def05 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/PolygonOptionsTest.kt @@ -0,0 +1,38 @@ +/* + * 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 + +import android.graphics.Color +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class PolygonOptionsTest { + @Test + fun testBuilder() { + val polygonOptions = + polygonOptions { + strokeWidth(1.0f) + strokeColor(Color.BLACK) + add(LatLng(1.0, 2.0)) + } + assertThat(polygonOptions.strokeWidth).isWithin(1e-6f).of(1.0f) + assertThat(polygonOptions.strokeColor).isEqualTo(Color.BLACK) + assertThat(polygonOptions.points).containsExactly(LatLng(1.0, 2.0)) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/PolylineOptionsTest.kt b/library/src/test/java/com/google/maps/android/model/PolylineOptionsTest.kt new file mode 100644 index 000000000..5f1a17537 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/PolylineOptionsTest.kt @@ -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.model + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class PolylineOptionsTest { + + @Test + fun testBuilder() { + val polylineOptions = polylineOptions { + add(LatLng(1.0, 2.0)) + clickable(true) + color(0) + geodesic(true) + width(1f) + } + assertThat(polylineOptions.points).containsExactly(LatLng(1.0, 2.0)) + assertThat(polylineOptions.isClickable).isTrue() + assertThat(polylineOptions.color).isEqualTo(0) + assertThat(polylineOptions.isGeodesic).isTrue() + assertThat(polylineOptions.width).isWithin(1e-6f).of(1f) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt b/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt new file mode 100644 index 000000000..261bac9f5 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaCameraTest.kt @@ -0,0 +1,36 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class StreetViewPanoramaCameraTest { + + @Test + fun `test that StreetViewPanoramaCamera is constructed`() { + val camera = streetViewPanoramaCamera { + bearing(1f) + tilt(20f) + zoom(2f) + } + assertThat(camera.bearing).isWithin(1e-6f).of(1f) + assertThat(camera.tilt).isWithin(1e-6f).of(20f) + assertThat(camera.zoom).isWithin(1e-6f).of(2f) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaOrientationTest.kt b/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaOrientationTest.kt new file mode 100644 index 000000000..1c3e05fd1 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/StreetViewPanoramaOrientationTest.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 + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class StreetViewPanoramaOrientationTest { + + @Test + fun `test that StreetViewPanoramaOrientation is constructed`() { + val orientation = streetViewPanoramaOrientation { + bearing(1f) + tilt(20f) + } + assertThat(orientation.bearing).isWithin(1e-6f).of(1f) + assertThat(orientation.tilt).isWithin(1e-6f).of(20f) + } +} diff --git a/library/src/test/java/com/google/maps/android/model/TileOverlayOptionsTest.kt b/library/src/test/java/com/google/maps/android/model/TileOverlayOptionsTest.kt new file mode 100644 index 000000000..c1e457495 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/model/TileOverlayOptionsTest.kt @@ -0,0 +1,38 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class TileOverlayOptionsTest { + + @Test + fun testBuilder() { + val tileOverlayOptions = tileOverlayOptions { + fadeIn(true) + transparency(0.5f) + visible(false) + zIndex(1f) + } + assertThat(tileOverlayOptions.fadeIn).isTrue() + assertThat(tileOverlayOptions.isVisible).isFalse() + assertThat(tileOverlayOptions.transparency).isWithin(1e-6f).of(0.5f) + assertThat(tileOverlayOptions.zIndex).isWithin(1e-6f).of(1f) + } +} diff --git a/library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt b/library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt index 694ad56ce..5d8270e35 100644 --- a/library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt +++ b/library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt @@ -16,46 +16,75 @@ package com.google.maps.android.utils.attribution import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.google.android.gms.maps.MapsApiSettings +import com.google.common.truth.Truth.assertThat import com.google.maps.android.utils.meta.AttributionId -import io.mockk.every -import io.mockk.just -import io.mockk.mockkStatic -import io.mockk.runs -import io.mockk.unmockkStatic -import io.mockk.verify import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner -@RunWith(RobolectricTestRunner::class) +/** + * Unit test suite for [AttributionIdInitializer]. + * + * **Purpose:** + * Verifies that the Jetpack App Startup `Initializer` for library usage attribution correctly + * registers the library's unique attribution identifier (`AttributionId.VALUE` = "maps-utils-android") + * with the Google Maps SDK's [MapsApiSettings] upon application startup, and declares no dependent + * startup initializers. + * + * **How it works:** + * Uses Mockito's static mocking (`mockStatic(MapsApiSettings::class.java)`) to intercept calls + * to the static SDK method [MapsApiSettings.addInternalUsageAttributionId]. Each test instantiates + * an initializer, checks its dependencies, and invokes `create(context)`. + * + * **How we know it is correct:** + * - **Code under test:** Correct if invoking `create(context)` registers `AttributionId.VALUE` exactly + * once with the provided `Context`, and `dependencies()` returns an empty list. + * - **Test:** Correct because `mapsApiSettingsMock.verify { ... }` will fail the test if the static + * attribution registration method was not invoked or received incorrect parameters. + */ +@RunWith(MockitoJUnitRunner::class) class AttributionIdInitializerTest { + + @Mock + private lateinit var context: Context + + private lateinit var mapsApiSettingsMock: MockedStatic + @Before fun setUp() { - mockkStatic(MapsApiSettings::class) - every { MapsApiSettings.addInternalUsageAttributionId(any(), any()) } just runs + mapsApiSettingsMock = mockStatic(MapsApiSettings::class.java) } @After fun tearDown() { - unmockkStatic(MapsApiSettings::class) + mapsApiSettingsMock.close() } + /** + * **Purpose:** Tests that the canonical [AttributionIdInitializer] registers usage attribution + * and has no initializer dependencies. + * + * **How it works:** Instantiates [AttributionIdInitializer], asserts `dependencies()` is empty, + * calls `create(context)`, and verifies static invocation of [MapsApiSettings.addInternalUsageAttributionId]. + * + * **How we know it is correct:** + * - **Code under test:** Proves canonical initialization correctly passes `AttributionId.VALUE` to Maps SDK. + * - **Test:** Verifies exact static invocation arguments; fails if attribution registration is omitted or altered. + */ @Test - fun `create adds internal usage attribution id`() { - val context = ApplicationProvider.getApplicationContext() + fun `test canonical AttributionIdInitializer create and dependencies`() { val initializer = AttributionIdInitializer() - + assertThat(initializer.dependencies()).isEmpty() initializer.create(context) - - verify { - MapsApiSettings.addInternalUsageAttributionId( - context, - AttributionId.VALUE, - ) + mapsApiSettingsMock.verify { + MapsApiSettings.addInternalUsageAttributionId(context, AttributionId.VALUE) } } } diff --git a/lint-checks/src/test/java/com/google/maps/android/lint/checks/GoogleMapDetectorTest.kt b/lint-checks/src/test/java/com/google/maps/android/lint/checks/GoogleMapDetectorTest.kt index db422fb57..ef2e2d86b 100644 --- a/lint-checks/src/test/java/com/google/maps/android/lint/checks/GoogleMapDetectorTest.kt +++ b/lint-checks/src/test/java/com/google/maps/android/lint/checks/GoogleMapDetectorTest.kt @@ -17,12 +17,16 @@ package com.google.maps.android.lint.checks import com.android.tools.lint.checks.infrastructure.LintDetectorTest import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestLintTask import com.android.tools.lint.detector.api.Detector import com.android.tools.lint.detector.api.Issue import com.android.tools.lint.detector.api.TextFormat @Suppress("UnstableApiUsage") class GoogleMapDetectorTest : LintDetectorTest() { + override fun lint(): TestLintTask = + super.lint().allowMissingSdk() + fun testSetOnMarkerDragListener() { lint() .files( diff --git a/llm-integration-prompt.md b/llm-integration-prompt.md index 1c3227120..3b5685a8c 100644 --- a/llm-integration-prompt.md +++ b/llm-integration-prompt.md @@ -16,6 +16,7 @@ dependencies { // Google Maps Utility Library implementation("com.google.maps.android:android-maps-utils:5.2.0") // x-release-please-version } + ``` ## 2. Core Features & Usage Patterns diff --git a/release-please-config.json b/release-please-config.json index 4a6a2ac02..e0f2c1024 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,8 +3,6 @@ "packages": { ".": { "release-type": "simple", - "prerelease": true, - "prerelease-type": "rc", "extra-files": [ "README.md", "build.gradle.kts", diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 9c3cabd59..48de376aa 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -76,10 +76,8 @@ dependencies { 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") {