Skip to content

Commit f606fd9

Browse files
dkhawkkikoso
andauthored
fix: Address exception on main thread during map initialization (#793)
* fix: Address IllegalStateException on main thread during map initialization Fixes a crash where `IllegalStateException: Method addObserver must be called on the main thread` was thrown during Google Maps initialization on certain Android devices after upgrading the library. This addresses the issue reported in #789. * fix: moved tests as Instrumentation Tests * refactor: Use application-scoped CoroutineScope Replaced GlobalScope with a custom CoroutineScope in MapsComposeApplication to ensure better lifecycle management for coroutines. Also clarified the comment regarding SDK initialization. --------- Co-authored-by: Enrique López-Mañas <[email protected]>
1 parent fe2fe6a commit f606fd9

File tree

7 files changed

+275
-140
lines changed

7 files changed

+275
-140
lines changed

maps-app/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ android {
6161
imageDifferenceThreshold = 0.035f // 3.5%
6262
}
6363
}
64+
65+
packaging {
66+
resources {
67+
pickFirsts += listOf(
68+
"META-INF/LICENSE.md",
69+
"META-INF/LICENSE-notice.md"
70+
)
71+
}
72+
}
6473
}
6574

6675
dependencies {
@@ -90,6 +99,8 @@ dependencies {
9099
androidTestImplementation(libs.androidx.test.compose.ui)
91100
androidTestImplementation(libs.kotlinx.coroutines.test)
92101
androidTestImplementation(libs.truth)
102+
androidTestImplementation(libs.mockk.android)
103+
//androidTestImplementation(kotlin("test"))
93104

94105
testImplementation(libs.test.junit)
95106
testImplementation(libs.robolectric)

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ import org.junit.After
2626
import org.junit.Test
2727
import org.junit.runner.RunWith
2828
import kotlin.time.Duration.Companion.milliseconds
29+
import com.google.android.gms.common.ConnectionResult
30+
import com.google.android.gms.common.GooglePlayServicesMissingManifestValueException
31+
import com.google.android.gms.maps.MapsInitializer
32+
import com.google.android.gms.maps.MapsApiSettings
33+
import io.mockk.coEvery
34+
import io.mockk.coVerify
35+
import io.mockk.mockkStatic
36+
import io.mockk.Runs
37+
import io.mockk.just
2938

3039
@OptIn(ExperimentalCoroutinesApi::class)
3140
@RunWith(AndroidJUnit4::class)
@@ -119,4 +128,108 @@ class GoogleMapsInitializerTest {
119128

120129
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.UNINITIALIZED)
121130
}
131+
132+
@Test
133+
fun testInitializeSuccessState() = runTest {
134+
// Arrange
135+
mockkStatic(MapsInitializer::class)
136+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.UNINITIALIZED)
137+
138+
coEvery { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS
139+
140+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
141+
// Act
142+
// Direct call pattern matching original successful test structure
143+
googleMapsInitializer.initialize(context)
144+
145+
// Assert
146+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
147+
coVerify(exactly = 1) { MapsInitializer.initialize(
148+
eq(context),
149+
any(),
150+
any(),
151+
)}
152+
}
153+
154+
@Test
155+
fun testInitializeConcurrentCallsOnlyRunOnce() = runTest {
156+
mockkStatic(MapsInitializer::class)
157+
coEvery { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS
158+
159+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
160+
val job1 = launch { googleMapsInitializer.initialize(context) }
161+
val job2 = launch { googleMapsInitializer.initialize(context) }
162+
163+
job1.join()
164+
job2.join()
165+
166+
// Assert: The actual initialization method should only have been called once
167+
coVerify(exactly = 1) { MapsInitializer.initialize(
168+
eq(context),
169+
any(),
170+
any(),
171+
)}
172+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
173+
}
174+
175+
@Test
176+
fun testInitializeUnrecoverableFailureSetsFailureState() = runTest {
177+
// Arrange
178+
mockkStatic(MapsInitializer::class)
179+
val error = GooglePlayServicesMissingManifestValueException()
180+
181+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
182+
var caughtException: Throwable? = null
183+
184+
coEvery {
185+
MapsInitializer.initialize(
186+
eq(context),
187+
isNull(),
188+
any()
189+
)
190+
} throws error
191+
192+
// Act
193+
val job = launch {
194+
try {
195+
googleMapsInitializer.initialize(context)
196+
} catch (e: GooglePlayServicesMissingManifestValueException) {
197+
caughtException = e
198+
}
199+
}
200+
job.join()
201+
202+
// Assert: The exception was caught, and the state became FAILURE
203+
assertThat(caughtException).isInstanceOf(GooglePlayServicesMissingManifestValueException::class.java)
204+
assertThat(caughtException).isEqualTo(error)
205+
206+
// 2. Assert the state was set to FAILURE
207+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.FAILURE)
208+
}
209+
210+
@Test
211+
fun testInitializeSuccessAlsoSetsAttributionId() = runTest {
212+
// Arrange: Mock MapsApiSettings locally
213+
mockkStatic(MapsInitializer::class, MapsApiSettings::class)
214+
215+
coEvery { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS
216+
coEvery { MapsApiSettings.addInternalUsageAttributionId(any(), any()) } just Runs
217+
218+
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
219+
220+
// Act
221+
// Direct call pattern matching original successful test structure
222+
googleMapsInitializer.initialize(context)
223+
224+
// Assert: Verify both the primary initialization and the attribution call occurred
225+
coVerify(exactly = 1) {
226+
MapsInitializer.initialize(
227+
eq(context),
228+
any(),
229+
any(),
230+
)
231+
}
232+
coVerify(exactly = 1) { MapsApiSettings.addInternalUsageAttributionId(any(), any()) }
233+
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
234+
}
122235
}
Lines changed: 86 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
2-
<!--
1+
<?xml version="1.0" encoding="utf-8"?><!--
32
Copyright 2023 Google LLC
43
54
Licensed under the Apache License, Version 2.0 (the "License");
@@ -17,95 +16,96 @@
1716

1817
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
1918

20-
<uses-permission android:name="android.permission.INTERNET" />
19+
<uses-permission android:name="android.permission.INTERNET" />
2120

22-
<application
23-
android:allowBackup="true"
24-
android:icon="@mipmap/ic_launcher"
25-
android:label="@string/app_name"
26-
android:roundIcon="@mipmap/ic_launcher_round"
27-
android:supportsRtl="true"
28-
android:theme="@style/Theme.AndroidMapsCompose" >
21+
<application
22+
android:name=".MapsComposeApplication"
23+
android:allowBackup="true"
24+
android:icon="@mipmap/ic_launcher"
25+
android:label="@string/app_name"
26+
android:roundIcon="@mipmap/ic_launcher_round"
27+
android:supportsRtl="true"
28+
android:theme="@style/Theme.AndroidMapsCompose">
2929

30-
<meta-data
31-
android:name="com.google.android.geo.API_KEY"
32-
android:value="${MAPS_API_KEY}" />
30+
<meta-data
31+
android:name="com.google.android.geo.API_KEY"
32+
android:value="${MAPS_API_KEY}" />
3333

34-
<activity
35-
android:name=".MainActivity"
36-
android:exported="true">
37-
<intent-filter>
38-
<action android:name="android.intent.action.MAIN" />
39-
<category android:name="android.intent.category.LAUNCHER" />
40-
</intent-filter>
41-
</activity>
34+
<activity
35+
android:name=".MainActivity"
36+
android:exported="true">
37+
<intent-filter>
38+
<action android:name="android.intent.action.MAIN" />
39+
<category android:name="android.intent.category.LAUNCHER" />
40+
</intent-filter>
41+
</activity>
4242

43-
<!--
44-
All activities in this sample are exported. This is for demonstration
45-
purposes, to make it easy to launch each sample activity directly.
43+
<!--
44+
All activities in this sample are exported. This is for demonstration
45+
purposes, to make it easy to launch each sample activity directly.
4646
47-
In a real-world application, you should carefully consider which activities
48-
to export. In most cases, only the main launcher activity should be exported.
49-
Exporting an activity means that any other app on the device can launch it.
50-
-->
51-
52-
<activity
53-
android:name=".BasicMapActivity"
54-
android:exported="true" />
55-
<activity
56-
android:name=".markerexamples.AdvancedMarkersActivity"
57-
android:exported="true"/>
58-
<activity
59-
android:name=".MapInColumnActivity"
60-
android:exported="true"/>
61-
<activity
62-
android:name=".MapsInLazyColumnActivity"
63-
android:exported="true"/>
64-
<activity
65-
android:name=".markerexamples.MarkerClusteringActivity"
66-
android:exported="true"/>
67-
<activity
68-
android:name=".LocationTrackingActivity"
69-
android:exported="true"/>
70-
<activity
71-
android:name=".ScaleBarActivity"
72-
android:exported="true"/>
73-
<activity
74-
android:name=".StreetViewActivity"
75-
android:exported="true"/>
76-
<activity
77-
android:name=".CustomControlsActivity"
78-
android:exported="true"/>
79-
<activity
80-
android:name=".AccessibilityActivity"
81-
android:exported="true"/>
82-
<activity
83-
android:name=".RecompositionActivity"
84-
android:exported="true"/>
85-
<activity
86-
android:name=".FragmentDemoActivity"
87-
android:exported="true"/>
88-
<activity
89-
android:name=".markerexamples.markerdragevents.MarkerDragEventsActivity"
90-
android:exported="true"/>
91-
<activity
92-
android:name=".markerexamples.markerscollection.MarkersCollectionActivity"
93-
android:exported="true"/>
94-
<activity
95-
android:name=".markerexamples.syncingdraggablemarkerwithdatamodel.SyncingDraggableMarkerWithDataModelActivity"
96-
android:exported="true"/>
97-
<activity
98-
android:name=".markerexamples.updatingnodragmarkerwithdatamodel.UpdatingNoDragMarkerWithDataModelActivity"
99-
android:exported="true"/>
100-
<activity
101-
android:name=".markerexamples.draggablemarkerscollectionwithpolygon.DraggableMarkersCollectionWithPolygonActivity"
102-
android:exported="true"/>
103-
<activity
104-
android:name=".TileOverlayActivity"
105-
android:exported="true"/>
47+
In a real-world application, you should carefully consider which activities
48+
to export. In most cases, only the main launcher activity should be exported.
49+
Exporting an activity means that any other app on the device can launch it.
50+
-->
10651

107-
<!-- Used by createComponentActivity() for unit testing -->
108-
<activity android:name="androidx.activity.ComponentActivity" />
52+
<activity
53+
android:name=".BasicMapActivity"
54+
android:exported="true" />
55+
<activity
56+
android:name=".markerexamples.AdvancedMarkersActivity"
57+
android:exported="true" />
58+
<activity
59+
android:name=".MapInColumnActivity"
60+
android:exported="true" />
61+
<activity
62+
android:name=".MapsInLazyColumnActivity"
63+
android:exported="true" />
64+
<activity
65+
android:name=".markerexamples.MarkerClusteringActivity"
66+
android:exported="true" />
67+
<activity
68+
android:name=".LocationTrackingActivity"
69+
android:exported="true" />
70+
<activity
71+
android:name=".ScaleBarActivity"
72+
android:exported="true" />
73+
<activity
74+
android:name=".StreetViewActivity"
75+
android:exported="true" />
76+
<activity
77+
android:name=".CustomControlsActivity"
78+
android:exported="true" />
79+
<activity
80+
android:name=".AccessibilityActivity"
81+
android:exported="true" />
82+
<activity
83+
android:name=".RecompositionActivity"
84+
android:exported="true" />
85+
<activity
86+
android:name=".FragmentDemoActivity"
87+
android:exported="true" />
88+
<activity
89+
android:name=".markerexamples.markerdragevents.MarkerDragEventsActivity"
90+
android:exported="true" />
91+
<activity
92+
android:name=".markerexamples.markerscollection.MarkersCollectionActivity"
93+
android:exported="true" />
94+
<activity
95+
android:name=".markerexamples.syncingdraggablemarkerwithdatamodel.SyncingDraggableMarkerWithDataModelActivity"
96+
android:exported="true" />
97+
<activity
98+
android:name=".markerexamples.updatingnodragmarkerwithdatamodel.UpdatingNoDragMarkerWithDataModelActivity"
99+
android:exported="true" />
100+
<activity
101+
android:name=".markerexamples.draggablemarkerscollectionwithpolygon.DraggableMarkersCollectionWithPolygonActivity"
102+
android:exported="true" />
103+
<activity
104+
android:name=".TileOverlayActivity"
105+
android:exported="true" />
109106

110-
</application>
107+
<!-- Used by createComponentActivity() for unit testing -->
108+
<activity android:name="androidx.activity.ComponentActivity" />
109+
110+
</application>
111111
</manifest>

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
1615
package com.google.maps.android.compose
1716

1817
import android.location.Location
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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
16+
17+
import android.app.Application
18+
import com.google.maps.android.compose.internal.DefaultGoogleMapsInitializer
19+
import kotlinx.coroutines.CoroutineScope
20+
import kotlinx.coroutines.Dispatchers
21+
import kotlinx.coroutines.SupervisorJob
22+
import kotlinx.coroutines.launch
23+
24+
class MapsComposeApplication : Application() {
25+
26+
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
27+
28+
override fun onCreate() {
29+
super.onCreate()
30+
// The DefaultGoogleMapsInitializer is not a singleton, but the Maps SDK is initialized just once.
31+
32+
applicationScope.launch {
33+
DefaultGoogleMapsInitializer().initialize(
34+
context = this@MapsComposeApplication,
35+
forceInitialization = false
36+
)
37+
}
38+
}
39+
40+
}

0 commit comments

Comments
 (0)