Skip to content

Commit 4e3770d

Browse files
authored
feat: Improve GoogleMapsInitializer error handling and retries (#778)
* fix: Improve GoogleMapsInitializer error handling and allow retries Refactored the error handling within `GoogleMapsInitializer` to make it more robust against initialization failures. Previously, any exception during the `initialize` call would permanently set the state to `FAILURE`, preventing any subsequent attempts. This change introduces more granular error handling: - Unrecoverable errors, such as `GooglePlayServicesMissingManifestValueException`, will correctly set the state to `FAILURE`. - Other exceptions, which may be transient, will now reset the state to `UNINITIALIZED`, allowing the initialization to be attempted again. Additionally, a `forceInitialization` parameter has been added to the `initialize` function to allow bypassing the initial state check for re-initialization scenarios. * feat: More improvements to GoogleMapsInitializer Introduced a new `preferredRenderer` parameter to the `initialize` function. Key changes: - The `initialize` function now accepts a `preferredRenderer` to specify which Maps renderer to use. - The `try-catch` block within the `initialize` function has been refactored to be more idiomatic. The `AdvancedMarkersActivity` example has been updated to use this improved initializer. It now calls `GoogleMapsInitializer.initialize` within a `LaunchedEffect`, making the initialization asynchronous and tied to the composable's lifecycle. * refactor: Remove preferredRenderer from Maps initialization The `preferredRenderer` parameter has been removed from the `GoogleMapsInitializer.initialize()` function. The Maps SDK for Android now automatically selects the latest available renderer, making this parameter redundant. This change simplifies the initialization API. The explicit initialization call in the `AdvancedMarkersActivity` sample has also been removed to reflect this simplification. * feat(maps-compose): Introduce interface-based GoogleMapsInitializer This commit refactors the `GoogleMapsInitializer` from a static object to an interface-based dependency provided via a `CompositionLocal`. This architectural change offers several key benefits: - **Improved Testability:** The initialization logic can now be easily mocked and tested in isolation, as demonstrated by the new unit tests using `mockk`. - **Dependency Injection:** It aligns with modern dependency injection principles, making the `GoogleMap` composable more configurable and less reliant on a global singleton. - **Robustness:** The error handling has been made more granular, distinguishing between unrecoverable manifest errors and transient, recoverable issues. The new `DefaultGoogleMapsInitializer` provides the concrete implementation, ensuring backward compatibility for existing usages while enabling advanced testing and customization scenarios.
1 parent eb277e2 commit 4e3770d

File tree

9 files changed

+282
-116
lines changed

9 files changed

+282
-116
lines changed

gradle/libs.versions.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ agp = "8.13.0"
44
androidCore = "1.7.0"
55
androidx-core = "1.17.0"
66
androidxtest = "1.7.0"
7-
compose-bom = "2025.08.01"
7+
compose-bom = "2025.09.01"
88
dokka = "2.0.0"
99
espresso = "3.7.0"
1010
gradleMavenPublishPlugin = "0.34.0"
@@ -16,7 +16,10 @@ kotlinxCoroutines = "1.10.2"
1616
leakcanaryAndroid = "2.14"
1717
mapsecrets = "2.0.1"
1818
mapsktx = "5.2.0"
19-
material3 = "1.3.2"
19+
material3 = "1.4.0"
20+
materialIconsExtendedAndroid = "1.7.8"
21+
mockk = "1.14.5"
22+
mockkAndroid = "1.14.5"
2023
org-jacoco-core = "0.8.13"
2124
screenshot = "0.0.1-alpha11"
2225
constraintlayout = "2.2.1"
@@ -30,6 +33,7 @@ androidx-compose-activity = { module = "androidx.activity:activity-compose", ver
3033
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
3134
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
3235
androidx-compose-material = { module = "androidx.compose.material:material" }
36+
androidx-compose-material-icons-extended-android = { module = "androidx.compose.material:material-icons-extended-android", version.ref = "materialIconsExtendedAndroid" }
3337
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
3438
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
3539
androidx-compose-ui-preview-tooling = { module = "androidx.compose.ui:ui-tooling-preview" }
@@ -52,6 +56,8 @@ leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", ve
5256
maps-ktx-std = { module = "com.google.maps.android:maps-ktx", version.ref = "mapsktx" }
5357
maps-ktx-utils = { module = "com.google.maps.android:maps-utils-ktx", version.ref = "mapsktx" }
5458
maps-secrets-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "mapsecrets" }
59+
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
60+
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkAndroid" }
5561
org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" }
5662
test-junit = { module = "junit:junit", version.ref = "junit" }
5763
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }

maps-app/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ android {
4545
compose = true
4646
}
4747

48-
4948
kotlin {
5049
compilerOptions {
5150
jvmTarget.set(JvmTarget.JVM_1_8)
@@ -75,6 +74,8 @@ dependencies {
7574
implementation(libs.androidx.compose.ui.preview.tooling)
7675
implementation(libs.androidx.constraintlayout)
7776
implementation(libs.material)
77+
implementation(libs.androidx.compose.material.icons.extended.android)
78+
7879
implementation(libs.screenshot.validation.api)
7980
debugImplementation(libs.androidx.compose.ui.tooling)
8081
debugImplementation(libs.leakcanary.android)
@@ -103,6 +104,10 @@ dependencies {
103104
implementation(project(":maps-compose"))
104105
implementation(project(":maps-compose-widgets"))
105106
implementation(project(":maps-compose-utils"))
107+
108+
testImplementation(libs.mockk)
109+
testImplementation(libs.mockk.android)
110+
testImplementation(kotlin("test"))
106111
}
107112

108113
secrets {

maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ package com.google.maps.android.compose
1717
import android.content.Context
1818
import androidx.compose.foundation.layout.Box
1919
import androidx.compose.foundation.layout.fillMaxSize
20-
import androidx.compose.material.Text
20+
import androidx.compose.material3.Text
2121
import androidx.compose.runtime.Composable
2222
import androidx.compose.runtime.mutableStateOf
2323
import androidx.compose.ui.Modifier
@@ -31,7 +31,7 @@ import com.google.android.gms.maps.model.CameraPosition
3131
import com.google.android.gms.maps.model.LatLng
3232
import com.google.common.truth.Truth.assertThat
3333
import com.google.maps.android.compose.LatLngSubject.Companion.assertThat
34-
import com.google.maps.android.compose.internal.GoogleMapsInitializer
34+
import com.google.maps.android.compose.internal.DefaultGoogleMapsInitializer
3535
import com.google.maps.android.compose.internal.InitializationState
3636
import kotlinx.coroutines.runBlocking
3737
import org.junit.Before
@@ -54,12 +54,13 @@ class GoogleMapViewTests {
5454
val countDownLatch = CountDownLatch(1)
5555

5656
val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
57+
val googleMapsInitializer = DefaultGoogleMapsInitializer()
5758

5859
runBlocking {
59-
GoogleMapsInitializer.initialize(appContext)
60+
googleMapsInitializer.initialize(appContext)
6061
}
6162

62-
assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
63+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
6364

6465
composeTestRule.setContent {
6566
GoogleMapView(

maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt renamed to maps-app/src/androidTest/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,31 @@
1111
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
14-
package com.google.maps.android.compose
14+
package com.google.maps.android.compose.internal
1515

1616
import android.content.Context
1717
import android.os.StrictMode
1818
import androidx.test.ext.junit.runners.AndroidJUnit4
1919
import androidx.test.platform.app.InstrumentationRegistry
2020
import com.google.common.truth.Truth.assertThat
21-
import com.google.maps.android.compose.internal.GoogleMapsInitializer
22-
import com.google.maps.android.compose.internal.InitializationState
2321
import kotlinx.coroutines.ExperimentalCoroutinesApi
22+
import kotlinx.coroutines.delay
23+
import kotlinx.coroutines.launch
2424
import kotlinx.coroutines.test.runTest
2525
import org.junit.After
2626
import org.junit.Test
2727
import org.junit.runner.RunWith
28+
import kotlin.time.Duration.Companion.milliseconds
2829

2930
@OptIn(ExperimentalCoroutinesApi::class)
3031
@RunWith(AndroidJUnit4::class)
3132
class GoogleMapsInitializerTest {
3233

34+
private val googleMapsInitializer = DefaultGoogleMapsInitializer()
35+
3336
@After
3437
fun tearDown() = runTest {
35-
GoogleMapsInitializer.reset()
38+
googleMapsInitializer.reset()
3639
}
3740

3841
@Test
@@ -64,11 +67,56 @@ class GoogleMapsInitializerTest {
6467
.build()
6568
)
6669

67-
GoogleMapsInitializer.initialize(context)
70+
googleMapsInitializer.initialize(context)
71+
72+
StrictMode.setThreadPolicy(threadPolicy)
73+
StrictMode.setVmPolicy(vmPolicy)
74+
75+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
76+
}
77+
78+
@Test
79+
fun testInitializationCancellationLeavesStateUninitialized() = runTest {
80+
// In an instrumentation test environment, Google Play services are available.
81+
// Therefore, we expect the initialization to succeed.
82+
83+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
84+
85+
// Note: we need to establish the Strict Mode settings here as there are violations outside
86+
// of our control if we try to set them in setUp
87+
val threadPolicy = StrictMode.getThreadPolicy()
88+
val vmPolicy = StrictMode.getVmPolicy()
89+
90+
StrictMode.setThreadPolicy(
91+
StrictMode.ThreadPolicy.Builder()
92+
.detectDiskReads()
93+
.detectAll()
94+
.penaltyLog()
95+
.penaltyDeath()
96+
.build()
97+
)
98+
StrictMode.setVmPolicy(
99+
StrictMode.VmPolicy.Builder()
100+
.detectAll()
101+
.detectLeakedClosableObjects()
102+
.penaltyLog()
103+
.penaltyDeath()
104+
.build()
105+
)
106+
107+
val job = launch {
108+
googleMapsInitializer.reset()
109+
googleMapsInitializer.initialize(context)
110+
}
111+
112+
// Allow the initialization coroutine to start before we cancel it.
113+
delay(1.milliseconds)
114+
job.cancel()
115+
job.join()
68116

69117
StrictMode.setThreadPolicy(threadPolicy)
70118
StrictMode.setVmPolicy(vmPolicy)
71119

72-
assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
120+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.UNINITIALIZED)
73121
}
74122
}

maps-app/src/main/java/com/google/maps/android/compose/markerexamples/AdvancedMarkersActivity.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ class AdvancedMarkersActivity : ComponentActivity(), OnMapsSdkInitializedCallbac
6262
@SuppressLint("SetTextI18n")
6363
override fun onCreate(savedInstanceState: Bundle?) {
6464
super.onCreate(savedInstanceState)
65-
MapsInitializer.initialize(applicationContext, MapsInitializer.Renderer.LATEST, this)
6665
enableEdgeToEdge()
6766
setContent {
6867
// Observing and controlling the camera's state can be done with a CameraPositionState

maps-app/src/test/java/com/google/maps/android/compose/GoogleMapsInitializerTest.kt

Lines changed: 0 additions & 48 deletions
This file was deleted.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.maps.android.compose.internal
16+
17+
import android.content.Context
18+
import com.google.android.gms.common.ConnectionResult
19+
import com.google.android.gms.common.GooglePlayServicesMissingManifestValueException
20+
import com.google.android.gms.maps.MapsInitializer
21+
import com.google.android.gms.maps.MapsApiSettings
22+
import io.mockk.every
23+
import io.mockk.mockk
24+
import io.mockk.mockkStatic
25+
import io.mockk.verify
26+
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.test.TestDispatcher
29+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
30+
import kotlinx.coroutines.test.runTest
31+
import org.junit.Assert.assertEquals
32+
import org.junit.Before
33+
import org.junit.Test
34+
import kotlin.test.assertFailsWith
35+
36+
@OptIn(ExperimentalCoroutinesApi::class)
37+
class GoogleMapsInitializerTest {
38+
39+
private val mockContext: Context = mockk(relaxed = true)
40+
private lateinit var testDispatcher: TestDispatcher
41+
private lateinit var googleMapsInitializer: GoogleMapsInitializer
42+
43+
@Before
44+
fun setUp() {
45+
// Mock the static methods we depend on
46+
mockkStatic(MapsInitializer::class)
47+
mockkStatic(MapsApiSettings::class)
48+
49+
// Default happy path behavior for mocks
50+
every { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS
51+
every { MapsApiSettings.addInternalUsageAttributionId(any(), any()) } returns Unit
52+
53+
testDispatcher = UnconfinedTestDispatcher()
54+
googleMapsInitializer = DefaultGoogleMapsInitializer(testDispatcher)
55+
}
56+
57+
@Test
58+
fun `initialize - when coroutine is cancelled - state resets to UNINITIALIZED`() = runTest {
59+
val job = launch {
60+
googleMapsInitializer.initialize(mockContext)
61+
}
62+
job.cancel()
63+
assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value)
64+
}
65+
66+
@Test
67+
fun `initialize - on first call - successfully initializes and state becomes SUCCESS`() = runTest {
68+
// Arrange
69+
assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value)
70+
71+
// Act
72+
googleMapsInitializer.initialize(mockContext)
73+
74+
// Assert
75+
assertEquals(InitializationState.SUCCESS, googleMapsInitializer.state.value)
76+
verify(exactly = 1) { MapsInitializer.initialize(mockContext) }
77+
verify(exactly = 1) { MapsApiSettings.addInternalUsageAttributionId(any(), any()) }
78+
}
79+
80+
@Test
81+
fun `initialize - when called concurrently - initialization only runs once`() = runTest {
82+
// Act: Launch two calls concurrently
83+
val job1 = launch { googleMapsInitializer.initialize(mockContext) }
84+
val job2 = launch { googleMapsInitializer.initialize(mockContext) }
85+
86+
job1.join()
87+
job2.join()
88+
89+
// Assert: The actual initialization method should only have been called once
90+
// thanks to the mutex and double-check logic.
91+
verify(exactly = 1) { MapsInitializer.initialize(mockContext) }
92+
assertEquals(InitializationState.SUCCESS, googleMapsInitializer.state.value)
93+
}
94+
95+
@Test
96+
fun `initialize - on unrecoverable failure - state becomes FAILURE`() = runTest {
97+
// Arrange
98+
val error = GooglePlayServicesMissingManifestValueException()
99+
every { MapsInitializer.initialize(any()) } throws error
100+
101+
// Act
102+
assertFailsWith<GooglePlayServicesMissingManifestValueException> {
103+
googleMapsInitializer.initialize(mockContext)
104+
}
105+
106+
// Assert: The state should be FAILURE and the exception should be re-thrown
107+
assertEquals(InitializationState.FAILURE, googleMapsInitializer.state.value)
108+
}
109+
110+
@Test
111+
fun `initialize - on recoverable failure - state resets to UNINITIALIZED and exception is thrown`() = runTest {
112+
// Arrange
113+
val error = RuntimeException("A network error occurred!")
114+
every { MapsInitializer.initialize(any()) } throws error
115+
116+
// Act & Assert
117+
assertFailsWith<RuntimeException> {
118+
googleMapsInitializer.initialize(mockContext)
119+
}
120+
assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value)
121+
}
122+
}

0 commit comments

Comments
 (0)