Skip to content

Commit 7b23a41

Browse files
committed
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.
1 parent b350c49 commit 7b23a41

8 files changed

Lines changed: 234 additions & 57 deletions

File tree

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ org-jacoco-core = "0.8.13"
2020
screenshot = "0.0.1-alpha11"
2121
constraintlayout = "2.2.1"
2222
material = "1.12.0"
23+
robolectric = "4.12.1"
24+
truth = "1.1.3"
2325

2426
[libraries]
2527
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
@@ -53,6 +55,8 @@ test-junit = { module = "junit:junit", version.ref = "junit" }
5355
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
5456
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
5557
screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" }
58+
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
59+
truth = { module = "com.google.truth:truth", version.ref = "truth" }
5660

5761
[plugins]
5862
android-application = { id = "com.android.application", version.ref = "agp" }

maps-app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ dependencies {
8181
androidTestImplementation(libs.test.junit)
8282
androidTestImplementation(libs.androidx.test.compose.ui)
8383
androidTestImplementation(libs.kotlinx.coroutines.test)
84+
androidTestImplementation(libs.truth)
85+
86+
testImplementation(libs.test.junit)
87+
testImplementation(libs.robolectric)
88+
testImplementation(libs.androidx.test.core)
89+
testImplementation(libs.truth)
8490

8591
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
8692

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class GoogleMapViewTests {
8585
}
8686

8787
@Test
88-
fun testRighColorSchemeAfterChangingIt() {
88+
fun testRightColorSchemeAfterChangingIt() {
8989
mapColorScheme = ComposeMapColorScheme.DARK
9090
initMap()
9191
mapColorScheme.assertEquals(ComposeMapColorScheme.DARK)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2024 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
16+
17+
import android.content.Context
18+
import androidx.test.ext.junit.runners.AndroidJUnit4
19+
import androidx.test.platform.app.InstrumentationRegistry
20+
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
23+
import org.junit.After
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
27+
@RunWith(AndroidJUnit4::class)
28+
class GoogleMapsInitializerTest {
29+
30+
@After
31+
fun tearDown() {
32+
GoogleMapsInitializer.reset()
33+
}
34+
35+
@Test
36+
fun testInitializationSuccess() {
37+
// In an instrumentation test environment, Google Play services are available.
38+
// Therefore, we expect the initialization to succeed.
39+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
40+
41+
GoogleMapsInitializer.initialize(context)
42+
43+
// Wait for the initialization to complete
44+
Thread.sleep(1000)
45+
46+
assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
47+
}
48+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2024 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
16+
17+
import android.content.Context
18+
import androidx.test.core.app.ApplicationProvider
19+
import com.google.common.truth.Truth.assertThat
20+
import com.google.maps.android.compose.internal.GoogleMapsInitializer
21+
import com.google.maps.android.compose.internal.InitializationState
22+
import org.junit.After
23+
import org.junit.Test
24+
import org.junit.runner.RunWith
25+
import org.robolectric.RobolectricTestRunner
26+
27+
@RunWith(RobolectricTestRunner::class)
28+
class GoogleMapsInitializerTest {
29+
30+
@After
31+
fun tearDown() {
32+
GoogleMapsInitializer.reset()
33+
}
34+
35+
@Test
36+
fun testInitializationFailure() {
37+
// In a unit test environment, Google Play services are not available.
38+
// Therefore, we expect the initialization to fail.
39+
val context: Context = ApplicationProvider.getApplicationContext()
40+
41+
GoogleMapsInitializer.initialize(context)
42+
43+
// Wait for the initialization to complete
44+
Thread.sleep(1000)
45+
46+
assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.FAILURE)
47+
}
48+
}

maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ import com.google.android.gms.maps.MapView
5050
import com.google.android.gms.maps.model.LatLng
5151
import com.google.android.gms.maps.model.MapColorScheme
5252
import com.google.android.gms.maps.model.PointOfInterest
53-
import com.google.maps.android.compose.internal.MapsApiAttribution
53+
import com.google.maps.android.compose.internal.GoogleMapsInitializer
54+
import com.google.maps.android.compose.internal.InitializationState
5455
import com.google.maps.android.ktx.awaitMap
5556
import kotlinx.coroutines.CoroutineScope
5657
import kotlinx.coroutines.CoroutineStart
@@ -113,16 +114,16 @@ public fun GoogleMap(
113114
return
114115
}
115116

116-
val isInitialized by MapsApiAttribution.isInitialized
117+
val initializationState by GoogleMapsInitializer.state
117118

118-
if (!isInitialized) {
119+
if (initializationState != InitializationState.SUCCESS) {
119120
val context = LocalContext.current
120121
LaunchedEffect(Unit) {
121-
MapsApiAttribution.addAttributionId(context)
122+
GoogleMapsInitializer.initialize(context)
122123
}
123124
}
124125

125-
if (isInitialized) {
126+
if (initializationState == InitializationState.SUCCESS) {
126127
// rememberUpdatedState and friends are used here to make these values observable to
127128
// the subcomposition without providing a new content function each recomposition
128129
val mapClickListeners = remember { MapClickListeners() }.also {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.google.maps.android.compose.internal
2+
3+
import android.content.Context
4+
import androidx.compose.runtime.State
5+
import androidx.compose.runtime.mutableStateOf
6+
import com.google.android.gms.common.ConnectionResult
7+
import com.google.android.gms.maps.MapsInitializer
8+
import com.google.android.gms.maps.MapsApiSettings
9+
import com.google.maps.android.compose.meta.AttributionId
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.Job
13+
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.sync.Mutex
15+
import kotlinx.coroutines.sync.withLock
16+
17+
/**
18+
* Enum representing the initialization state of the Google Maps SDK.
19+
*/
20+
public enum class InitializationState {
21+
/**
22+
* The SDK has not been initialized.
23+
*/
24+
UNINITIALIZED,
25+
26+
/**
27+
* The SDK is currently being initialized.
28+
*/
29+
INITIALIZING,
30+
31+
/**
32+
* The SDK has been successfully initialized.
33+
*/
34+
SUCCESS,
35+
36+
/**
37+
* The SDK initialization failed.
38+
*/
39+
FAILURE
40+
}
41+
42+
/**
43+
* A singleton object to manage the initialization of the Google Maps SDK.
44+
*
45+
* This object provides a state machine to track the initialization process and ensures that
46+
* the initialization is performed only once. It also provides a mechanism to reset the
47+
* initialization state, which can be useful in test environments.
48+
*
49+
* The initialization process consists of two main steps:
50+
* 1. Calling `MapsInitializer.initialize(context)` to initialize the Google Maps SDK.
51+
* 2. Calling `MapsApiSettings.addInternalUsageAttributionId(context, attributionId)` to add
52+
* the library's attribution ID to the Maps API settings.
53+
*
54+
* The state of the initialization is exposed via the `state` property, which is a [State] object
55+
* that can be observed for changes.
56+
*/
57+
public object GoogleMapsInitializer {
58+
private val _state = mutableStateOf(InitializationState.UNINITIALIZED)
59+
public val state: State<InitializationState> = _state
60+
61+
private var initializationJob: Job? = null
62+
private val mutex = Mutex()
63+
64+
/**
65+
* The value of the attribution ID. Set this to the empty string to opt out of attribution.
66+
*
67+
* This must be set before calling the `initialize` function.
68+
*/
69+
public var attributionId: String = AttributionId.VALUE
70+
71+
/**
72+
* Initializes the Google Maps SDK.
73+
*
74+
* This function starts the initialization process on a background thread. The process is
75+
* performed only once. If the initialization is already in progress or has completed,
76+
* this function does nothing.
77+
*
78+
* The initialization state can be observed via the `state` property.
79+
*
80+
* @param context The context to use for initialization.
81+
*/
82+
public fun initialize(context: Context) {
83+
CoroutineScope(Dispatchers.IO).launch {
84+
mutex.withLock {
85+
if (_state.value != InitializationState.UNINITIALIZED) {
86+
return@withLock
87+
}
88+
89+
_state.value = InitializationState.INITIALIZING
90+
initializationJob = launch {
91+
try {
92+
if (MapsInitializer.initialize(context) == ConnectionResult.SUCCESS) {
93+
MapsApiSettings.addInternalUsageAttributionId(context, attributionId)
94+
_state.value = InitializationState.SUCCESS
95+
}
96+
} catch (e: Exception) {
97+
// In tests where the map is mocked, this can fail.
98+
_state.value = InitializationState.FAILURE
99+
}
100+
}
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Resets the initialization state.
107+
*
108+
* This function cancels any ongoing initialization and resets the state to `UNINITIALIZED`.
109+
* This is useful in test environments where you might need to re-initialize the SDK
110+
* multiple times.
111+
*/
112+
public fun reset() {
113+
CoroutineScope(Dispatchers.IO).launch {
114+
mutex.withLock {
115+
initializationJob?.cancel()
116+
initializationJob = null
117+
_state.value = InitializationState.UNINITIALIZED
118+
}
119+
}
120+
}
121+
}

maps-compose/src/main/java/com/google/maps/android/compose/internal/MapsApiAttribution.kt

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)