Skip to content

Commit 87a4b5c

Browse files
authored
feat: Introduce a robust Google Maps initializer (#758)
* 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. * chore: address copyright header issues * 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. * 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. * chore: Update Material, Robolectric, and Truth dependencies * 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. * 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. * chore: add license headers to test files
1 parent b350c49 commit 87a4b5c

File tree

9 files changed

+386
-98
lines changed

9 files changed

+386
-98
lines changed

gradle/libs.versions.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[versions]
22
activitycompose = "1.10.1"
3-
agp = "8.12.1"
3+
agp = "8.13.0"
44
androidCore = "1.7.0"
55
androidx-core = "1.17.0"
66
androidxtest = "1.7.0"
@@ -19,7 +19,9 @@ mapsktx = "5.2.0"
1919
org-jacoco-core = "0.8.13"
2020
screenshot = "0.0.1-alpha11"
2121
constraintlayout = "2.2.1"
22-
material = "1.12.0"
22+
material = "1.13.0"
23+
robolectric = "4.16"
24+
truth = "1.4.4"
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ 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)
90+
testImplementation(libs.kotlinx.coroutines.test)
8491

8592
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
8693

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

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.google.maps.android.compose
1616

17+
import android.content.Context
1718
import androidx.compose.foundation.layout.Box
1819
import androidx.compose.foundation.layout.fillMaxSize
1920
import androidx.compose.material.Text
@@ -25,9 +26,14 @@ import androidx.compose.ui.test.junit4.createComposeRule
2526
import androidx.compose.ui.test.onNodeWithTag
2627
import androidx.compose.ui.test.onNodeWithText
2728
import androidx.compose.ui.test.performClick
29+
import androidx.test.platform.app.InstrumentationRegistry
2830
import com.google.android.gms.maps.model.CameraPosition
2931
import com.google.android.gms.maps.model.LatLng
30-
import org.junit.Assert.*
32+
import com.google.common.truth.Truth.assertThat
33+
import com.google.maps.android.compose.LatLngSubject.Companion.assertThat
34+
import com.google.maps.android.compose.internal.GoogleMapsInitializer
35+
import com.google.maps.android.compose.internal.InitializationState
36+
import kotlinx.coroutines.runBlocking
3137
import org.junit.Before
3238
import org.junit.Rule
3339
import org.junit.Test
@@ -46,6 +52,15 @@ class GoogleMapViewTests {
4652
private fun initMap(content: @Composable () -> Unit = {}) {
4753
check(hasValidApiKey) { "Maps API key not specified" }
4854
val countDownLatch = CountDownLatch(1)
55+
56+
val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
57+
58+
runBlocking {
59+
GoogleMapsInitializer.initialize(appContext)
60+
}
61+
62+
assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
63+
4964
composeTestRule.setContent {
5065
GoogleMapView(
5166
modifier = Modifier.fillMaxSize(),
@@ -59,7 +74,7 @@ class GoogleMapViewTests {
5974
}
6075
}
6176
val mapLoaded = countDownLatch.await(30, TimeUnit.SECONDS)
62-
assertTrue("Map loaded", mapLoaded)
77+
assertThat(mapLoaded).isTrue()
6378
}
6479

6580
@Before
@@ -75,32 +90,32 @@ class GoogleMapViewTests {
7590
@Test
7691
fun testStartingCameraPosition() {
7792
initMap()
78-
startingPosition.assertEquals(cameraPositionState.position.target)
93+
assertThat(cameraPositionState.position.target).isEqualTo(startingPosition)
7994
}
8095

8196
@Test
8297
fun testRightInitialColorScheme() {
8398
initMap()
84-
mapColorScheme.assertEquals(ComposeMapColorScheme.FOLLOW_SYSTEM)
99+
assertThat(mapColorScheme).isEqualTo(ComposeMapColorScheme.FOLLOW_SYSTEM)
85100
}
86101

87102
@Test
88-
fun testRighColorSchemeAfterChangingIt() {
103+
fun testRightColorSchemeAfterChangingIt() {
89104
mapColorScheme = ComposeMapColorScheme.DARK
90105
initMap()
91-
mapColorScheme.assertEquals(ComposeMapColorScheme.DARK)
106+
assertThat(mapColorScheme).isEqualTo(ComposeMapColorScheme.DARK)
92107
}
93108

94109
@Test
95110
fun testCameraReportsMoving() {
96111
initMap()
97-
assertEquals(CameraMoveStartedReason.NO_MOVEMENT_YET, cameraPositionState.cameraMoveStartedReason)
112+
assertThat(cameraPositionState.cameraMoveStartedReason).isEqualTo(CameraMoveStartedReason.NO_MOVEMENT_YET)
98113
zoom(shouldAnimate = true, zoomIn = true) {
99114
composeTestRule.waitUntil(timeout2) {
100115
cameraPositionState.isMoving
101116
}
102-
assertTrue(cameraPositionState.isMoving)
103-
assertEquals(CameraMoveStartedReason.DEVELOPER_ANIMATION, cameraPositionState.cameraMoveStartedReason)
117+
assertThat(cameraPositionState.isMoving).isTrue()
118+
assertThat(cameraPositionState.cameraMoveStartedReason).isEqualTo(CameraMoveStartedReason.DEVELOPER_ANIMATION)
104119
}
105120
}
106121

@@ -114,7 +129,7 @@ class GoogleMapViewTests {
114129
composeTestRule.waitUntil(timeout5) {
115130
!cameraPositionState.isMoving
116131
}
117-
assertFalse(cameraPositionState.isMoving)
132+
assertThat(cameraPositionState.isMoving).isFalse()
118133
}
119134
}
120135

@@ -128,11 +143,7 @@ class GoogleMapViewTests {
128143
composeTestRule.waitUntil(timeout3) {
129144
!cameraPositionState.isMoving
130145
}
131-
assertEquals(
132-
startingZoom + 1f,
133-
cameraPositionState.position.zoom,
134-
assertRoundingError.toFloat()
135-
)
146+
assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom + 1f)
136147
}
137148
}
138149

@@ -146,11 +157,7 @@ class GoogleMapViewTests {
146157
composeTestRule.waitUntil(timeout3) {
147158
!cameraPositionState.isMoving
148159
}
149-
assertEquals(
150-
startingZoom + 1f,
151-
cameraPositionState.position.zoom,
152-
assertRoundingError.toFloat()
153-
)
160+
assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom + 1f)
154161
}
155162
}
156163

@@ -164,11 +171,7 @@ class GoogleMapViewTests {
164171
composeTestRule.waitUntil(timeout3) {
165172
!cameraPositionState.isMoving
166173
}
167-
assertEquals(
168-
startingZoom - 1f,
169-
cameraPositionState.position.zoom,
170-
assertRoundingError.toFloat()
171-
)
174+
assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom - 1f)
172175
}
173176
}
174177

@@ -182,11 +185,7 @@ class GoogleMapViewTests {
182185
composeTestRule.waitUntil(timeout3) {
183186
!cameraPositionState.isMoving
184187
}
185-
assertEquals(
186-
startingZoom - 1f,
187-
cameraPositionState.position.zoom,
188-
assertRoundingError.toFloat()
189-
)
188+
assertThat(cameraPositionState.position.zoom).isWithin(assertRoundingError.toFloat()).of(startingZoom - 1f)
190189
}
191190
}
192191

@@ -195,10 +194,10 @@ class GoogleMapViewTests {
195194
initMap()
196195
composeTestRule.runOnUiThread {
197196
val projection = cameraPositionState.projection
198-
assertNotNull(projection)
199-
assertTrue(
197+
assertThat(projection).isNotNull()
198+
assertThat(
200199
projection!!.visibleRegion.latLngBounds.contains(startingPosition)
201-
)
200+
).isTrue()
202201
}
203202
}
204203

@@ -207,11 +206,11 @@ class GoogleMapViewTests {
207206
initMap()
208207
composeTestRule.runOnUiThread {
209208
val projection = cameraPositionState.projection
210-
assertNotNull(projection)
209+
assertThat(projection).isNotNull()
211210
val latLng = LatLng(23.4, 25.6)
212-
assertFalse(
211+
assertThat(
213212
projection!!.visibleRegion.latLngBounds.contains(latLng)
214-
)
213+
).isFalse()
215214
}
216215
}
217216

@@ -295,15 +294,15 @@ class GoogleMapViewTests {
295294
markerState = rememberUpdatedMarkerState(position = positionState.value)
296295
}
297296

298-
assertEquals(testPoint0, markerState.position)
297+
assertThat(markerState.position).isEqualTo(testPoint0)
299298

300299
positionState.value = testPoint1
301300
composeTestRule.waitForIdle()
302-
assertEquals(testPoint1, markerState.position)
301+
assertThat(markerState.position).isEqualTo(testPoint1)
303302

304303
positionState.value = testPoint2
305304
composeTestRule.waitForIdle()
306-
assertEquals(testPoint2, markerState.position)
305+
assertThat(markerState.position).isEqualTo(testPoint2)
307306
}
308307

309308
private fun zoom(
@@ -322,4 +321,4 @@ class GoogleMapViewTests {
322321

323322
assertionBlock()
324323
}
325-
}
324+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
package com.google.maps.android.compose
15+
16+
import android.content.Context
17+
import android.os.StrictMode
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 kotlinx.coroutines.ExperimentalCoroutinesApi
24+
import kotlinx.coroutines.test.runTest
25+
import org.junit.After
26+
import org.junit.Test
27+
import org.junit.runner.RunWith
28+
29+
@OptIn(ExperimentalCoroutinesApi::class)
30+
@RunWith(AndroidJUnit4::class)
31+
class GoogleMapsInitializerTest {
32+
33+
@After
34+
fun tearDown() = runTest {
35+
GoogleMapsInitializer.reset()
36+
}
37+
38+
@Test
39+
fun testInitializationSuccess() = runTest {
40+
// In an instrumentation test environment, Google Play services are available.
41+
// Therefore, we expect the initialization to succeed.
42+
43+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
44+
45+
// Note: we need to establish the Strict Mode settings here as there are violations outside
46+
// of our control if we try to set them in setUp
47+
val threadPolicy = StrictMode.getThreadPolicy()
48+
val vmPolicy = StrictMode.getVmPolicy()
49+
50+
StrictMode.setThreadPolicy(
51+
StrictMode.ThreadPolicy.Builder()
52+
.detectDiskReads()
53+
.detectAll()
54+
.penaltyLog()
55+
.penaltyDeath()
56+
.build()
57+
)
58+
StrictMode.setVmPolicy(
59+
StrictMode.VmPolicy.Builder()
60+
.detectAll()
61+
.detectLeakedClosableObjects()
62+
.penaltyLog()
63+
.penaltyDeath()
64+
.build()
65+
)
66+
67+
GoogleMapsInitializer.initialize(context)
68+
69+
StrictMode.setThreadPolicy(threadPolicy)
70+
StrictMode.setVmPolicy(vmPolicy)
71+
72+
assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
73+
}
74+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
2+
// Copyright 2025 Google LLC
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package com.google.maps.android.compose
17+
18+
import com.google.android.gms.maps.model.LatLng
19+
import com.google.common.truth.FailureMetadata
20+
import com.google.common.truth.Subject
21+
import com.google.common.truth.Truth.assertAbout
22+
23+
/**
24+
* A [Subject] for asserting facts about [LatLng] objects.
25+
*/
26+
class LatLngSubject(
27+
failureMetadata: FailureMetadata,
28+
private val actual: LatLng?
29+
) : Subject(failureMetadata, actual) {
30+
31+
/**
32+
* Asserts that the subject is equal to the given [expected] value, with a given [tolerance].
33+
*/
34+
fun isEqualTo(expected: LatLng, tolerance: Double = 1e-6) {
35+
if (actual == null) {
36+
failWithActual("expected", expected)
37+
return
38+
}
39+
40+
check("latitude").that(actual.latitude).isWithin(tolerance).of(expected.latitude)
41+
check("longitude").that(actual.longitude).isWithin(tolerance).of(expected.longitude)
42+
}
43+
44+
companion object {
45+
/**
46+
* A factory for creating [LatLngSubject] instances.
47+
*/
48+
fun assertThat(actual: LatLng?): LatLngSubject {
49+
return assertAbout(latLngs()).that(actual)
50+
}
51+
52+
private fun latLngs(): (failureMetadata: FailureMetadata, actual: LatLng?) -> LatLngSubject {
53+
return ::LatLngSubject
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)