From 7b23a4155c2714c1213d7cc23521522c6ceba7fb Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:18:39 -0600 Subject: [PATCH 1/8] feat: Introduce a robust Google Maps initializer This commit refactors the map initialization logic to be more robust and test-friendly. Previously, the map initialization was coupled with the attribution ID logic, which made it difficult to handle initialization failures, especially in test environments where the Maps SDK might be mocked. This change introduces a new `GoogleMapsInitializer` object that manages the initialization process with a state machine. This provides more control over the initialization process and allows for better error handling. The key changes are: - A new `GoogleMapsInitializer` object that manages the initialization of the Google Maps SDK. - An `InitializationState` enum that represents the different states of the initialization process. - An `initialize` function that starts the initialization process on a background thread. - A `reset` function that allows for re-initialization in test environments. - The `GoogleMap` composable now uses the `GoogleMapsInitializer` to ensure that the map is only displayed after the SDK has been successfully initialized. --- gradle/libs.versions.toml | 4 + maps-app/build.gradle.kts | 6 + .../android/compose/GoogleMapViewTests.kt | 2 +- .../compose/GoogleMapsInitializerTest.kt | 48 +++++++ .../compose/GoogleMapsInitializerTest.kt | 48 +++++++ .../google/maps/android/compose/GoogleMap.kt | 11 +- .../compose/internal/GoogleMapsInitializer.kt | 121 ++++++++++++++++++ .../compose/internal/MapsApiAttribution.kt | 51 -------- 8 files changed, 234 insertions(+), 57 deletions(-) create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt create mode 100644 maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt create mode 100644 maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt delete mode 100644 maps-compose/src/main/java/com/google/maps/android/compose/internal/MapsApiAttribution.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc84881e..4222be19 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,8 @@ org-jacoco-core = "0.8.13" screenshot = "0.0.1-alpha11" constraintlayout = "2.2.1" material = "1.12.0" +robolectric = "4.12.1" +truth = "1.1.3" [libraries] android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -53,6 +55,8 @@ test-junit = { module = "junit:junit", version.ref = "junit" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/maps-app/build.gradle.kts b/maps-app/build.gradle.kts index 29fe6fab..9935d9c2 100644 --- a/maps-app/build.gradle.kts +++ b/maps-app/build.gradle.kts @@ -81,6 +81,12 @@ dependencies { androidTestImplementation(libs.test.junit) androidTestImplementation(libs.androidx.test.compose.ui) androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.truth) + + testImplementation(libs.test.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.truth) screenshotTestImplementation(libs.androidx.compose.ui.tooling) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt index 54a6f25a..190e10b2 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt @@ -85,7 +85,7 @@ class GoogleMapViewTests { } @Test - fun testRighColorSchemeAfterChangingIt() { + fun testRightColorSchemeAfterChangingIt() { mapColorScheme = ComposeMapColorScheme.DARK initMap() mapColorScheme.assertEquals(ComposeMapColorScheme.DARK) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt new file mode 100644 index 00000000..b99d5215 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -0,0 +1,48 @@ +// Copyright 2024 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.compose + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.compose.internal.GoogleMapsInitializer +import com.google.maps.android.compose.internal.InitializationState +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GoogleMapsInitializerTest { + + @After + fun tearDown() { + GoogleMapsInitializer.reset() + } + + @Test + fun testInitializationSuccess() { + // In an instrumentation test environment, Google Play services are available. + // Therefore, we expect the initialization to succeed. + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + GoogleMapsInitializer.initialize(context) + + // Wait for the initialization to complete + Thread.sleep(1000) + + assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) + } +} \ No newline at end of file diff --git a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt new file mode 100644 index 00000000..bbe4cd38 --- /dev/null +++ b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -0,0 +1,48 @@ +// Copyright 2024 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.compose + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.compose.internal.GoogleMapsInitializer +import com.google.maps.android.compose.internal.InitializationState +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GoogleMapsInitializerTest { + + @After + fun tearDown() { + GoogleMapsInitializer.reset() + } + + @Test + fun testInitializationFailure() { + // In a unit test environment, Google Play services are not available. + // Therefore, we expect the initialization to fail. + val context: Context = ApplicationProvider.getApplicationContext() + + GoogleMapsInitializer.initialize(context) + + // Wait for the initialization to complete + Thread.sleep(1000) + + assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.FAILURE) + } +} \ No newline at end of file diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index d69f7f23..8a01f6f9 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -50,7 +50,8 @@ import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.MapColorScheme import com.google.android.gms.maps.model.PointOfInterest -import com.google.maps.android.compose.internal.MapsApiAttribution +import com.google.maps.android.compose.internal.GoogleMapsInitializer +import com.google.maps.android.compose.internal.InitializationState import com.google.maps.android.ktx.awaitMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -113,16 +114,16 @@ public fun GoogleMap( return } - val isInitialized by MapsApiAttribution.isInitialized + val initializationState by GoogleMapsInitializer.state - if (!isInitialized) { + if (initializationState != InitializationState.SUCCESS) { val context = LocalContext.current LaunchedEffect(Unit) { - MapsApiAttribution.addAttributionId(context) + GoogleMapsInitializer.initialize(context) } } - if (isInitialized) { + if (initializationState == InitializationState.SUCCESS) { // rememberUpdatedState and friends are used here to make these values observable to // the subcomposition without providing a new content function each recomposition val mapClickListeners = remember { MapClickListeners() }.also { diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt new file mode 100644 index 00000000..db9145c6 --- /dev/null +++ b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt @@ -0,0 +1,121 @@ +package com.google.maps.android.compose.internal + +import android.content.Context +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.MapsApiSettings +import com.google.maps.android.compose.meta.AttributionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Enum representing the initialization state of the Google Maps SDK. + */ +public enum class InitializationState { + /** + * The SDK has not been initialized. + */ + UNINITIALIZED, + + /** + * The SDK is currently being initialized. + */ + INITIALIZING, + + /** + * The SDK has been successfully initialized. + */ + SUCCESS, + + /** + * The SDK initialization failed. + */ + FAILURE +} + +/** + * A singleton object to manage the initialization of the Google Maps SDK. + * + * This object provides a state machine to track the initialization process and ensures that + * the initialization is performed only once. It also provides a mechanism to reset the + * initialization state, which can be useful in test environments. + * + * The initialization process consists of two main steps: + * 1. Calling `MapsInitializer.initialize(context)` to initialize the Google Maps SDK. + * 2. Calling `MapsApiSettings.addInternalUsageAttributionId(context, attributionId)` to add + * the library's attribution ID to the Maps API settings. + * + * The state of the initialization is exposed via the `state` property, which is a [State] object + * that can be observed for changes. + */ +public object GoogleMapsInitializer { + private val _state = mutableStateOf(InitializationState.UNINITIALIZED) + public val state: State = _state + + private var initializationJob: Job? = null + private val mutex = Mutex() + + /** + * The value of the attribution ID. Set this to the empty string to opt out of attribution. + * + * This must be set before calling the `initialize` function. + */ + public var attributionId: String = AttributionId.VALUE + + /** + * Initializes the Google Maps SDK. + * + * This function starts the initialization process on a background thread. The process is + * performed only once. If the initialization is already in progress or has completed, + * this function does nothing. + * + * The initialization state can be observed via the `state` property. + * + * @param context The context to use for initialization. + */ + public fun initialize(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + mutex.withLock { + if (_state.value != InitializationState.UNINITIALIZED) { + return@withLock + } + + _state.value = InitializationState.INITIALIZING + initializationJob = launch { + try { + if (MapsInitializer.initialize(context) == ConnectionResult.SUCCESS) { + MapsApiSettings.addInternalUsageAttributionId(context, attributionId) + _state.value = InitializationState.SUCCESS + } + } catch (e: Exception) { + // In tests where the map is mocked, this can fail. + _state.value = InitializationState.FAILURE + } + } + } + } + } + + /** + * Resets the initialization state. + * + * This function cancels any ongoing initialization and resets the state to `UNINITIALIZED`. + * This is useful in test environments where you might need to re-initialize the SDK + * multiple times. + */ + public fun reset() { + CoroutineScope(Dispatchers.IO).launch { + mutex.withLock { + initializationJob?.cancel() + initializationJob = null + _state.value = InitializationState.UNINITIALIZED + } + } + } +} \ No newline at end of file diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/internal/MapsApiAttribution.kt b/maps-compose/src/main/java/com/google/maps/android/compose/internal/MapsApiAttribution.kt deleted file mode 100644 index 6fbbeeb6..00000000 --- a/maps-compose/src/main/java/com/google/maps/android/compose/internal/MapsApiAttribution.kt +++ /dev/null @@ -1,51 +0,0 @@ - -package com.google.maps.android.compose.internal - -import android.content.Context -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import com.google.android.gms.maps.MapsApiSettings -import com.google.maps.android.compose.meta.AttributionId -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Internal singleton to ensure that the Maps API attribution ID is added only once. - */ -internal object MapsApiAttribution { - - private val hasBeenCalled = AtomicBoolean(false) - - private val _isInitialized = mutableStateOf(false) - val isInitialized: State = _isInitialized - - /** - * The value of the attribution ID. Set this to the empty string to opt out of attribution. - * - * This must be set before calling the GoogleMap composable. - */ - var attributionId: String = AttributionId.VALUE - - /** - * Adds the attribution ID to the Maps API settings. This is done on a background thread - * using [Dispatchers.IO]. The attribution ID is only added once. - * - * Adds a usage attribution ID to the initializer, which helps Google understand which libraries - * and samples are helpful to developers, such as usage of this library. - * To opt out of sending the usage attribution ID, it is safe to delete this function call - * or replace the value with an empty string. - * - * See https://developers.google.com/android/reference/com/google/android/gms/maps/MapsApiSettings#addInternalUsageAttributionId(android.content.Context,%20java.lang.String) - * - * @param context The context to use to add the attribution ID. - */ - suspend fun addAttributionId(context: Context) { - if (hasBeenCalled.compareAndSet(false, true)) { - withContext(Dispatchers.IO) { - MapsApiSettings.addInternalUsageAttributionId(context, attributionId) - _isInitialized.value = true - } - } - } -} From cdac9aa1e3ec83c89b94885ba5396909e87731d8 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:15:51 -0600 Subject: [PATCH 2/8] chore: address copyright header issues --- .../android/compose/GoogleMapsInitializerTest.kt | 2 +- .../android/compose/GoogleMapsInitializerTest.kt | 2 +- .../compose/internal/GoogleMapsInitializer.kt | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index b99d5215..9ff24a6d 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index bbe4cd38..81157840 100644 --- a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt index db9145c6..eb534f9e 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt @@ -1,3 +1,17 @@ +// Copyright 2025 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.compose.internal import android.content.Context From 6aefedaf67f323b87ca40ceefa489b31984fcc1f Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:30:09 -0600 Subject: [PATCH 3/8] refactor: Make GoogleMapsInitializer suspendable This change refactors the `GoogleMapsInitializer` to use suspend functions for initialization and reset operations, improving its testability and adherence to structured concurrency. Key changes: - Converted `GoogleMapsInitializer.initialize()` and `GoogleMapsInitializer.reset()` to `suspend` functions. - Removed the internal `CoroutineScope` and job management from `GoogleMapsInitializer`. - Updated unit and instrumentation tests to use `runTest` from `kotlinx-coroutines-test`, removing the need for `Thread.sleep()`. - Added the `kotlinx-coroutines-test` dependency to the `maps-app` module. --- maps-app/build.gradle.kts | 1 + .../compose/GoogleMapsInitializerTest.kt | 24 +++-------- .../compose/GoogleMapsInitializerTest.kt | 25 +++-------- .../compose/internal/GoogleMapsInitializer.kt | 42 +++++++++---------- 4 files changed, 33 insertions(+), 59 deletions(-) diff --git a/maps-app/build.gradle.kts b/maps-app/build.gradle.kts index 9935d9c2..3ab4a30f 100644 --- a/maps-app/build.gradle.kts +++ b/maps-app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) screenshotTestImplementation(libs.androidx.compose.ui.tooling) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index 9ff24a6d..53eb92ea 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,17 +1,3 @@ -// Copyright 2025 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.compose import android.content.Context @@ -20,29 +6,29 @@ import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import com.google.maps.android.compose.internal.GoogleMapsInitializer import com.google.maps.android.compose.internal.InitializationState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class GoogleMapsInitializerTest { @After - fun tearDown() { + fun tearDown() = runTest { GoogleMapsInitializer.reset() } @Test - fun testInitializationSuccess() { + fun testInitializationSuccess() = runTest { // In an instrumentation test environment, Google Play services are available. // Therefore, we expect the initialization to succeed. val context: Context = InstrumentationRegistry.getInstrumentation().targetContext GoogleMapsInitializer.initialize(context) - // Wait for the initialization to complete - Thread.sleep(1000) - assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) } } \ No newline at end of file diff --git a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index 81157840..b23d68ca 100644 --- a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,17 +1,3 @@ -// Copyright 2025 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.compose import android.content.Context @@ -19,30 +5,31 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.maps.android.compose.internal.GoogleMapsInitializer import com.google.maps.android.compose.internal.InitializationState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class GoogleMapsInitializerTest { @After - fun tearDown() { + fun tearDown() = runTest { GoogleMapsInitializer.reset() } @Test - fun testInitializationFailure() { + fun testInitializationFailure() = runTest { // In a unit test environment, Google Play services are not available. // Therefore, we expect the initialization to fail. val context: Context = ApplicationProvider.getApplicationContext() GoogleMapsInitializer.initialize(context) - // Wait for the initialization to complete - Thread.sleep(1000) - + // The initialization is now synchronous within the test scope, so we don't need to wait. assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.FAILURE) } } \ No newline at end of file diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt index eb534f9e..af5e7ff6 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt @@ -23,10 +23,11 @@ import com.google.android.gms.maps.MapsApiSettings import com.google.maps.android.compose.meta.AttributionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** * Enum representing the initialization state of the Google Maps SDK. @@ -72,7 +73,6 @@ public object GoogleMapsInitializer { private val _state = mutableStateOf(InitializationState.UNINITIALIZED) public val state: State = _state - private var initializationJob: Job? = null private val mutex = Mutex() /** @@ -93,24 +93,28 @@ public object GoogleMapsInitializer { * * @param context The context to use for initialization. */ - public fun initialize(context: Context) { - CoroutineScope(Dispatchers.IO).launch { + public suspend fun initialize(context: Context) { + if (_state.value != InitializationState.UNINITIALIZED) { + return + } + coroutineScope { mutex.withLock { + // Re-check state to prevent re-initialization even when calling this function in parallel if (_state.value != InitializationState.UNINITIALIZED) { return@withLock } - _state.value = InitializationState.INITIALIZING - initializationJob = launch { - try { - if (MapsInitializer.initialize(context) == ConnectionResult.SUCCESS) { - MapsApiSettings.addInternalUsageAttributionId(context, attributionId) - _state.value = InitializationState.SUCCESS - } - } catch (e: Exception) { - // In tests where the map is mocked, this can fail. - _state.value = InitializationState.FAILURE + } + + launch(Dispatchers.IO) { + try { + if (MapsInitializer.initialize(context) == ConnectionResult.SUCCESS) { + MapsApiSettings.addInternalUsageAttributionId(context, attributionId) + _state.value = InitializationState.SUCCESS } + } catch (e: Exception) { + // In tests where the map is mocked, this can fail. + _state.value = InitializationState.FAILURE } } } @@ -123,13 +127,9 @@ public object GoogleMapsInitializer { * This is useful in test environments where you might need to re-initialize the SDK * multiple times. */ - public fun reset() { - CoroutineScope(Dispatchers.IO).launch { - mutex.withLock { - initializationJob?.cancel() - initializationJob = null - _state.value = InitializationState.UNINITIALIZED - } + public suspend fun reset() { + mutex.withLock { + _state.value = InitializationState.UNINITIALIZED } } } \ No newline at end of file From 06d200af4fbdfc514f7a5c48a70e2b3c88f52766 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:54:42 -0600 Subject: [PATCH 4/8] refactor(maps-compose): Make GoogleMapsInitializer fully suspending The `initialize` function has been refactored to be a fully suspending operation. Key changes: - Replaced `launch(Dispatchers.IO)` with `withContext(Dispatchers.IO)`. This ensures the `initialize` function suspends until the blocking initialization call is complete, rather than returning immediately. - Added explicit handling for non-SUCCESS results from `MapsInitializer.initialize()` to set the state to `FAILURE`. - Restructured the `try-catch` block to correctly handle exceptions from the `withContext` block. --- .../compose/internal/GoogleMapsInitializer.kt | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt index af5e7ff6..f37ed9f8 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt @@ -21,10 +21,7 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.maps.MapsInitializer import com.google.android.gms.maps.MapsApiSettings import com.google.maps.android.compose.meta.AttributionId -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -94,29 +91,47 @@ public object GoogleMapsInitializer { * @param context The context to use for initialization. */ public suspend fun initialize(context: Context) { + // 1. Quick exit if already initialized or in progress. if (_state.value != InitializationState.UNINITIALIZED) { return } - coroutineScope { - mutex.withLock { - // Re-check state to prevent re-initialization even when calling this function in parallel - if (_state.value != InitializationState.UNINITIALIZED) { - return@withLock - } - _state.value = InitializationState.INITIALIZING + + // 2. Acquire the mutex, perform a double-check (in case another + // coroutine was also waiting), and set the state. + // This block is synchronous and ensures only one coroutine + // proceeds to the IO operation. + mutex.withLock { + if (_state.value != InitializationState.UNINITIALIZED) { + return // Another coroutine won the race while this one was suspended on the lock. } + _state.value = InitializationState.INITIALIZING + } + + // The lock is now released. - launch(Dispatchers.IO) { - try { - if (MapsInitializer.initialize(context) == ConnectionResult.SUCCESS) { - MapsApiSettings.addInternalUsageAttributionId(context, attributionId) - _state.value = InitializationState.SUCCESS - } - } catch (e: Exception) { - // In tests where the map is mocked, this can fail. + // 3. Run the blocking initialization code on the IO dispatcher. + // This function will SUSPEND until the withContext(Dispatchers.IO) block completes. + // If the calling scope is cancelled while waiting, withContext will throw + // a CancellationException, and the state will remain INITIALIZING + // (which the catch block will update to FAILURE). + try { + withContext(Dispatchers.IO) { + // This is the blocking call. The thread will be blocked here. + // If cancellation happens, the thread STILL finishes this call, + // but the coroutine will immediately throw CancellationException + // *after* this call returns, skipping the state assignments below. + if (MapsInitializer.initialize(context) == ConnectionResult.SUCCESS) { + MapsApiSettings.addInternalUsageAttributionId(context, attributionId) + _state.value = InitializationState.SUCCESS + } else { + // Handle cases where initialize() returns a non-SUCCESS code _state.value = InitializationState.FAILURE } } + } catch (_: Exception) { + // This will catch any exceptions from the init process (like from mocks in tests) + // Note: By default, this does NOT catch CancellationException. + _state.value = InitializationState.FAILURE } } From 69984e7c1979164f090b894e5aa4567b4ed560d0 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 8 Sep 2025 18:12:09 -0600 Subject: [PATCH 5/8] chore: Update Material, Robolectric, and Truth dependencies --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4222be19..5a93e394 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,9 +19,9 @@ mapsktx = "5.2.0" org-jacoco-core = "0.8.13" screenshot = "0.0.1-alpha11" constraintlayout = "2.2.1" -material = "1.12.0" -robolectric = "4.12.1" -truth = "1.1.3" +material = "1.13.0" +robolectric = "4.16" +truth = "1.4.4" [libraries] android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } From 0cd0f49d93e85cc048fef05b8598603e782cb3db Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Tue, 9 Sep 2025 05:00:02 -0600 Subject: [PATCH 6/8] test: Add StrictMode check to GoogleMapsInitializerTest Wraps the `GoogleMapsInitializer.initialize()` call with strict StrictMode policies. This ensures the initialization process does not perform any violations, such as disk reads, which would cause the test to fail. --- gradle/libs.versions.toml | 2 +- .../compose/GoogleMapsInitializerTest.kt | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a93e394..50428fda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activitycompose = "1.10.1" -agp = "8.12.1" +agp = "8.13.0" androidCore = "1.7.0" androidx-core = "1.17.0" androidxtest = "1.7.0" diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index 53eb92ea..d81fd2a9 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,6 +1,7 @@ package com.google.maps.android.compose import android.content.Context +import android.os.StrictMode import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat @@ -25,10 +26,36 @@ class GoogleMapsInitializerTest { fun testInitializationSuccess() = runTest { // In an instrumentation test environment, Google Play services are available. // Therefore, we expect the initialization to succeed. + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + // Note: we need to establish the Strict Mode settings here as there are violations outside + // of our control if we try to set them in setUp + val threadPolicy = StrictMode.getThreadPolicy() + val vmPolicy = StrictMode.getVmPolicy() + + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectAll() + .penaltyLog() + .penaltyDeath() + .build() + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .detectLeakedClosableObjects() + .penaltyLog() + .penaltyDeath() + .build() + ) + GoogleMapsInitializer.initialize(context) + StrictMode.setThreadPolicy(threadPolicy) + StrictMode.setVmPolicy(vmPolicy) + assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) } } \ No newline at end of file From 3e6ba8cb898ed3e5856819bc41c051ca5edfceda Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Tue, 9 Sep 2025 09:27:59 -0600 Subject: [PATCH 7/8] test: Refactor GoogleMapViewTests to use Truth assertions Key changes: - Introduced a custom `LatLngSubject` for the Truth assertion library to allow for `LatLng` comparisons with a tolerance. - Migrated assertions in `GoogleMapViewTests` from JUnit to Truth for improved readability and more expressive tests. - Added explicit Google Maps SDK initialization within the test setup. --- .../android/compose/GoogleMapViewTests.kt | 77 +++++++++---------- .../maps/android/compose/LatLngSubject.kt | 42 ++++++++++ 2 files changed, 80 insertions(+), 39 deletions(-) create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt index 190e10b2..7c5f7d7b 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt @@ -14,6 +14,7 @@ package com.google.maps.android.compose +import android.content.Context import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Text @@ -25,9 +26,14 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng -import org.junit.Assert.* +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.compose.LatLngSubject.Companion.assertThat +import com.google.maps.android.compose.internal.GoogleMapsInitializer +import com.google.maps.android.compose.internal.InitializationState +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule import org.junit.Test @@ -46,6 +52,15 @@ class GoogleMapViewTests { private fun initMap(content: @Composable () -> Unit = {}) { check(hasValidApiKey) { "Maps API key not specified" } val countDownLatch = CountDownLatch(1) + + val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext + + runBlocking { + GoogleMapsInitializer.initialize(appContext) + } + + assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) + composeTestRule.setContent { GoogleMapView( modifier = Modifier.fillMaxSize(), @@ -59,7 +74,7 @@ class GoogleMapViewTests { } } val mapLoaded = countDownLatch.await(30, TimeUnit.SECONDS) - assertTrue("Map loaded", mapLoaded) + assertThat(mapLoaded).isTrue() } @Before @@ -75,32 +90,32 @@ class GoogleMapViewTests { @Test fun testStartingCameraPosition() { initMap() - startingPosition.assertEquals(cameraPositionState.position.target) + assertThat(cameraPositionState.position.target).isEqualTo(startingPosition) } @Test fun testRightInitialColorScheme() { initMap() - mapColorScheme.assertEquals(ComposeMapColorScheme.FOLLOW_SYSTEM) + assertThat(mapColorScheme).isEqualTo(ComposeMapColorScheme.FOLLOW_SYSTEM) } @Test fun testRightColorSchemeAfterChangingIt() { mapColorScheme = ComposeMapColorScheme.DARK initMap() - mapColorScheme.assertEquals(ComposeMapColorScheme.DARK) + assertThat(mapColorScheme).isEqualTo(ComposeMapColorScheme.DARK) } @Test fun testCameraReportsMoving() { initMap() - assertEquals(CameraMoveStartedReason.NO_MOVEMENT_YET, cameraPositionState.cameraMoveStartedReason) + assertThat(cameraPositionState.cameraMoveStartedReason).isEqualTo(CameraMoveStartedReason.NO_MOVEMENT_YET) zoom(shouldAnimate = true, zoomIn = true) { composeTestRule.waitUntil(timeout2) { cameraPositionState.isMoving } - assertTrue(cameraPositionState.isMoving) - assertEquals(CameraMoveStartedReason.DEVELOPER_ANIMATION, cameraPositionState.cameraMoveStartedReason) + assertThat(cameraPositionState.isMoving).isTrue() + assertThat(cameraPositionState.cameraMoveStartedReason).isEqualTo(CameraMoveStartedReason.DEVELOPER_ANIMATION) } } @@ -114,7 +129,7 @@ class GoogleMapViewTests { composeTestRule.waitUntil(timeout5) { !cameraPositionState.isMoving } - assertFalse(cameraPositionState.isMoving) + assertThat(cameraPositionState.isMoving).isFalse() } } @@ -128,11 +143,7 @@ class GoogleMapViewTests { composeTestRule.waitUntil(timeout3) { !cameraPositionState.isMoving } - assertEquals( - startingZoom + 1f, - cameraPositionState.position.zoom, - assertRoundingError.toFloat() - ) + assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom + 1f) } } @@ -146,11 +157,7 @@ class GoogleMapViewTests { composeTestRule.waitUntil(timeout3) { !cameraPositionState.isMoving } - assertEquals( - startingZoom + 1f, - cameraPositionState.position.zoom, - assertRoundingError.toFloat() - ) + assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom + 1f) } } @@ -164,11 +171,7 @@ class GoogleMapViewTests { composeTestRule.waitUntil(timeout3) { !cameraPositionState.isMoving } - assertEquals( - startingZoom - 1f, - cameraPositionState.position.zoom, - assertRoundingError.toFloat() - ) + assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom - 1f) } } @@ -182,11 +185,7 @@ class GoogleMapViewTests { composeTestRule.waitUntil(timeout3) { !cameraPositionState.isMoving } - assertEquals( - startingZoom - 1f, - cameraPositionState.position.zoom, - assertRoundingError.toFloat() - ) + assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom - 1f) } } @@ -195,10 +194,10 @@ class GoogleMapViewTests { initMap() composeTestRule.runOnUiThread { val projection = cameraPositionState.projection - assertNotNull(projection) - assertTrue( + assertThat(projection).isNotNull() + assertThat( projection!!.visibleRegion.latLngBounds.contains(startingPosition) - ) + ).isTrue() } } @@ -207,11 +206,11 @@ class GoogleMapViewTests { initMap() composeTestRule.runOnUiThread { val projection = cameraPositionState.projection - assertNotNull(projection) + assertThat(projection).isNotNull() val latLng = LatLng(23.4, 25.6) - assertFalse( + assertThat( projection!!.visibleRegion.latLngBounds.contains(latLng) - ) + ).isFalse() } } @@ -295,15 +294,15 @@ class GoogleMapViewTests { markerState = rememberUpdatedMarkerState(position = positionState.value) } - assertEquals(testPoint0, markerState.position) + assertThat(markerState.position).isEqualTo(testPoint0) positionState.value = testPoint1 composeTestRule.waitForIdle() - assertEquals(testPoint1, markerState.position) + assertThat(markerState.position).isEqualTo(testPoint1) positionState.value = testPoint2 composeTestRule.waitForIdle() - assertEquals(testPoint2, markerState.position) + assertThat(markerState.position).isEqualTo(testPoint2) } private fun zoom( @@ -322,4 +321,4 @@ class GoogleMapViewTests { assertionBlock() } -} +} \ No newline at end of file diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt new file mode 100644 index 00000000..6655b2b5 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt @@ -0,0 +1,42 @@ + +package com.google.maps.android.compose + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.FailureMetadata +import com.google.common.truth.Subject +import com.google.common.truth.Truth.assertAbout + +/** + * A [Subject] for asserting facts about [LatLng] objects. + */ +class LatLngSubject( + failureMetadata: FailureMetadata, + private val actual: LatLng? +) : Subject(failureMetadata, actual) { + + /** + * Asserts that the subject is equal to the given [expected] value, with a given [tolerance]. + */ + fun isEqualTo(expected: LatLng, tolerance: Double = 1e-6) { + if (actual == null) { + failWithActual("expected", expected) + return + } + + check("latitude").that(actual.latitude).isWithin(tolerance).of(expected.latitude) + check("longitude").that(actual.longitude).isWithin(tolerance).of(expected.longitude) + } + + companion object { + /** + * A factory for creating [LatLngSubject] instances. + */ + fun assertThat(actual: LatLng?): LatLngSubject { + return assertAbout(latLngs()).that(actual) + } + + private fun latLngs(): (failureMetadata: FailureMetadata, actual: LatLng?) -> LatLngSubject { + return ::LatLngSubject + } + } +} From e150f3767e50caa452ee18a582ce5d62cd58b249 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Tue, 9 Sep 2025 09:36:04 -0600 Subject: [PATCH 8/8] chore: add license headers to test files --- .../android/compose/GoogleMapsInitializerTest.kt | 13 +++++++++++++ .../google/maps/android/compose/LatLngSubject.kt | 14 ++++++++++++++ .../android/compose/GoogleMapsInitializerTest.kt | 13 +++++++++++++ 3 files changed, 40 insertions(+) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index d81fd2a9..a7013694 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,3 +1,16 @@ +// Copyright 2025 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.compose import android.content.Context diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt index 6655b2b5..41535e55 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/LatLngSubject.kt @@ -1,4 +1,18 @@ +// Copyright 2025 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.compose import com.google.android.gms.maps.model.LatLng diff --git a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt index b23d68ca..38c6c03a 100644 --- a/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt +++ b/maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt @@ -1,3 +1,16 @@ +// Copyright 2025 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.compose import android.content.Context