diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 012a13e9..4c1c0a26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: distribution: 'temurin' - name: Build modules - run: ./gradlew build jacocoTestReport -x :maps-app:generateDebugScreenshotTestConfig -x :maps-app:testDebugScreenshotTest -x :maps-app:generateReleaseScreenshotTestConfig -x :maps-app:testReleaseScreenshotTest --stacktrace + run: ./gradlew build jacocoTestReport -x :maps-app:generateDebugScreenshotTestConfig -x :maps-app:generateReleaseScreenshotTestConfig --stacktrace - name: Run Screenshot Tests run: ./gradlew :maps-app:validateDebugScreenshotTest diff --git a/.gitignore b/.gitignore index 1590fc53..a77c626a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,4 @@ secrets.properties # This covers new IDEs, like Antigravity .vscode/ -**/bin/ \ No newline at end of file +build-logic/**/bin/ diff --git a/README.md b/README.md index c42ee1e6..ad75ab4d 100644 --- a/README.md +++ b/README.md @@ -497,10 +497,21 @@ The colors of the text, line, and shadow are also all configurable (e.g., based ## Internal usage attribution ID -This library calls the MapsApiSettings.addInternalUsageAttributionId method, which helps Google -understand which libraries and samples are helpful to developers and is optional. Instructions for -opting out of the identifier are provided in -[reference documentation](maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt#L77-L82). +This library calls the `addInternalUsageAttributionId` method, which helps Google understand which libraries and samples are helpful to developers and is optional. Instructions for opting out of the identifier are provided below. + +If you wish to disable this, you can do so by removing the initializer in your `AndroidManifest.xml` using the `tools:node="remove"` attribute: + +```xml + + + +``` ## Contributing diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 0ea4f542..8af5773a 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -7,6 +7,7 @@ import org.gradle.kotlin.dsl.* import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.api.tasks.testing.Test import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport class PublishingConventionPlugin : Plugin { override fun apply(project: Project) { @@ -20,22 +21,40 @@ class PublishingConventionPlugin : Plugin { private fun Project.applyPlugins() { apply(plugin = "com.android.library") - apply(plugin = "com.mxalbert.gradle.jacoco-android") apply(plugin = "org.jetbrains.dokka") + apply(plugin = "org.gradle.jacoco") apply(plugin = "com.vanniktech.maven.publish") } private fun Project.configureJacoco() { configure { - toolVersion = "0.8.7" - + toolVersion = "0.8.11" // Compatible with newer JDKs } - tasks.withType().configureEach { - extensions.configure(JacocoTaskExtension::class.java) { - isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") - } + // AGP 9.0+ built-in Jacoco support or manual configuration. + // We create a "jacocoTestReport" task to match the CI workflow. + + tasks.register("jacocoTestReport") { + // Dependencies + dependsOn("testDebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + // Source directories + val mainSrc = "${layout.projectDirectory}/src/main/java" + sourceDirectories.setFrom(files(mainSrc)) + + // Class directories - we need to point to where Kotlin compiles to + val debugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") + classDirectories.setFrom(files(debugTree)) + + // Execution data from the unit test task + executionData.setFrom(fileTree(layout.buildDirectory.get()) { + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + }) } } diff --git a/build.gradle.kts b/build.gradle.kts index e9c36c88..7e21ee67 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,4 +35,11 @@ allprojects { // {x-release-please-start-version} version = "7.0.0" // {x-release-please-end} +} + +tasks.register("installAndLaunch") { + description = "Installs and launches the demo app." + group = "install" + dependsOn(":maps-app:installDebug") + commandLine("adb", "shell", "am", "start", "-n", "com.google.maps.android.compose/.MainActivity") } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 2c6f44b5..2caeae0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,3 +34,13 @@ android.nonTransitiveRClass=false android.nonFinalResIds=false android.experimental.enableScreenshotTest=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b3f816b..66df61a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,69 +1,96 @@ +# This file is organized into functional groups: Base, Android, Material, Google Maps Platform (GMP), and Testing. +# Please maintain this structure when adding new dependencies or versions. + [versions] -activitycompose = "1.12.1" -agp = "8.13.1" -androidCore = "1.7.0" +# Base +androidCompileSdk = "36" +androidMinSdk = "23" +androidTargetSdk = "36" + +agp = "9.0.0" +dokka = "2.1.0" +gradleMavenPublishPlugin = "0.36.0" +kotlin = "2.3.0" +kotlinxCoroutines = "1.10.2" + +# Android +activitycompose = "1.12.2" androidx-core = "1.17.0" +androidx-startup = "1.2.0" +compose-bom = "2026.01.00" +constraintlayout = "2.2.1" + +# Material +material = "1.13.0" +material3 = "1.4.0" +materialIconsExtendedAndroid = "1.7.8" + +# Google Maps Platform +mapsecrets = "2.0.1" +mapsktx = "6.0.0" + +# Testing +androidCore = "1.7.0" androidxtest = "1.7.0" -compose-bom = "2025.12.00" -dokka = "2.1.0" espresso = "3.7.0" -gradleMavenPublishPlugin = "0.35.0" -jacoco-plugin = "0.2.1" junit = "4.13.2" junitktx = "1.3.0" -kotlin = "2.2.21" -kotlinxCoroutines = "1.10.2" leakcanaryAndroid = "2.14" -mapsecrets = "2.0.1" -mapsktx = "5.2.1" -material3 = "1.4.0" -materialIconsExtendedAndroid = "1.7.8" -mockk = "1.14.6" -mockkAndroid = "1.14.6" +mockk = "1.14.9" org-jacoco-core = "0.8.14" -screenshot = "0.0.1-alpha12" -constraintlayout = "2.2.1" -material = "1.13.0" -robolectric = "4.16" +jacoco-plugin = "0.2.1" +robolectric = "4.16.1" +screenshot = "0.0.1-alpha13" truth = "1.4.5" [libraries] +# Base android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } -androidx-compose-activity = { module = "androidx.activity:activity-compose", version.ref = "activitycompose" } +dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } +gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } +kotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } + +# Android +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activitycompose" } 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" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } + +# Material +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" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +# Google Maps Platform +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" } + +# Testing androidx-test-compose-ui = { module = "androidx.compose.ui:ui-test-junit4" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidCore" } androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } androidx-test-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitktx" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidCore" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxtest" } -dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } -gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } -jacoco-android-plugin = { module = "com.mxalbert.gradle:jacoco-android", version.ref = "jacoco-plugin", version.require = "0.2.1" } -kotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } -kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +jacoco-android-plugin = { module = "com.mxalbert.gradle:jacoco-android", version.ref = "jacoco-plugin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } -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" } +#mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkAndroid" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } 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" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } -screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } +test-junit = { module = "junit:junit", version.ref = "junit" } truth = { module = "com.google.truth:truth", version.ref = "truth" } [plugins] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2a84e188..23449a2b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/maps-app/build.gradle.kts b/maps-app/build.gradle.kts index e437f95a..0654b784 100644 --- a/maps-app/build.gradle.kts +++ b/maps-app/build.gradle.kts @@ -25,11 +25,13 @@ android { } namespace = "com.google.maps.android.compose" - compileSdk = 36 + compileSdk = libs.versions.androidCompileSdk.get().toInt() + + useLibrary("org.apache.http.legacy") defaultConfig { - minSdk = 23 - targetSdk = 36 + minSdk = libs.versions.androidMinSdk.get().toInt() + targetSdk = libs.versions.androidTargetSdk.get().toInt() versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -73,7 +75,7 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.activity) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material3) @@ -84,7 +86,6 @@ dependencies { implementation(libs.material) implementation(libs.androidx.compose.material.icons.extended.android) - screenshotTestImplementation(libs.screenshot.validation.api) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.leakcanary.android) @@ -100,23 +101,24 @@ dependencies { androidTestImplementation(libs.truth) androidTestImplementation(libs.mockk.android) - testImplementation(libs.test.junit) - testImplementation(libs.robolectric) + testImplementation(kotlin("test")) testImplementation(libs.androidx.test.core) - testImplementation(libs.truth) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.test.junit) + testImplementation(libs.truth) + screenshotTestImplementation(kotlin("test")) screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.mockk.android) + screenshotTestImplementation(libs.screenshot.validation.api) // Instead of the lines below, regular apps would load these libraries from Maven according to // the README installation instructions 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 { diff --git a/maps-app/src/androidTest/AndroidManifest.xml b/maps-app/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..6fae52d7 --- /dev/null +++ b/maps-app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt index 318d270a..abdd2a46 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt @@ -31,8 +31,6 @@ 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.DefaultGoogleMapsInitializer -import com.google.maps.android.compose.internal.InitializationState import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule @@ -54,13 +52,7 @@ class GoogleMapViewTests { val countDownLatch = CountDownLatch(1) val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext - val googleMapsInitializer = DefaultGoogleMapsInitializer() - runBlocking { - googleMapsInitializer.initialize(appContext) - } - - assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) composeTestRule.setContent { GoogleMapView( diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt index efe2d22e..fe55c3a5 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt @@ -16,8 +16,10 @@ package com.google.maps.android.compose import android.graphics.Point import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng @@ -44,6 +46,7 @@ class ScaleBarTests { val composeTestRule = createComposeRule() private lateinit var cameraPositionState: CameraPositionState + private lateinit var density: Density private fun initScaleBar(initialZoom: Float, initialPosition: LatLng) { check(hasValidApiKey) { "Maps API key not specified" } @@ -55,6 +58,7 @@ class ScaleBarTests { ) composeTestRule.setContent { + density = LocalDensity.current Box { GoogleMap( cameraPositionState = cameraPositionState, @@ -87,7 +91,9 @@ class ScaleBarTests { val projection = cameraPositionState.projection projection?.let { proj -> val widthInDp = 65.dp - val widthInPixels = widthInDp.value.toInt() + val widthInPixels = with(density) { + widthInDp.toPx().toInt() + } val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0)) val upperRightLatLng = proj.fromScreenLocation(Point(0, widthInPixels)) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt deleted file mode 100644 index 5408badf..00000000 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt +++ /dev/null @@ -1,235 +0,0 @@ -// 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 android.os.StrictMode -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -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 -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.coEvery -import io.mockk.coVerify -import io.mockk.mockkStatic -import io.mockk.Runs -import io.mockk.just - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -class GoogleMapsInitializerTest { - - private val googleMapsInitializer = DefaultGoogleMapsInitializer() - - @After - fun tearDown() = runTest { - googleMapsInitializer.reset() - } - - @Test - fun testInitializationSuccess() = 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() - ) - - 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.UNINITIALIZED) - } - - @Test - fun testInitializeSuccessState() = runTest { - // Arrange - mockkStatic(MapsInitializer::class) - assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.UNINITIALIZED) - - coEvery { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS - - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - // Act - // Direct call pattern matching original successful test structure - googleMapsInitializer.initialize(context) - - // Assert - assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) - coVerify(exactly = 1) { MapsInitializer.initialize( - eq(context), - any(), - any(), - )} - } - - @Test - fun testInitializeConcurrentCallsOnlyRunOnce() = runTest { - mockkStatic(MapsInitializer::class) - coEvery { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS - - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - val job1 = launch { googleMapsInitializer.initialize(context) } - val job2 = launch { googleMapsInitializer.initialize(context) } - - job1.join() - job2.join() - - // Assert: The actual initialization method should only have been called once - coVerify(exactly = 1) { MapsInitializer.initialize( - eq(context), - any(), - any(), - )} - assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) - } - - @Test - fun testInitializeUnrecoverableFailureSetsFailureState() = runTest { - // Arrange - mockkStatic(MapsInitializer::class) - val error = GooglePlayServicesMissingManifestValueException() - - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - var caughtException: Throwable? = null - - coEvery { - MapsInitializer.initialize( - eq(context), - isNull(), - any() - ) - } throws error - - // Act - val job = launch { - try { - googleMapsInitializer.initialize(context) - } catch (e: GooglePlayServicesMissingManifestValueException) { - caughtException = e - } - } - job.join() - - // Assert: The exception was caught, and the state became FAILURE - assertThat(caughtException).isInstanceOf(GooglePlayServicesMissingManifestValueException::class.java) - assertThat(caughtException).isEqualTo(error) - - // 2. Assert the state was set to FAILURE - assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.FAILURE) - } - - @Test - fun testInitializeSuccessAlsoSetsAttributionId() = runTest { - // Arrange: Mock MapsApiSettings locally - mockkStatic(MapsInitializer::class, MapsApiSettings::class) - - coEvery { MapsInitializer.initialize(any()) } returns ConnectionResult.SUCCESS - coEvery { MapsApiSettings.addInternalUsageAttributionId(any(), any()) } just Runs - - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - - // Act - // Direct call pattern matching original successful test structure - googleMapsInitializer.initialize(context) - - // Assert: Verify both the primary initialization and the attribution call occurred - coVerify(exactly = 1) { - MapsInitializer.initialize( - eq(context), - any(), - any(), - ) - } - coVerify(exactly = 1) { MapsApiSettings.addInternalUsageAttributionId(any(), any()) } - assertThat(googleMapsInitializer.state.value).isEqualTo(InitializationState.SUCCESS) - } -} \ No newline at end of file diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index e7af6d8e..5292fb8c 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -19,7 +19,6 @@ + + diff --git a/maps-app/src/main/java/com/google/maps/android/compose/MapsComposeApplication.kt b/maps-app/src/main/java/com/google/maps/android/compose/MapsComposeApplication.kt deleted file mode 100644 index bdeee187..00000000 --- a/maps-app/src/main/java/com/google/maps/android/compose/MapsComposeApplication.kt +++ /dev/null @@ -1,40 +0,0 @@ -// 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 - -import android.app.Application -import com.google.maps.android.compose.internal.DefaultGoogleMapsInitializer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -class MapsComposeApplication : Application() { - - private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - override fun onCreate() { - super.onCreate() - // The DefaultGoogleMapsInitializer is not a singleton, but the Maps SDK is initialized just once. - - applicationScope.launch { - DefaultGoogleMapsInitializer().initialize( - context = this@MapsComposeApplication, - forceInitialization = false - ) - } - } - -} diff --git a/maps-app/src/screenshotTest/AndroidManifest.xml b/maps-app/src/screenshotTest/AndroidManifest.xml index db93b49b..e366e537 100644 --- a/maps-app/src/screenshotTest/AndroidManifest.xml +++ b/maps-app/src/screenshotTest/AndroidManifest.xml @@ -1 +1,10 @@ - \ No newline at end of file + + + + + + diff --git a/maps-app/src/test/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt b/maps-app/src/test/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt deleted file mode 100644 index 33befe93..00000000 --- a/maps-app/src/test/java/com/google/maps/android/compose/internal/GoogleMapsInitializerTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -// 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.maps.MapsInitializer -import com.google.android.gms.maps.MapsApiSettings -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -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() - Dispatchers.setMain(testDispatcher) - 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 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 { - googleMapsInitializer.initialize(mockContext) - } - assertEquals(InitializationState.UNINITIALIZED, googleMapsInitializer.state.value) - } -} diff --git a/maps-compose-utils/build.gradle.kts b/maps-compose-utils/build.gradle.kts index 9cbf3ae9..41f9ccb8 100644 --- a/maps-compose-utils/build.gradle.kts +++ b/maps-compose-utils/build.gradle.kts @@ -13,10 +13,10 @@ android { } namespace = "com.google.maps.android.compose.utils" - compileSdk = 36 + compileSdk = libs.versions.androidCompileSdk.get().toInt() defaultConfig { - minSdk = 23 + minSdk = libs.versions.androidMinSdk.get().toInt() } compileOptions { @@ -34,10 +34,17 @@ android { jvmTarget.set(JvmTarget.JVM_1_8) freeCompilerArgs.addAll( "-Xexplicit-api=strict", - "-Xopt-in=kotlin.RequiresOptIn" + "-opt-in=kotlin.RequiresOptIn" ) } } + + buildTypes { + getByName("debug") { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } } dependencies { diff --git a/maps-compose-widgets/build.gradle.kts b/maps-compose-widgets/build.gradle.kts index 876bb3bd..4330e2da 100644 --- a/maps-compose-widgets/build.gradle.kts +++ b/maps-compose-widgets/build.gradle.kts @@ -20,10 +20,10 @@ android { } namespace = "com.google.maps.android.compose.widgets" - compileSdk = 36 + compileSdk = libs.versions.androidCompileSdk.get().toInt() defaultConfig { - minSdk = 23 + minSdk = libs.versions.androidMinSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -42,10 +42,17 @@ android { jvmTarget.set(JvmTarget.JVM_1_8) freeCompilerArgs.addAll( "-Xexplicit-api=strict", - "-Xopt-in=kotlin.RequiresOptIn" + "-opt-in=kotlin.RequiresOptIn" ) } } + + buildTypes { + getByName("debug") { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } } dependencies { @@ -64,6 +71,6 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.test.espresso) androidTestImplementation(libs.androidx.test.junit.ktx) - androidTestImplementation(libs.mockk) + androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.truth) } diff --git a/maps-compose/build.gradle.kts b/maps-compose/build.gradle.kts index b3872e13..6afbede7 100644 --- a/maps-compose/build.gradle.kts +++ b/maps-compose/build.gradle.kts @@ -13,10 +13,10 @@ android { } namespace = "com.google.maps.android.compose" - compileSdk = 36 + compileSdk = libs.versions.androidCompileSdk.get().toInt() defaultConfig { - minSdk = 23 + minSdk = libs.versions.androidMinSdk.get().toInt() } compileOptions { @@ -34,12 +34,19 @@ android { jvmTarget.set(JvmTarget.JVM_1_8) freeCompilerArgs.addAll( "-Xexplicit-api=strict", - "-Xopt-in=kotlin.RequiresOptIn" + "-opt-in=kotlin.RequiresOptIn" ) } } sourceSets["main"].java.srcDir("build/generated/source/artifactId") + + buildTypes { + getByName("debug") { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } } composeCompiler { @@ -60,6 +67,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.core) implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.startup.runtime) implementation(libs.kotlin) implementation(libs.kotlinx.coroutines.android) api(libs.maps.ktx.std) @@ -76,7 +84,7 @@ val attributionId = "gmp_git_androidmapscompose_v$version" val generateArtifactIdFile = tasks.register("generateArtifactIdFile") { val outputDir = layout.buildDirectory.dir("generated/source/artifactId") - val packageName = "com.google.maps.android.compose.meta" + val packageName = "com.google.maps.android.compose.utils.meta" val packagePath = packageName.replace('.', '/') val outputFile = outputDir.get().file("$packagePath/ArtifactId.kt").asFile diff --git a/maps-compose/src/main/AndroidManifest.xml b/maps-compose/src/main/AndroidManifest.xml index e67f88c1..d503585c 100644 --- a/maps-compose/src/main/AndroidManifest.xml +++ b/maps-compose/src/main/AndroidManifest.xml @@ -15,4 +15,19 @@ limitations under the License. --> - + + + + + + + + + diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index 5c2be9d3..d0266903 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -50,8 +50,7 @@ import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.MapColorScheme import com.google.android.gms.maps.model.PointOfInterest -import com.google.maps.android.compose.internal.InitializationState -import com.google.maps.android.compose.internal.LocalGoogleMapsInitializer + import com.google.maps.android.ktx.awaitMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -113,31 +112,18 @@ public fun GoogleMap( return } - val googleMapsInitializer = LocalGoogleMapsInitializer.current - val initializationState by googleMapsInitializer.state - - if (initializationState != InitializationState.SUCCESS) { - val context = LocalContext.current - LaunchedEffect(Unit) { - // Coroutine to initialize Google Maps SDK. - // This will run once when the composable is first displayed. - googleMapsInitializer.initialize(context) - } + // rememberUpdatedState and friends are used here to make these values observable to + // the subcomposition without providing a new content function each recomposition + val mapClickListeners = remember { MapClickListeners() }.also { + it.indoorStateChangeListener = indoorStateChangeListener + it.onMapClick = onMapClick + it.onMapLongClick = onMapLongClick + it.onMapLoaded = onMapLoaded + it.onMyLocationButtonClick = onMyLocationButtonClick + it.onMyLocationClick = onMyLocationClick + it.onPOIClick = onPOIClick } - if (initializationState == InitializationState.SUCCESS) { - // rememberUpdatedState and friends are used here to make these values observable to - // the subcomposition without providing a new content function each recomposition - val mapClickListeners = remember { MapClickListeners() }.also { - it.indoorStateChangeListener = indoorStateChangeListener - it.onMapClick = onMapClick - it.onMapLongClick = onMapLongClick - it.onMapLoaded = onMapLoaded - it.onMyLocationButtonClick = onMyLocationButtonClick - it.onMyLocationClick = onMyLocationClick - it.onPOIClick = onPOIClick - } - val mapUpdaterState = remember { MapUpdaterState( mergeDescendants, @@ -228,7 +214,6 @@ public fun GoogleMap( ) } }) - } } /** diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt b/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt deleted file mode 100644 index 6107f68c..00000000 --- a/maps-compose/src/main/java/com/google/maps/android/compose/internal/GoogleMapsInitializer.kt +++ /dev/null @@ -1,195 +0,0 @@ -// 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 android.os.StrictMode -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.State -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.mutableStateOf -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.maps.MapsInitializer -import com.google.android.gms.maps.MapsApiSettings -import com.google.maps.android.compose.meta.AttributionId -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext - -/** - * Enum representing the initialization state of the Google Maps SDK. - */ -public enum class InitializationState { - /** - * The SDK has not been initialized. - */ - UNINITIALIZED, - - /** - * The SDK is currently being initialized. - */ - INITIALIZING, - - /** - * The SDK has been successfully initialized. - */ - SUCCESS, - - /** - * The SDK initialization failed. - */ - FAILURE -} - -/** - * A singleton object to manage the initialization of the Google Maps SDK. - * - * This object provides a state machine to track the initialization process and ensures that - * the initialization is performed only once. It also provides a mechanism to reset the - * initialization state, which can be useful in test environments. - * - * The initialization process consists of two main steps: - * 1. Calling `MapsInitializer.initialize(context)` to initialize the Google Maps SDK. - * 2. Calling `MapsApiSettings.addInternalUsageAttributionId(context, attributionId)` to add - * the library's attribution ID to the Maps API settings. - * - * The state of the initialization is exposed via the `state` property, which is a [State] object - * that can be observed for changes. - */ -public interface GoogleMapsInitializer { - public val state: State - - /** - * The value of the attribution ID. Set this to the empty string to opt out of attribution. - * - * This must be set before calling the `initialize` function. - */ - public var attributionId: String - - /** - * Initializes Google Maps. This function must be called before using any other - * functions in this library. - * - * If initialization fails with a recoverable error (e.g., a network issue), - * the state will be reset to [InitializationState.UNINITIALIZED], allowing for a - * subsequent retry. In the case of an unrecoverable error (e.g., a missing - * manifest value), the state will be set to [InitializationState.FAILURE] and the - * original exception will be re-thrown. - * - * @param context The context to use for initialization. - * @param forceInitialization When true, initialization will be attempted even if it - * has already succeeded or is in progress. This is useful for retrying a - * previously failed initialization. - */ - public suspend fun initialize( - context: Context, - forceInitialization: Boolean = false, - ) - - /** - * Resets the initialization state. - * - * This function cancels any ongoing initialization and resets the state to `UNINITIALIZED`. - * This is primarily useful in test environments where the SDK might need to be - * re-initialized multiple times. - */ - public suspend fun reset() -} - -/** - * The default implementation of [GoogleMapsInitializer]. - * - * @param ioDispatcher The dispatcher to use for IO operations. - */ -public class DefaultGoogleMapsInitializer( - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, -) : GoogleMapsInitializer { - private val _state = mutableStateOf(InitializationState.UNINITIALIZED) - override val state: State = _state - - private val mutex = Mutex() - - override var attributionId: String = AttributionId.VALUE - - override suspend fun initialize( - context: Context, - forceInitialization: Boolean, - ) { - try { - if (!forceInitialization && - (_state.value == InitializationState.INITIALIZING || _state.value == InitializationState.SUCCESS) - ) { - return - } - - mutex.withLock { - if (_state.value != InitializationState.UNINITIALIZED) { - return - } - _state.value = InitializationState.INITIALIZING - } - - withContext(mainDispatcher) { - val scope = this - - val policy = StrictMode.getThreadPolicy() - try { - StrictMode.allowThreadDiskReads() - val result = MapsInitializer.initialize(context, null) { - scope.launch(ioDispatcher) { - MapsApiSettings.addInternalUsageAttributionId(context, attributionId) - _state.value = InitializationState.SUCCESS - } - } - - if (result != ConnectionResult.SUCCESS) { - _state.value = InitializationState.FAILURE - } - } finally { - StrictMode.setThreadPolicy(policy) - } - } - } catch (e: com.google.android.gms.common.GooglePlayServicesMissingManifestValueException) { - // This is an unrecoverable error. Play Services is not available (could be a test?) - // Set the state to FAILURE to prevent further attempts. - _state.value = InitializationState.FAILURE - throw e - } catch (e: Exception) { - // This could be a transient error. - // Reset to UNINITIALIZED to allow for a retry. - _state.value = InitializationState.UNINITIALIZED - throw e - } - } - - override suspend fun reset() { - mutex.withLock { - _state.value = InitializationState.UNINITIALIZED - } - } -} - -/** - * CompositionLocal that provides a [GoogleMapsInitializer]. - */ -public val LocalGoogleMapsInitializer: ProvidableCompositionLocal = - compositionLocalOf { - // Default implementation of the initializer - DefaultGoogleMapsInitializer() - } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/utils/attribution/AttributionIdInitializer.kt b/maps-compose/src/main/java/com/google/maps/android/compose/utils/attribution/AttributionIdInitializer.kt new file mode 100644 index 00000000..d4638371 --- /dev/null +++ b/maps-compose/src/main/java/com/google/maps/android/compose/utils/attribution/AttributionIdInitializer.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 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.utils.attribution + +import android.content.Context +import androidx.startup.Initializer +import com.google.android.gms.maps.MapsApiSettings +import com.google.maps.android.compose.utils.meta.AttributionId + +/** + * Adds a usage attribution ID to the initializer, which helps Google understand which libraries + * and samples are helpful to developers, such as usage of this library. + * To opt out of sending the usage attribution ID, please remove this initializer from your manifest. + */ +internal class AttributionIdInitializer : Initializer { + override fun create(context: Context) { + MapsApiSettings.addInternalUsageAttributionId( + /* context = */ context, + /* internalUsageAttributionId = */ AttributionId.VALUE + ) + } + + override fun dependencies(): List>> { + return emptyList() + } +}