Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ agp = "8.13.0"
androidCore = "1.7.0"
androidx-core = "1.17.0"
androidxtest = "1.7.0"
compose-bom = "2025.08.01"
compose-bom = "2025.09.01"
dokka = "2.0.0"
espresso = "3.7.0"
gradleMavenPublishPlugin = "0.34.0"
Expand All @@ -16,7 +16,10 @@ kotlinxCoroutines = "1.10.2"
leakcanaryAndroid = "2.14"
mapsecrets = "2.0.1"
mapsktx = "5.2.0"
material3 = "1.3.2"
material3 = "1.4.0"
materialIconsExtendedAndroid = "1.7.8"
mockk = "1.14.5"
mockkAndroid = "1.14.5"
org-jacoco-core = "0.8.13"
screenshot = "0.0.1-alpha11"
constraintlayout = "2.2.1"
Expand All @@ -30,6 +33,7 @@ androidx-compose-activity = { module = "androidx.activity:activity-compose", ver
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
androidx-compose-material = { module = "androidx.compose.material:material" }
androidx-compose-material-icons-extended-android = { module = "androidx.compose.material:material-icons-extended-android", version.ref = "materialIconsExtendedAndroid" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-preview-tooling = { module = "androidx.compose.ui:ui-tooling-preview" }
Expand All @@ -52,6 +56,8 @@ leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", ve
maps-ktx-std = { module = "com.google.maps.android:maps-ktx", version.ref = "mapsktx" }
maps-ktx-utils = { module = "com.google.maps.android:maps-utils-ktx", version.ref = "mapsktx" }
maps-secrets-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "mapsecrets" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkAndroid" }
org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" }
test-junit = { module = "junit:junit", version.ref = "junit" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
Expand Down
7 changes: 6 additions & 1 deletion maps-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ android {
compose = true
}


kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_1_8)
Expand Down Expand Up @@ -75,6 +74,8 @@ dependencies {
implementation(libs.androidx.compose.ui.preview.tooling)
implementation(libs.androidx.constraintlayout)
implementation(libs.material)
implementation(libs.androidx.compose.material.icons.extended.android)

implementation(libs.screenshot.validation.api)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.leakcanary.android)
Expand Down Expand Up @@ -103,6 +104,10 @@ dependencies {
implementation(project(":maps-compose"))
implementation(project(":maps-compose-widgets"))
implementation(project(":maps-compose-utils"))

testImplementation(libs.mockk)
testImplementation(libs.mockk.android)
testImplementation(kotlin("test"))
}

secrets {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
Expand All @@ -31,7 +31,7 @@ import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
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.DefaultGoogleMapsInitializer
import com.google.maps.android.compose.internal.InitializationState
import kotlinx.coroutines.runBlocking
import org.junit.Before
Expand All @@ -54,12 +54,13 @@ class GoogleMapViewTests {
val countDownLatch = CountDownLatch(1)

val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
val googleMapsInitializer = DefaultGoogleMapsInitializer()

runBlocking {
GoogleMapsInitializer.initialize(appContext)
googleMapsInitializer.initialize(appContext)
}

assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)

composeTestRule.setContent {
GoogleMapView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,31 @@
// 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
package com.google.maps.android.compose.internal

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
import com.google.maps.android.compose.internal.GoogleMapsInitializer
import com.google.maps.android.compose.internal.InitializationState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.milliseconds

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class GoogleMapsInitializerTest {

private val googleMapsInitializer = DefaultGoogleMapsInitializer()

@After
fun tearDown() = runTest {
GoogleMapsInitializer.reset()
googleMapsInitializer.reset()
}

@Test
Expand Down Expand Up @@ -64,11 +67,56 @@ class GoogleMapsInitializerTest {
.build()
)

GoogleMapsInitializer.initialize(context)
googleMapsInitializer.initialize(context)

StrictMode.setThreadPolicy(threadPolicy)
StrictMode.setVmPolicy(vmPolicy)

assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
}

@Test
fun testInitializationCancellationLeavesStateUninitialized() = 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()
)

val job = launch {
googleMapsInitializer.reset()
googleMapsInitializer.initialize(context)
}

// Allow the initialization coroutine to start before we cancel it.
delay(1.milliseconds)
job.cancel()
job.join()

StrictMode.setThreadPolicy(threadPolicy)
StrictMode.setVmPolicy(vmPolicy)

assertThat(GoogleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS)
assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.UNINITIALIZED)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ class AdvancedMarkersActivity : ComponentActivity(), OnMapsSdkInitializedCallbac
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MapsInitializer.initialize(applicationContext, MapsInitializer.Renderer.LATEST, this)
enableEdgeToEdge()
setContent {
// Observing and controlling the camera's state can be done with a CameraPositionState
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// 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
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GooglePlayServicesMissingManifestValueException
import com.google.android.gms.maps.MapsInitializer
import com.google.android.gms.maps.MapsApiSettings
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith

@OptIn(ExperimentalCoroutinesApi::class)
class GoogleMapsInitializerTest {

private val mockContext: Context = mockk(relaxed = true)
private lateinit var testDispatcher: TestDispatcher
private lateinit var googleMapsInitializer: GoogleMapsInitializer

@Before
fun setUp() {
// Mock the static methods we depend on
mockkStatic(MapsInitializer::class)
mockkStatic(MapsApiSettings::class)

// Default happy path behavior for mocks
every { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS
every { MapsApiSettings.addInternalUsageAttributionId(any(), any()) } returns Unit

testDispatcher = UnconfinedTestDispatcher()
googleMapsInitializer = DefaultGoogleMapsInitializer(testDispatcher)
}

@Test
fun `initialize - when coroutine is cancelled - state resets to UNINITIALIZED`() = runTest {
val job = launch {
googleMapsInitializer.initialize(mockContext)
}
job.cancel()
assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value)
}

@Test
fun `initialize - on first call - successfully initializes and state becomes SUCCESS`() = runTest {
// Arrange
assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value)

// Act
googleMapsInitializer.initialize(mockContext)

// Assert
assertEquals(InitializationState.SUCCESS, googleMapsInitializer.state.value)
verify(exactly = 1) { MapsInitializer.initialize(mockContext) }
verify(exactly = 1) { MapsApiSettings.addInternalUsageAttributionId(any(), any()) }
}

@Test
fun `initialize - when called concurrently - initialization only runs once`() = runTest {
// Act: Launch two calls concurrently
val job1 = launch { googleMapsInitializer.initialize(mockContext) }
val job2 = launch { googleMapsInitializer.initialize(mockContext) }

job1.join()
job2.join()

// Assert: The actual initialization method should only have been called once
// thanks to the mutex and double-check logic.
verify(exactly = 1) { MapsInitializer.initialize(mockContext) }
assertEquals(InitializationState.SUCCESS, googleMapsInitializer.state.value)
}

@Test
fun `initialize - on unrecoverable failure - state becomes FAILURE`() = runTest {
// Arrange
val error = GooglePlayServicesMissingManifestValueException()
every { MapsInitializer.initialize(any()) } throws error

// Act
assertFailsWith<GooglePlayServicesMissingManifestValueException> {
googleMapsInitializer.initialize(mockContext)
}

// Assert: The state should be FAILURE and the exception should be re-thrown
assertEquals(InitializationState.FAILURE, googleMapsInitializer.state.value)
}

@Test
fun `initialize - on recoverable failure - state resets to UNINITIALIZED and exception is thrown`() = runTest {
// Arrange
val error = RuntimeException("A network error occurred!")
every { MapsInitializer.initialize(any()) } throws error

// Act & Assert
assertFailsWith<RuntimeException> {
googleMapsInitializer.initialize(mockContext)
}
assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value)
}
}
Loading