From 6dd161d6290d32133926e6ad2307b161794b08b4 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 16:42:42 -0600
Subject: [PATCH 01/29] feat: Implement Maps 3D Compose wrapper, sample
catalog, and visual testing
---
maps3d-compose-demo/build.gradle.kts | 100 ++++
.../maps3dcomposedemo/BaseVisualTest.kt | 66 +++
.../maps3dcomposedemo/Maps3DVisualTest.kt | 71 +++
.../src/main/AndroidManifest.xml | 40 ++
.../example/maps3dcomposedemo/MainActivity.kt | 128 +++++
.../maps3dcomposedemo/SampleScreens.kt | 206 ++++++++
.../src/main/res/values/strings.xml | 19 +
maps3d-compose/build.gradle.kts | 64 +++
.../maps/android/compose3d/DataModels.kt | 84 +++
.../maps/android/compose3d/GoogleMap3D.kt | 132 +++++
.../maps/android/compose3d/Map3DRegistry.kt | 71 +++
.../maps/android/compose3d/Map3DState.kt | 253 +++++++++
.../android/compose3d/utils/CameraUpdate.kt | 141 +++++
.../android/compose3d/utils/GeoMathUtils.kt | 63 +++
.../maps/android/compose3d/utils/Units.kt | 180 +++++++
.../maps/android/compose3d/utils/Utilities.kt | 486 ++++++++++++++++++
.../src/main/res/values/strings.xml | 22 +
settings.gradle.kts | 7 +
visual-testing/build.gradle.kts | 72 +++
visual-testing/consumer-rules.pro | 0
visual-testing/proguard-rules.pro | 0
visual-testing/src/main/AndroidManifest.xml | 20 +
.../visualtesting/GeminiVisualTestHelper.kt | 233 +++++++++
.../visualtesting/PlaceholderTest.java | 27 +
24 files changed, 2485 insertions(+)
create mode 100644 maps3d-compose-demo/build.gradle.kts
create mode 100644 maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
create mode 100644 maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
create mode 100644 maps3d-compose-demo/src/main/AndroidManifest.xml
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
create mode 100644 maps3d-compose-demo/src/main/res/values/strings.xml
create mode 100644 maps3d-compose/build.gradle.kts
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
create mode 100644 maps3d-compose/src/main/res/values/strings.xml
create mode 100644 visual-testing/build.gradle.kts
create mode 100644 visual-testing/consumer-rules.pro
create mode 100644 visual-testing/proguard-rules.pro
create mode 100644 visual-testing/src/main/AndroidManifest.xml
create mode 100644 visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt
create mode 100644 visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java
diff --git a/maps3d-compose-demo/build.gradle.kts b/maps3d-compose-demo/build.gradle.kts
new file mode 100644
index 00000000..8b38d494
--- /dev/null
+++ b/maps3d-compose-demo/build.gradle.kts
@@ -0,0 +1,100 @@
+/*
+ * 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.
+ */
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.secrets.gradle.plugin)
+}
+
+android {
+ namespace = "com.example.maps3dcomposedemo"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "com.example.maps3dcomposedemo"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(project(":maps3d-compose"))
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+
+ // Maps 3D SDK
+ implementation(libs.play.services.maps3d)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ androidTestImplementation(libs.androidx.uiautomator)
+ androidTestImplementation(libs.kotlinx.serialization.json)
+ androidTestImplementation(project(":visual-testing"))
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
+
+secrets {
+ propertiesFileName = "secrets.properties"
+}
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
new file mode 100644
index 00000000..e72b4225
--- /dev/null
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.app.Instrumentation
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import com.google.maps.android.visualtesting.GeminiVisualTestHelper
+import org.junit.Assert.assertTrue
+import java.io.File
+
+abstract class BaseVisualTest {
+
+ protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
+ protected val uiDevice = UiDevice.getInstance(instrumentation)
+ protected val context: Context = instrumentation.targetContext
+ protected val helper = GeminiVisualTestHelper()
+
+ protected val geminiApiKey: String by lazy {
+ val key = BuildConfig.GEMINI_API_KEY
+ assertTrue(
+ "GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.",
+ key != "YOUR_GEMINI_API_KEY"
+ )
+ key
+ }
+
+ protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap {
+ val screenshotFile = File(context.cacheDir, filename)
+ val screenshotTaken = uiDevice.takeScreenshot(screenshotFile)
+ assertTrue("Failed to take screenshot: $filename", screenshotTaken)
+
+ val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath)
+ assertTrue("Failed to decode screenshot file: $filename", bitmap != null)
+ return bitmap
+ }
+
+ /**
+ * Waits for the map to render.
+ * Since MapView content (tiles, markers) is rendered on a GL surface and not exposed as
+ * accessibility nodes, we cannot rely on UiAutomator looking for text/markers.
+ * We use a stable delay to ensure rendering is complete.
+ */
+ protected fun waitForMapRendering(timeoutSeconds: Long = 30) {
+ val found = uiDevice.wait(Until.hasObject(By.desc("MapSteady")), timeoutSeconds * 1000)
+ assertTrue("Map did not become steady within ${timeoutSeconds} seconds", found)
+ }
+}
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
new file mode 100644
index 00000000..d98c0e8a
--- /dev/null
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class Maps3DVisualTest : BaseVisualTest() {
+
+ @Before
+ fun setup() {
+ // Launch the app
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ context.startActivity(intent)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+ }
+
+ @Test
+ fun verifyCatalogListRenders() = runBlocking {
+ // Wait for the list to appear
+ val found = uiDevice.wait(Until.hasObject(By.text("Maps 3D Compose Samples")), 5000)
+ assertTrue("Catalog list title not found", found)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("catalog_list_screenshot.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Check the image against the following criteria:
+ 1. Confirm that the title 'Maps 3D Compose Samples' is visible.
+ 2. Confirm that there are multiple list items visible (e.g., "Basic Map with Marker & Polyline", "Hello Map", etc.).
+
+ If all elements are present and look reasonable for a list of samples, reply with "PASSED".
+ If any element is missing or incorrect, please detail the discrepancy.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
+}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..51f09bd1
--- /dev/null
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
new file mode 100644
index 00000000..d378ea85
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ CatalogScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CatalogScreen() {
+ var selectedSample by remember { mutableStateOf(null) }
+
+ if (selectedSample == null) {
+ LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
+ item {
+ Text(
+ text = "Maps 3D Compose Samples",
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ item { SampleItem("Basic Map with Marker & Polyline") { selectedSample = "basic" } }
+ item { SampleItem("Hello Map") { selectedSample = "hello" } }
+ item { SampleItem("Camera Controls") { selectedSample = "camera" } }
+ item { SampleItem("Map Interactions") { selectedSample = "interactions" } }
+ item { SampleItem("Markers") { selectedSample = "markers" } }
+ item { SampleItem("Models") { selectedSample = "models" } }
+ item { SampleItem("Polygons") { selectedSample = "polygons" } }
+ item { SampleItem("Polylines") { selectedSample = "polylines" } }
+ item { SampleItem("Popovers") { selectedSample = "popovers" } }
+ }
+ } else {
+ Box(modifier = Modifier.fillMaxSize()) {
+ when (selectedSample) {
+ "basic" -> BasicMapSample()
+ "hello" -> HelloMapSample()
+ "camera" -> CameraControlsSample()
+ "interactions" -> MapInteractionsSample()
+ "markers" -> MarkersSample()
+ "models" -> ModelsSample()
+ "polygons" -> PolygonsSample()
+ "polylines" -> PolylinesSample()
+ "popovers" -> PopoversSample()
+ }
+
+ FloatingActionButton(
+ onClick = { selectedSample = null },
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .statusBarsPadding()
+ ) {
+ Text("Back")
+ }
+ }
+ }
+}
+
+@Composable
+fun SampleItem(title: String, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .clickable { onClick() },
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
new file mode 100644
index 00000000..99b598c4
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
@@ -0,0 +1,206 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.graphics.Color
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.MarkerConfig
+import com.google.maps.android.compose3d.PolylineConfig
+
+@Composable
+fun BasicMapSample() {
+ // Hoist the camera state
+ var cameraState by remember {
+ mutableStateOf(
+ camera {
+ center = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 0.0
+ }
+ heading = 0.0
+ tilt = 60.0
+ range = 2000.0
+ roll = 0.0
+ }
+ )
+ }
+
+ var mapMode by remember { mutableStateOf(Map3DMode.SATELLITE) }
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Sample markers
+ val markers = remember {
+ listOf(
+ MarkerConfig(
+ key = "golden_gate",
+ position = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 0.0
+ },
+ label = "Golden Gate Bridge",
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ )
+ )
+ }
+
+ // Sample polyline
+ val polylines = remember {
+ listOf(
+ PolylineConfig(
+ key = "sample_line",
+ points = listOf(
+ latLngAltitude { latitude = 37.8199; longitude = -122.4783; altitude = 0.0 },
+ latLngAltitude { latitude = 37.8299; longitude = -122.4883; altitude = 0.0 }
+ ),
+ color = Color.RED,
+ width = 10f,
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ )
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxSize().semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }) {
+ GoogleMap3D(
+ camera = cameraState,
+ markers = markers,
+ polylines = polylines,
+ mapMode = mapMode,
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = {
+ // Map is ready, we could perform additional setup here if needed
+ },
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+
+ // UI Controls
+ FloatingActionButton(
+ onClick = {
+ mapMode = if (mapMode == Map3DMode.SATELLITE) {
+ Map3DMode.HYBRID
+ } else {
+ Map3DMode.SATELLITE
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ ) {
+ Text(text = if (mapMode == Map3DMode.SATELLITE) "Hybrid" else "Satellite")
+ }
+
+ FloatingActionButton(
+ onClick = {
+ // Reset camera
+ cameraState = camera {
+ center = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 0.0
+ }
+ heading = 0.0
+ tilt = 60.0
+ range = 2000.0
+ roll = 0.0
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(16.dp)
+ ) {
+ Text(text = "Reset")
+ }
+ }
+}
+
+@Composable
+fun HelloMapSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Hello Map Sample Placeholder\nTODO: Implement basic map initialization.")
+ }
+}
+
+@Composable
+fun CameraControlsSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Camera Controls Sample Placeholder\nTODO: Implement camera movements and animations.")
+ }
+}
+
+@Composable
+fun MapInteractionsSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Map Interactions Sample Placeholder\nTODO: Implement click listeners and UI interactions.")
+ }
+}
+
+@Composable
+fun MarkersSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Markers Sample Placeholder\nTODO: Implement adding and customizing 3D markers.")
+ }
+}
+
+@Composable
+fun ModelsSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Models Sample Placeholder\nTODO: Implement loading 3D models (glTF).")
+ }
+}
+
+@Composable
+fun PolygonsSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Polygons Sample Placeholder\nTODO: Implement drawing polygons.")
+ }
+}
+
+@Composable
+fun PolylinesSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Polylines Sample Placeholder\nTODO: Implement drawing polylines.")
+ }
+}
+
+@Composable
+fun PopoversSample() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Popovers Sample Placeholder\nTODO: Implement showing interactive popovers.")
+ }
+}
diff --git a/maps3d-compose-demo/src/main/res/values/strings.xml b/maps3d-compose-demo/src/main/res/values/strings.xml
new file mode 100644
index 00000000..62ed188a
--- /dev/null
+++ b/maps3d-compose-demo/src/main/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Maps 3D Compose Demo
+
diff --git a/maps3d-compose/build.gradle.kts b/maps3d-compose/build.gradle.kts
new file mode 100644
index 00000000..e3d305c1
--- /dev/null
+++ b/maps3d-compose/build.gradle.kts
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.google.maps.android.compose3d"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+
+ // Compose
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+
+ // Maps 3D SDK
+ implementation(libs.play.services.maps3d)
+ implementation(libs.maps.utils.ktx)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
new file mode 100644
index 00000000..26dea034
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.compose3d
+
+import android.view.View
+import androidx.compose.runtime.Immutable
+import com.google.android.gms.maps3d.model.ImageView
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.CollisionBehavior
+import com.google.android.gms.maps3d.model.LatLngAltitude
+
+/**
+ * Data class representing a Marker to be added to the 3D map.
+ */
+@Immutable
+data class MarkerConfig(
+ val key: String,
+ val position: LatLngAltitude,
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
+ val styleView: ImageView? = null,
+ val label: String = "",
+ val zIndex: Int = 0,
+ val isExtruded: Boolean = false,
+ val isDrawnWhenOccluded: Boolean = false,
+ val collisionBehavior: Int = CollisionBehavior.REQUIRED
+)
+
+/**
+ * Data class representing a Polyline to be added to the 3D map.
+ */
+@Immutable
+data class PolylineConfig(
+ val key: String,
+ val points: List,
+ val color: Int,
+ val width: Float,
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
+ val zIndex: Int = 0,
+ val outerColor: Int = 0,
+ val outerWidth: Float = 0f
+)
+
+/**
+ * Data class representing a Polygon to be added to the 3D map.
+ */
+@Immutable
+data class PolygonConfig(
+ val key: String,
+ val path: List,
+ val innerPaths: List> = emptyList(),
+ val fillColor: Int,
+ val strokeColor: Int,
+ val strokeWidth: Float,
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND
+)
+
+/**
+ * Data class representing a 3D Model to be added to the 3D map.
+ */
+@Immutable
+data class ModelConfig(
+ val key: String,
+ val position: LatLngAltitude,
+ val url: String,
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
+ val scale: Float = 1.0f,
+ val heading: Double = 0.0,
+ val tilt: Double = 0.0,
+ val roll: Double = 0.0
+)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
new file mode 100644
index 00000000..00a7b970
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.compose3d
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.Map3DOptions
+import com.google.android.gms.maps3d.Map3DView
+import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.CameraRestriction
+import com.google.android.gms.maps3d.model.Map3DMode
+
+/**
+ * A declarative Compose wrapper for the Google Maps 3D SDK [Map3DView].
+ *
+ * This composable allows you to display a 3D map and control it using standard Compose state.
+ * It handles the underlying view lifecycle and synchronizes state objects (markers, polylines,
+ * polygons, and models) with the imperative SDK instance.
+ *
+ * Literate Programming Note: Initialization in the Maps 3D SDK is tricky. We cannot rely solely
+ * on `getMap3DViewAsync`. We must wait for `setOnMapReadyListener` to fire before adding any
+ * content, otherwise additions might be ignored. Furthermore, this listener only fires ONCE
+ * in the lifetime of the application. To handle this, we track readiness globally in
+ * [Map3DRegistry] and defer all state updates until we are certain the map is ready.
+ *
+ * @param camera The hoisted camera state to apply to the map.
+ * @param markers The list of markers to display on the map.
+ * @param polylines The list of polylines to display on the map.
+ * @param polygons The list of polygons to display on the map.
+ * @param models The list of 3D models to display on the map.
+ * @param cameraRestriction The camera restriction to apply to the map.
+ * @param mapMode The map mode (e.g., SATELLITE, HYBRID).
+ * @param modifier The modifier to apply to the layout.
+ * @param options The options to initialize the [Map3DView] with.
+ * @param onMapReady Optional callback invoked when the [GoogleMap3D] instance is ready.
+ */
+@Composable
+fun GoogleMap3D(
+ camera: Camera,
+ markers: List = emptyList(),
+ polylines: List = emptyList(),
+ polygons: List = emptyList(),
+ models: List = emptyList(),
+ cameraRestriction: CameraRestriction? = null,
+ @Map3DMode mapMode: Int = Map3DMode.SATELLITE,
+ modifier: Modifier = Modifier,
+ options: Map3DOptions = Map3DOptions(),
+ onMapReady: (GoogleMap3D) -> Unit = {},
+ onMapSteady: () -> Unit = {}
+) {
+ val state = remember { Map3DState() }
+ val hasCalledOnMapReady = remember { mutableStateOf(false) }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ val map3dView = Map3DView(context, options)
+ map3dView.onCreate(null)
+ map3dView
+ },
+ update = { map3dView ->
+ map3dView.getMap3DViewAsync(object : OnMap3DViewReadyCallback {
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ Map3DRegistry.setInstance(googleMap3D)
+
+ googleMap3D.setOnMapSteadyListener { isSteady ->
+ if (isSteady) {
+ onMapSteady()
+ }
+ }
+
+ fun applyUpdates() {
+ if (!hasCalledOnMapReady.value) {
+ onMapReady(googleMap3D)
+ hasCalledOnMapReady.value = true
+ }
+
+ // Sync hoisted state with the imperative map instance
+ googleMap3D.setCamera(camera)
+ googleMap3D.setCameraRestriction(cameraRestriction)
+ googleMap3D.setMapMode(mapMode)
+
+ state.syncMarkers(googleMap3D, markers)
+ state.syncPolylines(googleMap3D, polylines)
+ state.syncPolygons(googleMap3D, polygons)
+ state.syncModels(googleMap3D, models)
+ }
+
+ if (Map3DRegistry.isMapReady) {
+ // Map was already ready (e.g. reused instance), apply updates immediately
+ applyUpdates()
+ } else {
+ // First time initialization, must wait for listener
+ googleMap3D.setOnMapReadyListener {
+ googleMap3D.setOnMapReadyListener(null) // Clear it immediately
+ Map3DRegistry.markReady()
+ applyUpdates()
+ }
+ }
+ }
+
+ override fun onError(error: Exception) {
+ throw error
+ }
+ })
+ },
+ onRelease = { map3dView ->
+ state.clear()
+ Map3DRegistry.clearInstance()
+ map3dView.onDestroy()
+ }
+ )
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt
new file mode 100644
index 00000000..cc7590e5
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.compose3d
+
+import com.google.android.gms.maps3d.GoogleMap3D
+
+/**
+ * A global registry to manage the single instance of [GoogleMap3D] provided by the SDK.
+ *
+ * The Maps 3D SDK typically creates and reuses a single [GoogleMap3D] instance across
+ * multiple [com.google.android.gms.maps3d.Map3DView] instances. To adhere to Compose
+ * design principles and avoid passing this imperative object through ViewModels, this
+ * registry holds the instance and provides access to it for state synchronization.
+ *
+ * We also track whether the map has ever been "ready" via `OnMapReadyListener`,
+ * as the SDK only triggers this once per application lifetime.
+ */
+object Map3DRegistry {
+ private var mapInstance: GoogleMap3D? = null
+
+ /**
+ * Tracks whether the map has been initialized and is ready for content.
+ * The SDK only calls `OnMapReadyListener` once.
+ */
+ var isMapReady: Boolean = false
+ internal set
+
+ /**
+ * Sets the [GoogleMap3D] instance. This should be called when the map is ready
+ * in the [GoogleMap3D] composable.
+ */
+ fun setInstance(map: GoogleMap3D) {
+ mapInstance = map
+ }
+
+ /**
+ * Retrieves the current [GoogleMap3D] instance, if available.
+ */
+ fun getInstance(): GoogleMap3D? = mapInstance
+
+ /**
+ * Clears the instance. This should be called when the map view is destroyed or
+ * no longer needed to prevent potential leaks.
+ */
+ fun clearInstance() {
+ mapInstance = null
+ // We do not reset isMapReady here, as the underlying SDK instance might still be ready
+ // even if we detach from a specific view.
+ }
+
+ /**
+ * Marks the map as ready. Called when the `OnMapReadyListener` fires.
+ */
+ fun markReady() {
+ isMapReady = true
+ }
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
new file mode 100644
index 00000000..85a256d4
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -0,0 +1,253 @@
+/*
+ * 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.compose3d
+
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.model.Hole
+import com.google.android.gms.maps3d.model.Marker
+import com.google.android.gms.maps3d.model.Polyline
+import com.google.android.gms.maps3d.model.Polygon
+import com.google.android.gms.maps3d.model.Model
+import com.google.android.gms.maps3d.model.markerOptions
+import com.google.android.gms.maps3d.model.polylineOptions
+import com.google.android.gms.maps3d.model.polygonOptions
+import com.google.android.gms.maps3d.model.modelOptions
+import com.google.android.gms.maps3d.model.vector3D
+import com.google.android.gms.maps3d.model.orientation
+
+/**
+ * Internal state holder for the Maps 3D Compose library.
+ *
+ * This class maintains the mapping between user-provided configuration keys and the
+ * actual SDK objects created on the map. Since the SDK objects are largely immutable
+ * or do not support property updates in place, this class recreates them when their
+ * configuration changes.
+ */
+class Map3DState {
+ private val markers = mutableMapOf>()
+ private val polylines = mutableMapOf>()
+ private val polygons = mutableMapOf>()
+ private val models = mutableMapOf>()
+
+ /**
+ * Synchronizes the markers on the map with the provided list of configurations.
+ */
+ fun syncMarkers(map: GoogleMap3D, markerConfigs: List) {
+ val keysToRemove = markers.keys.toMutableSet()
+
+ markerConfigs.forEach { config ->
+ keysToRemove.remove(config.key)
+ val existing = markers[config.key]
+
+ if (existing != null) {
+ val (oldConfig, marker) = existing
+ if (oldConfig != config) {
+ // Config changed, recreate
+ marker.remove()
+ val newMarker = createMarker(map, config)
+ if (newMarker != null) {
+ markers[config.key] = Pair(config, newMarker)
+ }
+ }
+ } else {
+ // New marker
+ val newMarker = createMarker(map, config)
+ if (newMarker != null) {
+ markers[config.key] = Pair(config, newMarker)
+ }
+ }
+ }
+
+ keysToRemove.forEach { key ->
+ markers[key]?.second?.remove()
+ markers.remove(key)
+ }
+ }
+
+ private fun createMarker(map: GoogleMap3D, config: MarkerConfig): Marker? {
+ return map.addMarker(markerOptions {
+ position = config.position
+ altitudeMode = config.altitudeMode
+ config.styleView?.let { setStyle(it) }
+ label = config.label
+ zIndex = config.zIndex
+ isExtruded = config.isExtruded
+ isDrawnWhenOccluded = config.isDrawnWhenOccluded
+ collisionBehavior = config.collisionBehavior
+ })
+ }
+
+ /**
+ * Synchronizes the polylines on the map with the provided list of configurations.
+ */
+ fun syncPolylines(map: GoogleMap3D, polylineConfigs: List) {
+ val keysToRemove = polylines.keys.toMutableSet()
+
+ polylineConfigs.forEach { config ->
+ keysToRemove.remove(config.key)
+ val existing = polylines[config.key]
+
+ if (existing != null) {
+ val (oldConfig, polyline) = existing
+ if (oldConfig != config) {
+ // Config changed, recreate
+ polyline.remove()
+ val newPolyline = createPolyline(map, config)
+ if (newPolyline != null) {
+ polylines[config.key] = Pair(config, newPolyline)
+ }
+ }
+ } else {
+ // New polyline
+ val newPolyline = createPolyline(map, config)
+ if (newPolyline != null) {
+ polylines[config.key] = Pair(config, newPolyline)
+ }
+ }
+ }
+
+ keysToRemove.forEach { key ->
+ polylines[key]?.second?.remove()
+ polylines.remove(key)
+ }
+ }
+
+ private fun createPolyline(map: GoogleMap3D, config: PolylineConfig): Polyline? {
+ return map.addPolyline(polylineOptions {
+ this.path = config.points
+ strokeColor = config.color
+ strokeWidth = config.width.toDouble()
+ altitudeMode = config.altitudeMode
+ zIndex = config.zIndex
+ outerColor = config.outerColor
+ outerWidth = config.outerWidth.toDouble()
+ })
+ }
+
+ /**
+ * Synchronizes the polygons on the map with the provided list of configurations.
+ */
+ fun syncPolygons(map: GoogleMap3D, polygonConfigs: List) {
+ val keysToRemove = polygons.keys.toMutableSet()
+
+ polygonConfigs.forEach { config ->
+ keysToRemove.remove(config.key)
+ val existing = polygons[config.key]
+
+ if (existing != null) {
+ val (oldConfig, polygon) = existing
+ if (oldConfig != config) {
+ // Config changed, recreate
+ polygon.remove()
+ val newPolygon = createPolygon(map, config)
+ if (newPolygon != null) {
+ polygons[config.key] = Pair(config, newPolygon)
+ }
+ }
+ } else {
+ // New polygon
+ val newPolygon = createPolygon(map, config)
+ if (newPolygon != null) {
+ polygons[config.key] = Pair(config, newPolygon)
+ }
+ }
+ }
+
+ keysToRemove.forEach { key ->
+ polygons[key]?.second?.remove()
+ polygons.remove(key)
+ }
+ }
+
+ private fun createPolygon(map: GoogleMap3D, config: PolygonConfig): Polygon? {
+ return map.addPolygon(polygonOptions {
+ this.path = config.path
+ innerPaths = config.innerPaths.map { Hole(it) }
+ fillColor = config.fillColor
+ strokeColor = config.strokeColor
+ strokeWidth = config.strokeWidth.toDouble()
+ altitudeMode = config.altitudeMode
+ })
+ }
+
+ /**
+ * Synchronizes the 3D models on the map with the provided list of configurations.
+ */
+ fun syncModels(map: GoogleMap3D, modelConfigs: List) {
+ val keysToRemove = models.keys.toMutableSet()
+
+ modelConfigs.forEach { config ->
+ keysToRemove.remove(config.key)
+ val existing = models[config.key]
+
+ if (existing != null) {
+ val (oldConfig, model) = existing
+ if (oldConfig != config) {
+ // Config changed, recreate
+ model.remove()
+ val newModel = createModel(map, config)
+ if (newModel != null) {
+ models[config.key] = Pair(config, newModel)
+ }
+ }
+ } else {
+ // New model
+ val newModel = createModel(map, config)
+ if (newModel != null) {
+ models[config.key] = Pair(config, newModel)
+ }
+ }
+ }
+
+ keysToRemove.forEach { key ->
+ models[key]?.second?.remove()
+ models.remove(key)
+ }
+ }
+
+ private fun createModel(map: GoogleMap3D, config: ModelConfig): Model? {
+ return map.addModel(modelOptions {
+ position = config.position
+ url = config.url
+ altitudeMode = config.altitudeMode
+ scale = vector3D {
+ x = config.scale.toDouble()
+ y = config.scale.toDouble()
+ z = config.scale.toDouble()
+ }
+ orientation = orientation {
+ heading = config.heading
+ tilt = config.tilt
+ roll = config.roll
+ }
+ })
+ }
+
+ /**
+ * Clears all state and removes all objects from the map.
+ */
+ fun clear() {
+ markers.values.forEach { it.second.remove() }
+ markers.clear()
+ polylines.values.forEach { it.second.remove() }
+ polylines.clear()
+ polygons.values.forEach { it.second.remove() }
+ polygons.clear()
+ models.values.forEach { it.second.remove() }
+ models.clear()
+ }
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt
new file mode 100644
index 00000000..5a98083a
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.compose3d.utils
+
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.OnCameraAnimationEndListener
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.FlyAroundOptions
+import com.google.android.gms.maps3d.model.FlyToOptions
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+/**
+ * Represents an update to the camera of a [GoogleMap3D].
+ *
+ * This sealed class provides different ways to update the camera, such as flying to a specific location,
+ * flying around a point, or simply moving the camera to a new position.
+ *
+ * The main advantage is to allow creation of the [awaitCameraUpdate] method.
+ *
+ * Each subclass of [CameraUpdate] defines how the camera should be updated through its `invoke` method.
+ *
+ * Subclasses:
+ * - [FlyTo]: Represents a camera fly-to animation.
+ * - [FlyAround]: Represents a camera fly-around animation.
+ * - [Move]: Represents a direct camera move without animation.
+ */
+sealed class CameraUpdate {
+ abstract operator fun invoke(controller: GoogleMap3D)
+
+ data class FlyTo(val options: FlyToOptions) : CameraUpdate() {
+ override fun invoke(controller: GoogleMap3D) {
+ controller.flyCameraTo(options)
+ }
+ }
+
+ data class FlyAround(val options: FlyAroundOptions) : CameraUpdate() {
+ override fun invoke(controller: GoogleMap3D) {
+ controller.flyCameraAround(options)
+ }
+ }
+
+ data class Move(val camera: Camera) : CameraUpdate() {
+ override fun invoke(controller: GoogleMap3D) {
+ controller.setCamera(camera)
+ }
+ }
+}
+
+fun FlyToOptions.toCameraUpdate(): CameraUpdate {
+ return CameraUpdate.FlyTo(this.toValidFlyToOptions())
+}
+
+fun FlyAroundOptions.toCameraUpdate(): CameraUpdate {
+ return CameraUpdate.FlyAround(this.toValidFlyAroundOptions())
+}
+
+fun FlyToOptions.toValidFlyToOptions(): FlyToOptions {
+ return this.copy(
+ endCamera = this.endCamera.toValidCamera()
+ )
+}
+
+fun FlyAroundOptions.toValidFlyAroundOptions(): FlyAroundOptions {
+ return this.copy(
+ center = this.center.toValidCamera()
+ )
+}
+
+/**
+ * Suspends the coroutine until the camera update animation is finished.
+ *
+ * If the [cameraUpdate] is a [CameraUpdate.Move], it will be applied immediately without waiting.
+ *
+ * Otherwise, it will wait for the camera animation to finish, then it will resume the coroutine.
+ *
+ * You can pass in an existing [cameraChangedListener] that will be invoked when the camera
+ * animation finishes and also will be restored afterwards.
+ *
+ * @param controller The [GoogleMap3D] instance to apply the camera update to.
+ * @param cameraUpdate The [CameraUpdate] to apply.
+ * @param cameraChangedListener An optional existing listener to invoke and restore
+ */
+suspend fun awaitCameraUpdate(
+ controller: GoogleMap3D,
+ cameraUpdate: CameraUpdate,
+ cameraChangedListener: OnCameraAnimationEndListener? = null
+) = suspendCancellableCoroutine { continuation ->
+ // No need to wait if the update is a move
+ if (cameraUpdate is CameraUpdate.Move) {
+ cameraUpdate.invoke(controller)
+ return@suspendCancellableCoroutine
+ }
+
+ // If the coroutine is canceled, stop the camera animation as well.
+ continuation.invokeOnCancellation {
+ controller.stopCameraAnimation()
+ }
+
+ controller.setCameraAnimationEndListener {
+ cameraChangedListener?.onCameraAnimationEnd()
+ controller.setCameraAnimationEndListener(cameraChangedListener)
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ cameraUpdate.invoke(controller)
+}
+
+/**
+ * Suspends the coroutine until the current camera animation is finished.
+ *
+ * In a 3D environment, this is essential for sequencing cinematic movements.
+ */
+suspend fun GoogleMap3D.awaitCameraAnimation() = suspendCancellableCoroutine { continuation ->
+ setCameraAnimationEndListener {
+ setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ setCameraAnimationEndListener(null)
+ }
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt
new file mode 100644
index 00000000..39046273
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.compose3d.utils
+
+import com.google.android.gms.maps.model.LatLng
+
+object GeoMathUtils {
+
+ /**
+ * Instantly finds the mathematical coordinate along the path given an absolute distance in meters.
+ * Replaces expensive haversine math inside the render loop with a precomputed distance array lookup.
+ */
+ fun getInterpolatedPoint(
+ distance: Double, path: List, cumulativeDistances: DoubleArray
+ ): LatLng {
+ if (distance <= 0.0) return path.first()
+ if (distance >= cumulativeDistances.last()) return path.last()
+
+ var idx = cumulativeDistances.binarySearch(distance)
+ if (idx < 0) {
+ idx = -(idx + 1) - 1 // insertion point - 1
+ }
+ idx = idx.coerceIn(0, cumulativeDistances.size - 2)
+
+ val p1 = path[idx]
+ val p2 = path[idx + 1]
+ val d1 = cumulativeDistances[idx]
+ val d2 = cumulativeDistances[idx + 1]
+
+ val fraction = (distance - d1) / (d2 - d1)
+ if (fraction <= 0.0) return p1
+ if (fraction >= 1.0) return p2
+
+ val lat = p1.latitude + (p2.latitude - p1.latitude) * fraction
+ val lng = p1.longitude + (p2.longitude - p1.longitude) * fraction
+ return LatLng(lat, lng)
+ }
+
+ /**
+ * Ensures the camera takes the shortest rotational path (e.g., crossing 359 to 0 smoothly)
+ * instead of violently spinning backward.
+ */
+ fun slerpHeading(current: Float, target: Float, factor: Float): Float {
+ var dh = target - current
+ while (dh > 180f) dh -= 360f
+ while (dh <= -180f) dh += 360f
+ return current + dh * factor
+ }
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt
new file mode 100644
index 00000000..b37233b9
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt
@@ -0,0 +1,180 @@
+/*
+ * 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.compose3d.utils
+
+import android.content.res.Resources
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.res.stringResource
+import com.google.maps.android.compose3d.R
+
+const val METERS_PER_FOOT = 3.28084
+const val METERS_PER_KILOMETER = 1_000
+const val FEET_PER_METER = 1 / METERS_PER_FOOT
+const val FEET_PER_MILE = 5_280
+const val MILES_PER_METER = 0.000621371
+
+/** A value class to wrap a value representing a measurement in meters. */
+@Immutable
+@JvmInline
+value class Meters(val value: Double) : Comparable {
+ override fun compareTo(other: Meters) = value.compareTo(other.value)
+
+ operator fun minus(other: Meters) = Meters(value = this.value - other.value)
+}
+
+/** Create a Meters class from a [Number] */
+@Stable
+inline val Number.meters: Meters
+ get() = Meters(value = this.toDouble())
+
+/** Create a Meters class from a [Number] */
+@Stable
+inline val Number.m: Meters
+ get() = Meters(value = this.toDouble())
+
+/** Create a Meters class from a [Number] of kilometers */
+@Stable
+inline val Number.km: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
+
+/** Create a Meters class from a [Number] of feet */
+@Stable
+inline val Number.feet: Meters
+ get() = Meters(value = this.toDouble() * FEET_PER_METER)
+
+/** Create a Meters class from a [Number] of miles */
+@Stable
+inline val Number.miles: Meters
+ get() = Meters(value = this.toDouble() / MILES_PER_METER)
+
+/** Gets the number of equivalent feet from a meters value class */
+@Stable
+inline val Meters.toFeet: Double
+ get() = value * METERS_PER_FOOT
+
+/** Gets the value of a meters class as a Double */
+@Stable
+inline val Meters.toMeters: Double
+ get() = value
+
+/** Gets the number of equivalent kilometers from a meters value class */
+@Stable
+inline val Meters.toKilometers: Double
+ get() = value / METERS_PER_KILOMETER
+
+/** Gets the number of equivalent kilometers from a meters value class */
+@Stable
+inline val Meters.toMiles: Double
+ get() = (value * MILES_PER_METER)
+
+@Stable
+fun Meters.plus(other: Meters) = Meters(value = this.value + other.value)
+
+/**
+ * A data class representing a value with a string resource ID for its units template.
+ *
+ * @property value: The numerical value.
+ * @property unitsTemplate: The string resource ID for the units.
+ */
+data class ValueWithUnitsTemplate(val value: Double, @StringRes val unitsTemplate: Int)
+
+/** Abstract base class for all units converters. */
+abstract class UnitsConverter {
+ abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate
+
+ abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate
+
+ @Composable
+ fun toDistanceString(meters: Meters): String {
+ val (value, resourceId) = toDistanceUnits(meters = meters)
+ return stringResource(id = resourceId, value)
+ }
+
+ fun toDistanceString(resources: Resources, meters: Meters): String {
+ val (value, resourceId) = toDistanceUnits(meters = meters)
+ return resources.getString(resourceId, value)
+ }
+
+ @Composable
+ fun toElevationString(meters: Meters): String {
+ val (value, resourceId) = toElevationUnits(meters = meters)
+ return stringResource(id = resourceId, value)
+ }
+}
+
+/**
+ * Returns the appropriate [UnitsConverter] based on the given country code.
+ *
+ * @param countryCode The country code to determine the units converter for.
+ * @return The appropriate [UnitsConverter] for the specified country code.
+ */
+fun getUnitsConverter(countryCode: String?): UnitsConverter {
+ // TODO: other counties that use imperial units for distances?
+ return if (countryCode == "US") {
+ ImperialUnitsConverter
+ } else {
+ MetricUnitsConverter
+ }
+}
+
+/** Class to render measurements in imperial units. */
+object ImperialUnitsConverter : UnitsConverter() {
+ override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate {
+ return if (meters < 0.25.miles) {
+ ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet)
+ } else {
+ ValueWithUnitsTemplate(meters.toMiles, R.string.in_miles)
+ }
+ }
+
+ override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate {
+ return ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet)
+ }
+}
+
+/** Class to render measurements in metric units. */
+object MetricUnitsConverter : UnitsConverter() {
+ override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate {
+ return if (meters < 1000.meters) {
+ ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters)
+ } else {
+ ValueWithUnitsTemplate(meters.toKilometers, R.string.in_kilometers)
+ }
+ }
+
+ override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate {
+ return ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters)
+ }
+}
+
+/** A composition local that provides a [UnitsConverter] instance. */
+val LocalUnitsConverter = compositionLocalOf { MetricUnitsConverter }
+
+/** Creates a string to show the distance formatted with units */
+@Composable
+fun Meters.toDistanceString(): String {
+ return LocalUnitsConverter.current.toDistanceString(this)
+}
+
+@Composable
+fun Meters.toElevationString(): String {
+ return LocalUnitsConverter.current.toElevationString(this)
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
new file mode 100644
index 00000000..7a8c5a2e
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
@@ -0,0 +1,486 @@
+/*
+ * 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.compose3d.utils
+
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.FlyAroundOptions
+import com.google.android.gms.maps3d.model.FlyToOptions
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.flyAroundOptions
+import com.google.android.gms.maps3d.model.flyToOptions
+import com.google.android.gms.maps3d.model.latLngAltitude
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.floor
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+val headingRange = 0.0..360.0
+val tiltRange = 0.0..90.0
+val rangeRange = 0.0..63170000.0
+val rollRange = -360.0..360.0
+
+val latitudeRange = -90.0..90.0
+val longitudeRange = -180.0..180.0
+val altitudeRange = 0.0..LatLngAltitude.MAX_ALTITUDE_METERS
+
+const val DEFAULT_HEADING = 0.0
+const val DEFAULT_TILT = 60.0
+const val DEFAULT_RANGE = 1500.0
+const val DEFAULT_ROLL = 0.0
+
+/**
+ * Converts a nullable Camera object into a valid, non-null Camera object.
+ * If the input is null, returns the DEFAULT_CAMERA configuration.
+ * If the input is non-null, validates its components (center, heading, tilt, roll, range)
+ * using helper functions (toValidLocation, toHeading, toTilt, toRoll, toRange).
+ *
+ * @receiver The nullable Camera object to validate.
+ * @return A valid, non-null Camera object.
+ */
+fun Camera?.toValidCamera(): Camera {
+ // Use elvis operator for concise null handling
+ val source = this ?: return Camera.DEFAULT_CAMERA // Return default camera if source is null
+
+ // If source is not null, validate its components
+ return camera {
+ // Validate center using the provided toValidLocation function
+ center = source.center.toValidLocation()
+ // Validate orientation and range using the existing to...() functions
+ heading = source.heading.toHeading()
+ tilt = source.tilt.toTilt()
+ roll = source.roll.toRoll()
+ range = source.range.toRange()
+ }
+}
+
+/**
+ * Coerces the latitude, longitude, and altitude of a LatLngAltitude object
+ * to be within their valid ranges. Longitude is clamped, not wrapped here.
+ *
+ * @receiver The LatLngAltitude to validate.
+ * @return A new LatLngAltitude object with validated components.
+ */
+fun LatLngAltitude.toValidLocation(): LatLngAltitude {
+ val objectToCopy = this
+ return latLngAltitude {
+ // Coerce latitude within -90.0 to 90.0
+ latitude = objectToCopy.latitude.coerceIn(latitudeRange)
+ // Coerce longitude within -180.0 to 180.0 (Note: wrapping might be preferred sometimes)
+ longitude = objectToCopy.longitude.coerceIn(longitudeRange)
+ // Coerce altitude within 0.0 to MAX_ALTITUDE_METERS
+ altitude = objectToCopy.altitude.coerceIn(altitudeRange)
+ }
+}
+
+/**
+ * Converts a Number? to a valid heading value (0.0 to 360.0).
+ * Returns 0.0 if the input is null.
+ * Uses wrapIn to ensure the value is within the headingRange.
+ *
+ * @receiver The Number? to convert.
+ * @return The heading value as a Double within [0.0, 360.0).
+ */
+fun Number?.toHeading(): Double =
+ this?.toDouble()?.wrapIn(headingRange.start, headingRange.endInclusive) ?: DEFAULT_HEADING
+
+/**
+ * Converts a Number? to a valid tilt value (0.0 to 90.0).
+ * Returns 0.0 if the input is null.
+ * Clamps the value to the tiltRange, as tilt doesn't typically wrap.
+ *
+ * @receiver The Number? to convert.
+ * @return The tilt value as a Double clamped within [0.0, 90.0].
+ */
+fun Number?.toTilt(): Double = this?.toDouble()?.coerceIn(tiltRange) ?: DEFAULT_TILT
+
+/**
+ * Converts a Number? to a valid roll value (-360.0 to 360.0 or often -180..180).
+ * Returns 0.0 if the input is null.
+ * Uses wrapIn to ensure the value is within the rollRange.
+ * Consider using -180..180 range and wrapIn(lower, upper) for standard roll representation.
+ *
+ * @receiver The Number? to convert.
+ * @return The roll value as a Double within the defined rollRange.
+ */
+fun Number?.toRoll(): Double = this?.toDouble()?.wrapIn(rollRange) ?: DEFAULT_ROLL
+
+/**
+ * Converts a Number? to a valid range value (0.0 to ~63,170,000.0).
+ * Returns 0.0 if the input is null.
+ * Clamps the value to the rangeRange, as range/distance doesn't wrap.
+ *
+ * @receiver The Number? to convert.
+ * @return The range value as a Double clamped within the defined rangeRange.
+ */
+fun Number?.toRange(): Double = this?.toDouble()?.coerceIn(rangeRange) ?: DEFAULT_RANGE
+
+// Assumes we are close to the range
+fun Double.wrapIn(range: ClosedFloatingPointRange): Double {
+ var answer = this
+ val delta = range.endInclusive - range.start
+ while (answer > range.endInclusive) {
+ answer -= delta
+ }
+ while (answer < range.start) {
+ answer += delta
+ }
+
+ return answer
+}
+
+/**
+ * Wraps a Float value within a specified range.
+ * If the value is outside the range, it is adjusted by repeatedly adding or subtracting
+ * the range's span (delta) until it falls within the range.
+ *
+ * @param range The ClosedFloatingPointRange within which to wrap the value.
+ * @return The wrapped Float value, guaranteed to be within the specified range.
+ */
+fun Float.wrapIn(range: ClosedFloatingPointRange): Float {
+ var answer = this
+ val delta = range.endInclusive - range.start
+ while (answer > range.endInclusive) {
+ answer -= delta
+ }
+ while (answer < range.start) {
+ answer += delta
+ }
+
+ return answer
+}
+
+/**
+ * Wraps a Double value within the specified range [lower, upper).
+ * This method ensures that the returned value always falls within the specified range.
+ * If the value is outside the range, it will be "wrapped around" to fit within the range.
+ * For example, if the range is [0.0, 360.0) and the input is 370.0, the output will be 10.0.
+ * If the range is [0.0, 360.0) and the input is -10.0, the output will be 350.0.
+ *
+ * @param lower The lower bound of the range (inclusive).
+ * @param upper The upper bound of the range (exclusive).
+ * @return The wrapped value within the range [lower, upper).
+ * @throws IllegalArgumentException if the upper bound is not greater than the lower bound.
+ */
+fun Double.wrapIn(lower: Double, upper: Double): Double {
+ val range = upper - lower
+ if (range <= 0) {
+ throw IllegalArgumentException("Upper bound must be greater than lower bound")
+ }
+ val offset = this - lower
+ return lower + (offset - floor(offset / range) * range)
+}
+
+/**
+ * Extension function on Number to get the nearest compass direction string
+ * from a given heading in degrees.
+ *
+ * 0 degrees is North, 90 is East, 180 is South, 270 is West.
+ * Handles headings outside the standard 0-360 range (e.g., -90 or 450 degrees).
+ *
+ * @return A string representing the nearest compass direction (e.g., "N", "NNE", "NE").
+ */
+fun Number.toCompassDirection(): String {
+ val directions = listOf(
+ "N", "NNE", "NE", "ENE",
+ "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW",
+ "W", "WNW", "NW", "NNW"
+ )
+
+ val headingDegrees = this.toDouble()
+
+ // Normalize heading to 0-359.99... degrees
+ val normalizedHeading = (headingDegrees % 360.0 + 360.0) % 360.0
+
+ // Each of the 16 directions covers an arc of 360/16 = 22.5 degrees.
+ // We add half of this (11.25) to the normalized heading before dividing
+ // to correctly align with the center of each compass arc.
+ val segment = 22.5
+ val index = floor((normalizedHeading + (segment / 2)) / segment).toInt() % directions.size
+
+ return directions[index]
+}
+
+/**
+ * Creates a new [Camera] object by copying the current [Camera] and optionally overriding
+ * its center, heading, tilt, range, and roll properties.
+ *
+ * @param center The new center [LatLngAltitude] to use, or null to keep the current center.
+ * @param heading The new heading (bearing) to use, or null to keep the current heading.
+ * @param tilt The new tilt (pitch) to use, or null to keep the current tilt.
+ * @param range The new range (distance from the center) to use, or null to keep the current range.
+ * @param roll The new roll to use, or null to keep the current roll.
+ * @return A new [Camera] object with the specified properties updated.
+ */
+fun Camera.copy(
+ center: LatLngAltitude? = null,
+ heading: Double? = null,
+ tilt: Double? = null,
+ range: Double? = null,
+ roll: Double? = null,
+): Camera {
+ val objectToCopy = this
+ return camera {
+ this.center = center ?: objectToCopy.center
+ this.heading = heading ?: objectToCopy.heading
+ this.tilt = tilt ?: objectToCopy.tilt
+ this.range = range ?: objectToCopy.range
+ this.roll = roll ?: objectToCopy.roll
+ }
+}
+
+fun FlyAroundOptions.copy(
+ center: Camera? = null,
+ durationInMillis: Long? = null,
+ rounds: Double? = null,
+) : FlyAroundOptions {
+ val objectToCopy = this
+
+ return flyAroundOptions {
+ this.center = (center ?: objectToCopy.center)
+ this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis
+ this.rounds = rounds ?: objectToCopy.rounds
+ }
+}
+
+fun FlyToOptions.copy(
+ endCamera: Camera? = null,
+ durationInMillis: Long? = null,
+) : FlyToOptions {
+ val objectToCopy = this
+
+ return flyToOptions {
+ this.endCamera = (endCamera ?: objectToCopy.endCamera)
+ this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis
+ }
+}
+
+/**
+ * Converts a [Camera] object to a formatted string representation.
+ *
+ * This function takes a [Camera] object, validates it using [toValidCamera], and then
+ * constructs a multi-line string that represents the camera's properties in a human-readable
+ * format. The string includes the camera's center (latitude, longitude, altitude),
+ * heading, tilt, and range.
+ *
+ * The latitude, longitude, altitude, heading, tilt, and range are formatted to specific
+ * decimal places for readability (6, 6, 1, 0, 0, 0 respectively).
+ *
+ * The output string is designed to be easily copied and pasted directly into code to recreate
+ * a [Camera] object with the same parameters. This is especially useful for quickly positioning
+ * the camera to a specific view.
+ *
+ * Example output:
+ * ```
+ * camera {
+ * center = latLngAltitude {
+ * latitude = 34.052235
+ * longitude = -118.243685
+ * altitude = 100.0
+ * }
+ * heading = 90
+ * tilt = 45
+ * range = 5000
+ * }
+ * ```
+ *
+ * @receiver The [Camera] object to convert.
+ * @return A string representation of the [Camera] object, suitable for pasting into source code.
+ */
+fun Camera.toCameraString(): String {
+ val camera = this.toValidCamera()
+ return """
+ camera {
+ center = latLngAltitude {
+ latitude = ${camera.center.latitude.format(6)}
+ longitude = ${camera.center.longitude.format(6)}
+ altitude = ${camera.center.altitude.format(1)}
+ }
+ heading = ${camera.heading.format(0)}
+ tilt = ${camera.tilt.format(0)}
+ range = ${camera.range.format(0)}
+ }""".trimIndent()
+}
+
+/**
+ * Formats a nullable Double to a string with a specified number of decimal places.
+ *
+ * If the Double is null, returns "null".
+ * If decimalPlaces is 0, it formats the number with no decimal places and appends ".0".
+ * If decimalPlaces is greater than 0, it formats the number with the specified number of decimal places.
+ *
+ * Note, this is intended for logging and debugging not for display to the user.
+ *
+ * @receiver The nullable Double to format.
+ * @param decimalPlaces The number of decimal places to include in the formatted string.
+ * @return The formatted string representation of the Double, or "null" if the input is null.
+ */
+internal fun Double?.format(decimalPlaces: Int): String {
+ if (this == null) return "null"
+
+ return if (decimalPlaces == 0) {
+ String.format(Locale.US, "%.0f.0", this)
+ } else {
+ String.format(Locale.US, "%.${decimalPlaces}f", this)
+ }
+}
+
+/**
+ * Smooths a path of LatLng points using Chaikin's algorithm.
+ *
+ * Chaikin's algorithm works by cutting corners. Each iteration replaces each
+ * internal point with two points, each 1/4 and 3/4 along the edge between the
+ * previous and next points.
+ *
+ * @param iterations The number of smoothing iterations to perform. Higher
+ * values result in smoother curves but more points.
+ * @return A new list of smoothed [LatLng] points.
+ */
+fun List.smoothPath(iterations: Int = 1): List {
+ if (size < 3 || iterations <= 0) return this
+
+ var currentPath = this
+ repeat(iterations) {
+ val nextPath = mutableListOf()
+ // Keep the first point
+ nextPath.add(currentPath.first())
+
+ for (i in 0 until currentPath.size - 1) {
+ val p0 = currentPath[i]
+ val p1 = currentPath[i + 1]
+
+ // Point at 1/4 of the way
+ val q = LatLng(
+ p0.latitude * 0.75 + p1.latitude * 0.25,
+ p0.longitude * 0.75 + p1.longitude * 0.25
+ )
+
+ // Point at 3/4 of the way
+ val r = LatLng(
+ p0.latitude * 0.25 + p1.latitude * 0.75,
+ p0.longitude * 0.25 + p1.longitude * 0.75
+ )
+
+ nextPath.add(q)
+ nextPath.add(r)
+ }
+
+ // Keep the last point
+ nextPath.add(currentPath.last())
+ currentPath = nextPath
+ }
+
+ return currentPath
+}
+
+/**
+ * Calculates the heading (bearing) from one LatLng to another.
+ *
+ * @return The heading in degrees clockwise from North.
+ */
+fun calculateHeading(from: LatLng, to: LatLng): Double {
+ val lat1 = Math.toRadians(from.latitude)
+ val lon1 = Math.toRadians(from.longitude)
+ val lat2 = Math.toRadians(to.latitude)
+ val lon2 = Math.toRadians(to.longitude)
+
+ val dLon = lon2 - lon1
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) -
+ sin(lat1) * cos(lat2) * cos(dLon)
+
+ val bearing = Math.toDegrees(atan2(y, x))
+ return (bearing + 360.0) % 360.0
+}
+
+/**
+ * Simplifies a path of LatLng points using the Ramer-Douglas-Peucker algorithm.
+ *
+ * This algorithm reduces the number of points in a curve that is approximated
+ * by a series of points, while preserving the overall shape.
+ *
+ * @param epsilon The maximum distance between the original path and the
+ * simplified path. Higher values result in more simplification.
+ * Value is in degrees (very rough approximation).
+ * @return A new list of simplified [LatLng] points.
+ */
+fun List.simplifyPath(epsilon: Double = 0.001): List {
+ if (size < 3) return this
+
+ var maxDistance = 0.0
+ var index = 0
+ val first = first()
+ val last = last()
+
+ for (i in 1 until size - 1) {
+ val distance = perpendicularDistance(this[i], first, last)
+ if (distance > maxDistance) {
+ index = i
+ maxDistance = distance
+ }
+ }
+
+ return if (maxDistance > epsilon) {
+ val left = subList(0, index + 1).simplifyPath(epsilon)
+ val right = subList(index, size).simplifyPath(epsilon)
+ left.dropLast(1) + right
+ } else {
+ listOf(first, last)
+ }
+}
+
+/**
+ * Calculates the perpendicular distance from a point to a line segment.
+ */
+private fun perpendicularDistance(point: LatLng, start: LatLng, end: LatLng): Double {
+ val x = point.longitude
+ val y = point.latitude
+ val x1 = start.longitude
+ val y1 = start.latitude
+ val x2 = end.longitude
+ val y2 = end.latitude
+
+ val area = abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1)
+ val bottom = sqrt((y2 - y1).pow(2.0) + (x2 - x1).pow(2.0))
+ return area / bottom
+}
+
+/**
+ * Calculates the distance in meters between two [LatLng] points using the Haversine formula.
+ */
+fun haversineDistance(p1: LatLng, p2: LatLng): Double {
+ val r = 6371000.0 // Earth radius in meters
+ val lat1 = Math.toRadians(p1.latitude)
+ val lon1 = Math.toRadians(p1.longitude)
+ val lat2 = Math.toRadians(p2.latitude)
+ val lon2 = Math.toRadians(p2.longitude)
+
+ val dLat = lat2 - lat1
+ val dLon = lon2 - lon1
+
+ val a = sin(dLat / 2).pow(2.0) +
+ cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2.0)
+ val c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+ return r * c
+}
diff --git a/maps3d-compose/src/main/res/values/strings.xml b/maps3d-compose/src/main/res/values/strings.xml
new file mode 100644
index 00000000..221e967e
--- /dev/null
+++ b/maps3d-compose/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$,.0f ft
+ %1$,.1f miles
+ %1$,.0f m
+ %1$,.1f km
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8477ae32..43b454d8 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -59,3 +59,10 @@ include(":Maps3DSamples:ApiDemos:common")
// PlacesUIKit3D
include(":PlacesUIKit3D")
+
+// Maps 3D Compose Library
+include(":maps3d-compose")
+include(":maps3d-compose-demo")
+
+// Visual Testing
+include(":visual-testing")
diff --git a/visual-testing/build.gradle.kts b/visual-testing/build.gradle.kts
new file mode 100644
index 00000000..8b6ef90e
--- /dev/null
+++ b/visual-testing/build.gradle.kts
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+import org.gradle.api.tasks.testing.Test
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlinx.serialization)
+}
+
+android {
+ namespace = "com.google.maps.android.visualtesting"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = 23
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ jvmToolchain(17)
+ }
+ testOptions {
+ animationsDisabled = true
+ unitTests.isIncludeAndroidResources = true
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core.ktx)
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.truth)
+
+ // Dependencies for GeminiVisualTestHelper
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.androidx.uiautomator)
+}
diff --git a/visual-testing/consumer-rules.pro b/visual-testing/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/visual-testing/proguard-rules.pro b/visual-testing/proguard-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/visual-testing/src/main/AndroidManifest.xml b/visual-testing/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..342a838f
--- /dev/null
+++ b/visual-testing/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt
new file mode 100644
index 00000000..83ead3ef
--- /dev/null
+++ b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt
@@ -0,0 +1,233 @@
+/*
+ * 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.visualtesting
+
+import android.graphics.Bitmap
+import android.util.Base64
+import android.util.Log
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+
+/**
+ * Helper class to interact with the Gemini API for visual verification and action.
+ *
+ * This version uses org.json for parsing to avoid binary compatibility issues with kotlinx.serialization.
+ */
+class GeminiVisualTestHelper {
+
+ private val client = HttpClient(CIO) {
+ install(HttpTimeout) {
+ requestTimeoutMillis = 60000
+ connectTimeoutMillis = 60000
+ socketTimeoutMillis = 60000
+ }
+ }
+
+ /**
+ * Executes a UI action based on a natural language prompt.
+ * It analyzes the current UI hierarchy and asks Gemini to determine the best action.
+ */
+ suspend fun performActionFromPrompt(prompt: String, uiDevice: UiDevice, apiKey: String) {
+ val hierarchyStream = ByteArrayOutputStream()
+ uiDevice.dumpWindowHierarchy(hierarchyStream)
+ val hierarchyXml = hierarchyStream.toString("UTF-8")
+
+ val systemPrompt = """
+ You are an expert Android QA automaton. Your task is to translate a natural language command
+ into a specific action to be performed on a UI. Given a UI hierarchy (in XML format),
+ determine the correct action and selector.
+
+ The available actions are: "click", "longClick", "setText".
+
+ Your response MUST be a single, well-formed JSON object with "action" and "selector" keys.
+ The "selector" object must contain exactly one of "text", "contentDescription", or "resourceId".
+ If the action is "setText", you must also include a "textValue" field at the top level.
+
+ Example for a click:
+ { "action": "click", "selector": { "text": "Login" } }
+
+ Example for setting text:
+ { "action": "setText", "selector": { "resourceId": "com.example.app:id/email_input" }, "textValue": "test@example.com" }
+ """.trimIndent()
+
+ val fullPrompt = "$systemPrompt\n\nCommand: \"$prompt\"\n\nUI Hierarchy:\n$hierarchyXml"
+
+ val modelName = "gemini-2.5-flash"
+
+ val requestJson = JSONObject().apply {
+ put("contents", JSONArray().apply {
+ put(JSONObject().apply {
+ put("parts", JSONArray().apply {
+ put(JSONObject().apply { put("text", fullPrompt) })
+ })
+ })
+ })
+ }
+
+ val response: HttpResponse = client.post("https://generativelanguage.googleapis.com/v1/models/$modelName:generateContent?key=$apiKey") {
+ contentType(ContentType.Application.Json)
+ setBody(requestJson.toString())
+ }
+
+ if (response.status != HttpStatusCode.OK) {
+ val errorBody = response.bodyAsText()
+ Log.e("GeminiVisualTestHelper", "Action API Error: ${response.status} $errorBody")
+ throw Exception("Gemini Action API returned an error: ${response.status}\n$errorBody")
+ }
+
+ val rawBody = response.bodyAsText()
+ val jsonResponse = JSONObject(rawBody)
+ val actionJson = jsonResponse.getJSONArray("candidates")
+ .getJSONObject(0)
+ .getJSONObject("content")
+ .getJSONArray("parts")
+ .getJSONObject(0)
+ .getString("text")
+
+ // Remove markdown code block delimiters if present
+ val cleanedActionJson = actionJson.removePrefix("```json\n").removeSuffix("\n```")
+
+ Log.d("GeminiVisualTestHelper", "Received Action JSON: $cleanedActionJson")
+
+ try {
+ val aiAction = JSONObject(cleanedActionJson)
+ val action = aiAction.getString("action")
+ val selectorObj = aiAction.getJSONObject("selector")
+
+ val selector = when {
+ selectorObj.has("text") -> By.text(selectorObj.getString("text"))
+ selectorObj.has("contentDescription") -> By.desc(selectorObj.getString("contentDescription"))
+ selectorObj.has("resourceId") -> By.res(selectorObj.getString("resourceId"))
+ else -> throw IllegalArgumentException("Selector must have text, contentDescription, or resourceId.")
+ }
+
+ val uiObject = uiDevice.wait(Until.findObject(selector), 10000)
+ ?: throw Exception("Could not find UI element for selector: $selector")
+
+ when (action.lowercase()) {
+ "click" -> uiObject.click()
+ "longclick" -> uiObject.longClick()
+ "settext" -> {
+ val textToSet = aiAction.getString("textValue")
+ uiObject.text = textToSet
+ }
+ else -> throw UnsupportedOperationException("Action '$action' is not supported.")
+ }
+ } catch (e: Exception) {
+ Log.e("GeminiVisualTestHelper", "Failed to parse or execute AI action", e)
+ throw e
+ }
+ }
+
+ /**
+ * Fetches and logs the list of available Gemini models for the given API key.
+ */
+ suspend fun listAvailableModels(apiKey: String) {
+ try {
+ val response: HttpResponse = client.get("https://generativelanguage.googleapis.com/v1/models?key=$apiKey")
+ val rawBody = response.bodyAsText()
+ val jsonResponse = JSONObject(rawBody)
+ val models = jsonResponse.getJSONArray("models")
+ val modelNames = StringBuilder()
+ for (i in 0 until models.length()) {
+ val model = models.getJSONObject(i)
+ modelNames.append(" - ${model.getString("name")} (Display Name: ${model.getString("displayName")})\n")
+ }
+ Log.i("GeminiVisualTestHelper", "Available Gemini Models:\n$modelNames")
+ } catch (e: Exception) {
+ Log.e("GeminiVisualTestHelper", "Failed to list available models", e)
+ }
+ }
+
+ /**
+ * Analyzes an image with a given prompt using the Gemini API.
+ */
+ suspend fun analyzeImage(
+ bitmap: Bitmap,
+ prompt: String,
+ apiKey: String
+ ): String? {
+ // Log available models first for easier debugging.
+ listAvailableModels(apiKey)
+
+ val base64Image = bitmap.toBase64EncodedJpeg()
+
+ val requestJson = JSONObject().apply {
+ put("contents", JSONArray().apply {
+ put(JSONObject().apply {
+ put("parts", JSONArray().apply {
+ put(JSONObject().apply { put("text", prompt) })
+ put(JSONObject().apply {
+ put("inline_data", JSONObject().apply {
+ put("mime_type", "image/jpeg")
+ put("data", base64Image)
+ })
+ })
+ })
+ })
+ })
+ }
+
+ val response: HttpResponse = client.post("https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=$apiKey") {
+ contentType(ContentType.Application.Json)
+ setBody(requestJson.toString())
+ }
+
+ if (response.status != HttpStatusCode.OK) {
+ val errorBody = response.bodyAsText()
+ Log.e("GeminiVisualTestHelper", "API Error: ${response.status} $errorBody")
+ throw Exception("Gemini API returned an error: ${response.status}\n$errorBody")
+ }
+
+ val rawBody = response.bodyAsText()
+ val jsonResponse = JSONObject(rawBody)
+
+ val candidates = jsonResponse.optJSONArray("candidates")
+ if (candidates == null || candidates.length() == 0) {
+ Log.w("GeminiVisualTestHelper", "Gemini API returned empty candidates. Full response: $rawBody")
+ throw Exception("Gemini API returned no candidates.")
+ }
+
+ return candidates.getJSONObject(0)
+ .getJSONObject("content")
+ .getJSONArray("parts")
+ .getJSONObject(0)
+ .optString("text")
+ }
+
+ private fun Bitmap.toBase64EncodedJpeg(): String {
+ val outputStream = ByteArrayOutputStream()
+ compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
+ val byteArray = outputStream.toByteArray()
+ return Base64.encodeToString(byteArray, Base64.NO_WRAP)
+ }
+}
diff --git a/visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java b/visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java
new file mode 100644
index 00000000..7452a247
--- /dev/null
+++ b/visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java
@@ -0,0 +1,27 @@
+/*
+ * 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.visualtesting;
+
+import org.junit.Test;
+import static org.junit.Assert.assertTrue;
+
+public class PlaceholderTest {
+ @Test
+ public void testPlaceholder() {
+ assertTrue(true);
+ }
+}
From 403066e1450c743a528fe9fcb2dd04e3e8467d12 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 16:49:09 -0600
Subject: [PATCH 02/29] feat: Implement Hello Map sample as a separate activity
centered on Flatirons
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 43 ++++++++
.../src/main/AndroidManifest.xml | 5 +
.../maps3dcomposedemo/HelloMapActivity.kt | 104 ++++++++++++++++++
.../example/maps3dcomposedemo/MainActivity.kt | 9 +-
4 files changed, 160 insertions(+), 1 deletion(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index d98c0e8a..9d80cf7f 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -22,6 +22,8 @@ import androidx.test.uiautomator.Until
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Before
+import android.content.Intent
+import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit
@@ -68,4 +70,45 @@ class Maps3DVisualTest : BaseVisualTest() {
geminiResponse?.contains("PASSED", ignoreCase = true) == true
)
}
+
+ @Test
+ fun verifyHelloMapRenders() = runBlocking {
+ // Launch HelloMapActivity
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val intent = Intent(context, HelloMapActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load (up to 60 seconds for 3D SDK)
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("hello_map_screenshot.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Check the image against the following criteria:
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that the landscape appears to be a mountain range (Flatirons in Boulder).
+
+ If all elements are present and look reasonable for a 3D map of a mountain area, reply with "PASSED".
+ If any element is missing or incorrect, please detail the discrepancy.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index 51f09bd1..75ff2e29 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -35,6 +35,11 @@
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
new file mode 100644
index 00000000..e8723c43
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+
+class HelloMapActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ HelloMapScreen { finish() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HelloMapScreen(onBackClick: () -> Unit) {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ val flatironsCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 39.9988
+ longitude = -105.2761
+ altitude = 0.0
+ }
+ heading = 270.0 // Look west towards the Flatirons
+ tilt = 60.0
+ range = 2000.0
+ roll = 0.0
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = flatironsCamera,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+
+ // Back button
+ FloatingActionButton(
+ onClick = onBackClick,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .statusBarsPadding()
+ ) {
+ Text("Back")
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index d378ea85..386e9c5e 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -39,8 +39,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import android.content.Intent
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
@@ -62,6 +64,7 @@ class MainActivity : ComponentActivity() {
@Composable
fun CatalogScreen() {
+ val context = LocalContext.current
var selectedSample by remember { mutableStateOf(null) }
if (selectedSample == null) {
@@ -74,7 +77,11 @@ fun CatalogScreen() {
)
}
item { SampleItem("Basic Map with Marker & Polyline") { selectedSample = "basic" } }
- item { SampleItem("Hello Map") { selectedSample = "hello" } }
+ item {
+ SampleItem("Hello Map") {
+ context.startActivity(Intent(context, HelloMapActivity::class.java))
+ }
+ }
item { SampleItem("Camera Controls") { selectedSample = "camera" } }
item { SampleItem("Map Interactions") { selectedSample = "interactions" } }
item { SampleItem("Markers") { selectedSample = "markers" } }
From 2a2fc84e8eb8d4279527a289ef955c43a4986794 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 16:54:46 -0600
Subject: [PATCH 03/29] feat: Switch Hello Map location to Delicate Arch and
fix visual test
---
.../java/com/example/maps3dcomposedemo/BaseVisualTest.kt | 6 +++++-
.../com/example/maps3dcomposedemo/Maps3DVisualTest.kt | 4 ++--
.../com/example/maps3dcomposedemo/HelloMapActivity.kt | 8 ++++----
3 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
index e72b4225..284a1e96 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
@@ -44,12 +44,16 @@ abstract class BaseVisualTest {
}
protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap {
- val screenshotFile = File(context.cacheDir, filename)
+ val screenshotFile = File(context.getExternalFilesDir(null), filename)
val screenshotTaken = uiDevice.takeScreenshot(screenshotFile)
assertTrue("Failed to take screenshot: $filename", screenshotTaken)
val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath)
assertTrue("Failed to decode screenshot file: $filename", bitmap != null)
+
+ println("Screenshot saved to device: ${screenshotFile.absolutePath}")
+ println("To pull: adb pull ${screenshotFile.absolutePath}")
+
return bitmap
}
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 9d80cf7f..34398941 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -94,9 +94,9 @@ class Maps3DVisualTest : BaseVisualTest() {
Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
Check the image against the following criteria:
1. Confirm that a 3D map view is visible.
- 2. Confirm that the landscape appears to be a mountain range (Flatirons in Boulder).
+ 2. Confirm that the landscape appears to be a desert arch area (like Delicate Arch).
- If all elements are present and look reasonable for a 3D map of a mountain area, reply with "PASSED".
+ If all elements are present and look reasonable for a 3D map of an arch area, reply with "PASSED".
If any element is missing or incorrect, please detail the discrepancy.
""".trimIndent()
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
index e8723c43..74fbfa81 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
@@ -66,13 +66,13 @@ fun HelloMapScreen(onBackClick: () -> Unit) {
val flatironsCamera = remember {
camera {
center = latLngAltitude {
- latitude = 39.9988
- longitude = -105.2761
+ latitude = 38.7436
+ longitude = -109.4993
altitude = 0.0
}
- heading = 270.0 // Look west towards the Flatirons
+ heading = 0.0
tilt = 60.0
- range = 2000.0
+ range = 500.0
roll = 0.0
}
}
From d6560b00adc4be292994b4523738cd2c9da6cf68 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:08:56 -0600
Subject: [PATCH 04/29] feat: Calibrate camera pose for Delicate Arch in
HelloMapActivity
---
.../example/maps3dcomposedemo/Maps3DVisualTest.kt | 8 ++++----
.../example/maps3dcomposedemo/HelloMapActivity.kt | 13 +++++++------
2 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 34398941..26ea52f8 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -83,8 +83,8 @@ class Maps3DVisualTest : BaseVisualTest() {
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
- // Wait for the map to render and tiles to load (up to 60 seconds for 3D SDK)
- waitForMapRendering(60)
+ // Wait for the map to render and tiles to load (up to 120 seconds for 3D SDK)
+ waitForMapRendering(120)
// Capture a screenshot
val screenshotBitmap = captureScreenshot("hello_map_screenshot.png")
@@ -94,9 +94,9 @@ class Maps3DVisualTest : BaseVisualTest() {
Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
Check the image against the following criteria:
1. Confirm that a 3D map view is visible.
- 2. Confirm that the landscape appears to be a desert arch area (like Delicate Arch).
+ 2. Confirm that the Delicate Arch itself is clearly visible and a prominent part of the scene (it should look like a large freestanding rock arch).
- If all elements are present and look reasonable for a 3D map of an arch area, reply with "PASSED".
+ If all elements are present and the Delicate Arch is clearly visible, reply with "PASSED".
If any element is missing or incorrect, please detail the discrepancy.
""".trimIndent()
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
index 74fbfa81..e7b7f626 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.GoogleMap3D as Map3D
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
@@ -66,13 +67,13 @@ fun HelloMapScreen(onBackClick: () -> Unit) {
val flatironsCamera = remember {
camera {
center = latLngAltitude {
- latitude = 38.7436
- longitude = -109.4993
- altitude = 0.0
+ latitude = 38.74349839523953
+ longitude = -109.49930710001824
+ altitude = 1467.1204001315878
}
- heading = 0.0
- tilt = 60.0
- range = 500.0
+ heading = 151.8340412978984
+ tilt = 68.31411315130784
+ range = 250.56850704659155
roll = 0.0
}
}
From 3830272ec11b303e58301fbc7f4ae736b7392912 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:17:51 -0600
Subject: [PATCH 05/29] chore: Add visual test examples and gradle daemon
properties
---
gradle/gradle-daemon-jvm.properties | 12 ++
visual-test-examples/BaseVisualTest.kt | 70 +++++++++++
visual-test-examples/ClusteringVisualTest.kt | 112 ++++++++++++++++++
.../IconGeneratorVisualTest.kt | 103 ++++++++++++++++
visual-test-examples/KmlVisualTest.kt | 75 ++++++++++++
.../RendererVisualTestBase.kt | 97 +++++++++++++++
6 files changed, 469 insertions(+)
create mode 100644 gradle/gradle-daemon-jvm.properties
create mode 100644 visual-test-examples/BaseVisualTest.kt
create mode 100644 visual-test-examples/ClusteringVisualTest.kt
create mode 100644 visual-test-examples/IconGeneratorVisualTest.kt
create mode 100644 visual-test-examples/KmlVisualTest.kt
create mode 100644 visual-test-examples/RendererVisualTestBase.kt
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 00000000..6c1139ec
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainVersion=21
diff --git a/visual-test-examples/BaseVisualTest.kt b/visual-test-examples/BaseVisualTest.kt
new file mode 100644
index 00000000..a41125cd
--- /dev/null
+++ b/visual-test-examples/BaseVisualTest.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.utils.demo
+
+import android.app.Instrumentation
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import com.google.maps.android.visualtesting.GeminiVisualTestHelper
+import org.junit.Assert.assertTrue
+import java.io.File
+
+abstract class BaseVisualTest {
+
+ protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
+ protected val uiDevice = UiDevice.getInstance(instrumentation)
+ protected val context: Context = instrumentation.targetContext
+ protected val helper = GeminiVisualTestHelper()
+
+ protected val geminiApiKey: String by lazy {
+ val key = BuildConfig.GEMINI_API_KEY
+ assertTrue(
+ "GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.",
+ key != "YOUR_GEMINI_API_KEY"
+ )
+ key
+ }
+
+ protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap {
+ val screenshotFile = File(context.cacheDir, filename)
+ val screenshotTaken = uiDevice.takeScreenshot(screenshotFile)
+ assertTrue("Failed to take screenshot: $filename", screenshotTaken)
+
+ val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath)
+ assertTrue("Failed to decode screenshot file: $filename", bitmap != null)
+ return bitmap
+ }
+
+ /**
+ * Waits for the map to render.
+ * Since MapView content (tiles, markers) is rendered on a GL surface and not exposed as
+ * accessibility nodes, we cannot rely on UiAutomator looking for text/markers.
+ * We use a stable delay to ensure rendering is complete.
+ */
+ protected fun waitForMapRendering(seconds: Long = 3) {
+ // Optional: Wait for map container if possible
+ // uiDevice.wait(Until.hasObject(By.descContains("Google Map")), 5000)
+
+ try {
+ java.util.concurrent.TimeUnit.SECONDS.sleep(seconds)
+ } catch (e: InterruptedException) {
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/visual-test-examples/ClusteringVisualTest.kt b/visual-test-examples/ClusteringVisualTest.kt
new file mode 100644
index 00000000..032eeef3
--- /dev/null
+++ b/visual-test-examples/ClusteringVisualTest.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.utils.demo
+
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class ClusteringVisualTest : BaseVisualTest() {
+
+ @Before
+ fun setup() {
+ // Launch the app
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ context.startActivity(intent)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+ }
+
+ @Test
+ fun naturalLanguageClickTest() = runBlocking {
+ // Use a natural language prompt to perform the click action
+ helper.performActionFromPrompt("Click the CLUSTERING button", uiDevice, geminiApiKey)
+
+ // Wait for the clustering screen to load and map to render
+ TimeUnit.SECONDS.sleep(5)
+
+ // Capture a screenshot to verify the result of the action
+ val screenshotBitmap = captureScreenshot("natural_lang_click_screenshot.png")
+
+ // --- Perform a visual assertion on the new screen ---
+ val prompt = "Does this image show a map with several markers clustered together? Answer only YES or NO."
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ println("Gemini's analysis after natural language click: $geminiResponse")
+ assertTrue(
+ "Visual verification failed. Gemini did not confirm the presence of a map with clusters.",
+ geminiResponse?.contains("YES", ignoreCase = true) == true
+ )
+ }
+
+ @Test
+ fun verifyClusteringScreenContent() = runBlocking {
+ // Wait for the app to load and find the "Clustering" button
+ val clusteringButton = uiDevice.wait(Until.findObject(By.text("CLUSTERING")), 10000)
+
+ if (clusteringButton == null) {
+ // Dump window hierarchy to logcat for debugging
+ val outputStream = ByteArrayOutputStream()
+ uiDevice.dumpWindowHierarchy(outputStream)
+ Log.e("ClusteringVisualTest", "Could not find clustering button. UI Hierarchy:\n${outputStream.toString("UTF-8")}")
+
+ // Take a screenshot for visual inspection
+ val screenshotFile = File(context.cacheDir, "test_failure_screenshot.png")
+ uiDevice.takeScreenshot(screenshotFile)
+ Log.e("ClusteringVisualTest", "Debug screenshot saved to device cache.")
+ }
+
+ assertNotNull("Clustering button not found. Check logcat for UI hierarchy dump and debug screenshot.", clusteringButton)
+ clusteringButton.click()
+
+ // Wait for the clustering screen to load and map to render
+ TimeUnit.SECONDS.sleep(5)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("clustering_screenshot.png")
+
+ // --- STEP 2: Define your verification prompt ---
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. Check the image against the following three acceptance criteria:
+ Geographic Bounds: Confirm the map is centered on North London and Hertfordshire, specifically showing landmarks like St Albans, Enfield, and the M25 ring road.
+ Primary Cluster: Verify the presence of either a Red cluster marker labeled '200+' or a Green cluster marker labeled '100+' located centrally over the North London area.
+ Secondary Cluster: Verify the presence of a Blue cluster marker labeled '10+' located to the southwest of the green marker.
+ If all three elements are present and legible, just confirm that the visual test has PASSED. If any element is missing or incorrect, please detail the discrepancy.
+ """.trimIndent()
+
+ // --- STEP 3: Analyze the image using Gemini ---
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ // --- STEP 4: Assert on Gemini's response ---
+ println("Gemini's analysis: $geminiResponse")
+ // Example assertion: Check if Gemini confirms the presence of clusters
+ assertTrue(
+ "PASSED",
+ geminiResponse!!.contains("PASSED", ignoreCase = true)
+ )
+ }
+}
\ No newline at end of file
diff --git a/visual-test-examples/IconGeneratorVisualTest.kt b/visual-test-examples/IconGeneratorVisualTest.kt
new file mode 100644
index 00000000..1d5e2f6a
--- /dev/null
+++ b/visual-test-examples/IconGeneratorVisualTest.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.utils.demo
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private val prompt = """
+ Task: Act as a Visual QA system to verify an Android application screenshot. Compare the
+ provided image against the following technical specifications. Your goal is to determine if the
+ rendering is correct.
+
+ 1. Map Verification
+ Confirm that a map is visible in the background. The map should be centered on the Sydney,
+ Australia area. Major landmarks or labels like Sydney, Hornsby, and Cronulla should be visible.
+ Be flexible with minor labels as they may change, but the general geographic layout must match the reference.
+ 2. Marker Content and Styling
+ There must be exactly six markers on the screen. Verify each of the following:
+
+ * Marker A: Text is "Default". Background is white. Orientation is standard horizontal.
+ * Marker B: Text is "Custom color". Background is cyan/light blue. Orientation is standard horizontal.
+ * Marker C: Text is "Rotated 90 degrees". Background is red. Both the bubble and the text are rotated 90 degrees clockwise.
+ * Marker D: Text is "Rotate=90, ContentRotate=-90". Background is purple. The bubble is rotated 90 degrees clockwise, but the text inside remains horizontal.
+ * Marker E: Text is "ContentRotate=90". Background is green. The bubble is horizontal, but the text inside is rotated 90 degrees clockwise.
+ * Marker F: Text is "Mixing different fonts". Background is orange. The word "Mixing" must be in italics. The words "different fonts" must be in bold.
+
+ 3. Spatial Grid Verification
+ Verify that the markers are positioned correctly relative to one another in a rough grid layout:
+
+ * Top-Left: The Orange marker ("Mixing different fonts").
+ * Top-Right: The Green marker ("ContentRotate=90").
+ * Middle-Left: The Red marker ("Rotated 90 degrees").
+ * Middle-Center: The White marker ("Default").
+ * Bottom-Left: The Purple marker ("Rotate=90, ContentRotate=-90").
+ * Bottom-Right: The Cyan marker ("Custom color").
+
+ 4. Output Requirements
+ Provide your response in the following format:
+
+ * Status: [PASSED or FAILED]
+ * Justification: Provide a concise explanation for the classification. If FAILED, list the
+ specific markers or map elements that do not meet the criteria.
+""".trimIndent()
+
+@RunWith(AndroidJUnit4::class)
+class IconGeneratorVisualTest : BaseVisualTest() {
+
+ @Before
+ fun setup() {
+ // Launch the app
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ context.startActivity(intent)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+ }
+
+ @Test
+ fun testIconMarkers() = runBlocking {
+ // Launch IconGeneratorDemoActivity directly
+ val intent = Intent(context, IconGeneratorDemoActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ context.startActivity(intent)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for map rendering (Markers are bitmaps, so we can't search for text "Default")
+ waitForMapRendering(5)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("clustering_screenshot.png")
+
+ // --- STEP 3: Analyze the image using Gemini ---
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ // --- STEP 4: Assert on Gemini's response ---
+ println("Gemini's analysis: $geminiResponse")
+
+ requireNotNull(geminiResponse) { "Gemini response was null, check API key or network connection." }
+ assertTrue(
+ "Gemini validation failed. Response: $geminiResponse",
+ geminiResponse.contains("PASSED", ignoreCase = true)
+ )
+ }
+}
diff --git a/visual-test-examples/KmlVisualTest.kt b/visual-test-examples/KmlVisualTest.kt
new file mode 100644
index 00000000..9ac5415c
--- /dev/null
+++ b/visual-test-examples/KmlVisualTest.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.utils.demo
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.time.Duration.Companion.seconds
+
+@RunWith(AndroidJUnit4::class)
+class KmlVisualTest : BaseVisualTest() {
+ @Test
+ fun verifyKmlLayerOverlay() = runBlocking {
+ // Launch KmlDemoActivity directly
+ val intent = Intent(context, KmlDemoActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 15000)
+
+ // Wait for the KML screen to load and map to render
+ delay(10.seconds)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("kml_screenshot.png")
+
+ // --- STEP 2: Define your verification prompt ---
+ val prompt = """
+ Task: Analyze the provided image and verify it against the following three strict criteria.
+ Criteria Checklist:
+ Location: The image must display a map of the Googleplex (look for text labels such as "Googleplex", "Amphitheatre Pkwy", or "Charleston Rd").
+ Subject Matter: The map must feature highlighted building footprints (polygonal shapes overlaying the buildings).
+ Color Palette: The building footprints must explicitly include all four of the following colors: Blue, Red, Green, and Yellow.
+
+ Decision Logic:
+ If ALL criteria are met, the test passes.
+ If ANY criterion is not met, the test fails.
+
+ Required Output Format:
+ Provide your response in the following format:
+ Test Result: [PASS / FAIL]
+ Verification Details:
+ Location Check: [State if Googleplex is confirmed]
+ Footprint Check: [State if footprints are visible]
+ Color Check: [List the colors found]
+ Failure Explanation: [If FAIL, you must explain exactly which specific criterion was not met. If PASS, write "None".]
+ """.trimIndent()
+
+ // --- STEP 3: Analyze the image using Gemini ---
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ // --- STEP 4: Assert on Gemini's response ---
+ assertWithMessage("Gemini's analysis failed: $geminiResponse").that(geminiResponse).contains("Test Result: PASS")
+ assertWithMessage("Gemini's analysis failed: $geminiResponse").that(geminiResponse).doesNotContain("Test Result: FAIL")
+ }
+}
diff --git a/visual-test-examples/RendererVisualTestBase.kt b/visual-test-examples/RendererVisualTestBase.kt
new file mode 100644
index 00000000..006ffd77
--- /dev/null
+++ b/visual-test-examples/RendererVisualTestBase.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.utils.demo
+
+import android.content.Intent
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import org.junit.Assert.assertTrue
+import java.util.concurrent.TimeUnit
+
+abstract class RendererVisualTestBase : BaseVisualTest() {
+
+ protected fun launchActivity() {
+ val intent = Intent(context, RendererDemoActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ context.startActivity(intent)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+ // Wait for map initialization
+ TimeUnit.SECONDS.sleep(3)
+ }
+
+ protected suspend fun clickButton(label: String) {
+ // Ensure bottom sheet is expanded enough to find the button
+ expandBottomSheet()
+
+ // Try to find by text first for speed
+ val element = uiDevice.findObject(By.textContains(label))
+ if (element != null) {
+ element.click()
+ } else {
+ // Fallback to AI if standard selector fails
+ helper.performActionFromPrompt("Click the $label button or chip", uiDevice, geminiApiKey)
+ }
+
+ // Wait for action to settle (bottom sheet collapse, map render)
+ TimeUnit.SECONDS.sleep(2)
+ }
+
+ protected fun expandBottomSheet() {
+ val bottomSheet = uiDevice.findObject(By.res(context.packageName, "bottom_sheet"))
+ if (bottomSheet != null) {
+ val startY = uiDevice.displayHeight - 100
+ val endY = uiDevice.displayHeight / 2
+ uiDevice.swipe(uiDevice.displayWidth / 2, startY, uiDevice.displayWidth / 2, endY, 10)
+ TimeUnit.SECONDS.sleep(1)
+ }
+ }
+
+ protected fun collapseBottomSheet() {
+ val bottomSheet = uiDevice.findObject(By.res(context.packageName, "bottom_sheet"))
+ if (bottomSheet != null) {
+ val startY = uiDevice.displayHeight - 200
+ val endY = uiDevice.displayHeight - 100
+ uiDevice.swipe(uiDevice.displayWidth / 2, startY, uiDevice.displayWidth / 2, endY, 10)
+ TimeUnit.SECONDS.sleep(1)
+ }
+ }
+
+ protected suspend fun verifyMapContent(description: String) {
+ val screenshotBitmap = captureScreenshot("visual_test_${System.currentTimeMillis()}.png")
+
+ val prompt = """
+ Analyze this screenshot of a map app.
+ Check if the following condition is met:
+ "$description"
+
+ Also check if the bottom sheet is collapsed (showing only the top "peek" area with "Load File"/"Clear" buttons and "Presets" header, but NOT the full list of chips).
+
+ CRITICAL: Start your response with YES if BOTH the condition is met AND the bottom sheet is collapsed.
+ Start your response with NO if ANY condition is not met.
+ Then explain your reasoning.
+ """.trimIndent()
+
+ val response = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini Verification for '$description': $response")
+
+ assertTrue(
+ "Visual verification failed. Response: $response",
+ response?.trim()?.startsWith("YES", ignoreCase = true) == true
+ )
+ }
+}
From 80b110fb401b52ca525fd59c98dde7fdcdba5e9e Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:28:17 -0600
Subject: [PATCH 06/29] feat: Add Camera Controls activity and calibrate for
Seattle
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 40 +++++++
.../src/main/AndroidManifest.xml | 5 +
.../CameraControlsActivity.kt | 105 ++++++++++++++++++
.../example/maps3dcomposedemo/MainActivity.kt | 6 +-
4 files changed, 155 insertions(+), 1 deletion(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 26ea52f8..3979ed59 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -111,4 +111,44 @@ class Maps3DVisualTest : BaseVisualTest() {
geminiResponse?.contains("PASSED", ignoreCase = true) == true
)
}
+
+ @org.junit.Test
+ fun verifyCameraControlsRenders() = kotlinx.coroutines.runBlocking {
+ // Launch CameraControlsActivity directly
+ val intent = android.content.Intent(context, CameraControlsActivity::class.java).apply {
+ addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("camera_controls_screenshot.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Check the image against the following criteria:
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that the Space Needle or surrounding Seattle urban area (like stadiums/arenas) is visible and prominent.
+
+ If all elements are present and look reasonable for a 3D map of Seattle, reply with "PASSED".
+ If any element is missing or incorrect, please detail the discrepancy.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ org.junit.Assert.assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index 75ff2e29..d99b21ef 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -40,6 +40,11 @@
android:name=".HelloMapActivity"
android:exported="false"
android:label="Hello Map" />
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
new file mode 100644
index 00000000..0c009f0d
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+
+class CameraControlsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ CameraControlsScreen { finish() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CameraControlsScreen(onBackClick: () -> Unit) {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Calibrated camera centered around Seattle (Space Needle area)
+ val seattleCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 47.620527586075134
+ longitude = -122.34935779313246
+ altitude = 155.2680153221576
+ }
+ heading = 153.21621713443722
+ tilt = 79.73103098583165
+ range = 584.2747848654835
+ roll = 0.0
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = seattleCamera,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+
+ // Back button
+ FloatingActionButton(
+ onClick = onBackClick,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .statusBarsPadding()
+ ) {
+ Text("Back")
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index 386e9c5e..0db251f7 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -82,7 +82,11 @@ fun CatalogScreen() {
context.startActivity(Intent(context, HelloMapActivity::class.java))
}
}
- item { SampleItem("Camera Controls") { selectedSample = "camera" } }
+ item {
+ SampleItem("Camera Controls") {
+ context.startActivity(Intent(context, CameraControlsActivity::class.java))
+ }
+ }
item { SampleItem("Map Interactions") { selectedSample = "interactions" } }
item { SampleItem("Markers") { selectedSample = "markers" } }
item { SampleItem("Models") { selectedSample = "models" } }
From 11e1d5eef2326a0e67d16f3a0d3c9352781759d1 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:33:15 -0600
Subject: [PATCH 07/29] feat: Add Map Interactions sample calibrated for Denver
Capitol
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 40 ++++++
.../src/main/AndroidManifest.xml | 5 +
.../example/maps3dcomposedemo/MainActivity.kt | 6 +-
.../MapInteractionsActivity.kt | 136 ++++++++++++++++++
4 files changed, 186 insertions(+), 1 deletion(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 3979ed59..40cc8b4b 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -151,4 +151,44 @@ class Maps3DVisualTest : BaseVisualTest() {
geminiResponse?.contains("PASSED", ignoreCase = true) == true
)
}
+
+ @org.junit.Test
+ fun verifyMapInteractionsRenders() = kotlinx.coroutines.runBlocking {
+ // Launch MapInteractionsActivity directly
+ val intent = android.content.Intent(context, MapInteractionsActivity::class.java).apply {
+ addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("map_interactions_screenshot.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Check the image against the following criteria:
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that the Colorado State Capitol building (with its gold dome) or the surrounding Denver downtown area is visible.
+
+ If all elements are present and look reasonable for a 3D map of Denver, reply with "PASSED".
+ If any element is missing or incorrect, please detail the discrepancy.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ org.junit.Assert.assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index d99b21ef..9fb7c269 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -45,6 +45,11 @@
android:name=".CameraControlsActivity"
android:exported="false"
android:label="Camera Controls" />
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index 0db251f7..073d4012 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -87,7 +87,11 @@ fun CatalogScreen() {
context.startActivity(Intent(context, CameraControlsActivity::class.java))
}
}
- item { SampleItem("Map Interactions") { selectedSample = "interactions" } }
+ item {
+ SampleItem("Map Interactions") {
+ context.startActivity(Intent(context, MapInteractionsActivity::class.java))
+ }
+ }
item { SampleItem("Markers") { selectedSample = "markers" } }
item { SampleItem("Models") { selectedSample = "models" } }
item { SampleItem("Polygons") { selectedSample = "polygons" } }
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
new file mode 100644
index 00000000..82b7e9fc
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.Card
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+
+class MapInteractionsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ MapInteractionsScreen { finish() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MapInteractionsScreen(onBackClick: () -> Unit) {
+ var isMapSteady by remember { mutableStateOf(false) }
+ var clickedInfo by remember { mutableStateOf("Click on the map to see details") }
+ var map3dInstance by remember { mutableStateOf(null) }
+
+ // Calibrated camera centered around Colorado State Capitol
+ val calibratedCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 39.73924812963158
+ longitude = -104.98498430890453
+ altitude = 1640.448817525612
+ }
+ heading = 93.65938810195067
+ tilt = 61.598097303273555
+ range = 494.79807973388233
+ roll = 0.0
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = calibratedCamera,
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = { instance ->
+ map3dInstance = instance
+ // Set up click listener directly on the instance
+ instance.setMap3DClickListener { location, placeId ->
+ clickedInfo = if (placeId != null) {
+ "Clicked Place ID: $placeId\nAt: ${location.latitude}, ${location.longitude}"
+ } else {
+ "Clicked Location: ${location.latitude}, ${location.longitude}"
+ }
+ }
+ },
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+
+ // Back button
+ FloatingActionButton(
+ onClick = onBackClick,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .statusBarsPadding()
+ ) {
+ Text("Back")
+ }
+
+ // Click Info Card
+ Card(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .padding(bottom = 32.dp) // Extra padding to avoid system bars
+ ) {
+ Text(
+ text = clickedInfo,
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
From 3cf36b867a7c1107e12b24a44e2577c13f83e41d Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:40:48 -0600
Subject: [PATCH 08/29] refactor: Reorder parameters in GoogleMap3D to follow
Compose convention
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 36 +++++++++++++++----
.../maps/android/compose3d/GoogleMap3D.kt | 2 +-
2 files changed, 31 insertions(+), 7 deletions(-)
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 40cc8b4b..f92e5ea1 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -115,7 +115,7 @@ class Maps3DVisualTest : BaseVisualTest() {
@org.junit.Test
fun verifyCameraControlsRenders() = kotlinx.coroutines.runBlocking {
// Launch CameraControlsActivity directly
- val intent = android.content.Intent(context, CameraControlsActivity::class.java).apply {
+ val intent = Intent(context, CameraControlsActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
@@ -166,18 +166,42 @@ class Maps3DVisualTest : BaseVisualTest() {
// Wait for the map to render and tiles to load
waitForMapRendering(60)
+ // Strategy 1: Click the center of the screen and surrounding points
+ val screenWidth = uiDevice.displayWidth
+ val screenHeight = uiDevice.displayHeight
+
+ val centerX = screenWidth / 2
+ val centerY = screenHeight / 2
+
+ // Click center and a few points around it to increase chances
+ uiDevice.click(centerX, centerY)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX + 50, centerY + 50)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX - 50, centerY - 50)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX + 50, centerY - 50)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX - 50, centerY + 50)
+
+ // Wait for the click info card to update (let Gemini handle verification if UiAutomator misses it)
+ uiDevice.wait(
+ androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.textContains("Clicked")),
+ 5000
+ )
+
// Capture a screenshot
val screenshotBitmap = captureScreenshot("map_interactions_screenshot.png")
// Define the verification prompt for Gemini
val prompt = """
- Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
- Check the image against the following criteria:
+ Please act as a UI tester and analyze this screenshot.
1. Confirm that a 3D map view is visible.
- 2. Confirm that the Colorado State Capitol building (with its gold dome) or the surrounding Denver downtown area is visible.
+ 2. Read the text in the card at the bottom of the screen and report exactly what it says.
+ 3. Confirm if it shows clicked coordinates or place ID (i.e., does it contain the word 'Clicked').
- If all elements are present and look reasonable for a 3D map of Denver, reply with "PASSED".
- If any element is missing or incorrect, please detail the discrepancy.
+ If the map is visible and the card shows clicked info, reply with "PASSED".
+ Otherwise, report what you see.
""".trimIndent()
// Analyze the image using Gemini
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index 00a7b970..9a77b90a 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -56,13 +56,13 @@ import com.google.android.gms.maps3d.model.Map3DMode
@Composable
fun GoogleMap3D(
camera: Camera,
+ modifier: Modifier = Modifier,
markers: List = emptyList(),
polylines: List = emptyList(),
polygons: List = emptyList(),
models: List = emptyList(),
cameraRestriction: CameraRestriction? = null,
@Map3DMode mapMode: Int = Map3DMode.SATELLITE,
- modifier: Modifier = Modifier,
options: Map3DOptions = Map3DOptions(),
onMapReady: (GoogleMap3D) -> Unit = {},
onMapSteady: () -> Unit = {}
From f5c9160e92428f7056b8a47ad9e6d62c4747a76b Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:43:07 -0600
Subject: [PATCH 09/29] test: Use conventional UiAutomator testing for Map
Interactions fallback
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 111 ++++++++----------
.../MapInteractionsActivity.kt | 4 +-
2 files changed, 54 insertions(+), 61 deletions(-)
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index f92e5ea1..a98415aa 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -153,66 +153,57 @@ class Maps3DVisualTest : BaseVisualTest() {
}
@org.junit.Test
- fun verifyMapInteractionsRenders() = kotlinx.coroutines.runBlocking {
- // Launch MapInteractionsActivity directly
- val intent = android.content.Intent(context, MapInteractionsActivity::class.java).apply {
- addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
-
- // Wait for the activity to be displayed
- uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
-
- // Wait for the map to render and tiles to load
- waitForMapRendering(60)
-
- // Strategy 1: Click the center of the screen and surrounding points
- val screenWidth = uiDevice.displayWidth
- val screenHeight = uiDevice.displayHeight
-
- val centerX = screenWidth / 2
- val centerY = screenHeight / 2
-
- // Click center and a few points around it to increase chances
- uiDevice.click(centerX, centerY)
- android.os.SystemClock.sleep(500)
- uiDevice.click(centerX + 50, centerY + 50)
- android.os.SystemClock.sleep(500)
- uiDevice.click(centerX - 50, centerY - 50)
- android.os.SystemClock.sleep(500)
- uiDevice.click(centerX + 50, centerY - 50)
- android.os.SystemClock.sleep(500)
- uiDevice.click(centerX - 50, centerY + 50)
-
- // Wait for the click info card to update (let Gemini handle verification if UiAutomator misses it)
- uiDevice.wait(
- androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.textContains("Clicked")),
- 5000
- )
-
- // Capture a screenshot
- val screenshotBitmap = captureScreenshot("map_interactions_screenshot.png")
-
- // Define the verification prompt for Gemini
- val prompt = """
- Please act as a UI tester and analyze this screenshot.
- 1. Confirm that a 3D map view is visible.
- 2. Read the text in the card at the bottom of the screen and report exactly what it says.
- 3. Confirm if it shows clicked coordinates or place ID (i.e., does it contain the word 'Clicked').
+ fun verifyMapInteractionsRenders() {
+ kotlinx.coroutines.runBlocking {
+ // Launch MapInteractionsActivity directly
+ val intent = android.content.Intent(context, MapInteractionsActivity::class.java).apply {
+ addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
- If the map is visible and the card shows clicked info, reply with "PASSED".
- Otherwise, report what you see.
- """.trimIndent()
-
- // Analyze the image using Gemini
- val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
-
- println("Gemini's analysis: $geminiResponse")
-
- // Assert on Gemini's response
- org.junit.Assert.assertTrue(
- "Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
- )
+ // Wait for the activity to be displayed
+ uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Strategy 1: Click the center of the screen and surrounding points
+ val screenWidth = uiDevice.displayWidth
+ val screenHeight = uiDevice.displayHeight
+
+ val centerX = screenWidth / 2
+ val centerY = screenHeight / 2
+
+ // Click center and a few points around it to increase chances
+ uiDevice.click(centerX, centerY)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX + 50, centerY + 50)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX - 50, centerY - 50)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX + 50, centerY - 50)
+ android.os.SystemClock.sleep(500)
+ uiDevice.click(centerX - 50, centerY + 50)
+
+ // Wait for the click info card to update with text containing "Clicked"
+ val textUpdated = uiDevice.wait(
+ androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.descContains("Clicked")),
+ 5000
+ )
+ org.junit.Assert.assertTrue("Card text did not update after click", textUpdated)
+
+ // Verify that the text contains coordinates or place ID
+ val cardObject = uiDevice.findObject(androidx.test.uiautomator.By.descContains("Clicked"))
+ val description = cardObject.contentDescription
+ println("Card text: $description")
+
+ org.junit.Assert.assertTrue(
+ "Card text should contain 'Location' or 'Place ID'",
+ description.contains("Location") || description.contains("Place ID")
+ )
+
+ // Capture a screenshot for visual confirmation
+ captureScreenshot("map_interactions_success.png")
+ }
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
index 82b7e9fc..f7ad487e 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
@@ -128,7 +128,9 @@ fun MapInteractionsScreen(onBackClick: () -> Unit) {
) {
Text(
text = clickedInfo,
- modifier = Modifier.padding(16.dp),
+ modifier = Modifier
+ .padding(16.dp)
+ .semantics { contentDescription = clickedInfo },
style = MaterialTheme.typography.bodyMedium
)
}
From 8a1c7593b2ba15262f04862055ca76fba92d033d Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:48:54 -0600
Subject: [PATCH 10/29] test: Verify Markers sample with visual test
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 40 +++++++++++++++++++
.../src/main/AndroidManifest.xml | 6 ++-
.../example/maps3dcomposedemo/MainActivity.kt | 6 ++-
3 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index a98415aa..5973afb2 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -206,4 +206,44 @@ class Maps3DVisualTest : BaseVisualTest() {
captureScreenshot("map_interactions_success.png")
}
}
+
+ @org.junit.Test
+ fun verifyMarkersRenders() {
+ kotlinx.coroutines.runBlocking {
+ // Launch MarkersActivity directly
+ val intent = android.content.Intent(context, MarkersActivity::class.java).apply {
+ addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("markers_devils_tower.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that an alien icon or marker is visible on top of the prominent rock formation (Devils Tower).
+
+ If the map is visible and the alien marker is seen on the tower, reply with "PASSED".
+ Otherwise, report what you see.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ org.junit.Assert.assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
+ }
}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index 9fb7c269..37e8533d 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -48,8 +48,10 @@
+ android:exported="false" />
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index 073d4012..a6ee9dca 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -92,7 +92,11 @@ fun CatalogScreen() {
context.startActivity(Intent(context, MapInteractionsActivity::class.java))
}
}
- item { SampleItem("Markers") { selectedSample = "markers" } }
+ item {
+ SampleItem("Markers") {
+ context.startActivity(Intent(context, MarkersActivity::class.java))
+ }
+ }
item { SampleItem("Models") { selectedSample = "models" } }
item { SampleItem("Polygons") { selectedSample = "polygons" } }
item { SampleItem("Polylines") { selectedSample = "polylines" } }
From 365d86f873481f0a5a0d5f7d6523ccd92feabb28 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:50:31 -0600
Subject: [PATCH 11/29] test: Verify Markers sample with popover and extruded
marker
---
.../maps3dcomposedemo/MarkersActivity.kt | 153 ++++++++++++++++++
.../src/main/res/drawable/alien.png | Bin 0 -> 20960 bytes
2 files changed, 153 insertions(+)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
create mode 100644 maps3d-compose-demo/src/main/res/drawable/alien.png
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
new file mode 100644
index 00000000..5c1353b0
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.ImageView
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import android.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import com.google.android.gms.maps3d.model.markerOptions
+import com.google.android.gms.maps3d.model.popoverOptions
+import com.google.android.gms.maps3d.Popover
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.MarkerConfig
+
+class MarkersActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ MarkersScreen { finish() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MarkersScreen(onBackClick: () -> Unit) {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Camera centered on Devils Tower
+ val devilsTowerCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 44.589994
+ longitude = -104.715326
+ altitude = 1508.9
+ }
+ heading = 1.0
+ tilt = 75.0
+ range = 1635.0
+ roll = 0.0
+ }
+ }
+
+ val context = LocalContext.current
+ var activePopover by remember { mutableStateOf(null) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = devilsTowerCamera,
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ onMapReady = { googleMap3D ->
+ val alienMarker = googleMap3D.addMarker(markerOptions {
+ position = latLngAltitude {
+ latitude = 44.59054845363309
+ longitude = -104.715177415273
+ altitude = 10.0
+ }
+ zIndex = 1
+ label = "Devil's Tower Alien"
+ isExtruded = true
+ isDrawnWhenOccluded = true
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH
+ setStyle(ImageView(R.drawable.alien))
+ })
+
+ alienMarker?.setClickListener {
+ val textView = android.widget.TextView(context).apply {
+ text = "They didn't just come to sculpt mashed potatoes."
+ setPadding(32, 16, 32, 16)
+ setTextColor(Color.BLACK)
+ setBackgroundColor(Color.WHITE)
+ }
+ val newPopover = googleMap3D.addPopover(popoverOptions {
+ positionAnchor = alienMarker
+ altitudeMode = AltitudeMode.ABSOLUTE
+ content = textView
+ autoCloseEnabled = true
+ autoPanEnabled = false
+ })
+
+ activePopover?.remove()
+ activePopover = newPopover
+ activePopover?.show()
+ }
+ }
+ )
+
+ // Back button
+ FloatingActionButton(
+ onClick = onBackClick,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .statusBarsPadding()
+ ) {
+ Text("Back")
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/res/drawable/alien.png b/maps3d-compose-demo/src/main/res/drawable/alien.png
new file mode 100644
index 0000000000000000000000000000000000000000..2c38fc9e8967d8898dddfdc716f0aed4098c7a9a
GIT binary patch
literal 20960
zcmX_n18^o?*X|9C$EfVcnnrvQK(698~-2mtV8005YdIi1RU{|f*4+;Q;TLHlTkE8Gp|4Zrq8~%r21t9;&{x??u`aiZo0oecY
z!2iypeHYvL2hfgE+AaV91L=PO;#nf-4gd%Nq{W2QJVCbnpnUa4TE1*|QZ;&>8GbkF
z9c_2TcEPtYe})1ILw|m3%K{HUq@{rILO^LEFXDJZ)gMf?SkL;VQ>|pWX*JtEKWcu}
zd|zjG{l%(}WqyU?CEeQUYMah|oy}ymnB`{GB=0RbbnjB%maI~v;e2M~AKlqL*!=d8
z!vuNJRGS5>#hF_)KH%g`8|dP0m=iO0+}Zn`KK$xII{K}7!*6HX?6&L47L0;l6DF)sKz1IjdvK;Ao!QimH
z3cw`!2msju>jV*k09ikEIhhr|-t1w$9ga-48ZjeJoPn2D#>cnw-@>R`wf6u~pag;+
zYe0u9O6Ksr06
z*&Fc2Qp5ETx%#i?4C9*BGGu#kGd&$^OTpS#hZSTJ|4cFcGv(rUIMFTuN
z0-jP&z^w;edu2p?7AVoLZDM+FinWo^<
zbzR)Vq?jwuW!cYhj$kd+<)j*+tjB)7UzqZ`=Xo0+k?*G8s7K!*6tC01RO2SLU(o-f
zoq?5=wY$FFaeYZqkl*8EHaEF|a1@@9f0Qj?qUJot|MRC#2?j$tYI`MlLZJGB9e_XR
z$vTG)cuRT0nuw(WG6Ie1U(QB^8nI^664kw_*KoO16W8?Wg^|4AYIHn}-9_O4viAd(
zQek?mMxF@J@(Wks2jfe^g1w22jm_QhO!nBGL`nE_v5@Ox|43Jg7svU
zow3KSgNKMniR%KPFl^j$BX-x*FN~Tz`=bHDsFrUMuGs?Pn$x4$YDfjebK*dQel=7|+Ts;;scxHG<
zZrg#hrs>Px4sn{
zj*T!tF-XAU1pYQ8#$yIr$dZSLXMbC_$^7_lfTv4jU4YOzqW{A$|Iun~ny#ftg_IB#
zqGOe2Oc5ESiCg_zN?ji#GRgaHOizsAuIo|^F&QD>2?u@Jih+-XfN_iRpIduKy#l?)
z3pH((nwvg#gb4&Uvy;!qHTrGdWLF4cnSC1g+F=3eoyWlqC|g@wM(@wpgkO_3bu&w+
z@YIL`cM(R*84HyH5!g7O0OrXW+s*WXdtiS$-mK~!&4pA7NFS!17vY>iRtlb%%vB?W>0m(!^
zYa3c9fyZgqsjkjy?Y25CBfQAC75flxd1MgJ@^U2}sa=rDYUTyuPGI9lMUEO)=4O1z
zq}kmDt)G${HN8|JR@11Iu)y4E_$+?z+pme_l_v9b6$GG2iuNcDM+ZrrUJFNU
zi!`1p?zOg%M7u4NqO#^9h|e#p}I7iTX60v`o%4vL{H~
zEsxy8Bz$~W8wGI;!F9Lv*W#bsiK#Vb2$-Z5c8z8A`hQZQ79y0fv$MZ`ke=Y0%;{cK
zrH;;TZsy#*uLTIMY}mR+WxJHY>wQ1;?XUDiyY~+XxUqgz{ML)kGH_o({~HlQqj&rzLfOG&Zl*->sWg_0PN<
zf&Apa``SOKzm=WLmR-jSucf|4hA}Idq{$!HnTElwPe;6h2!Wf%DVLc$JyF%yPjq{_
z`2DcIzYkzpX6HXKa9mJ_U0hu~R#nnUaP2HGR5F%nyxt`aQNLJb14jVOqyP_=LP*(U
zKm^0VD>x0}mWaaRtRt1_o*&A+B{iDA0cE3sPiog8*-t8?9+zsKzvNr
zP(c}k5brl-iS*CZHZ3U6L8XL5M?(`kP~ma6j~I3~;7AnZ;pGkSxL)V)U;K^DZ~nkl
z6Cd9;r(!)Tocag87+aYXX}L!luD?*_K?*{t87(hu*IDvw7liB;m(YN(HfMx?a)hkB
z8_yu={uQ;8vJ3SH@5xM%%*&uv0csjIQ(L>Te_knnKMA&CWn~3?C$N(`a}uwwC!RJq
zp2W(QL-%DNCr2*1?svd?#NMv=9579*Vbzp#Im9h-Udqrj>0}*L?bN{w@|};8a}I%V
z2-`kgu{+Mfbm_*oIJqo#tm(F5Q_96*pBgh_rihf?PV+WbIZVRN0VvxZ*;zDov4ZUO`ZM5GGJsKB2Gp94f5
zEfq>|pI{QQ6~W)zF{zmdq`ksvt7LeWy<1)y#^~rE5nG(Vj$5Y%gQrK{)y)kWuAFJZ
z-Q=gu;4!HhRX9F|h5$1ko!-&)NtunE4dU796#@z}vJMMYQXADa)TeA;9Z*2ev$v(C
zrSRqmdysT!pdAp48`WiY;N9RgM4_y!OZj%;zJAp*xU-`;Y-q}DsFaq$W|Sl)rI3;%
za}q9wyQ@lo>U}J1-_v}Yel>+F1bu|X_=E(iMLY_=T}|M0td_@>>7$31M~~+A_;~aB
zG|}xB1DTkTKy}srZjYRe#%TA|OQmb5RA1WNLf$;KfetV}`}m-G>)UcAzp2Q39SXuP
zW5t5={{mS|l|)pTEfn2H?DK(V5h9&F7TP<&1g7rL+uk%`pr=QCu!b7{J+?Fc7Cxr0vsS^P#t6T8{QpRO!DaE__u0AykPavET2@l<2vRKRRhr=HKjzpqTiV?PN
zTe3luUT}YX1$9fUdwUO`sZuWR!0%sOe}qRw#GqLB5S)Oiwf5@EoBSw7XeD@zh3kbe
z!rO?eWhkzjkcnj_;JAuE9i8Gr&jL=woh-Eh7KFS#MHGc0WVCT_Lg
zpiwwFjC${N$;aZi?>>c4x={MfNHtHBf5Etp^9%0ISdpZ`%doh7u|INq$qu>8{Q}7`
zBjPxXWA85}(KL{M;V-;MxD>Evl!%Qd#1X3fMwc-vYF4%ZivT;;1vnneCDNZ3C9|DP{w!VdQU4b
zw1HbdRh^?3Qs_bf_BEfqKeDh3gHMtc4<2Mut*YV$lkwh`1yaF@*5D=)AuxRLe&4dZ2!m
zbm5xa{j5ZC)SH6C_7*8a#6>p=nvTKg`S*5k?OYbG@$TZbJg>%VuwKXlJ4I|tl5-CcfCxf#mc#IX&s0fo1~@gkFaIxg
zrN6d6@G+7R^oEcB^aYf1d36FpuyBVFJ205j5{h{}t}(lwu23l)-Eap#PS-hvP(hMJ
zY;Mqn_mqpei}3WiU!sJbaupjN2>a$vC%55o
zc}n5&dZ%;^ZYa4VwlBrH^oiPd6?OfjQnIJu
z#Jkm~;c&Kwzl|DEEruV|vYdU&jy?vc!HKBKc!6`mhTfWze7TbeIYF}=g%JS9^>9mGp^GCoBX
z!jYoNLuy1H9vj?4ZwsHQ2w*(Y=et=j6Oq7OUHIsV-C1jBcz7E`7=$}XF)m5kedBIZ
z*1*7kA9ZBpBXVM*n>sprTe<2Y>NH^dDsNiZqXA8`fl!L_#RTA)DGQQ)+_
zqO<~?gPq+uEj7Kn4N((yHgl#hU6)@b5C#rO9!rY*l3(Sx30;baOie7TxxWpQsGy3|
z(kT24J#}SXp?`%>`W>TAV{jn1sI^o=i=Kbc6;KMf=5q&;z%mh?`eAlFf7V3|*|niN
zyekYg@&b)&TD9-D{4UF&=SWgrPfkwV;;khi0d;p{`pfj$&QBlt<6mvG4Nq(KH$u61
zGSFab0(ESUNrfxp+{{*_`(^g?8H_Exysl={a2zIK-ARTENIgIN2v{A#5RN;7^xFzr
zU7DY#GHZ5~-si#DXeL6)y|X*TG<5)AE85V^EQ70XkdTC`Ohs7eDWbS~v_!3zLMEo=
zF__Qk672>ggiy1O!jPGYy4BG0as6UpVW}L&LpCbWGUM2B?<9Gd-3ZtPA;v&5}+
zqS`Oxc*TzUg_-WSnZA^|-ytAgYoAurY7c)6!!k1?XDmHl{<-ZD>mzyTCZeOOX5=C1
zbie7ZzGqig=YO@h+gg4u$wt_mGWvX(NFG59_&`M_Ab016xB<_a
z{dJZD5IJ!l#55f0k5xdN6%y7+Do2AH*5%O$6#TIoYPzA9yoiQ-2~cMDTTYb&q6yE&GknX^b)!3IYb{$g%~$@Ec`^HrCNA
zZXyyGn5Erhj=$4jbCQAo$0MVuNzyt8iC3i^Kph395Ec;~9bVy)yB;2-p>9*m^a>TZ
zn_$Sr57&4=I$+zXzW`5~2?H(^j_`?R4DqbmlM*Og?IdFe`=4R>@PPKc6R9)Ja&~Bd|r)Vaxy6o0DbmySVlgM*sla$|HFu>#osH;
zCXDs7C}PV14M=R2>K$+X2Ct7nQp{QC35NRqkJ#9UXmMXC-@o
z+U!|M%=jkWCKMFG9utd17eQDkS^@+Y1Z@C3GYHDc+QgGnlZD9aGRC2yVG+Z>OcN6_
zsN@%rc*>FgNlZaynt^9LaNchRiXTGL2F*Z<3z~)PZHf+VA|@>&6p$J^g$yHk^7)H|
z$li-Uaz?}DKo)fkwTR%QvWo%XAo8Iw#3@2nb2-#=7kN_M2aj`$e803?{+sy-q>Ms8
zLq?T9p+Als?kGWcg|MQS+_&M*+G`(Dcaz_lQR~n0e6A+EzCWK!3AkOE9?~H0V^b~Av_uI6FTsa2
z*0q+fVnT+)N+5rS&tg0+cPeNy7X%PC*&;W_3lU5US!=BqOCOvjiq0OF9e(433Zaef!kksLkSWwK?R_{c6q=W0Hu-mlk&{r3^AFngl?>YXv2P
zrg1l`O6W_8HjN%|I70+DFUPGrH7Od(mQsWKpt%ciL(yl!xdGN_UW}jY0tzLLWuW9?
zb}6B}c-FmivT&HSugdAk*xEhcmMg|66!Y{#>1uft*TDD=UReEKs|@7d{(OJhS&Lg~
zr^z_L!N88A6rhQTi#Pv`jEyncUO*H~Fuy|(wg{%sAm0nN`0
ze*a70d-L^)%W01jD?Kr=WHHO}>tRlbdmyxNUo4`M9IyuhgBHgS2^)}%^+C+5xe*;?
zEXm83_Qr#Zn+_PLM3K+1`5TY03YQ(jS){TZhnPnR@`9TL{Yu2>mGsKWtT?SFw&VQf
zn%7lWTWYtx`O19;wm5fisNSIu(uf7v{CeWYLeo-hXr+PBIAVyva
z1gkDxUgpu!aiw&L!xr=kFx!>E3;V>~>$0sSi3tn&?uH`zvJ
z*Wbp}ROt4H$5sohz(!b2Hb?t1APXlN{D(0D8=s3!YQMIryhi#wvVRezJyc?O4c>Z-
z?#EuL<~(CcRMxrm?J=P~Ce+jAkPsZH0aExHts^ZJZ7IxQn8hs|wX`=abO>8YNogex
z$w@sw2gTlI^Vw3oJRWNU%1PbiBUd~W#VG&X=a_lz>uE@b)7hC7+$hnJ3Q8f6d-~(0^~e-j~tmuFU0|y6S4i!
zsTC$v_P6vt6y%Q_7MNtVEcO)C%n5Rq5R?NX8*#fC5SAh$J^GK){_GIWrL%1=HdA47sa@RWM!n$
z7j6Ri?}ky0&mS@y56s1X3pb5!dMK|lVS>&`-tax?5dnFf2R(PDAf
z_kkFb<)f*Z%Z_~ge{BabZm)O)2$!}OGM&~)r7XAY)lXcn7PH~jct8&fpp4Pbqp`|9c(rrMoQ^ZP(Vm`>V($?~QcH`3d&<1IA!9>9
zNtItBH`IyTu*DAlTn}SCSF8EO;-Pm@edLmRaB{PlMwd1^A(Mz3Y%&KqhABzMa{)w
zvzqUm+NBrZ0~ZEe!lYkrX=x_2+wQev%Yg`Sv?05rL{8xXpL6MZag)ct!nAjWBNXU<
zNen00PW5=Q?YsQb8i&W`SwQVGY^h!+jy*)xp2*k}X$w<~ji^MLgejCgfS$6`od8D1
z8L*r0*P6Dr&yz*1B~8wFl#J;WRMvhqQvTe(&0m|$J*N9l?hiPeMfbQG0(OeCAKlw9
z0pM70AD;oXiQx$NGM^7i^dYT&ttM(FsxIFhtoF=VOlhb6fipTgaF`{cTr1vuxN@O_
zBYkoL1*fLmTSb_m_yYDh-s+ZG^E;Y70kZ!3{-@0qOLt{+S^h@zfx@Zn)fjV^LSB8#
z*S>Z)6Vg(-3r^H!*L~01c748kn#vB}k9E?_3%74K^->+%piw1`v`1{HFsDT*frBb+
z3j=n6cB4%@xtvT0}}qM@kuA$NDq!36pSKXO0zHd??q?Y7zIzHNmua&8;O8T!Lo$mDu^
zty`ub|5hqj_R)0Gz1mWLz0jozdzEgEHLb*4^s`HZ%+BknX7@fe%^CmeXz19c
z(N_@VUMc$V;!(-Fr0;jR0&TNeo$0$LB2B^PY&3ygQMyn(dT#l&-TZ~Ruxf?;35ke$
zMy0D_-Rb)FP7FB#h2SAEQlU(#(E2x6Wj^XItpqDCcgD-`YX4sR
z>La-Az@$;oZKAA$ECn`~=q|J5*+;{tLz{UFBf}+4{KH^~>AGh1=
zAUVjh;7dW)HkAS)iB0j{%OL?9j-wQRx0=mPfFj;{pJG&Y;Jjn?2r5|%x}UTKKl-U5r@;Hel8rY)#i0fOPzXl{%sVO`pVc0h
zHHEdz&E&NDoBAfffUgr9hrZ7zMW!@rNo@Qzcmde3a7(6XM1J@E;a}l|zBl^qP-Fhj
zKJk##xq=2O@DAul?L{DofOW~j@`I&s*lU!c7)2o5Rw#NL)_GO)+OXJIB%K(~60=B8
zZm|Iq_iS;8>iTXr0TZprAg9Cn=|3T9sr2j2iZ}0N!Q@&VSMU;xX0((((d5<9V!AsN
z_IfK|w;vyRLOSvulb)@#>^T_+OS92hB*TZRx;Un>6k}=SH}$!-wNZMoJj~TB0@rQD
z;9}75LOX$BHc~pyPYh%*Tve8opY`QZ?RRDuU)3%d=GT$unO1J@dBv?-p7QZzp>}Bh2Z_``_49w70Y`D
zg+f-5u_yF^<{@|qk2q30R>9&Kv*!zwVa2$E-cQ>G#M(CDoon$Ns_RrRTBO*3i$kgd
z`iOy7*D3S0Re0PDlzrp)ZNF#ye1^OxF2u4wJ^enDzy{1#U)25UL)ImKj)Fw&irl9a!Is`eLO^bx;nAhOeEWF4hAx|wi}84)hP;_
zpED@Xfb0O$O99322TI%)oV8F+{eZ1ji!}s3t$1XneLF*n(iw>bjQ$R`a^dGP;#nIy
zQVS=OdURT`mefxGQ#v~79*|En>&unqk6oj|Ymqm})6$K$(~-{*;~i0;>%
z;3C1^Ac<5@q_i}?E2N!P-+;1?9!X~4u2~SsG1!ZLCK%FLU}KSZBDQ6G(Oj^Z2{F$e
z;g{(akFth-RAV64E~#z*+@5;}4PCD&wExAkq_+D$UVf0j(Hm>&X!1vR_5RT%B
z^^Bw>6;w1+F;z*dB|#bQPoJCTPNu;o3CS_nu+UH*WGf8lcSKNNa0_+4|3=3tGL6Xg
z6UEucAak58IkFIN!|WxyR?Jsw(+gD|{bIbvvB-A2exT(wIsVUwuOH%;%QJsfz|-{H
z6UkiIuU};DEcvo?J)d~eCkoOs&PF)p*O58JARXLe@|TyFIH0KU*qmVDaH&B(;JKSq
z77O$;@mN!X3Qv4uhdu#*utj7_feDQa3e5@%K1E$gB5@n>Ak1RgA>k38TxqA`yiSMZ
z??dWUIlzaO_T1VcNdkXU{_3HwqT=EX6buQ(?_o6qgX^lL3dn%FiDU9PzWeFM+1lES
zlD3_~yyzQ8Gqh=nst9at&6@(_M=~*(b-uY_7%%P^;SRfqPrW|(*!7M}RvQr!-)+%<
z7J~&;MZN}z^t56Uzq`^YU(Z8-B+b#cBgO!3zrU+B2lW}&FggZ~u6osq*4~2f9Bz1G
z;#7n3(-D#-@5%V^n5%|f<9O^X>?Byu`tj?ep|nC+6WB?L^%q*{mO|gk`WnX!)HKfM
zdAuiiDT|`TJ^g?9WCrh+Lc`gvilx4afnDPbD40Y(iZQhY7!5#@-fT
zOQ1CB40sLSTj{~>@8(P|-X4}t@pW5&5&_rIkkG0TMTs1NwmcaUWR!-EGKrBJ2+JFl
zQ8Gp$BnwNSyL7`z?y5&(e5z1v*-x|XnlAsm75)~{*cyTD8S8J@9-D4Yt$)%EMac3;
zsJh`s3RxT*LcMKjmOps}l$}tR&{#iNzHh>J6!n|TFNgQ{lhlUQ!bT}6u)PaX*#>QM
zz;9i?hY+KML&A2Q&2Wk&}PnD_P_d6|Vspi)ZG0k8-L6d%k>>we=}Tii?TUSFYYR
zbbgxX=%S#a!eTP&cfWT%`*{R>2YsF~6-qxhw&Ed%B%I^fzM8;N)6qrzZZNSJ-5Uyr
zy58*YQM2)}Ny;|}%*@iZ(By1p88IjG@owx2*LJLvOZj_2Ti{hlQ`?F>B#D>BYcbof
zfJ&K{&*kmP{x*oAw;mDO#LQH$sU^D|6Yl^nKw~;+O#q#L`C>aQ`+a}+q1w^$+tE`}
zE`v#$(`!_&8VmR5X%MIr+I>5b19Qe5I%=65Mw5L9#IRJN4q_S31a)ya9xq>PGDhtz*30j~FaCUu;Lbu8dk(U&Xzd
z-KT27k2A%k@4FMhxe>U&FVe9XX2!lxcQ*>XfE-Y<)Y|4|ytoGmWlc@mr>pHqrhXPU
zTVJt&b|6%di;UDDOcN1G(6z*=k+xN4%n}_PS4hDc0djr{-SKoLX@>wAotT(}1PuNg
zKmml`K#h#UZu6AO|3gaB((&dDy^Yb}`QyHCJxrMl+Hs-Vv7t5n_wBNHxK!FNO#?rt
zQJcO1H>>m!7Qj)$P7aSigM5`V|O
zUdDFnW@ZUI`%Phvi@Y)Af$3g{Bcf}ty@VKLvteOPFI&&s*$0?dSRQ{G&EAe=(<>nn
zrbQGTkjw7oiVlJmF_KEa!Q%C4AKcr(0@0Ot>Rf=ukOEy+l&Mv@Ao)@c<=l<3I-u
z6_qS37SHd`Unm@!ZS2H^dga%N;J3iRcoue
zg^wn&CCoeu$`ct6I)J9<=(;BHAuoxHsjY6WX8UayGHdY
zjhXnU$aw$Vy>QoPjA_iz#YHj3hX++Jh@#eB<@2K$y<*~;=61Z>hn~fcKTz*dHZIBK
zl2iutwj`MS0|mkT`(h+)d@7C=Ak*-HX30*^2;4(tEG&9qx$C#tq&ry6fz7|f)TZcR
zm@sV~&sS>W*J@gYR0X}Bw>;`zU*`h8kQG{e?8B!*$}Wn!lI=pOdSJ>!PjvXc!>^Bcm8<+
zv9J5Szvveh8W!R|ccD7D?~fz=C!{lC*Qpf-B^DPqAeljXO`)PCj}+t!en%%KO->|JimRBA&}AWsSVkN3!r$|#Szr{VA}CJ
zercL}S7PO)MizdvwA9p%b-T}G5&v64{toQ>gw{I}h-TKE_kDt@WN2%eHbwFLIlR41
zn@IjH#(T5J=YtgIj}tIxW$UC{w#Utx?vdbnA-!0I0oT|4J*X90ms*SMt0uDa=MQd+
z(a;#`0`m+43__gWVYndpOK$11=W0IFgAoH)cpf7*=TJB
znHevAyo~ksBxs3fJb7njweuCcQ^N?@9$u}{jeC7-i9BS`F_F8@(p7W0ZVVEBa&c4R
ztz+GE2369Ot?thzyO1{9-z>~DG{2a6eWoGM_OcEIqc_T$epbDCQ*gh%QIy+W2|-ur
z9I*w*@3O1%zT=sD5_~X=FcX1t;T_S1dsQL4_+FMA)?xdiO~b9&jz=?o&8f)sOmyFG
z`#d&TD4pGth)_rND1Xs<7@KQSadh0IV$)Q8rnNclQb7PDH@>6uhMh$vc_M
zz(*X1`okIuvtZ%HH}v>3dk39WB2eLKdaJYEOTxVnl&(J~e+Hj1i^zHHtufA0QBz|_
zl;F!pW6~>=6CD;~B9D|6WIhBZE6YKkHS%&c!Xr>$X@;rt%l}~a
z*w(|>Sq^;O`P@yj`_pPRq%o0dmN|mHWNl7835KUJB~-JK8=Qs_L;1FQmhldgUf}p%
z$+qjB0%|uOrxA@VsD|?bZSrnA<0MZ)aMG*)CUc`~$zJLfX1~#Fb=X?-heBLz41CAw
zWa}IpZcO|4#~=w-UMHFf_E_Fa88iGdhy|ihV0?4JSiK}E8dSZ6Bln6Vs+wYQJaSdY
z&k1x$L9LHDW
zE!~M47CI)$A=?pyIH;`xgm6=+S
zrYNxbY_ZPOiAOv7sV^?6bOF_M5*cg{02~I37KrZkO1FwX#6z76;5r39sedl7!UBPv
zd|w+3A1HoF!Qmy*^Xo)4#NIlgWDAvOx`(a
za~Uy+65NL~lv%El-3tiH>r0MONl-`Y79xy$PquyB>iV9nFQ5qh*btFX_?hgjbal5*
zj0jEW5OEL4sh5l#k5?Y7J_Oxy={xMh`3KLgqLHq8l?v=dwhRg8Te{Rd>0@MeD&1{(
z^(gGJRgae95@Du+D@4{fvc5C_tcs+V{1UadRcp0*fOqfZeO(9DrVw^RHXS7uxT}yR
z>|DwY)!-<}^qfAT_$et44ybvFCkQkwI-Dc;AhE62TVIc#g-;pz
z{(C%MBYyFksv+H|hW-VSG#p-&h+}$z5c+!kA9z%AhXmnu-_Re?M{=9FTU)Uh$qpFu
zQ0_N*wCw6!c>((E|CGCY75tp`RafzwiO2afQ4afUu6^Hy(1G8iVefEr1hp{nKy>+=
z+*OgOc`PgqkQXJd$F05;S?`f1KIi{qnEz9MA9zX
zeTkHD(tCTnpj8-f01zXTu9+5zAWKPM^pivga|3Nsh{X9ZdH>EiF?jt`H=Ry+flc6_
zFc-kO?epD3^rC)1;PbGcXtZr6CXC*A0?tj@e-b7-E+q}v<}|(DE~d`6-ytwruE3v4
z+j0|NU}*T~a5u{3(*{-ic$gEIa;Lm#$1FODrk_myW7hkx3tqcoJdyG;gUcBYRwh21
z#j1%)h#5UG#-psKK^#QXuAh#xwiyJg%(liEK};$!_=FE5AFB8h?}X4vtscCvUQ~>F
zj^8)}E`IPHlI%CGr&9X-6rU#|P521=M=0UNl_VlTKMWbOmV$XC9{18!Z8K6TRb6G$
zaTNHAHYS~3;3EOa<8{xwJ=#^~*ULeCSzp_9Y2f3?8m(kh!MRfN2E{)UQM3S
zd=iWQ_xsu?0iUy}sfDBunwsHzX$^1u_uLfYm)+grL>bbbV2Cibz5J_9>@XtZtpec?
zo-jCUl}KM}C5hwv)U&8v(ao2m!{gGNasGum1M@U$pBJgq?Y*Mj4)kdF7Gv81?q(#c
zU5;KIs{=}{Pv|t+S-wIm0Bg@tV6-;^$i0#*h*}f|N+1y{YDrGF!l=Gc+KS56I1eYIlqnr7bC-X#Y01}+XB4gigY^MIF|!s)oD+FyswYTx2*
z+oiPJ28YAC3C;5G@+It#>_K%scRFvYik$IO4AkM3R%>D5Va);`J=d2%-Q4(rjv#Iw
zREsGaey{(G7zgI`-TaBVoOU=t*dfXXlyZw={i1V7FdKGU4MF+@lls9QHFW7
zqoHn?%SL;NS{oAFh^lZXLL-D=Wn`NuPK@o~vIIo->zZ%E1gg_JwgfT*70x1qvh-tc
z$+ZJxhazyX2g&hkmCRi~SdH$;qOh8|^dUfgelkoC9gTK70j5A>l#KAXehB*u19XB2
zC-Y*6%6b*w1kMHuU`UP7HJF()P*sv*X|M81as_NkCkZJI4Zc3K=_xG5s(me1iU|Md
zIjpo%Q>1&2h|)871@WT5Oop0i?%+tru}2Au+FDL9^(7&d7lsB_BMK43o+ycX^jGxNe~
zwfiusab-AZIZ%;hc3_~!NE@p0mzJi$|yWC}nSf=V~{96k;Loe|P$1E=tF3_lP8!Jo63a1bJaAJ
z2Cc5zBXfCv0`DQ}wBf63D^SE7l9({&iK-Dqct4mHZ<$rP%yuH^xPuhHkhKvFz$mTc
zhG+$kh26}MQe!wpz6W0W*<7lObc?KbnTeG{rJ(GrB|3qU^_UmrjS3IEYMSaIXNy|I
z&b)A8LZfCf8wc-EXR^BoGVH5Fu!5e4N))bjyTU_Z6(V~+CB)Sg71|pXn+kkrIdYT}
zX|dUF!AxE615gCh0cWY{tn%i9c0iihg(~Pm(vR7tY`754
zPiS)Bv1kJjWC*&Ty>9c3OqMEVZjdLxn?fJI9}NVz-=`G=yswVZ1OC*TErs2hm6R|e
z3Vb9G^Ki_Hx^73E+=z3ktca&ALkcC*WRQ<5HQe4hPY$UTJLhm>~$
z_%VJQcn8LQ&Tpv8x(49NM|j+1cZ#{^(eaTC18@`fHwFeAJX!3WY>BQ0aym-9hpzg<
zGD?o+6$amO=sizLyaQ)ZV|h0D%+32ZL1441VPR>%n=J4Vpb@F*>8V#=vu1E`hJ6hg
z5`tY!aX?G#L_mJMPNCV5X_DCu%LAw2Pv}jHn#GZ8?GYZ9}$M+T|F9tW*&w0@#0f2F?7@w*zyOnKWL0m+Nsze>
z@&z*dEtwb9NPJIWtETGHhL`5@TBZLHg;RsC=YSzVVIc1!+Jj7i6eD5A@FSv-?>U6<
zqX1n;atPOy9N9*SqWKH{dxtiLyAdCW)!RLq|Bt4
zig6<@vI|YO#=ITJd*ufb@8t8nipoOuR(NJ2eFPy59hGB9`RrgYAFbNoBp+tQ%YSx+
z7wF@fT#%c!bKaLlI9K-NwEpC>dsJk_v*@+lNu=J5QSU%0Z4gTQ!xo6Q$1nc(w*qgC
zn)ZV{lYjoNDw`@lMsP@_S7bA(rGWUGO4afa>6?PjMQSOu)InzpemC{UrEWq@3bU>f$pGF~*Yk@b>j4cIpcA}Id*Ja`G4jCBLZdhu}@qYox$(sCS{?}hO&gYw>d
zvM7PrM9gv@gQvI2*7dogrd>A&V~C>!{8`yrT3Tqj>gsz-tG|r~bqa;c%Mk5bf(Lkz
z_P0VR6g#sf&w-Xc#3wNKZhH8kRYz~{*|}Pbop$}xR+tzJTF(1vnXIsUEP2*Pnh2Fj
zd2=OmLGkSOEi39r{*UnT=5k7^*$omD@S4lf(twH%A-J4L{VzZ4HY>Px)7tM6}
zwbg}VFx8MzsC)GeUj|+1HO({(rzip>1c>3dk3zsy5t5$&xZn8!49NJy1@fU6!BEd)
zap%t7emDcX-L4e(wO8)1W?*@r3UIkJ(Cg_qF1S|Z203Z|qAN<|5m(M>44gJWqP
z_~BZdKO-pn;INT0R)x8fd_9;3YtQ00PHixN{amfsfdr)9g+1l*{GjT|UjSiDDz47T
z%A~4DA|AtGh+*l~T>)GGQ;`+qKzYgYBm2-`KLLgzvahylXd5)&Bb)>y)nk8ea7VX(
z6uzQ*g%52Pk`57|?u0Pi&s0F-%zHQxhUghp{cyqb
zOD0UJ=)pE5DyglhE1M7WqtL5hV8g8pQ4tk(n#-qk<+f{mG71TI493hIWuW7(8YNka
zKv@{4&V|D?Lg$Whb#aBZUNk4Y9pIiYSvcOi4wCY7c)tkoY3q%QhewMxUttNNLo|>qoycKFVHGol7^j
zilLC^%Zkm!G~r?1|0TZy?t}>w3rpZ3&8qLH`b%~|leltqd1(z54&Z7IC%~2T%d}YZ
z4~X4Z6qopPq4Uo$F%E?st`-#b6jE-SDyp3o?+Ar)ldy>T85w%$T_+_9H($Cb265mp
zP;VcFTHzT^NRLkwmU5v)Jvs0`|BhhI>r6OPG5C(h{XC~K(!&vZ0E?KPpWmD>X)|C8
zpXll{y;OEUasWRFiLpWJd%FI32vH5+jy{8B332v@YTXL1h=BPlV`TwK+W;5u;`#@k
z+A*;_;;zJO+x{WwT@{Hr8WFcV*#{|RvFt7`UK&Fm7&usY0z01e{|iYGw(d&7
zL*~bxV#t%ZQDKrM4ratKMA}+FXf-6G7OEu6!CP*cbSd~KwAKin*$19_4VO;OKY3i}
zFv5)n_*eiMR=yrr4_GuIDWM)0Q!TsLDJiNbP=Fo8BVd4$a++XsPl_6blX!k%2d6O0
zZ2$le#z{m$RDdgivMRX@4DV#CPzXjWiKYTQuf{%zh<`~)i8WghL=jux&6bFSH|ep?
zv0q_fVIVAg>KA_V8}aCqPm1xArpQ=y##ka0LyCZ;q$GI{=3-sKX@D1COA$esi5Sj$
z_*{XT$K)PPkj-&1xmoUlD5RCt^TF8^EmAJ=0uK?m=k2)J2SL$6%aEA@e4MPS<0Usu
zlAI6mpe}DJH}BfDE2*}jE@JNd#hL+w28s*>pw65#S5}?E^x%R0>Q{dITjOWjwxvA%
z;~z&q`|Ps~I4o;VwS&MgidZ?|;dTH?*-b1ZGLkx8B6)DaHJ+P@E)L>KjnZmJ0WRpK
zbp5ps3ULT*^v
z4XibM)Mznr(q!LbIY+X8@xlw^P(gSUWc|_6a>Wsk$N>c0Lzjp@>;Z6zIS9nD2{~P2
zC~@@QMdIO{GN8g~4-MpCOg0h)baFdQ%gmEA1=+i3b?AUiUyz_T0s3C>wN_h47(zr+
z1A~K&v53vF!-B^SlNivnD&s7%zhM4+jjxYi6hg$uz#I{eL(uxLuDwQ&>%h3^{iG=t
zH^Q?cF$Puuiyfp^8r=Bm4sO&~sO|6~PD&-P%*$wm$_iJZf%ACD;z$bg$~F8&OQhW<
zr)#o^PQ6Nh)2NuTLimtxN<%EBuDYrrq`9>%GA1EWhlSxV+69u5U2zqFp_<3ej^3e>
zqsRE{-L*4)<$Eg!Jo&_v<={dU*UOhE!1RcjwJ=12^F81bISp93x~fX7UHuW)<`*MI
zV}4a^g78HYf*$4wnw;5bat>4#f}Tzz>KVGbSiE!zrUFGt{hr+Jb&9al=1`!kI=xR9I|is
zz7&A(j~RI?*jwCA7}DgjjT{K!CM`@R7YT?1T=}mXahAKce%Ns{yV
zGA}u52O9wdSxIl_$vBv*vNBA`+%2Qkd@(2*GGr+G0}Vqd@J;~Q(Yw%k8ek^q!K<5m
zu!aJ?8q)o!^qjPZVh9I0O`FV+;n^Yy(|N327F$P4bLmUJ`OQ|WUZVcR3%^*Dnx2-t
ze8mc>>&T^Ja1Za5wk3SibU|WrF+BMmEIh~8&m`Ao#^IYPN$|OR37Rd2R^TXvUMt;3(MRE+iUzCWEJhbksLX-r%K9@9(FBdEC6
zGdn0e#q$_ma1$ihWToHJmY$K(6d4{_TUuJ0fw$`zeaTJKc#x<9LeSd%eSHewdVkgX
z&CQ(`;6CG&c80}SA@hAhLP8MD=Mo*T|FETSo)lG04qMTr}W=~M`B7UJRh);k$SubbR$Es!zE!6orbUvOhce_U4wZ*y?lpx$5+BIZHA
zX`9*XUyb{$tq@6MV)h&IKI9?C%e)`pa{y?;?v7!`P5F33#L4i$1lfKhp>7dFZO%u>H<)y^@
z;*>LQ(f@p!gpKOnfCImi`YAf494@@3-
zklSkQapI+Dy$DaFUqPWTc5ap=J6sl)+z-yI_}SA>e^yyu9kYG=_DTQwR#@ze$AC?vg)>JcJPr~E@*dp~;P1gIARXj%fv%Y)J&p
z4x?HpT0=Wtt?KFpcQY!Ppwsj~%P7@MiXy&!+S1wSw)ghh5teAdq>az{7QR@x8};`^
z&SwG1(MWBWSd#PFtFKo*^`jr}ID5V@EH*KwH9a}=0xr4Iawc#%>Dmx_SA(_3`mpkn
z$zk&hv=D4$gAE3l0YGf%fdQV|V(H?7+)ao!W*7&p@fwrf>@*ooE;w!6T%s2%KgsJk
zz#{n=^UuT=O>zlF1U$Jzfs`1l<7EV4(Wbb|X>jLvhVFf+zLAhjJbhh_&S=eE7MFEJape}^!F^`)L{4a_f)Imw8
zECYksky+y6R6YFdJ$IRJtgRo`n-CY>?$)Y$5&MHtE=;~K0dRse^b{%kDKhxFB?~)b
z5FjuxM8#J&d6EN`$xeuoMl3qf2Xlj)GM+7lqDO|KK#XyQNf%yl*4@?Rz`W#ky-CZK
zLk;ZT2YfFg0R37j<;Ezu8=6bCUZrZs`)yp*^EnWr$wC5%3~805qg}oouCLb-6c8Yz
zA3VT0p-Oh@b-E@H&2H)B(lCl0M}|iG>6&1^gXPp0fElk4a+%V~iV&{5Fh>p*W
z3CeA)Ep1LNxl8VsCSWM+l8ZqA_62ygH^&WFbJD}#{wWn6zQohv`t;&rhoz?z12Y9vvNXjN-#!h8
z50~^+K_k+^vKd=dTa}5^F(?CP0OBDxPx?k;2bE<|LvTq^$-rO#_BS)J(A<>vj;@RW
zS%Y-+!D6ilfr$o+NuRI)MVcxw89|C8bkao1D=yG!xNW%|k~n0)Askggki_eaX|?N|_W
z{8LXowIVk+cXUi*Qs~|H-mjl9VY14{&qt1O2`xhdsp&v}k{S?1E@JKK;y|kx=6)2BdOnOhPCLG#t`5Kg)KC2IM`|n;8N7Sf&QX7Q^RJP4
zr}N{#_s~NdFfr~p2yk&3_ZtD^E0y88!52$~DerJ@*3&=x>9V}Mg2@x7&kX#|ckkCk
zL_{mLcF23#1p+vBwhDvuHQsn1Nw?Jw&800UG>E2P>gX~B1VDC}%r1m~yKOL0(16C(
z#AT91#0R3DX(?qC_3}IJRE-)l&a`IrN2!O795y%*77ZIw*R*NV@^Fv0BUkRf0Y9%x
z=7Y1eSeHJ0<-6~W$va&za{SaOfsa1%geCyaaH^MFc!*sfwG)EQPjWu@Y?z^lb@d%O
zjiw_!BAT}2OEi^0%(XJ8uy(PFFEQgNN)nD4;C@n*5o%m!_~ua@mDVE#T>uwbY0-1&}xJZ1|2<`li?o_7=kx5V9Kfv4tT`+02~_A
zIMWP%LfYT
zm>aN?c!LA)CxN}p+BkIJfMbt7`shAXk~05N9o-JgX$9;yd+HkMqVe{LkvT^W2FJxE
z7+^QjgoZ_^{ILcERv2esAtx@ox$FyGZs{&A;<}?f)f2{yxqt;tX=1)K-+vXJU+X#S
z39l=;-Sxib&NcRX{q;Z1*}i?}M68w1_qJ;2fui*ilW}Dv8kb_nlua9~kF6=mDP`j)
zjw`(L_B)Tq#RU~%!M`spx6A$nU1i9b^3$0ge6VI1w5YU_($WOX3kZeN-H3G$G|>Nb
z5IkrxE(Z*EPK2}c#KuHb&6+XmFjf-ZgI9GFp*7I8ctfY_2A{P8P@Mi34e=9w=+L1M
zgdM5@l5}u0;=vElppaPAvhFyXnQ$6`Vx8=Wy+<--9_T5m>1Mhje^oHxKuyw}r*VE5lR5h!e2
zQxX%2<@*4+ex-Ng`vJKbu`#&ns_zQ?B22hm1p;vdt;5C
Date: Mon, 13 Apr 2026 17:52:06 -0600
Subject: [PATCH 12/29] feat: Export all sample activities in manifest
---
maps3d-compose-demo/src/main/AndroidManifest.xml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index 37e8533d..c1d2e90c 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -38,20 +38,20 @@
+ android:exported="true" />
+ android:exported="true" />
From 4bf066449fc4d58acb8c8b3533ec7cba0ef7b0ef Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:53:48 -0600
Subject: [PATCH 13/29] feat: Remove unnecessary Back buttons from sample
activities
---
.../maps3dcomposedemo/CameraControlsActivity.kt | 15 +++------------
.../example/maps3dcomposedemo/HelloMapActivity.kt | 15 +++------------
.../maps3dcomposedemo/MapInteractionsActivity.kt | 15 +++------------
.../example/maps3dcomposedemo/MarkersActivity.kt | 15 +++------------
4 files changed, 12 insertions(+), 48 deletions(-)
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
index 0c009f0d..3a461159 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
@@ -52,7 +52,7 @@ class CameraControlsActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- CameraControlsScreen { finish() }
+ CameraControlsScreen()
}
}
}
@@ -60,7 +60,7 @@ class CameraControlsActivity : ComponentActivity() {
}
@Composable
-fun CameraControlsScreen(onBackClick: () -> Unit) {
+fun CameraControlsScreen() {
var isMapSteady by remember { mutableStateOf(false) }
// Calibrated camera centered around Seattle (Space Needle area)
@@ -91,15 +91,6 @@ fun CameraControlsScreen(onBackClick: () -> Unit) {
}
)
- // Back button
- FloatingActionButton(
- onClick = onBackClick,
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(16.dp)
- .statusBarsPadding()
- ) {
- Text("Back")
- }
+
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
index e7b7f626..0791d927 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
@@ -53,7 +53,7 @@ class HelloMapActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- HelloMapScreen { finish() }
+ HelloMapScreen()
}
}
}
@@ -61,7 +61,7 @@ class HelloMapActivity : ComponentActivity() {
}
@Composable
-fun HelloMapScreen(onBackClick: () -> Unit) {
+fun HelloMapScreen() {
var isMapSteady by remember { mutableStateOf(false) }
val flatironsCamera = remember {
@@ -91,15 +91,6 @@ fun HelloMapScreen(onBackClick: () -> Unit) {
}
)
- // Back button
- FloatingActionButton(
- onClick = onBackClick,
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(16.dp)
- .statusBarsPadding()
- ) {
- Text("Back")
- }
+
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
index f7ad487e..649b24bb 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
@@ -55,7 +55,7 @@ class MapInteractionsActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- MapInteractionsScreen { finish() }
+ MapInteractionsScreen()
}
}
}
@@ -63,7 +63,7 @@ class MapInteractionsActivity : ComponentActivity() {
}
@Composable
-fun MapInteractionsScreen(onBackClick: () -> Unit) {
+fun MapInteractionsScreen() {
var isMapSteady by remember { mutableStateOf(false) }
var clickedInfo by remember { mutableStateOf("Click on the map to see details") }
var map3dInstance by remember { mutableStateOf(null) }
@@ -108,16 +108,7 @@ fun MapInteractionsScreen(onBackClick: () -> Unit) {
}
)
- // Back button
- FloatingActionButton(
- onClick = onBackClick,
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(16.dp)
- .statusBarsPadding()
- ) {
- Text("Back")
- }
+
// Click Info Card
Card(
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
index 5c1353b0..703c631b 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
@@ -61,7 +61,7 @@ class MarkersActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- MarkersScreen { finish() }
+ MarkersScreen()
}
}
}
@@ -69,7 +69,7 @@ class MarkersActivity : ComponentActivity() {
}
@Composable
-fun MarkersScreen(onBackClick: () -> Unit) {
+fun MarkersScreen() {
var isMapSteady by remember { mutableStateOf(false) }
// Camera centered on Devils Tower
@@ -139,15 +139,6 @@ fun MarkersScreen(onBackClick: () -> Unit) {
}
)
- // Back button
- FloatingActionButton(
- onClick = onBackClick,
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(16.dp)
- .statusBarsPadding()
- ) {
- Text("Back")
- }
+
}
}
From 1c0604545d878593477a043b76d93d9a40680550 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 17:55:46 -0600
Subject: [PATCH 14/29] feat: Support onClick callback in MarkerConfig and use
it in MarkersActivity
---
.../maps3dcomposedemo/MarkersActivity.kt | 65 ++++++++++---------
.../maps/android/compose3d/DataModels.kt | 3 +-
.../maps/android/compose3d/Map3DState.kt | 10 ++-
3 files changed, 47 insertions(+), 31 deletions(-)
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
index 703c631b..2c4a6ab2 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
@@ -89,43 +89,31 @@ fun MarkersScreen() {
val context = LocalContext.current
var activePopover by remember { mutableStateOf(null) }
+ var googleMap3DInstance by remember { mutableStateOf(null) }
- Box(
- modifier = Modifier
- .fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
- ) {
- GoogleMap3D(
- camera = devilsTowerCamera,
- mapMode = Map3DMode.HYBRID,
- modifier = Modifier.fillMaxSize(),
- onMapSteady = {
- isMapSteady = true
+ val alienMarker = remember {
+ MarkerConfig(
+ key = "alien",
+ position = latLngAltitude {
+ latitude = 44.59054845363309
+ longitude = -104.715177415273
+ altitude = 10.0
},
- onMapReady = { googleMap3D ->
- val alienMarker = googleMap3D.addMarker(markerOptions {
- position = latLngAltitude {
- latitude = 44.59054845363309
- longitude = -104.715177415273
- altitude = 10.0
- }
- zIndex = 1
- label = "Devil's Tower Alien"
- isExtruded = true
- isDrawnWhenOccluded = true
- altitudeMode = AltitudeMode.RELATIVE_TO_MESH
- setStyle(ImageView(R.drawable.alien))
- })
-
- alienMarker?.setClickListener {
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH,
+ styleView = ImageView(R.drawable.alien),
+ label = "Devil's Tower Alien",
+ isExtruded = true,
+ isDrawnWhenOccluded = true,
+ onClick = { marker ->
+ googleMap3DInstance?.let { map ->
val textView = android.widget.TextView(context).apply {
text = "They didn't just come to sculpt mashed potatoes."
setPadding(32, 16, 32, 16)
setTextColor(Color.BLACK)
setBackgroundColor(Color.WHITE)
}
- val newPopover = googleMap3D.addPopover(popoverOptions {
- positionAnchor = alienMarker
+ val newPopover = map.addPopover(popoverOptions {
+ positionAnchor = marker
altitudeMode = AltitudeMode.ABSOLUTE
content = textView
autoCloseEnabled = true
@@ -138,6 +126,25 @@ fun MarkersScreen() {
}
}
)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = devilsTowerCamera,
+ mapMode = Map3DMode.HYBRID,
+ markers = listOf(alienMarker),
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ onMapReady = { instance ->
+ googleMap3DInstance = instance
+ }
+ )
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index 26dea034..2ee3d207 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -36,7 +36,8 @@ data class MarkerConfig(
val zIndex: Int = 0,
val isExtruded: Boolean = false,
val isDrawnWhenOccluded: Boolean = false,
- val collisionBehavior: Int = CollisionBehavior.REQUIRED
+ val collisionBehavior: Int = CollisionBehavior.REQUIRED,
+ val onClick: ((com.google.android.gms.maps3d.model.Marker) -> Unit)? = null
)
/**
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
index 85a256d4..b0f21e09 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -79,7 +79,7 @@ class Map3DState {
}
private fun createMarker(map: GoogleMap3D, config: MarkerConfig): Marker? {
- return map.addMarker(markerOptions {
+ val marker = map.addMarker(markerOptions {
position = config.position
altitudeMode = config.altitudeMode
config.styleView?.let { setStyle(it) }
@@ -89,6 +89,14 @@ class Map3DState {
isDrawnWhenOccluded = config.isDrawnWhenOccluded
collisionBehavior = config.collisionBehavior
})
+
+ config.onClick?.let { callback ->
+ marker?.setClickListener {
+ callback(marker)
+ }
+ }
+
+ return marker
}
/**
From 29699c1c043da259d20b292aa23db6e9c068b507 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 13 Apr 2026 18:03:36 -0600
Subject: [PATCH 15/29] refactor: Clean up fully qualified class names in
DataModels and MarkersActivity
---
.../main/java/com/example/maps3dcomposedemo/MarkersActivity.kt | 3 ++-
.../main/java/com/google/maps/android/compose3d/DataModels.kt | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
index 2c4a6ab2..20ab05d6 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
@@ -48,6 +48,7 @@ import androidx.compose.ui.platform.LocalContext
import com.google.android.gms.maps3d.model.markerOptions
import com.google.android.gms.maps3d.model.popoverOptions
import com.google.android.gms.maps3d.Popover
+import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
@@ -89,7 +90,7 @@ fun MarkersScreen() {
val context = LocalContext.current
var activePopover by remember { mutableStateOf(null) }
- var googleMap3DInstance by remember { mutableStateOf(null) }
+ var googleMap3DInstance by remember { mutableStateOf(null) }
val alienMarker = remember {
MarkerConfig(
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index 2ee3d207..976acdf6 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -22,6 +22,7 @@ import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.CollisionBehavior
import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.Marker
/**
* Data class representing a Marker to be added to the 3D map.
@@ -37,7 +38,7 @@ data class MarkerConfig(
val isExtruded: Boolean = false,
val isDrawnWhenOccluded: Boolean = false,
val collisionBehavior: Int = CollisionBehavior.REQUIRED,
- val onClick: ((com.google.android.gms.maps3d.model.Marker) -> Unit)? = null
+ val onClick: ((Marker) -> Unit)? = null
)
/**
From 667fb6e6acc3f0831e18eb4d8917c17481e3ae93 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 11:03:46 -0600
Subject: [PATCH 16/29] feat: Migrate Sanitas Loop polyline to maps3d-compose
- Added PolylineConfig enhancements (drawsOccludedSegments, onClick).
- Created PolylinesActivity with Sanitas Loop demo (double polyline, hovering).
- Wired click listener to show Toast via rememberCoroutineScope.
- Removed useless MAPS_API_KEY warning from build files.
---
.../ApiDemos/java-app/build.gradle.kts | 5 +-
.../ApiDemos/kotlin-app/build.gradle.kts | 5 +-
Maps3DSamples/advanced/app/build.gradle.kts | 5 +-
.../maps3dcomposedemo/Maps3DVisualTest.kt | 121 +-
.../src/main/AndroidManifest.xml | 8 +
.../com/example/maps3dcomposedemo/Data.kt | 1554 +++++++++++++++++
.../example/maps3dcomposedemo/MainActivity.kt | 12 +-
.../maps3dcomposedemo/PolygonsActivity.kt | 134 ++
.../maps3dcomposedemo/PolylinesActivity.kt | 163 ++
.../maps/android/compose3d/DataModels.kt | 8 +-
.../maps/android/compose3d/GoogleMap3D.kt | 3 +-
.../maps/android/compose3d/Map3DState.kt | 34 +-
.../google/maps/android/compose3d/Mappers.kt | 15 +
snippets/java-app/build.gradle.kts | 5 +-
snippets/kotlin-app/build.gradle.kts | 5 +-
15 files changed, 2017 insertions(+), 60 deletions(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
index 648ead02..785d19aa 100644
--- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
@@ -64,10 +64,7 @@ if (!isCI) {
if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
-
- if (secrets.getProperty("MAPS_API_KEY") != null) {
- println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.")
- }
+ }
}
}
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
index e50557d5..f7dfd9f1 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
@@ -65,10 +65,7 @@ if (!isCI) {
if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
-
- if (secrets.getProperty("MAPS_API_KEY") != null) {
- println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.")
- }
+ }
}
}
}
diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts
index ce0330ad..5ec9af49 100644
--- a/Maps3DSamples/advanced/app/build.gradle.kts
+++ b/Maps3DSamples/advanced/app/build.gradle.kts
@@ -64,10 +64,7 @@ if (!isCI) {
if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
-
- if (secrets.getProperty("MAPS_API_KEY") != null) {
- println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.")
- }
+ }
}
}
}
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 5973afb2..16f490b4 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -112,16 +112,16 @@ class Maps3DVisualTest : BaseVisualTest() {
)
}
- @org.junit.Test
- fun verifyCameraControlsRenders() = kotlinx.coroutines.runBlocking {
+ @Test
+ fun verifyCameraControlsRenders() = runBlocking {
// Launch CameraControlsActivity directly
val intent = Intent(context, CameraControlsActivity::class.java).apply {
- addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
// Wait for the activity to be displayed
- uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
// Wait for the map to render and tiles to load
waitForMapRendering(60)
@@ -146,23 +146,23 @@ class Maps3DVisualTest : BaseVisualTest() {
println("Gemini's analysis: $geminiResponse")
// Assert on Gemini's response
- org.junit.Assert.assertTrue(
+ assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
geminiResponse?.contains("PASSED", ignoreCase = true) == true
)
}
- @org.junit.Test
+ @Test
fun verifyMapInteractionsRenders() {
- kotlinx.coroutines.runBlocking {
+ runBlocking {
// Launch MapInteractionsActivity directly
- val intent = android.content.Intent(context, MapInteractionsActivity::class.java).apply {
- addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ val intent = Intent(context, MapInteractionsActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
// Wait for the activity to be displayed
- uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
// Wait for the map to render and tiles to load
waitForMapRendering(60)
@@ -187,17 +187,17 @@ class Maps3DVisualTest : BaseVisualTest() {
// Wait for the click info card to update with text containing "Clicked"
val textUpdated = uiDevice.wait(
- androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.descContains("Clicked")),
+ Until.hasObject(By.descContains("Clicked")),
5000
)
- org.junit.Assert.assertTrue("Card text did not update after click", textUpdated)
+ assertTrue("Card text did not update after click", textUpdated)
// Verify that the text contains coordinates or place ID
- val cardObject = uiDevice.findObject(androidx.test.uiautomator.By.descContains("Clicked"))
+ val cardObject = uiDevice.findObject(By.descContains("Clicked"))
val description = cardObject.contentDescription
println("Card text: $description")
- org.junit.Assert.assertTrue(
+ assertTrue(
"Card text should contain 'Location' or 'Place ID'",
description.contains("Location") || description.contains("Place ID")
)
@@ -207,17 +207,17 @@ class Maps3DVisualTest : BaseVisualTest() {
}
}
- @org.junit.Test
+ @Test
fun verifyMarkersRenders() {
- kotlinx.coroutines.runBlocking {
+ runBlocking {
// Launch MarkersActivity directly
- val intent = android.content.Intent(context, MarkersActivity::class.java).apply {
- addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ val intent = Intent(context, MarkersActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
// Wait for the activity to be displayed
- uiDevice.wait(androidx.test.uiautomator.Until.hasObject(androidx.test.uiautomator.By.pkg(context.packageName).depth(0)), 10000)
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
// Wait for the map to render and tiles to load
waitForMapRendering(60)
@@ -240,7 +240,88 @@ class Maps3DVisualTest : BaseVisualTest() {
println("Gemini's analysis: $geminiResponse")
// Assert on Gemini's response
- org.junit.Assert.assertTrue(
+ assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
+ }
+
+ @Test
+ fun verifyPolylinesRenders() {
+ // TODO: Add test for polyline click listener when we can reliably click on polylines.
+ runBlocking {
+ // Launch PolylinesActivity directly
+ val intent = Intent(context, PolylinesActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("polylines_sanitas.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that a red polyline (line) is visible on the map, representing a trail.
+
+ If the map is visible and the red polyline is seen, reply with "PASSED".
+ Otherwise, report what you see.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
+ }
+
+ @Test
+ fun verifyPolygonsRenders() {
+ runBlocking {
+ // Launch PolygonsActivity directly
+ val intent = Intent(context, PolygonsActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("polygons_denver_zoo.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that a yellow translucent polygon with a green border is visible on the map.
+
+ If the map is visible and the yellow polygon is seen, reply with "PASSED".
+ Otherwise, report what you see.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
geminiResponse?.contains("PASSED", ignoreCase = true) == true
)
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index c1d2e90c..fc08cf6f 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -52,6 +52,14 @@
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt
new file mode 100644
index 00000000..94486584
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt
@@ -0,0 +1,1554 @@
+// 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.example.maps3dcomposedemo
+
+/**
+ * A trail up Mount Sanitas in Boulder, Colorado.
+ */
+internal val sanitasLoop = """
+ 40.0201040, -105.2976640
+ 40.0201080, -105.2976450
+ 40.0201640, -105.2975120
+ 40.0202200, -105.2973740
+ 40.0202500, -105.2972760
+ 40.0202960, -105.2971410
+ 40.0203080, -105.2970990
+ 40.0203320, -105.2970070
+ 40.0203640, -105.2969400
+ 40.0203710, -105.2969250
+ 40.0203770, -105.2969220
+ 40.0203910, -105.2969130
+ 40.0203940, -105.2969120
+ 40.0204200, -105.2969130
+ 40.0204630, -105.2968910
+ 40.0205270, -105.2968280
+ 40.0206030, -105.2967570
+ 40.0206590, -105.2966100
+ 40.0206990, -105.2964870
+ 40.0207290, -105.2963090
+ 40.0207300, -105.2963070
+ 40.0207490, -105.2963130
+ 40.0208050, -105.2963460
+ 40.0208120, -105.2963490
+ 40.0209090, -105.2963280
+ 40.0209720, -105.2963220
+ 40.0209820, -105.2963320
+ 40.0209940, -105.2963650
+ 40.0210100, -105.2963890
+ 40.0210470, -105.2964310
+ 40.0210580, -105.2964600
+ 40.0210620, -105.2964670
+ 40.0210700, -105.2964730
+ 40.0210930, -105.2964810
+ 40.0211260, -105.2964920
+ 40.0211330, -105.2964940
+ 40.0211460, -105.2964970
+ 40.0211790, -105.2964880
+ 40.0211850, -105.2964900
+ 40.0211940, -105.2964990
+ 40.0212070, -105.2965170
+ 40.0212440, -105.2965230
+ 40.0212690, -105.2965050
+ 40.0212820, -105.2964860
+ 40.0213030, -105.2964820
+ 40.0213910, -105.2965070
+ 40.0214170, -105.2965640
+ 40.0214310, -105.2966060
+ 40.0214510, -105.2966250
+ 40.0214610, -105.2966250
+ 40.0214900, -105.2966160
+ 40.0215010, -105.2966140
+ 40.0215420, -105.2966170
+ 40.0215490, -105.2966240
+ 40.0215680, -105.2966660
+ 40.0215850, -105.2966580
+ 40.0215670, -105.2967210
+ 40.0215720, -105.2967320
+ 40.0216080, -105.2967810
+ 40.0216000, -105.2967920
+ 40.0215890, -105.2968390
+ 40.0215740, -105.2968660
+ 40.0215710, -105.2968720
+ 40.0215470, -105.2969270
+ 40.0215430, -105.2969400
+ 40.0215280, -105.2969570
+ 40.0214980, -105.2970100
+ 40.0214950, -105.2970270
+ 40.0214980, -105.2970750
+ 40.0215000, -105.2971370
+ 40.0215080, -105.2971650
+ 40.0215100, -105.2971650
+ 40.0215240, -105.2971730
+ 40.0215250, -105.2971720
+ 40.0215290, -105.2971720
+ 40.0215290, -105.2971710
+ 40.0215290, -105.2971630
+ 40.0215270, -105.2971670
+ 40.0215180, -105.2972120
+ 40.0215070, -105.2972370
+ 40.0214640, -105.2973310
+ 40.0214550, -105.2973440
+ 40.0214360, -105.2973800
+ 40.0214170, -105.2974290
+ 40.0213830, -105.2974730
+ 40.0213650, -105.2974980
+ 40.0213680, -105.2975510
+ 40.0213580, -105.2976040
+ 40.0213680, -105.2976650
+ 40.0213750, -105.2976830
+ 40.0213900, -105.2976930
+ 40.0213970, -105.2977160
+ 40.0213920, -105.2977290
+ 40.0213900, -105.2977480
+ 40.0213930, -105.2977630
+ 40.0214020, -105.2977960
+ 40.0214160, -105.2978200
+ 40.0214300, -105.2978340
+ 40.0214340, -105.2978420
+ 40.0214310, -105.2978830
+ 40.0214340, -105.2979180
+ 40.0214370, -105.2979360
+ 40.0214660, -105.2979600
+ 40.0214740, -105.2979790
+ 40.0214800, -105.2979830
+ 40.0215000, -105.2979910
+ 40.0215080, -105.2980210
+ 40.0215110, -105.2980290
+ 40.0215200, -105.2980420
+ 40.0215480, -105.2980680
+ 40.0215530, -105.2980690
+ 40.0215690, -105.2980540
+ 40.0215930, -105.2980560
+ 40.0215980, -105.2980760
+ 40.0216060, -105.2980770
+ 40.0216220, -105.2980850
+ 40.0216290, -105.2980880
+ 40.0216460, -105.2981000
+ 40.0216670, -105.2981260
+ 40.0216740, -105.2981480
+ 40.0216770, -105.2981580
+ 40.0216810, -105.2981680
+ 40.0216980, -105.2981840
+ 40.0217130, -105.2982030
+ 40.0217270, -105.2982190
+ 40.0217490, -105.2982340
+ 40.0217780, -105.2982490
+ 40.0217890, -105.2982530
+ 40.0218190, -105.2982880
+ 40.0218230, -105.2982990
+ 40.0218330, -105.2983010
+ 40.0218490, -105.2983030
+ 40.0218840, -105.2982950
+ 40.0218970, -105.2982890
+ 40.0219190, -105.2982790
+ 40.0219350, -105.2982620
+ 40.0219670, -105.2982660
+ 40.0219710, -105.2982700
+ 40.0219930, -105.2983070
+ 40.0220040, -105.2983340
+ 40.0220350, -105.2983630
+ 40.0220870, -105.2983850
+ 40.0220870, -105.2983820
+ 40.0221100, -105.2983540
+ 40.0221360, -105.2983430
+ 40.0221470, -105.2983430
+ 40.0221620, -105.2983330
+ 40.0221870, -105.2983190
+ 40.0222350, -105.2983130
+ 40.0222710, -105.2982950
+ 40.0222850, -105.2982950
+ 40.0223160, -105.2982870
+ 40.0223250, -105.2982920
+ 40.0223410, -105.2983040
+ 40.0223890, -105.2983220
+ 40.0224060, -105.2983260
+ 40.0224260, -105.2983290
+ 40.0224320, -105.2983270
+ 40.0224440, -105.2983310
+ 40.0224840, -105.2983290
+ 40.0224890, -105.2983310
+ 40.0224900, -105.2983330
+ 40.0225380, -105.2983600
+ 40.0225490, -105.2983650
+ 40.0225830, -105.2983730
+ 40.0225900, -105.2983740
+ 40.0226190, -105.2983930
+ 40.0226310, -105.2984710
+ 40.0226420, -105.2984710
+ 40.0226520, -105.2984700
+ 40.0226630, -105.2984700
+ 40.0226790, -105.2984670
+ 40.0227100, -105.2984830
+ 40.0227230, -105.2984930
+ 40.0227370, -105.2984810
+ 40.0227400, -105.2984820
+ 40.0227550, -105.2984930
+ 40.0227960, -105.2985050
+ 40.0228030, -105.2985160
+ 40.0228200, -105.2985270
+ 40.0228320, -105.2985310
+ 40.0228380, -105.2985310
+ 40.0228600, -105.2985560
+ 40.0228660, -105.2985600
+ 40.0228770, -105.2985560
+ 40.0228830, -105.2985570
+ 40.0228900, -105.2985590
+ 40.0229210, -105.2985790
+ 40.0229390, -105.2985990
+ 40.0229510, -105.2986240
+ 40.0230030, -105.2986460
+ 40.0230410, -105.2986660
+ 40.0230680, -105.2986750
+ 40.0231020, -105.2986900
+ 40.0231320, -105.2986980
+ 40.0231910, -105.2987450
+ 40.0232120, -105.2987550
+ 40.0232230, -105.2987590
+ 40.0232660, -105.2987540
+ 40.0232920, -105.2987570
+ 40.0233090, -105.2987630
+ 40.0233560, -105.2987770
+ 40.0234730, -105.2987670
+ 40.0235930, -105.2987510
+ 40.0236340, -105.2987430
+ 40.0236560, -105.2987430
+ 40.0236810, -105.2987430
+ 40.0237340, -105.2987310
+ 40.0238230, -105.2987030
+ 40.0238450, -105.2986990
+ 40.0238800, -105.2987020
+ 40.0239000, -105.2987190
+ 40.0239100, -105.2987370
+ 40.0239260, -105.2987690
+ 40.0239460, -105.2988040
+ 40.0239990, -105.2988670
+ 40.0240350, -105.2989020
+ 40.0240550, -105.2989150
+ 40.0240600, -105.2989170
+ 40.0240680, -105.2989230
+ 40.0240750, -105.2989280
+ 40.0240780, -105.2989360
+ 40.0241120, -105.2989990
+ 40.0242240, -105.2990910
+ 40.0242650, -105.2990930
+ 40.0242920, -105.2991000
+ 40.0243420, -105.2991160
+ 40.0244060, -105.2991360
+ 40.0244160, -105.2991380
+ 40.0244660, -105.2991390
+ 40.0244770, -105.2991420
+ 40.0244970, -105.2991480
+ 40.0245320, -105.2991610
+ 40.0245770, -105.2991810
+ 40.0246660, -105.2991740
+ 40.0246750, -105.2991730
+ 40.0247120, -105.2991710
+ 40.0247460, -105.2991620
+ 40.0247690, -105.2991650
+ 40.0248050, -105.2991710
+ 40.0248510, -105.2991710
+ 40.0248910, -105.2992000
+ 40.0248980, -105.2992110
+ 40.0249060, -105.2992220
+ 40.0249230, -105.2992250
+ 40.0249390, -105.2992230
+ 40.0249620, -105.2992380
+ 40.0249830, -105.2992570
+ 40.0250010, -105.2992730
+ 40.0250150, -105.2992790
+ 40.0250530, -105.2992880
+ 40.0250590, -105.2992950
+ 40.0250680, -105.2993240
+ 40.0250890, -105.2993520
+ 40.0251040, -105.2993710
+ 40.0251680, -105.2994320
+ 40.0252780, -105.2995230
+ 40.0253010, -105.2995500
+ 40.0253350, -105.2995950
+ 40.0253500, -105.2996020
+ 40.0253880, -105.2996130
+ 40.0254040, -105.2996150
+ 40.0254370, -105.2996420
+ 40.0254490, -105.2996530
+ 40.0254680, -105.2996690
+ 40.0255100, -105.2996920
+ 40.0255470, -105.2997460
+ 40.0255530, -105.2997600
+ 40.0255700, -105.2997800
+ 40.0256120, -105.2998030
+ 40.0256440, -105.2998160
+ 40.0256990, -105.2998290
+ 40.0257590, -105.2998430
+ 40.0257870, -105.2998510
+ 40.0258060, -105.2998610
+ 40.0258890, -105.2998780
+ 40.0258940, -105.2998790
+ 40.0259070, -105.2998880
+ 40.0259410, -105.2998980
+ 40.0260250, -105.2999160
+ 40.0260350, -105.2999140
+ 40.0260440, -105.2999140
+ 40.0261120, -105.2999520
+ 40.0261450, -105.2999620
+ 40.0261780, -105.2999810
+ 40.0262350, -105.3000140
+ 40.0262880, -105.3000300
+ 40.0263100, -105.3000360
+ 40.0263190, -105.3000370
+ 40.0263460, -105.3000490
+ 40.0263640, -105.3000570
+ 40.0264090, -105.3000870
+ 40.0264460, -105.3001080
+ 40.0264740, -105.3001430
+ 40.0264780, -105.3001480
+ 40.0265140, -105.3002100
+ 40.0265290, -105.3002120
+ 40.0265690, -105.3002070
+ 40.0265760, -105.3002040
+ 40.0266160, -105.3002220
+ 40.0266560, -105.3002270
+ 40.0266800, -105.3002210
+ 40.0267120, -105.3001900
+ 40.0267680, -105.3002060
+ 40.0267830, -105.3002270
+ 40.0268000, -105.3002380
+ 40.0268170, -105.3002460
+ 40.0268440, -105.3002490
+ 40.0268590, -105.3002560
+ 40.0268790, -105.3002710
+ 40.0268880, -105.3002940
+ 40.0269060, -105.3003340
+ 40.0269160, -105.3003550
+ 40.0269280, -105.3003730
+ 40.0269340, -105.3003790
+ 40.0269530, -105.3003920
+ 40.0269680, -105.3004060
+ 40.0269820, -105.3004160
+ 40.0270260, -105.3004380
+ 40.0270430, -105.3004500
+ 40.0270900, -105.3005030
+ 40.0271200, -105.3005240
+ 40.0271280, -105.3005340
+ 40.0271480, -105.3005630
+ 40.0271760, -105.3005730
+ 40.0271910, -105.3005830
+ 40.0272370, -105.3006310
+ 40.0272990, -105.3007520
+ 40.0273310, -105.3007940
+ 40.0273510, -105.3008320
+ 40.0273780, -105.3008640
+ 40.0274180, -105.3008810
+ 40.0274410, -105.3008940
+ 40.0275000, -105.3009660
+ 40.0275080, -105.3009790
+ 40.0275240, -105.3010140
+ 40.0275310, -105.3010200
+ 40.0275390, -105.3010270
+ 40.0275460, -105.3010300
+ 40.0275560, -105.3010330
+ 40.0275630, -105.3010370
+ 40.0275870, -105.3010660
+ 40.0275950, -105.3010740
+ 40.0276070, -105.3010950
+ 40.0276220, -105.3011170
+ 40.0276220, -105.3011250
+ 40.0276330, -105.3011680
+ 40.0276430, -105.3011910
+ 40.0276640, -105.3012190
+ 40.0276880, -105.3012260
+ 40.0276970, -105.3012270
+ 40.0277470, -105.3012490
+ 40.0277520, -105.3012480
+ 40.0278080, -105.3012660
+ 40.0278980, -105.3013300
+ 40.0279030, -105.3013400
+ 40.0279340, -105.3013870
+ 40.0279390, -105.3013920
+ 40.0279540, -105.3014140
+ 40.0279600, -105.3014230
+ 40.0279700, -105.3014390
+ 40.0279840, -105.3014580
+ 40.0279860, -105.3014860
+ 40.0279880, -105.3014980
+ 40.0279890, -105.3015140
+ 40.0279950, -105.3015160
+ 40.0280040, -105.3015440
+ 40.0280110, -105.3015530
+ 40.0280130, -105.3015660
+ 40.0280200, -105.3015770
+ 40.0280360, -105.3015860
+ 40.0280420, -105.3016010
+ 40.0280690, -105.3016490
+ 40.0281510, -105.3017030
+ 40.0281560, -105.3017130
+ 40.0281890, -105.3017560
+ 40.0282470, -105.3017780
+ 40.0282820, -105.3018160
+ 40.0282880, -105.3018230
+ 40.0283250, -105.3018550
+ 40.0283430, -105.3018660
+ 40.0283800, -105.3018950
+ 40.0283930, -105.3019020
+ 40.0284060, -105.3019120
+ 40.0284130, -105.3019290
+ 40.0284140, -105.3019380
+ 40.0284200, -105.3019530
+ 40.0284190, -105.3019610
+ 40.0284230, -105.3019790
+ 40.0284440, -105.3019960
+ 40.0284470, -105.3020090
+ 40.0284490, -105.3020250
+ 40.0284780, -105.3020400
+ 40.0284860, -105.3020410
+ 40.0285160, -105.3020710
+ 40.0285330, -105.3020870
+ 40.0285850, -105.3020990
+ 40.0286070, -105.3021040
+ 40.0286350, -105.3021120
+ 40.0286710, -105.3021290
+ 40.0286790, -105.3021410
+ 40.0286870, -105.3021450
+ 40.0286940, -105.3021480
+ 40.0287080, -105.3021580
+ 40.0287170, -105.3021700
+ 40.0287210, -105.3021760
+ 40.0287280, -105.3021790
+ 40.0287470, -105.3021870
+ 40.0287510, -105.3021880
+ 40.0287670, -105.3022000
+ 40.0288070, -105.3022200
+ 40.0288160, -105.3022240
+ 40.0288210, -105.3022260
+ 40.0288440, -105.3022450
+ 40.0288470, -105.3022430
+ 40.0288470, -105.3022430
+ 40.0288470, -105.3022430
+ 40.0288480, -105.3022430
+ 40.0288490, -105.3022430
+ 40.0288490, -105.3022430
+ 40.0288490, -105.3022430
+ 40.0288490, -105.3022430
+ 40.0288490, -105.3022440
+ 40.0288480, -105.3022440
+ 40.0288480, -105.3022450
+ 40.0288470, -105.3022460
+ 40.0288470, -105.3022470
+ 40.0288440, -105.3022490
+ 40.0288410, -105.3022500
+ 40.0288370, -105.3022510
+ 40.0288370, -105.3022510
+ 40.0288370, -105.3022510
+ 40.0288370, -105.3022500
+ 40.0288380, -105.3022480
+ 40.0288790, -105.3022840
+ 40.0288880, -105.3022960
+ 40.0289260, -105.3023360
+ 40.0289430, -105.3023540
+ 40.0289500, -105.3023620
+ 40.0289540, -105.3023670
+ 40.0289670, -105.3023870
+ 40.0289750, -105.3024050
+ 40.0289880, -105.3024370
+ 40.0290040, -105.3024630
+ 40.0290070, -105.3024680
+ 40.0290170, -105.3024840
+ 40.0290190, -105.3024890
+ 40.0290300, -105.3024980
+ 40.0290350, -105.3025000
+ 40.0290400, -105.3025030
+ 40.0290410, -105.3025100
+ 40.0290480, -105.3025270
+ 40.0290650, -105.3025330
+ 40.0290770, -105.3025430
+ 40.0290980, -105.3025610
+ 40.0291040, -105.3025960
+ 40.0291180, -105.3026160
+ 40.0291260, -105.3026180
+ 40.0291380, -105.3026220
+ 40.0291480, -105.3026290
+ 40.0291580, -105.3026370
+ 40.0291610, -105.3026440
+ 40.0291680, -105.3026550
+ 40.0291910, -105.3026680
+ 40.0291980, -105.3026730
+ 40.0292330, -105.3026690
+ 40.0292520, -105.3026760
+ 40.0293140, -105.3026920
+ 40.0293330, -105.3026960
+ 40.0293560, -105.3026990
+ 40.0293910, -105.3027160
+ 40.0294060, -105.3027310
+ 40.0294250, -105.3027450
+ 40.0294290, -105.3027490
+ 40.0294330, -105.3027520
+ 40.0294480, -105.3027580
+ 40.0294710, -105.3027650
+ 40.0294770, -105.3027690
+ 40.0294910, -105.3027780
+ 40.0295140, -105.3028000
+ 40.0295260, -105.3028150
+ 40.0295560, -105.3028290
+ 40.0295620, -105.3028330
+ 40.0295960, -105.3028470
+ 40.0296070, -105.3028410
+ 40.0296080, -105.3028370
+ 40.0296070, -105.3028370
+ 40.0296260, -105.3028580
+ 40.0296530, -105.3028790
+ 40.0297020, -105.3029200
+ 40.0297270, -105.3029350
+ 40.0297360, -105.3029350
+ 40.0297710, -105.3029490
+ 40.0297800, -105.3029530
+ 40.0298070, -105.3029600
+ 40.0298260, -105.3029870
+ 40.0298310, -105.3029950
+ 40.0298440, -105.3030660
+ 40.0298400, -105.3030800
+ 40.0298420, -105.3030950
+ 40.0298490, -105.3031100
+ 40.0299040, -105.3032390
+ 40.0299150, -105.3032920
+ 40.0299350, -105.3033310
+ 40.0299430, -105.3033400
+ 40.0299540, -105.3033470
+ 40.0299780, -105.3033560
+ 40.0300220, -105.3033750
+ 40.0300390, -105.3033890
+ 40.0300450, -105.3033930
+ 40.0300670, -105.3034060
+ 40.0300970, -105.3034200
+ 40.0301070, -105.3034250
+ 40.0301630, -105.3034100
+ 40.0301980, -105.3034020
+ 40.0302560, -105.3033930
+ 40.0302950, -105.3033910
+ 40.0304050, -105.3034310
+ 40.0304170, -105.3034360
+ 40.0304280, -105.3034400
+ 40.0304520, -105.3034510
+ 40.0305200, -105.3034770
+ 40.0305290, -105.3034830
+ 40.0305580, -105.3034980
+ 40.0305960, -105.3035100
+ 40.0306810, -105.3035960
+ 40.0306940, -105.3036230
+ 40.0307100, -105.3036550
+ 40.0307220, -105.3036720
+ 40.0307350, -105.3036940
+ 40.0307400, -105.3037010
+ 40.0307860, -105.3037390
+ 40.0308050, -105.3037570
+ 40.0308080, -105.3037660
+ 40.0308220, -105.3038180
+ 40.0308330, -105.3038520
+ 40.0308350, -105.3038650
+ 40.0308580, -105.3038900
+ 40.0308650, -105.3039130
+ 40.0308830, -105.3039360
+ 40.0309350, -105.3039550
+ 40.0309490, -105.3039580
+ 40.0309850, -105.3039820
+ 40.0310340, -105.3040170
+ 40.0310490, -105.3040240
+ 40.0310780, -105.3040320
+ 40.0311160, -105.3040480
+ 40.0311260, -105.3040560
+ 40.0311850, -105.3040800
+ 40.0311990, -105.3040850
+ 40.0312340, -105.3041020
+ 40.0312950, -105.3041110
+ 40.0313040, -105.3041200
+ 40.0313250, -105.3041390
+ 40.0313580, -105.3041730
+ 40.0314180, -105.3042230
+ 40.0314310, -105.3042280
+ 40.0314600, -105.3042380
+ 40.0314920, -105.3042540
+ 40.0315050, -105.3042580
+ 40.0315270, -105.3042740
+ 40.0315520, -105.3042890
+ 40.0316390, -105.3043180
+ 40.0317520, -105.3043350
+ 40.0318500, -105.3043680
+ 40.0318820, -105.3043760
+ 40.0319260, -105.3043830
+ 40.0319470, -105.3043890
+ 40.0319950, -105.3044190
+ 40.0320180, -105.3044350
+ 40.0320730, -105.3044390
+ 40.0320930, -105.3044460
+ 40.0321170, -105.3044510
+ 40.0321290, -105.3044550
+ 40.0321710, -105.3044470
+ 40.0322100, -105.3044430
+ 40.0322180, -105.3044490
+ 40.0322230, -105.3044590
+ 40.0322690, -105.3044700
+ 40.0322740, -105.3044580
+ 40.0322800, -105.3044490
+ 40.0323020, -105.3044360
+ 40.0323260, -105.3044360
+ 40.0323630, -105.3044390
+ 40.0323690, -105.3044460
+ 40.0323760, -105.3044550
+ 40.0323890, -105.3044680
+ 40.0324090, -105.3044750
+ 40.0324300, -105.3044940
+ 40.0324390, -105.3045030
+ 40.0324510, -105.3045340
+ 40.0324610, -105.3045350
+ 40.0324860, -105.3045440
+ 40.0325080, -105.3045620
+ 40.0325220, -105.3045720
+ 40.0325380, -105.3045800
+ 40.0325470, -105.3045840
+ 40.0325660, -105.3046050
+ 40.0325930, -105.3046130
+ 40.0326270, -105.3046180
+ 40.0326560, -105.3046370
+ 40.0326620, -105.3046470
+ 40.0326650, -105.3046700
+ 40.0326780, -105.3047240
+ 40.0326870, -105.3047350
+ 40.0327330, -105.3047650
+ 40.0327540, -105.3047820
+ 40.0327650, -105.3047880
+ 40.0327760, -105.3047960
+ 40.0327840, -105.3048020
+ 40.0327970, -105.3048370
+ 40.0328020, -105.3048600
+ 40.0328110, -105.3048740
+ 40.0328350, -105.3048980
+ 40.0328450, -105.3049050
+ 40.0328570, -105.3049110
+ 40.0328700, -105.3049140
+ 40.0329060, -105.3049140
+ 40.0329620, -105.3049470
+ 40.0329700, -105.3049560
+ 40.0330310, -105.3049990
+ 40.0330370, -105.3050040
+ 40.0330600, -105.3050220
+ 40.0330750, -105.3050360
+ 40.0331270, -105.3050700
+ 40.0332040, -105.3051260
+ 40.0332150, -105.3051390
+ 40.0332260, -105.3051530
+ 40.0332340, -105.3051720
+ 40.0332340, -105.3051900
+ 40.0332500, -105.3052230
+ 40.0332560, -105.3052280
+ 40.0332870, -105.3052500
+ 40.0332920, -105.3052730
+ 40.0333040, -105.3052830
+ 40.0333390, -105.3053040
+ 40.0333500, -105.3053140
+ 40.0333890, -105.3053370
+ 40.0333960, -105.3053520
+ 40.0334030, -105.3053620
+ 40.0334310, -105.3053720
+ 40.0334550, -105.3053800
+ 40.0334740, -105.3053890
+ 40.0335150, -105.3054040
+ 40.0335210, -105.3054230
+ 40.0335310, -105.3054340
+ 40.0335490, -105.3054590
+ 40.0335660, -105.3054740
+ 40.0335780, -105.3054790
+ 40.0336220, -105.3054800
+ 40.0336310, -105.3054800
+ 40.0336740, -105.3054740
+ 40.0336850, -105.3054730
+ 40.0337070, -105.3055020
+ 40.0337200, -105.3054980
+ 40.0337320, -105.3054990
+ 40.0337490, -105.3055230
+ 40.0337610, -105.3055690
+ 40.0337600, -105.3055900
+ 40.0337630, -105.3056010
+ 40.0337950, -105.3056330
+ 40.0338110, -105.3056400
+ 40.0338290, -105.3056460
+ 40.0338470, -105.3056840
+ 40.0338730, -105.3057140
+ 40.0339050, -105.3057320
+ 40.0339410, -105.3057380
+ 40.0339490, -105.3057360
+ 40.0339640, -105.3057420
+ 40.0339750, -105.3057520
+ 40.0339870, -105.3057600
+ 40.0340080, -105.3057550
+ 40.0340230, -105.3057140
+ 40.0340290, -105.3056880
+ 40.0340500, -105.3056630
+ 40.0340780, -105.3056370
+ 40.0340860, -105.3056210
+ 40.0340860, -105.3056130
+ 40.0340870, -105.3056060
+ 40.0341010, -105.3055920
+ 40.0341100, -105.3055800
+ 40.0341090, -105.3055690
+ 40.0341170, -105.3055490
+ 40.0341470, -105.3055410
+ 40.0341500, -105.3055360
+ 40.0341760, -105.3055150
+ 40.0341820, -105.3055070
+ 40.0341960, -105.3055060
+ 40.0342170, -105.3054940
+ 40.0342290, -105.3054910
+ 40.0342340, -105.3054910
+ 40.0342670, -105.3054760
+ 40.0342910, -105.3054790
+ 40.0342990, -105.3054790
+ 40.0343220, -105.3054470
+ 40.0343310, -105.3054030
+ 40.0343320, -105.3053870
+ 40.0343370, -105.3053570
+ 40.0343520, -105.3053580
+ 40.0343770, -105.3053620
+ 40.0344000, -105.3053570
+ 40.0344070, -105.3053290
+ 40.0344080, -105.3053290
+ 40.0344070, -105.3053210
+ 40.0344070, -105.3053210
+ 40.0344060, -105.3053210
+ 40.0344050, -105.3053200
+ 40.0344050, -105.3053200
+ 40.0344040, -105.3053190
+ 40.0344040, -105.3053190
+ 40.0344040, -105.3053180
+ 40.0344040, -105.3053140
+ 40.0344030, -105.3053140
+ 40.0344030, -105.3053050
+ 40.0344070, -105.3053010
+ 40.0344070, -105.3053010
+ 40.0344050, -105.3053000
+ 40.0344040, -105.3053000
+ 40.0344030, -105.3052960
+ 40.0344030, -105.3052950
+ 40.0344030, -105.3052950
+ 40.0344030, -105.3052950
+ 40.0344030, -105.3052950
+ 40.0344040, -105.3052950
+ 40.0344040, -105.3052950
+ 40.0344040, -105.3052950
+ 40.0344040, -105.3052950
+ 40.0344050, -105.3052950
+ 40.0344020, -105.3052960
+ 40.0344020, -105.3052950
+ 40.0344010, -105.3052940
+ 40.0343990, -105.3052880
+ 40.0343990, -105.3052880
+ 40.0343990, -105.3052880
+ 40.0344000, -105.3052880
+ 40.0344000, -105.3052880
+ 40.0344000, -105.3052880
+ 40.0344000, -105.3052880
+ 40.0344000, -105.3052880
+ 40.0344000, -105.3052880
+ 40.0344000, -105.3052870
+ 40.0344000, -105.3052870
+ 40.0344050, -105.3052780
+ 40.0344260, -105.3052210
+ 40.0344320, -105.3051550
+ 40.0344390, -105.3051470
+ 40.0344460, -105.3051390
+ 40.0344530, -105.3051310
+ 40.0344600, -105.3051230
+ 40.0344670, -105.3051160
+ 40.0344740, -105.3051080
+ 40.0344810, -105.3051000
+ 40.0344880, -105.3050920
+ 40.0344950, -105.3050840
+ 40.0345020, -105.3050760
+ 40.0345090, -105.3050680
+ 40.0345170, -105.3050600
+ 40.0345240, -105.3050520
+ 40.0345310, -105.3050440
+ 40.0345380, -105.3050370
+ 40.0345450, -105.3050290
+ 40.0345520, -105.3050210
+ 40.0345590, -105.3050130
+ 40.0345660, -105.3050050
+ 40.0345730, -105.3049970
+ 40.0345800, -105.3049890
+ 40.0345870, -105.3049810
+ 40.0345940, -105.3049730
+ 40.0346010, -105.3049650
+ 40.0346080, -105.3049570
+ 40.0346150, -105.3049500
+ 40.0346220, -105.3049420
+ 40.0346290, -105.3049340
+ 40.0346360, -105.3049260
+ 40.0346430, -105.3049180
+ 40.0346500, -105.3049100
+ 40.0346570, -105.3049020
+ 40.0346640, -105.3048940
+ 40.0346710, -105.3048860
+ 40.0346780, -105.3048780
+ 40.0346860, -105.3048710
+ 40.0346930, -105.3048630
+ 40.0347000, -105.3048550
+ 40.0347070, -105.3048470
+ 40.0347140, -105.3048390
+ 40.0347210, -105.3048310
+ 40.0347280, -105.3048230
+ 40.0347350, -105.3048150
+ 40.0347420, -105.3048070
+ 40.0347490, -105.3047990
+ 40.0347560, -105.3047910
+ 40.0347630, -105.3047840
+ 40.0347700, -105.3047760
+ 40.0347770, -105.3047680
+ 40.0347840, -105.3047600
+ 40.0347910, -105.3047520
+ 40.0347980, -105.3047440
+ 40.0348050, -105.3047360
+ 40.0348120, -105.3047280
+ 40.0348190, -105.3047200
+ 40.0348260, -105.3047120
+ 40.0348330, -105.3047040
+ 40.0348400, -105.3046970
+ 40.0348470, -105.3046890
+ 40.0348540, -105.3046810
+ 40.0348620, -105.3046730
+ 40.0348690, -105.3046650
+ 40.0348760, -105.3046570
+ 40.0348830, -105.3046490
+ 40.0348900, -105.3046410
+ 40.0348970, -105.3046330
+ 40.0349040, -105.3046250
+ 40.0349110, -105.3046180
+ 40.0349180, -105.3046100
+ 40.0349250, -105.3046020
+ 40.0349320, -105.3045940
+ 40.0349390, -105.3045860
+ 40.0349460, -105.3045780
+ 40.0349690, -105.3045820
+ 40.0350130, -105.3045850
+ 40.0350690, -105.3045930
+ 40.0351040, -105.3046080
+ 40.0351160, -105.3046070
+ 40.0351320, -105.3045960
+ 40.0351360, -105.3045390
+ 40.0351300, -105.3045160
+ 40.0351160, -105.3044850
+ 40.0350970, -105.3044110
+ 40.0350990, -105.3044000
+ 40.0350960, -105.3043820
+ 40.0350910, -105.3043720
+ 40.0350400, -105.3043410
+ 40.0350290, -105.3043310
+ 40.0350120, -105.3043120
+ 40.0349950, -105.3042880
+ 40.0349800, -105.3042380
+ 40.0349770, -105.3042160
+ 40.0349660, -105.3041800
+ 40.0349280, -105.3041110
+ 40.0348900, -105.3040480
+ 40.0348720, -105.3040370
+ 40.0348330, -105.3040260
+ 40.0348040, -105.3040150
+ 40.0347580, -105.3039720
+ 40.0347430, -105.3039610
+ 40.0347140, -105.3039560
+ 40.0346960, -105.3039340
+ 40.0346870, -105.3039260
+ 40.0346840, -105.3039150
+ 40.0346310, -105.3038530
+ 40.0345880, -105.3038520
+ 40.0345760, -105.3038380
+ 40.0345580, -105.3038190
+ 40.0345310, -105.3038000
+ 40.0345240, -105.3037890
+ 40.0345100, -105.3037740
+ 40.0344840, -105.3037530
+ 40.0344790, -105.3037480
+ 40.0344750, -105.3037460
+ 40.0344720, -105.3037150
+ 40.0344660, -105.3037000
+ 40.0344600, -105.3036940
+ 40.0344560, -105.3036890
+ 40.0344550, -105.3036990
+ 40.0344500, -105.3036770
+ 40.0344530, -105.3036650
+ 40.0344530, -105.3036650
+ 40.0344530, -105.3036650
+ 40.0344530, -105.3036650
+ 40.0344530, -105.3036650
+ 40.0344520, -105.3036630
+ 40.0344510, -105.3036520
+ 40.0344500, -105.3036450
+ 40.0344450, -105.3036320
+ 40.0344240, -105.3036160
+ 40.0344300, -105.3035900
+ 40.0344430, -105.3035590
+ 40.0344500, -105.3035400
+ 40.0344660, -105.3034550
+ 40.0344690, -105.3034320
+ 40.0344720, -105.3034110
+ 40.0344940, -105.3034170
+ 40.0345100, -105.3033910
+ 40.0345100, -105.3033640
+ 40.0345080, -105.3033490
+ 40.0345070, -105.3033390
+ 40.0345060, -105.3033330
+ 40.0345030, -105.3033250
+ 40.0345050, -105.3033150
+ 40.0344830, -105.3032790
+ 40.0344630, -105.3032580
+ 40.0344450, -105.3032560
+ 40.0344330, -105.3032320
+ 40.0344290, -105.3031960
+ 40.0344120, -105.3031550
+ 40.0344030, -105.3031340
+ 40.0344130, -105.3031340
+ 40.0344590, -105.3031170
+ 40.0344710, -105.3030840
+ 40.0344770, -105.3030880
+ 40.0344990, -105.3030880
+ 40.0345150, -105.3030930
+ 40.0345230, -105.3030940
+ 40.0345320, -105.3031040
+ 40.0345520, -105.3030910
+ 40.0345430, -105.3030200
+ 40.0345340, -105.3029230
+ 40.0345280, -105.3028920
+ 40.0344980, -105.3028620
+ 40.0344810, -105.3028480
+ 40.0344410, -105.3028210
+ 40.0344250, -105.3028040
+ 40.0344250, -105.3027930
+ 40.0344290, -105.3027870
+ 40.0344380, -105.3027690
+ 40.0344570, -105.3027320
+ 40.0344600, -105.3027170
+ 40.0344710, -105.3026780
+ 40.0344690, -105.3026660
+ 40.0344680, -105.3026500
+ 40.0344720, -105.3026240
+ 40.0345040, -105.3025570
+ 40.0345090, -105.3025410
+ 40.0344910, -105.3024820
+ 40.0344850, -105.3024620
+ 40.0344840, -105.3024450
+ 40.0344880, -105.3024280
+ 40.0345190, -105.3023920
+ 40.0345170, -105.3023730
+ 40.0345250, -105.3023480
+ 40.0345320, -105.3023110
+ 40.0345250, -105.3022970
+ 40.0345190, -105.3022840
+ 40.0345310, -105.3022500
+ 40.0345230, -105.3022010
+ 40.0345130, -105.3021900
+ 40.0344950, -105.3021690
+ 40.0344790, -105.3021330
+ 40.0344760, -105.3021110
+ 40.0344810, -105.3020910
+ 40.0344850, -105.3020640
+ 40.0344780, -105.3020400
+ 40.0344700, -105.3020130
+ 40.0344820, -105.3019800
+ 40.0344810, -105.3019510
+ 40.0344850, -105.3019470
+ 40.0345000, -105.3019310
+ 40.0345030, -105.3019200
+ 40.0345090, -105.3019130
+ 40.0345150, -105.3019090
+ 40.0345260, -105.3019050
+ 40.0345410, -105.3019010
+ 40.0345510, -105.3018980
+ 40.0345680, -105.3018930
+ 40.0345750, -105.3018900
+ 40.0345840, -105.3018880
+ 40.0346090, -105.3018610
+ 40.0346070, -105.3018460
+ 40.0346010, -105.3018270
+ 40.0345890, -105.3018070
+ 40.0345810, -105.3017890
+ 40.0345700, -105.3017720
+ 40.0345580, -105.3017380
+ 40.0345450, -105.3017180
+ 40.0345370, -105.3017100
+ 40.0345060, -105.3016930
+ 40.0344860, -105.3016770
+ 40.0344840, -105.3016680
+ 40.0344670, -105.3016340
+ 40.0344540, -105.3016270
+ 40.0344380, -105.3016040
+ 40.0344140, -105.3015920
+ 40.0343910, -105.3015830
+ 40.0343700, -105.3015790
+ 40.0343660, -105.3015770
+ 40.0343520, -105.3015690
+ 40.0343330, -105.3015560
+ 40.0342880, -105.3015610
+ 40.0342770, -105.3015600
+ 40.0342580, -105.3015550
+ 40.0342550, -105.3015470
+ 40.0342570, -105.3015290
+ 40.0342730, -105.3014840
+ 40.0342870, -105.3014700
+ 40.0342960, -105.3014630
+ 40.0343140, -105.3014440
+ 40.0343320, -105.3014270
+ 40.0343530, -105.3014000
+ 40.0343630, -105.3013870
+ 40.0343830, -105.3013760
+ 40.0344030, -105.3013610
+ 40.0343980, -105.3013290
+ 40.0343890, -105.3013150
+ 40.0343630, -105.3013030
+ 40.0343610, -105.3013000
+ 40.0343870, -105.3012650
+ 40.0343870, -105.3012590
+ 40.0343830, -105.3012410
+ 40.0343860, -105.3012300
+ 40.0344030, -105.3012050
+ 40.0344130, -105.3011920
+ 40.0344330, -105.3011770
+ 40.0344470, -105.3011650
+ 40.0344580, -105.3011480
+ 40.0344700, -105.3011390
+ 40.0345070, -105.3011280
+ 40.0345780, -105.3011060
+ 40.0346210, -105.3011210
+ 40.0346840, -105.3011290
+ 40.0346690, -105.3010910
+ 40.0346000, -105.3010300
+ 40.0345560, -105.3010070
+ 40.0345330, -105.3010040
+ 40.0344580, -105.3009900
+ 40.0344270, -105.3009860
+ 40.0343370, -105.3009550
+ 40.0343170, -105.3009870
+ 40.0343040, -105.3009930
+ 40.0342820, -105.3009920
+ 40.0342610, -105.3009850
+ 40.0342420, -105.3009910
+ 40.0342340, -105.3009860
+ 40.0342290, -105.3009710
+ 40.0342370, -105.3009650
+ 40.0342470, -105.3009590
+ 40.0342900, -105.3009150
+ 40.0343320, -105.3008830
+ 40.0343490, -105.3008850
+ 40.0343600, -105.3008820
+ 40.0343850, -105.3008670
+ 40.0343900, -105.3008600
+ 40.0344020, -105.3008460
+ 40.0344130, -105.3008310
+ 40.0344260, -105.3007970
+ 40.0344350, -105.3007810
+ 40.0344730, -105.3007500
+ 40.0345350, -105.3007350
+ 40.0345500, -105.3007340
+ 40.0346700, -105.3007530
+ 40.0346820, -105.3007550
+ 40.0347320, -105.3007820
+ 40.0347880, -105.3007640
+ 40.0347940, -105.3007510
+ 40.0347960, -105.3007040
+ 40.0347840, -105.3006740
+ 40.0347580, -105.3006500
+ 40.0347350, -105.3006270
+ 40.0347280, -105.3006170
+ 40.0346850, -105.3005680
+ 40.0346610, -105.3005550
+ 40.0346380, -105.3005320
+ 40.0346120, -105.3005080
+ 40.0345950, -105.3005040
+ 40.0345810, -105.3005020
+ 40.0345710, -105.3004940
+ 40.0345490, -105.3004710
+ 40.0345340, -105.3004620
+ 40.0344880, -105.3004230
+ 40.0344600, -105.3004030
+ 40.0344340, -105.3003780
+ 40.0344000, -105.3003520
+ 40.0343720, -105.3003320
+ 40.0343600, -105.3003160
+ 40.0343380, -105.3002880
+ 40.0343060, -105.3002800
+ 40.0342900, -105.3002780
+ 40.0342660, -105.3002620
+ 40.0342630, -105.3002470
+ 40.0342630, -105.3002320
+ 40.0342720, -105.3002200
+ 40.0342850, -105.3002040
+ 40.0342990, -105.3001690
+ 40.0343230, -105.3000990
+ 40.0343360, -105.3000850
+ 40.0343510, -105.3000810
+ 40.0343970, -105.3000740
+ 40.0344270, -105.3000630
+ 40.0344570, -105.3000650
+ 40.0345060, -105.3000550
+ 40.0345410, -105.3000650
+ 40.0345760, -105.3000650
+ 40.0345950, -105.3000700
+ 40.0346820, -105.3001080
+ 40.0347480, -105.3000830
+ 40.0347930, -105.3000780
+ 40.0348360, -105.3000710
+ 40.0348950, -105.3000540
+ 40.0349310, -105.3000420
+ 40.0349690, -105.3000360
+ 40.0349920, -105.3000380
+ 40.0350400, -105.3000500
+ 40.0350770, -105.3000520
+ 40.0351010, -105.3000550
+ 40.0351340, -105.3000560
+ 40.0351940, -105.3000680
+ 40.0352310, -105.3000800
+ 40.0352740, -105.3000780
+ 40.0353610, -105.3000980
+ 40.0354130, -105.3001010
+ 40.0354740, -105.3000870
+ 40.0355250, -105.3000620
+ 40.0355750, -105.3000320
+ 40.0355870, -105.3000220
+ 40.0356210, -105.3000110
+ 40.0356760, -105.2999770
+ 40.0356910, -105.2999640
+ 40.0357260, -105.2999400
+ 40.0357330, -105.2999240
+ 40.0357430, -105.2999040
+ 40.0357670, -105.2998310
+ 40.0357590, -105.2997830
+ 40.0357530, -105.2997600
+ 40.0357500, -105.2996880
+ 40.0357600, -105.2996070
+ 40.0357700, -105.2995770
+ 40.0357840, -105.2995280
+ 40.0358190, -105.2994390
+ 40.0358500, -105.2993650
+ 40.0358600, -105.2993400
+ 40.0358630, -105.2992790
+ 40.0358560, -105.2991780
+ 40.0358540, -105.2990970
+ 40.0358660, -105.2990400
+ 40.0358750, -105.2990080
+ 40.0358950, -105.2989530
+ 40.0359020, -105.2989260
+ 40.0359100, -105.2989010
+ 40.0359160, -105.2988490
+ 40.0359260, -105.2987620
+ 40.0359300, -105.2987450
+ 40.0359330, -105.2986890
+ 40.0359340, -105.2985940
+ 40.0359240, -105.2985090
+ 40.0359120, -105.2984090
+ 40.0358960, -105.2983000
+ 40.0358790, -105.2982370
+ 40.0358460, -105.2981180
+ 40.0357880, -105.2980190
+ 40.0357310, -105.2979480
+ 40.0356840, -105.2979010
+ 40.0356590, -105.2978730
+ 40.0355460, -105.2977650
+ 40.0354820, -105.2976970
+ 40.0354390, -105.2976530
+ 40.0354160, -105.2976320
+ 40.0353140, -105.2975640
+ 40.0352370, -105.2975200
+ 40.0351720, -105.2974730
+ 40.0351050, -105.2973880
+ 40.0350680, -105.2973370
+ 40.0350420, -105.2972930
+ 40.0350050, -105.2972350
+ 40.0349820, -105.2971930
+ 40.0349470, -105.2971640
+ 40.0348160, -105.2970830
+ 40.0348030, -105.2970650
+ 40.0347400, -105.2969750
+ 40.0346940, -105.2969270
+ 40.0346660, -105.2969030
+ 40.0345570, -105.2968410
+ 40.0345360, -105.2968370
+ 40.0344320, -105.2968230
+ 40.0343920, -105.2968140
+ 40.0342360, -105.2967800
+ 40.0340430, -105.2967070
+ 40.0339020, -105.2966860
+ 40.0338840, -105.2966810
+ 40.0337840, -105.2966790
+ 40.0337440, -105.2966780
+ 40.0336550, -105.2966540
+ 40.0335000, -105.2966370
+ 40.0333980, -105.2966340
+ 40.0333530, -105.2966190
+ 40.0333150, -105.2965980
+ 40.0332740, -105.2965830
+ 40.0332290, -105.2965810
+ 40.0331610, -105.2965700
+ 40.0331200, -105.2965610
+ 40.0330010, -105.2965110
+ 40.0329780, -105.2965080
+ 40.0328490, -105.2964770
+ 40.0327910, -105.2964570
+ 40.0326720, -105.2964400
+ 40.0326540, -105.2964350
+ 40.0325260, -105.2964060
+ 40.0324480, -105.2964020
+ 40.0323720, -105.2964050
+ 40.0323210, -105.2963920
+ 40.0321960, -105.2963850
+ 40.0321540, -105.2963650
+ 40.0321130, -105.2963570
+ 40.0319710, -105.2963080
+ 40.0319510, -105.2963000
+ 40.0319310, -105.2962960
+ 40.0318730, -105.2963040
+ 40.0318340, -105.2962960
+ 40.0317320, -105.2962710
+ 40.0316930, -105.2962620
+ 40.0316750, -105.2962650
+ 40.0316320, -105.2962640
+ 40.0316150, -105.2962650
+ 40.0315120, -105.2962540
+ 40.0314360, -105.2962390
+ 40.0313820, -105.2962220
+ 40.0313210, -105.2961890
+ 40.0312850, -105.2961910
+ 40.0312660, -105.2961880
+ 40.0311810, -105.2961060
+ 40.0310430, -105.2960390
+ 40.0309710, -105.2960530
+ 40.0309220, -105.2960430
+ 40.0308970, -105.2960370
+ 40.0307340, -105.2960110
+ 40.0306940, -105.2960180
+ 40.0306110, -105.2960530
+ 40.0305640, -105.2960810
+ 40.0305250, -105.2961100
+ 40.0304390, -105.2961230
+ 40.0304000, -105.2961330
+ 40.0303370, -105.2961480
+ 40.0302260, -105.2961670
+ 40.0301300, -105.2961680
+ 40.0300600, -105.2961190
+ 40.0299570, -105.2961260
+ 40.0299400, -105.2961420
+ 40.0299100, -105.2961670
+ 40.0298900, -105.2961710
+ 40.0298260, -105.2961540
+ 40.0298050, -105.2961490
+ 40.0296840, -105.2960950
+ 40.0296050, -105.2960690
+ 40.0295240, -105.2960900
+ 40.0294530, -105.2960970
+ 40.0294110, -105.2960900
+ 40.0292820, -105.2960810
+ 40.0291940, -105.2960940
+ 40.0291720, -105.2961040
+ 40.0290570, -105.2961100
+ 40.0290200, -105.2961020
+ 40.0289470, -105.2960900
+ 40.0289260, -105.2960960
+ 40.0288870, -105.2961110
+ 40.0288270, -105.2961050
+ 40.0287980, -105.2961020
+ 40.0286990, -105.2960880
+ 40.0285460, -105.2960930
+ 40.0285070, -105.2961010
+ 40.0284440, -105.2961240
+ 40.0283810, -105.2961380
+ 40.0283670, -105.2961440
+ 40.0282890, -105.2961640
+ 40.0282350, -105.2961460
+ 40.0282140, -105.2961350
+ 40.0282020, -105.2961350
+ 40.0281210, -105.2961120
+ 40.0281070, -105.2960970
+ 40.0280130, -105.2960340
+ 40.0279960, -105.2960300
+ 40.0279620, -105.2960340
+ 40.0279240, -105.2960350
+ 40.0278970, -105.2960400
+ 40.0278940, -105.2960400
+ 40.0278810, -105.2960300
+ 40.0278570, -105.2960280
+ 40.0278410, -105.2960260
+ 40.0277940, -105.2959990
+ 40.0277810, -105.2960060
+ 40.0277630, -105.2960150
+ 40.0277320, -105.2960430
+ 40.0277230, -105.2960550
+ 40.0276630, -105.2960980
+ 40.0276190, -105.2960930
+ 40.0275430, -105.2960670
+ 40.0274950, -105.2960480
+ 40.0274120, -105.2960170
+ 40.0273360, -105.2959930
+ 40.0272950, -105.2959930
+ 40.0272710, -105.2959890
+ 40.0271740, -105.2959660
+ 40.0271370, -105.2959730
+ 40.0271110, -105.2959670
+ 40.0270980, -105.2959590
+ 40.0270750, -105.2959600
+ 40.0270290, -105.2959910
+ 40.0270040, -105.2959950
+ 40.0269630, -105.2959980
+ 40.0269390, -105.2959940
+ 40.0269030, -105.2959800
+ 40.0268810, -105.2959760
+ 40.0268120, -105.2959750
+ 40.0267780, -105.2959680
+ 40.0267160, -105.2959540
+ 40.0266220, -105.2959860
+ 40.0266040, -105.2959930
+ 40.0265570, -105.2960470
+ 40.0265360, -105.2960640
+ 40.0265190, -105.2960710
+ 40.0264970, -105.2960750
+ 40.0264730, -105.2960800
+ 40.0264620, -105.2960850
+ 40.0264470, -105.2960850
+ 40.0263900, -105.2960860
+ 40.0263570, -105.2960810
+ 40.0263250, -105.2960830
+ 40.0263100, -105.2960820
+ 40.0262520, -105.2960890
+ 40.0262120, -105.2960950
+ 40.0261190, -105.2961230
+ 40.0260970, -105.2961220
+ 40.0260760, -105.2961250
+ 40.0259890, -105.2960960
+ 40.0259780, -105.2960840
+ 40.0259340, -105.2960810
+ 40.0258340, -105.2960780
+ 40.0258220, -105.2960790
+ 40.0257550, -105.2960960
+ 40.0257090, -105.2960680
+ 40.0256680, -105.2960620
+ 40.0256520, -105.2960480
+ 40.0256390, -105.2960310
+ 40.0256190, -105.2959970
+ 40.0255740, -105.2959480
+ 40.0255050, -105.2958840
+ 40.0254730, -105.2958780
+ 40.0254440, -105.2958870
+ 40.0254150, -105.2959120
+ 40.0254020, -105.2959160
+ 40.0253730, -105.2959250
+ 40.0253300, -105.2959360
+ 40.0253050, -105.2959380
+ 40.0252940, -105.2959370
+ 40.0252840, -105.2959410
+ 40.0252580, -105.2959480
+ 40.0252260, -105.2959730
+ 40.0252140, -105.2959810
+ 40.0252050, -105.2959870
+ 40.0251790, -105.2959900
+ 40.0251520, -105.2959920
+ 40.0251060, -105.2960140
+ 40.0250920, -105.2960220
+ 40.0250700, -105.2960310
+ 40.0250480, -105.2960320
+ 40.0249980, -105.2960380
+ 40.0249620, -105.2960420
+ 40.0249450, -105.2960390
+ 40.0249040, -105.2960160
+ 40.0248480, -105.2959830
+ 40.0248280, -105.2959690
+ 40.0247580, -105.2959590
+ 40.0247170, -105.2959450
+ 40.0246170, -105.2958810
+ 40.0245990, -105.2958730
+ 40.0245500, -105.2958450
+ 40.0244740, -105.2958090
+ 40.0244400, -105.2957950
+ 40.0244000, -105.2957820
+ 40.0242900, -105.2956960
+ 40.0242720, -105.2956800
+ 40.0242330, -105.2956590
+ 40.0242180, -105.2956540
+ 40.0241400, -105.2956220
+ 40.0239730, -105.2956060
+ 40.0239590, -105.2956030
+ 40.0238420, -105.2955900
+ 40.0238250, -105.2955850
+ 40.0237110, -105.2955380
+ 40.0237020, -105.2955280
+ 40.0236250, -105.2954360
+ 40.0235470, -105.2953140
+ 40.0235330, -105.2952880
+ 40.0234340, -105.2951490
+ 40.0233580, -105.2950810
+ 40.0233420, -105.2950790
+ 40.0233310, -105.2950920
+ 40.0233270, -105.2951070
+ 40.0233260, -105.2951350
+ 40.0233320, -105.2952590
+ 40.0233720, -105.2953700
+ 40.0234080, -105.2954670
+ 40.0234230, -105.2955140
+ 40.0234720, -105.2956300
+ 40.0234900, -105.2956400
+ 40.0235720, -105.2957480
+ 40.0235880, -105.2957660
+ 40.0236920, -105.2958760
+ 40.0237080, -105.2958840
+ 40.0237520, -105.2959150
+ 40.0237610, -105.2959330
+ 40.0237670, -105.2959570
+ 40.0237670, -105.2960260
+ 40.0237630, -105.2960570
+ 40.0237230, -105.2961180
+ 40.0236820, -105.2961250
+ 40.0236580, -105.2961280
+ 40.0236500, -105.2961400
+ 40.0236280, -105.2961570
+ 40.0236090, -105.2961600
+ 40.0236060, -105.2961730
+ 40.0236020, -105.2961790
+ 40.0235470, -105.2961500
+ 40.0235300, -105.2961440
+ 40.0234280, -105.2961060
+ 40.0233660, -105.2960700
+ 40.0233360, -105.2960520
+ 40.0232150, -105.2960090
+ 40.0231960, -105.2960020
+ 40.0230930, -105.2960610
+ 40.0230680, -105.2960680
+ 40.0229940, -105.2960820
+ 40.0228820, -105.2960840
+ 40.0227630, -105.2960860
+ 40.0227290, -105.2960950
+ 40.0227120, -105.2960990
+ 40.0226960, -105.2961090
+ 40.0226670, -105.2961250
+ 40.0226510, -105.2961300
+ 40.0226110, -105.2961420
+ 40.0225940, -105.2961510
+ 40.0225340, -105.2961560
+ 40.0224130, -105.2962030
+ 40.0223910, -105.2962050
+ 40.0222950, -105.2962240
+ 40.0222760, -105.2962310
+ 40.0221810, -105.2962610
+ 40.0220920, -105.2963130
+ 40.0220660, -105.2963330
+ 40.0220310, -105.2963790
+ 40.0219240, -105.2964790
+ 40.0218820, -105.2965520
+ 40.0218770, -105.2965630
+ 40.0218100, -105.2966340
+ 40.0217380, -105.2966670
+ 40.0217160, -105.2966900
+ 40.0216770, -105.2967490
+ 40.0216600, -105.2967830
+ 40.0216550, -105.2968000
+ 40.0216510, -105.2968200
+ 40.0216400, -105.2968400
+ 40.0215920, -105.2969220
+ 40.0215720, -105.2969480
+ 40.0215440, -105.2970120
+ 40.0215380, -105.2970470
+ 40.0215380, -105.2971040
+ 40.0215290, -105.2971160
+ 40.0215270, -105.2971190
+ 40.0215320, -105.2971200
+ 40.0215410, -105.2971100
+ 40.0215440, -105.2971000
+ 40.0215670, -105.2970410
+ 40.0215740, -105.2970240
+ 40.0216070, -105.2969910
+ 40.0216350, -105.2969600
+ 40.0216400, -105.2969440
+ 40.0216540, -105.2969090
+ 40.0216780, -105.2968650
+ 40.0217040, -105.2968140
+ 40.0217040, -105.2968000
+ 40.0217050, -105.2967790
+ 40.0217000, -105.2967360
+ 40.0216850, -105.2967180
+ 40.0216750, -105.2967110
+ 40.0216530, -105.2966920
+ 40.0216310, -105.2966500
+ 40.0216320, -105.2966410
+ 40.0216270, -105.2966050
+ 40.0216100, -105.2965880
+ 40.0216050, -105.2965610
+ 40.0216020, -105.2965430
+ 40.0215680, -105.2964980
+ 40.0215550, -105.2964940
+ 40.0215290, -105.2964860
+ 40.0215080, -105.2964620
+ 40.0215010, -105.2964440
+ 40.0214880, -105.2964180
+ 40.0214820, -105.2964100
+ 40.0214350, -105.2963820
+ 40.0213820, -105.2963700
+ 40.0213570, -105.2963720
+ 40.0213080, -105.2964050
+ 40.0212920, -105.2964100
+ 40.0212760, -105.2964020
+ 40.0212630, -105.2963850
+ 40.0212380, -105.2963640
+ 40.0211680, -105.2963830
+ 40.0211520, -105.2963820
+ 40.0211380, -105.2963790
+ 40.0211010, -105.2963510
+ 40.0210890, -105.2963300
+ 40.0210830, -105.2963200
+ 40.0210730, -105.2963030
+ 40.0210640, -105.2962860
+ 40.0210550, -105.2962770
+ 40.0210380, -105.2962560
+ 40.0210170, -105.2962200
+ 40.0209940, -105.2961930
+ 40.0209820, -105.2961920
+ 40.0209640, -105.2961920
+ 40.0209500, -105.2962040
+ 40.0208990, -105.2962220
+ 40.0208880, -105.2962210
+ 40.0208580, -105.2962220
+ 40.0208460, -105.2962270
+ 40.0207540, -105.2962410
+ 40.0207370, -105.2962310
+ 40.0207090, -105.2962250
+ 40.0207080, -105.2962210
+ 40.0206950, -105.2962290
+ 40.0206880, -105.2962310
+ 40.0206600, -105.2962330
+ 40.0206430, -105.2962690
+ 40.0206410, -105.2963240
+ 40.0206190, -105.2964510
+ 40.0206050, -105.2965010
+ 40.0205950, -105.2965470
+ 40.0205660, -105.2966680
+ 40.0205570, -105.2966910
+ 40.0205160, -105.2967500
+ 40.0204830, -105.2967840
+ 40.0204450, -105.2968090
+ 40.0204260, -105.2968160
+ 40.0203550, -105.2968520
+ 40.0203440, -105.2968680
+ 40.0203000, -105.2969500
+ 40.0202780, -105.2970310
+ 40.0202670, -105.2970960
+ 40.0202650, -105.2971150
+ 40.0202410, -105.2972730
+ 40.0202330, -105.2972980
+ 40.0201860, -105.2974740
+ 40.0202110, -105.2976040
+ 40.0202120, -105.2976260
+ 40.0201700, -105.2977960
+ 40.0201510, -105.2978440
+""".trimIndent()
\ No newline at end of file
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index a6ee9dca..f1e2e93a 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -98,8 +98,16 @@ fun CatalogScreen() {
}
}
item { SampleItem("Models") { selectedSample = "models" } }
- item { SampleItem("Polygons") { selectedSample = "polygons" } }
- item { SampleItem("Polylines") { selectedSample = "polylines" } }
+ item {
+ SampleItem("Polygons") {
+ context.startActivity(Intent(context, PolygonsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Polylines") {
+ context.startActivity(Intent(context, PolylinesActivity::class.java))
+ }
+ }
item { SampleItem("Popovers") { selectedSample = "popovers" } }
}
} else {
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
new file mode 100644
index 00000000..3c7db26a
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
@@ -0,0 +1,134 @@
+// 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.example.maps3dcomposedemo
+
+import android.graphics.Color
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.PolygonConfig
+
+/**
+ * Activity that demonstrates the use of polygons on a 3D map using Compose.
+ *
+ * This activity displays a polygon representing the Denver Zoo area.
+ * It uses the `PolygonConfig` to draw a filled shape with a border and a hole.
+ */
+class PolygonsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ PolygonsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PolygonsScreen() {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Define the camera position centered around Denver Zoo
+ val denverCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 39.748477
+ longitude = -104.947575
+ altitude = 1.0
+ }
+ heading = -68.0
+ tilt = 47.0
+ roll = 0.0
+ range = 2251.0
+ }
+ }
+
+ // Define the outline for the zoo
+ val zooOutline = remember {
+ listOf(
+ latLngAltitude { latitude = 39.7508987; longitude = -104.9565381; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7502883; longitude = -104.9565489; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7501976; longitude = -104.9563557; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7501481; longitude = -104.955594; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7499171; longitude = -104.9553043; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7508987; longitude = -104.9565381; altitude = 0.0 } // Close loop
+ )
+ }
+
+ // Define a hole inside the zoo area
+ val zooHole = remember {
+ listOf(
+ latLngAltitude { latitude = 39.7498; longitude = -104.9535; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7498; longitude = -104.9525; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7488; longitude = -104.9525; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7488; longitude = -104.9535; altitude = 0.0 },
+ latLngAltitude { latitude = 39.7498; longitude = -104.9535; altitude = 0.0 } // Close loop
+ )
+ }
+
+ // Create a polygon config
+ val polygonConfig = remember {
+ PolygonConfig(
+ key = "denver_zoo",
+ path = zooOutline,
+ innerPaths = listOf(zooHole),
+ fillColor = Color.argb(70, 255, 255, 0), // Translucent yellow
+ strokeColor = Color.GREEN,
+ strokeWidth = 3f,
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = denverCamera,
+ mapMode = Map3DMode.HYBRID,
+ polygons = listOf(polygonConfig),
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
new file mode 100644
index 00000000..f0dff896
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
@@ -0,0 +1,163 @@
+// 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.example.maps3dcomposedemo
+
+import android.graphics.Color
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import kotlinx.coroutines.launch
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.Map3DRegistry
+import com.google.maps.android.compose3d.PolylineConfig
+import com.google.maps.android.compose3d.toPolylineOptions
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Activity that demonstrates the use of polylines on a 3D map using Compose.
+ *
+ * This activity displays a polyline representing a portion of the Sanitas Loop trail
+ * in Boulder, Colorado. It uses the `PolylineConfig` to create a stroked effect
+ * with a red inner line and a translucent black outer line.
+ */
+class PolylinesActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ PolylinesScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PolylinesScreen() {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Define the camera position centered around Mount Sanitas trailhead
+ val boulderCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 40.029349
+ longitude = -105.300354
+ altitude = 1833.9
+ }
+ heading = 326.0
+ tilt = 75.0
+ roll = 0.0
+ range = 3757.0
+ }
+ }
+
+ // Parse the full trail data from sanitasLoop
+ val trailPoints = remember {
+ sanitasLoop.lines()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .map {
+ val parts = it.split(",")
+ latLngAltitude {
+ latitude = parts[0].trim().toDouble()
+ longitude = parts[1].trim().toDouble()
+ altitude = 0.5
+ }
+ }
+ }
+
+ // Create a polyline config with a stroked effect
+ val polylineConfig = remember {
+ PolylineConfig(
+ key = "sanitas_loop",
+ points = trailPoints,
+ color = Color.RED,
+ width = 7f,
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
+ zIndex = 5,
+ outerColor = Color.BLACK,
+ outerWidth = 13f,
+ drawsOccludedSegments = true,
+ onClick = { polyline ->
+ Log.d("PolylinesActivity", "Polyline clicked: $polyline")
+ scope.launch {
+ Toast.makeText(context, "Hiking time!", Toast.LENGTH_SHORT).show()
+ }
+ }
+ )
+ }
+
+ val bgPolylineConfig = remember {
+ PolylineConfig(
+ key = "sanitas_loop_background",
+ points = trailPoints,
+ color = Color.BLACK,
+ width = 11f,
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
+ zIndex = 4,
+ outerColor = Color.BLACK,
+ outerWidth = 13f,
+ drawsOccludedSegments = true,
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = boulderCamera,
+ mapMode = Map3DMode.HYBRID,
+ polylines = listOf(bgPolylineConfig, polylineConfig),
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+ }
+}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index 976acdf6..a2346e76 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -17,12 +17,15 @@
package com.google.maps.android.compose3d
import android.view.View
+import androidx.annotation.WorkerThread
import androidx.compose.runtime.Immutable
import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.CollisionBehavior
import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.android.gms.maps3d.model.Marker
+import com.google.android.gms.maps3d.model.polylineOptions
+import com.google.maps.android.compose3d.utils.toValidLocation
/**
* Data class representing a Marker to be added to the 3D map.
@@ -53,7 +56,10 @@ data class PolylineConfig(
val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
val zIndex: Int = 0,
val outerColor: Int = 0,
- val outerWidth: Float = 0f
+ val outerWidth: Float = 0f,
+ val drawsOccludedSegments: Boolean = false,
+ @get:WorkerThread
+ val onClick: ((com.google.android.gms.maps3d.model.Polyline) -> Unit)? = null
)
/**
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index 9a77b90a..c830dbb3 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -28,6 +28,7 @@ import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
import com.google.android.gms.maps3d.model.Camera
import com.google.android.gms.maps3d.model.CameraRestriction
import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.maps.android.compose3d.utils.toValidCamera
/**
* A declarative Compose wrapper for the Google Maps 3D SDK [Map3DView].
@@ -95,7 +96,7 @@ fun GoogleMap3D(
}
// Sync hoisted state with the imperative map instance
- googleMap3D.setCamera(camera)
+ googleMap3D.setCamera(camera.toValidCamera())
googleMap3D.setCameraRestriction(cameraRestriction)
googleMap3D.setMapMode(mapMode)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
index b0f21e09..f75d631d 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -28,6 +28,10 @@ import com.google.android.gms.maps3d.model.polygonOptions
import com.google.android.gms.maps3d.model.modelOptions
import com.google.android.gms.maps3d.model.vector3D
import com.google.android.gms.maps3d.model.orientation
+import com.google.maps.android.compose3d.utils.toValidLocation
+import com.google.maps.android.compose3d.utils.toHeading
+import com.google.maps.android.compose3d.utils.toTilt
+import com.google.maps.android.compose3d.utils.toRoll
/**
* Internal state holder for the Maps 3D Compose library.
@@ -80,7 +84,7 @@ class Map3DState {
private fun createMarker(map: GoogleMap3D, config: MarkerConfig): Marker? {
val marker = map.addMarker(markerOptions {
- position = config.position
+ position = config.position.toValidLocation()
altitudeMode = config.altitudeMode
config.styleView?.let { setStyle(it) }
label = config.label
@@ -135,15 +139,13 @@ class Map3DState {
}
private fun createPolyline(map: GoogleMap3D, config: PolylineConfig): Polyline? {
- return map.addPolyline(polylineOptions {
- this.path = config.points
- strokeColor = config.color
- strokeWidth = config.width.toDouble()
- altitudeMode = config.altitudeMode
- zIndex = config.zIndex
- outerColor = config.outerColor
- outerWidth = config.outerWidth.toDouble()
- })
+ val polyline = map.addPolyline(config.toPolylineOptions())
+ config.onClick?.let { callback ->
+ polyline.setClickListener {
+ callback(polyline)
+ }
+ }
+ return polyline
}
/**
@@ -183,8 +185,8 @@ class Map3DState {
private fun createPolygon(map: GoogleMap3D, config: PolygonConfig): Polygon? {
return map.addPolygon(polygonOptions {
- this.path = config.path
- innerPaths = config.innerPaths.map { Hole(it) }
+ this.path = config.path.map { it.toValidLocation() }
+ innerPaths = config.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) }
fillColor = config.fillColor
strokeColor = config.strokeColor
strokeWidth = config.strokeWidth.toDouble()
@@ -229,7 +231,7 @@ class Map3DState {
private fun createModel(map: GoogleMap3D, config: ModelConfig): Model? {
return map.addModel(modelOptions {
- position = config.position
+ position = config.position.toValidLocation()
url = config.url
altitudeMode = config.altitudeMode
scale = vector3D {
@@ -238,9 +240,9 @@ class Map3DState {
z = config.scale.toDouble()
}
orientation = orientation {
- heading = config.heading
- tilt = config.tilt
- roll = config.roll
+ heading = config.heading.toHeading()
+ tilt = config.tilt.toTilt()
+ roll = config.roll.toRoll()
}
})
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
new file mode 100644
index 00000000..db5f259c
--- /dev/null
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
@@ -0,0 +1,15 @@
+package com.google.maps.android.compose3d
+
+import com.google.android.gms.maps3d.model.polylineOptions
+import com.google.maps.android.compose3d.utils.toValidLocation
+
+fun PolylineConfig.toPolylineOptions() = polylineOptions {
+ this.path = points.map { it.toValidLocation() }
+ strokeColor = color
+ strokeWidth = width.toDouble()
+ altitudeMode = altitudeMode
+ zIndex = zIndex
+ outerColor = outerColor
+ outerWidth = outerWidth
+ drawsOccludedSegments = drawsOccludedSegments
+}
diff --git a/snippets/java-app/build.gradle.kts b/snippets/java-app/build.gradle.kts
index 36a52b16..4e9accbf 100644
--- a/snippets/java-app/build.gradle.kts
+++ b/snippets/java-app/build.gradle.kts
@@ -64,10 +64,7 @@ if (!isCI) {
if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
-
- if (secrets.getProperty("MAPS_API_KEY") != null) {
- println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.")
- }
+ }
}
}
}
diff --git a/snippets/kotlin-app/build.gradle.kts b/snippets/kotlin-app/build.gradle.kts
index 98db299d..b14adc58 100644
--- a/snippets/kotlin-app/build.gradle.kts
+++ b/snippets/kotlin-app/build.gradle.kts
@@ -64,10 +64,7 @@ if (!isCI) {
if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
-
- if (secrets.getProperty("MAPS_API_KEY") != null) {
- println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.")
- }
+ }
}
}
}
From 0c9127028b53ad794aeb7764440d0cf62a8210a1 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 11:09:44 -0600
Subject: [PATCH 17/29] fix: Remove extra brace in build.gradle.kts files
---
Maps3DSamples/ApiDemos/java-app/build.gradle.kts | 1 -
Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts | 1 -
Maps3DSamples/advanced/app/build.gradle.kts | 1 -
snippets/java-app/build.gradle.kts | 1 -
snippets/kotlin-app/build.gradle.kts | 1 -
5 files changed, 5 deletions(-)
diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
index 785d19aa..c40b263e 100644
--- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
@@ -65,7 +65,6 @@ if (!isCI) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
}
- }
}
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
index f7dfd9f1..60963ffa 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
@@ -66,7 +66,6 @@ if (!isCI) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
}
- }
}
}
diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts
index 5ec9af49..93761b73 100644
--- a/Maps3DSamples/advanced/app/build.gradle.kts
+++ b/Maps3DSamples/advanced/app/build.gradle.kts
@@ -65,7 +65,6 @@ if (!isCI) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
}
- }
}
}
diff --git a/snippets/java-app/build.gradle.kts b/snippets/java-app/build.gradle.kts
index 4e9accbf..98941bb0 100644
--- a/snippets/java-app/build.gradle.kts
+++ b/snippets/java-app/build.gradle.kts
@@ -65,7 +65,6 @@ if (!isCI) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
}
- }
}
}
diff --git a/snippets/kotlin-app/build.gradle.kts b/snippets/kotlin-app/build.gradle.kts
index b14adc58..a76e447f 100644
--- a/snippets/kotlin-app/build.gradle.kts
+++ b/snippets/kotlin-app/build.gradle.kts
@@ -65,7 +65,6 @@ if (!isCI) {
throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').")
}
}
- }
}
}
From e57028823e38f3af07f8f628841a5af0b5b0c651 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 11:17:44 -0600
Subject: [PATCH 18/29] build: Add installAndLaunch task to maps3d-compose-demo
---
maps3d-compose-demo/build.gradle.kts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/maps3d-compose-demo/build.gradle.kts b/maps3d-compose-demo/build.gradle.kts
index 8b38d494..072f44fd 100644
--- a/maps3d-compose-demo/build.gradle.kts
+++ b/maps3d-compose-demo/build.gradle.kts
@@ -98,3 +98,10 @@ dependencies {
secrets {
propertiesFileName = "secrets.properties"
}
+
+tasks.register("installAndLaunch") {
+ description = "Installs and launches the demo app."
+ group = "install"
+ dependsOn("installDebug")
+ commandLine("adb", "shell", "am", "start", "-n", "com.example.maps3dcomposedemo/.MainActivity")
+}
From 1f35ae9676f3d081527c0d080340c9dc102b360f Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 12:06:29 -0600
Subject: [PATCH 19/29] feat: Add extruded museum and click support to Polygons
example
- Added extruded museum building to PolygonsActivity.kt using a custom extrusion helper.
- Added onClick callback to PolygonConfig in DataModels.kt and handled it in Map3DState.kt.
- Updated PolygonsActivity.kt to use click listeners on the zoo and museum polygons.
- Expanded unit tests in MappersTest.kt to cover default values.
- Cleaned up fully qualified names in DataModels.kt.
- Added missing imports in PolygonsActivity.kt to resolve compilation errors.
---
.../maps3dkotlin/polygons/PolygonsActivity.kt | 4 -
.../maps3dcomposedemo/PolygonsActivity.kt | 170 +++++++++++--
maps3d-compose/build.gradle.kts | 1 +
.../maps/android/compose3d/DataModels.kt | 23 +-
.../maps/android/compose3d/Map3DState.kt | 35 +--
.../google/maps/android/compose3d/Mappers.kt | 73 +++++-
.../maps/android/compose3d/MappersTest.kt | 232 ++++++++++++++++++
7 files changed, 479 insertions(+), 59 deletions(-)
create mode 100644 maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt
index 0998df3e..f3430946 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt
@@ -15,7 +15,6 @@
package com.example.maps3dkotlin.polygons
import android.graphics.Color
-import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
@@ -36,8 +35,6 @@ import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.android.gms.maps3d.model.polygonOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
/**
* This activity demonstrates how to create and display polygons on a 3D map. It showcases
@@ -163,7 +160,6 @@ class PolygonsActivity : SampleBaseActivity() {
}
companion object {
- private val TAG = PolygonsActivity::class.java.simpleName
private const val DENVER_LATITUDE = 39.748477
private const val DENVER_LONGITUDE = -104.947575
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
index 3c7db26a..3b6f2c59 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
@@ -16,8 +16,12 @@ package com.example.maps3dcomposedemo
import android.graphics.Color
import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,12 +33,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import kotlinx.coroutines.launch
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.PolygonConfig
@@ -63,6 +69,8 @@ class PolygonsActivity : ComponentActivity() {
@Composable
fun PolygonsScreen() {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
var isMapSteady by remember { mutableStateOf(false) }
// Define the camera position centered around Denver Zoo
@@ -71,7 +79,7 @@ fun PolygonsScreen() {
center = latLngAltitude {
latitude = 39.748477
longitude = -104.947575
- altitude = 1.0
+ altitude = 1609.34 // Denver is a mile high!
}
heading = -68.0
tilt = 47.0
@@ -82,25 +90,70 @@ fun PolygonsScreen() {
// Define the outline for the zoo
val zooOutline = remember {
- listOf(
- latLngAltitude { latitude = 39.7508987; longitude = -104.9565381; altitude = 0.0 },
- latLngAltitude { latitude = 39.7502883; longitude = -104.9565489; altitude = 0.0 },
- latLngAltitude { latitude = 39.7501976; longitude = -104.9563557; altitude = 0.0 },
- latLngAltitude { latitude = 39.7501481; longitude = -104.955594; altitude = 0.0 },
- latLngAltitude { latitude = 39.7499171; longitude = -104.9553043; altitude = 0.0 },
- latLngAltitude { latitude = 39.7508987; longitude = -104.9565381; altitude = 0.0 } // Close loop
- )
+ """
+ 39.7508987, -104.9565381
+ 39.7502883, -104.9565489
+ 39.7501976, -104.9563557
+ 39.7501481, -104.955594
+ 39.7499171, -104.9553043
+ 39.7495872, -104.9551648
+ 39.7492407, -104.954961
+ 39.7489685, -104.9548859
+ 39.7484488, -104.9548966
+ 39.7481189, -104.9548859
+ 39.7479539, -104.9547679
+ 39.7479209, -104.9544567
+ 39.7476487, -104.9535341
+ 39.7475085, -104.9525792
+ 39.7474095, -104.9519247
+ 39.747525, -104.9513776
+ 39.7476734, -104.9511844
+ 39.7478137, -104.9506265
+ 39.7477559, -104.9496395
+ 39.7477477, -104.9486203
+ 39.7478467, -104.9475796
+ 39.7482344, -104.9465818
+ 39.7486138, -104.9457878
+ 39.7491005, -104.9454874
+ 39.7495789, -104.945938
+ 39.7500491, -104.9466998
+ 39.7503213, -104.9474615
+ 39.7505358, -104.9486954
+ 39.7505111, -104.950648
+ 39.7511215, -104.9506587
+ 39.7511173, -104.9527187
+ 39.7511091, -104.9546445
+ 39.7508987, -104.9565381
+ """.trimIndent()
+ .lines()
+ .map { line -> line.split(",").map { it.trim().toDouble() } }
+ .map { (lat, lng) ->
+ latLngAltitude {
+ latitude = lat
+ longitude = lng
+ altitude = 0.0
+ }
+ }
}
// Define a hole inside the zoo area
val zooHole = remember {
- listOf(
- latLngAltitude { latitude = 39.7498; longitude = -104.9535; altitude = 0.0 },
- latLngAltitude { latitude = 39.7498; longitude = -104.9525; altitude = 0.0 },
- latLngAltitude { latitude = 39.7488; longitude = -104.9525; altitude = 0.0 },
- latLngAltitude { latitude = 39.7488; longitude = -104.9535; altitude = 0.0 },
- latLngAltitude { latitude = 39.7498; longitude = -104.9535; altitude = 0.0 } // Close loop
- )
+ """
+ 39.7498, -104.9535
+ 39.7498, -104.9525
+ 39.7488, -104.9525
+ 39.7488, -104.9535
+ 39.7498, -104.9535
+ """.trimIndent()
+ .lines()
+ .map { line -> line.split(",").map { it.trim().toDouble() } }
+ .map { (lat, lng) ->
+ latLngAltitude {
+ latitude = lat
+ longitude = lng
+ altitude = 0.0
+ }
+ }
}
// Create a polygon config
@@ -112,10 +165,56 @@ fun PolygonsScreen() {
fillColor = Color.argb(70, 255, 255, 0), // Translucent yellow
strokeColor = Color.GREEN,
strokeWidth = 3f,
- altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
+ onClick = { polygon ->
+ Log.d("PolygonsActivity", "Polygon clicked: $polygon")
+ scope.launch {
+ Toast.makeText(context, "Zoo time!", Toast.LENGTH_SHORT).show()
+ }
+ }
)
}
+ // Define the base face for the museum
+ val museumBaseFace = remember {
+ """
+ 39.74812392425406, -104.94414971628434
+ 39.7465307929639, -104.94370889409778
+ 39.747031745033794, -104.9415078562927
+ 39.74837320615968, -104.94194414397013
+ 39.74812392425406, -104.94414971628434
+ """.trimIndent()
+ .lines()
+ .map { line -> line.split(",").map { it.trim().toDouble() } }
+ .map { (lat, lng) ->
+ latLngAltitude {
+ latitude = lat
+ longitude = lng
+ altitude = 1609.34 // Denver is a mile high!
+ }
+ }
+ }
+
+ // Create extruded polygons for the museum
+ val museumPolygons = remember {
+ extrudePolygon(museumBaseFace, 50.0).mapIndexed { index, outline ->
+ PolygonConfig(
+ key = "museum_face_$index",
+ path = outline,
+ fillColor = Color.argb(70, 255, 0, 255), // Semi-transparent magenta
+ strokeColor = Color.MAGENTA,
+ strokeWidth = 3f,
+ altitudeMode = AltitudeMode.ABSOLUTE,
+ onClick = { polygon ->
+ Log.d("PolygonsActivity", "Museum face clicked: $polygon")
+ scope.launch {
+ Toast.makeText(context, "Museum time!", Toast.LENGTH_SHORT).show()
+ }
+ }
+ )
+ }
+ }
+
Box(
modifier = Modifier
.fillMaxSize()
@@ -124,7 +223,7 @@ fun PolygonsScreen() {
GoogleMap3D(
camera = denverCamera,
mapMode = Map3DMode.HYBRID,
- polygons = listOf(polygonConfig),
+ polygons = listOf(polygonConfig) + museumPolygons,
modifier = Modifier.fillMaxSize(),
onMapSteady = {
isMapSteady = true
@@ -132,3 +231,38 @@ fun PolygonsScreen() {
)
}
}
+
+/**
+ * Extrudes a flat polygon (defined by basePoints, all at the same altitude)
+ * upwards by a given extrusionHeight to form a 3D prism.
+ */
+fun extrudePolygon(
+ basePoints: List,
+ extrusionHeight: Double
+): List> {
+ if (basePoints.size < 3) return emptyList()
+ if (extrusionHeight <= 0) return emptyList()
+
+ val baseAltitude = basePoints.first().altitude
+ val topPoints = basePoints.map { basePoint ->
+ latLngAltitude {
+ latitude = basePoint.latitude
+ longitude = basePoint.longitude
+ altitude = baseAltitude + extrusionHeight
+ }
+ }
+
+ val faces = mutableListOf>()
+ faces.add(basePoints.toList())
+ faces.add(topPoints.toList().reversed())
+
+ for (i in basePoints.indices) {
+ val p1Base = basePoints[i]
+ val p2Base = basePoints[(i + 1) % basePoints.size]
+ val p1Top = topPoints[i]
+ val p2Top = topPoints[(i + 1) % basePoints.size]
+ faces.add(listOf(p1Base, p2Base, p2Top, p1Top))
+ }
+
+ return faces
+}
diff --git a/maps3d-compose/build.gradle.kts b/maps3d-compose/build.gradle.kts
index e3d305c1..5e2cd44e 100644
--- a/maps3d-compose/build.gradle.kts
+++ b/maps3d-compose/build.gradle.kts
@@ -59,6 +59,7 @@ dependencies {
implementation(libs.maps.utils.ktx)
testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index a2346e76..659f2a1d 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -16,16 +16,15 @@
package com.google.maps.android.compose3d
-import android.view.View
import androidx.annotation.WorkerThread
import androidx.compose.runtime.Immutable
-import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.CollisionBehavior
+import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.android.gms.maps3d.model.Marker
-import com.google.android.gms.maps3d.model.polylineOptions
-import com.google.maps.android.compose3d.utils.toValidLocation
+import com.google.android.gms.maps3d.model.Polygon
+import com.google.android.gms.maps3d.model.Polyline
/**
* Data class representing a Marker to be added to the 3D map.
@@ -59,7 +58,7 @@ data class PolylineConfig(
val outerWidth: Float = 0f,
val drawsOccludedSegments: Boolean = false,
@get:WorkerThread
- val onClick: ((com.google.android.gms.maps3d.model.Polyline) -> Unit)? = null
+ val onClick: ((Polyline) -> Unit)? = null
)
/**
@@ -73,9 +72,18 @@ data class PolygonConfig(
val fillColor: Int,
val strokeColor: Int,
val strokeWidth: Float,
- val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
+ val onClick: ((Polygon) -> Unit)? = null
)
+/**
+ * Sealed class representing the scale of a 3D model.
+ */
+sealed class ModelScale {
+ data class Uniform(val value: Float) : ModelScale()
+ data class PerAxis(val x: Float, val y: Float, val z: Float) : ModelScale()
+}
+
/**
* Data class representing a 3D Model to be added to the 3D map.
*/
@@ -85,8 +93,9 @@ data class ModelConfig(
val position: LatLngAltitude,
val url: String,
val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
- val scale: Float = 1.0f,
+ val scale: ModelScale = ModelScale.Uniform(1.0f),
val heading: Double = 0.0,
val tilt: Double = 0.0,
val roll: Double = 0.0
)
+
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
index f75d631d..d0e340e9 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -19,19 +19,12 @@ package com.google.maps.android.compose3d
import com.google.android.gms.maps3d.GoogleMap3D
import com.google.android.gms.maps3d.model.Hole
import com.google.android.gms.maps3d.model.Marker
-import com.google.android.gms.maps3d.model.Polyline
-import com.google.android.gms.maps3d.model.Polygon
import com.google.android.gms.maps3d.model.Model
+import com.google.android.gms.maps3d.model.Polygon
+import com.google.android.gms.maps3d.model.Polyline
import com.google.android.gms.maps3d.model.markerOptions
-import com.google.android.gms.maps3d.model.polylineOptions
import com.google.android.gms.maps3d.model.polygonOptions
-import com.google.android.gms.maps3d.model.modelOptions
-import com.google.android.gms.maps3d.model.vector3D
-import com.google.android.gms.maps3d.model.orientation
import com.google.maps.android.compose3d.utils.toValidLocation
-import com.google.maps.android.compose3d.utils.toHeading
-import com.google.maps.android.compose3d.utils.toTilt
-import com.google.maps.android.compose3d.utils.toRoll
/**
* Internal state holder for the Maps 3D Compose library.
@@ -184,7 +177,7 @@ class Map3DState {
}
private fun createPolygon(map: GoogleMap3D, config: PolygonConfig): Polygon? {
- return map.addPolygon(polygonOptions {
+ val polygon = map.addPolygon(polygonOptions {
this.path = config.path.map { it.toValidLocation() }
innerPaths = config.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) }
fillColor = config.fillColor
@@ -192,6 +185,12 @@ class Map3DState {
strokeWidth = config.strokeWidth.toDouble()
altitudeMode = config.altitudeMode
})
+ config.onClick?.let { callback ->
+ polygon.setClickListener {
+ callback(polygon)
+ }
+ }
+ return polygon
}
/**
@@ -230,21 +229,7 @@ class Map3DState {
}
private fun createModel(map: GoogleMap3D, config: ModelConfig): Model? {
- return map.addModel(modelOptions {
- position = config.position.toValidLocation()
- url = config.url
- altitudeMode = config.altitudeMode
- scale = vector3D {
- x = config.scale.toDouble()
- y = config.scale.toDouble()
- z = config.scale.toDouble()
- }
- orientation = orientation {
- heading = config.heading.toHeading()
- tilt = config.tilt.toTilt()
- roll = config.roll.toRoll()
- }
- })
+ return map.addModel(config.toModelOptions())
}
/**
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
index db5f259c..f6c57ce6 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
@@ -1,15 +1,78 @@
package com.google.maps.android.compose3d
+import com.google.android.gms.maps3d.model.Hole
+import com.google.android.gms.maps3d.model.markerOptions
+import com.google.android.gms.maps3d.model.modelOptions
+import com.google.android.gms.maps3d.model.orientation
+import com.google.android.gms.maps3d.model.polygonOptions
import com.google.android.gms.maps3d.model.polylineOptions
+import com.google.android.gms.maps3d.model.vector3D
import com.google.maps.android.compose3d.utils.toValidLocation
+/**
+ * Extension function to map [PolylineConfig] to [PolylineOptions].
+ */
fun PolylineConfig.toPolylineOptions() = polylineOptions {
this.path = points.map { it.toValidLocation() }
strokeColor = color
strokeWidth = width.toDouble()
- altitudeMode = altitudeMode
- zIndex = zIndex
- outerColor = outerColor
- outerWidth = outerWidth
- drawsOccludedSegments = drawsOccludedSegments
+ altitudeMode = this@toPolylineOptions.altitudeMode
+ zIndex = this@toPolylineOptions.zIndex
+ outerColor = this@toPolylineOptions.outerColor
+ outerWidth = this@toPolylineOptions.outerWidth.toDouble()
+ drawsOccludedSegments = this@toPolylineOptions.drawsOccludedSegments
}
+
+/**
+ * Extension function to map [MarkerConfig] to [MarkerOptions].
+ */
+fun MarkerConfig.toMarkerOptions() = markerOptions {
+ id = key
+ position = this@toMarkerOptions.position.toValidLocation()
+ altitudeMode = this@toMarkerOptions.altitudeMode
+ label = this@toMarkerOptions.label
+ isExtruded = this@toMarkerOptions.isExtruded
+ isDrawnWhenOccluded = this@toMarkerOptions.isDrawnWhenOccluded
+ collisionBehavior = this@toMarkerOptions.collisionBehavior
+ styleView?.let { setStyle(it) }
+}
+
+/**
+ * Extension function to map [PolygonConfig] to [PolygonOptions].
+ */
+fun PolygonConfig.toPolygonOptions() = polygonOptions {
+ path = this@toPolygonOptions.path.map { it.toValidLocation() }
+ innerPaths = this@toPolygonOptions.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) }
+ fillColor = this@toPolygonOptions.fillColor
+ strokeColor = this@toPolygonOptions.strokeColor
+ strokeWidth = this@toPolygonOptions.strokeWidth.toDouble()
+ altitudeMode = this@toPolygonOptions.altitudeMode
+}
+
+/**
+ * Extension function to map [ModelConfig] to [ModelOptions].
+ */
+fun ModelConfig.toModelOptions() = modelOptions {
+ id = key
+ position = this@toModelOptions.position.toValidLocation()
+ altitudeMode = this@toModelOptions.altitudeMode
+ orientation = orientation {
+ heading = this@toModelOptions.heading
+ tilt = this@toModelOptions.tilt
+ roll = this@toModelOptions.roll
+ }
+ url = this@toModelOptions.url
+ scale = when (val s = this@toModelOptions.scale) {
+ is ModelScale.Uniform -> vector3D {
+ x = s.value.toDouble()
+ y = s.value.toDouble()
+ z = s.value.toDouble()
+ }
+ is ModelScale.PerAxis -> vector3D {
+ x = s.x.toDouble()
+ y = s.y.toDouble()
+ z = s.z.toDouble()
+ }
+ }
+}
+
diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt
new file mode 100644
index 00000000..d3bc7c39
--- /dev/null
+++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt
@@ -0,0 +1,232 @@
+package com.google.maps.android.compose3d
+
+import android.graphics.Color
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.CollisionBehavior
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.latLngAltitude
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class MappersTest {
+
+ private fun assertLatLngAltitudeEquals(expected: LatLngAltitude, actual: LatLngAltitude) {
+ assertEquals(expected.latitude, actual.latitude, 0.0)
+ assertEquals(expected.longitude, actual.longitude, 0.0)
+ assertEquals(expected.altitude, actual.altitude, 0.0)
+ }
+
+ @Test
+ fun testPolylineConfigToPolylineOptions() {
+ val points = listOf(
+ latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 },
+ latLngAltitude { latitude = 4.0; longitude = 5.0; altitude = 6.0 }
+ )
+ val config = PolylineConfig(
+ key = "test_polyline",
+ points = points,
+ color = Color.RED,
+ width = 5f,
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
+ zIndex = 1,
+ outerColor = Color.BLACK,
+ outerWidth = 2f,
+ drawsOccludedSegments = true
+ )
+
+ val options = config.toPolylineOptions()
+
+ assertEquals(points.size, options.path.size)
+ for (i in points.indices) {
+ assertLatLngAltitudeEquals(points[i], options.path[i])
+ }
+ assertEquals(Color.RED, options.strokeColor)
+ assertEquals(5.0, options.strokeWidth, 0.0)
+ assertEquals(AltitudeMode.RELATIVE_TO_GROUND, options.altitudeMode)
+ assertEquals(1, options.zIndex)
+ assertEquals(Color.BLACK, options.outerColor)
+ assertEquals(2.0, options.outerWidth, 0.0)
+ assertEquals(true, options.drawsOccludedSegments)
+ }
+
+ @Test
+ fun testMarkerConfigToMarkerOptions() {
+ val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val config = MarkerConfig(
+ key = "test_marker",
+ position = position,
+ altitudeMode = AltitudeMode.ABSOLUTE,
+ label = "Test Label",
+ zIndex = 2,
+ isExtruded = true,
+ isDrawnWhenOccluded = true,
+ collisionBehavior = CollisionBehavior.REQUIRED
+ )
+
+ val options = config.toMarkerOptions()
+
+ assertEquals("test_marker", options.id)
+ assertLatLngAltitudeEquals(position, options.position)
+ assertEquals(AltitudeMode.ABSOLUTE, options.altitudeMode)
+ assertEquals("Test Label", options.label)
+ assertEquals(true, options.isExtruded)
+ assertEquals(true, options.isDrawnWhenOccluded)
+ assertEquals(CollisionBehavior.REQUIRED, options.collisionBehavior)
+ }
+
+ @Test
+ fun testPolygonConfigToPolygonOptions() {
+ val path = listOf(
+ latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 0.0 },
+ latLngAltitude { latitude = 3.0; longitude = 4.0; altitude = 0.0 },
+ latLngAltitude { latitude = 5.0; longitude = 6.0; altitude = 0.0 }
+ )
+ val hole = listOf(
+ latLngAltitude { latitude = 1.5; longitude = 2.5; altitude = 0.0 },
+ latLngAltitude { latitude = 2.0; longitude = 3.0; altitude = 0.0 }
+ )
+ val config = PolygonConfig(
+ key = "test_polygon",
+ path = path,
+ innerPaths = listOf(hole),
+ fillColor = Color.YELLOW,
+ strokeColor = Color.GREEN,
+ strokeWidth = 3f,
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ )
+
+ val options = config.toPolygonOptions()
+
+ assertEquals(path.size, options.path.size)
+ for (i in path.indices) {
+ assertLatLngAltitudeEquals(path[i], options.path[i])
+ }
+ assertEquals(1, options.innerPaths.size)
+ // Hole comparison might fail if Hole doesn't have equals.
+ // Let's just check if it's not null for now, or if we can assume it works.
+ // I'll keep it as is and see if it fails.
+ assertEquals(Color.YELLOW, options.fillColor)
+ assertEquals(Color.GREEN, options.strokeColor)
+ assertEquals(3.0, options.strokeWidth, 0.0)
+ assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode)
+ }
+
+ @Test
+ fun testModelConfigToModelOptions() {
+ val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val config = ModelConfig(
+ key = "test_model",
+ position = position,
+ url = "http://example.com/model.glb",
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
+ scale = ModelScale.Uniform(2.0f),
+ heading = 10.0,
+ tilt = 20.0,
+ roll = 30.0
+ )
+
+ val options = config.toModelOptions()
+
+ assertEquals("test_model", options.id)
+ assertLatLngAltitudeEquals(position, options.position)
+ assertEquals(AltitudeMode.RELATIVE_TO_GROUND, options.altitudeMode)
+ assertEquals("http://example.com/model.glb", options.url)
+ assertEquals(2.0, options.scale.x, 0.0)
+ assertEquals(2.0, options.scale.y, 0.0)
+ assertEquals(2.0, options.scale.z, 0.0)
+ assertEquals(10.0, options.orientation.heading, 0.0)
+ assertEquals(20.0, options.orientation.tilt, 0.0)
+ assertEquals(30.0, options.orientation.roll, 0.0)
+ }
+
+ @Test
+ fun testModelConfigToModelOptionsWithPerAxisScale() {
+ val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val config = ModelConfig(
+ key = "test_model_per_axis",
+ position = position,
+ url = "http://example.com/model.glb",
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
+ scale = ModelScale.PerAxis(1.0f, 2.0f, 3.0f),
+ heading = 10.0,
+ tilt = 20.0,
+ roll = 30.0
+ )
+
+ val options = config.toModelOptions()
+
+ assertEquals("test_model_per_axis", options.id)
+ assertEquals(1.0, options.scale.x, 0.0)
+ assertEquals(2.0, options.scale.y, 0.0)
+ assertEquals(3.0, options.scale.z, 0.0)
+ }
+ @Test
+ fun testPolylineConfigToPolylineOptions_defaults() {
+ val points = listOf(
+ latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ )
+ val config = PolylineConfig(
+ key = "test_polyline_defaults",
+ points = points,
+ color = Color.RED,
+ width = 5f
+ )
+
+ val options = config.toPolylineOptions()
+
+ assertEquals(points.size, options.path.size)
+ assertEquals(Color.RED, options.strokeColor)
+ assertEquals(5.0, options.strokeWidth, 0.0)
+ assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode)
+ assertEquals(0, options.zIndex)
+ assertEquals(0, options.outerColor)
+ assertEquals(0.0, options.outerWidth, 0.0)
+ assertEquals(false, options.drawsOccludedSegments)
+ }
+
+ @Test
+ fun testMarkerConfigToMarkerOptions_defaults() {
+ val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val config = MarkerConfig(
+ key = "test_marker_defaults",
+ position = position
+ )
+
+ val options = config.toMarkerOptions()
+
+ assertEquals("test_marker_defaults", options.id)
+ assertLatLngAltitudeEquals(position, options.position)
+ assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode)
+ assertEquals("", options.label)
+ assertEquals(0, options.zIndex)
+ assertEquals(false, options.isExtruded)
+ assertEquals(false, options.isDrawnWhenOccluded)
+ assertEquals(CollisionBehavior.REQUIRED, options.collisionBehavior)
+ }
+
+ @Test
+ fun testPolygonConfigToPolygonOptions_defaults() {
+ val path = listOf(
+ latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 0.0 }
+ )
+ val config = PolygonConfig(
+ key = "test_polygon_defaults",
+ path = path,
+ fillColor = Color.YELLOW,
+ strokeColor = Color.GREEN,
+ strokeWidth = 3f
+ )
+
+ val options = config.toPolygonOptions()
+
+ assertEquals(path.size, options.path.size)
+ assertEquals(0, options.innerPaths.size)
+ assertEquals(Color.YELLOW, options.fillColor)
+ assertEquals(Color.GREEN, options.strokeColor)
+ assertEquals(3.0, options.strokeWidth, 0.0)
+ assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode)
+ }
+}
From 09625244898ee3506a8c8ec02da04a736f4a9127 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 13:15:20 -0600
Subject: [PATCH 20/29] feat(compose): Add Map Options and Popovers samples,
and fix CameraRestriction crash
- Add MapOptionsActivity to demonstrate map options and camera restrictions.
- Add PopoversActivity to demonstrate popovers.
- Implement toValidCameraRestriction() extension function to sanitize CameraRestriction parameters.
- Provide safe defaults from defined ranges when parameters are null to prevent crashes in the native SDK.
- Ensure min <= max for altitude, heading, and tilt by swapping values if necessary.
- Refactor GoogleMap3D.kt to use the new extension function.
- Update MainActivity and AndroidManifest to include new samples.
- Clean up SampleScreens.kt.
---
.../src/main/AndroidManifest.xml | 8 +
.../example/maps3dcomposedemo/MainActivity.kt | 18 +-
.../maps3dcomposedemo/MapOptionsActivity.kt | 155 ++++++++++++++++++
.../maps3dcomposedemo/PopoversActivity.kt | 147 +++++++++++++++++
.../maps3dcomposedemo/SampleScreens.kt | 49 ------
.../maps/android/compose3d/GoogleMap3D.kt | 3 +-
.../maps/android/compose3d/utils/Utilities.kt | 57 +++++++
7 files changed, 379 insertions(+), 58 deletions(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index fc08cf6f..ed56e1b0 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -60,6 +60,14 @@
android:name=".PolygonsActivity"
android:exported="true"
android:label="Polygons" />
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index f1e2e93a..116a2a5c 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -108,20 +108,22 @@ fun CatalogScreen() {
context.startActivity(Intent(context, PolylinesActivity::class.java))
}
}
- item { SampleItem("Popovers") { selectedSample = "popovers" } }
+ item {
+ SampleItem("Popovers") {
+ context.startActivity(Intent(context, PopoversActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Map Options") {
+ context.startActivity(Intent(context, MapOptionsActivity::class.java))
+ }
+ }
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
when (selectedSample) {
"basic" -> BasicMapSample()
- "hello" -> HelloMapSample()
- "camera" -> CameraControlsSample()
- "interactions" -> MapInteractionsSample()
- "markers" -> MarkersSample()
"models" -> ModelsSample()
- "polygons" -> PolygonsSample()
- "polylines" -> PolylinesSample()
- "popovers" -> PopoversSample()
}
FloatingActionButton(
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt
new file mode 100644
index 00000000..8eed1853
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt
@@ -0,0 +1,155 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.cameraRestriction
+import com.google.android.gms.maps3d.model.latLngBounds
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+
+class MapOptionsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ MapOptionsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MapOptionsScreen() {
+ var mapMode by remember { mutableStateOf(Map3DMode.SATELLITE) }
+ var isRestricted by remember { mutableStateOf(false) }
+
+ // Camera centered on Devils Tower
+ val devilsTowerCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 44.589994
+ longitude = -104.715326
+ altitude = 1508.9
+ }
+ heading = 1.0
+ tilt = 75.0
+ range = 1635.0
+ roll = 0.0
+ }
+ }
+
+ // Define a restriction area around Devils Tower
+ val restriction = remember {
+ cameraRestriction {
+ bounds = latLngBounds {
+ northEastLat = 44.595
+ northEastLng = -104.710
+ southWestLat = 44.585
+ southWestLng = -104.720
+ }
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = devilsTowerCamera,
+ mapMode = mapMode,
+ cameraRestriction = if (isRestricted) restriction else null,
+ modifier = Modifier.fillMaxSize()
+ )
+
+ // Control Panel
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .background(
+ color = Color.Black.copy(alpha = 0.6f),
+ shape = RoundedCornerShape(16.dp)
+ )
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Map Options",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ onClick = { mapMode = Map3DMode.SATELLITE },
+ modifier = Modifier.weight(1f),
+ enabled = mapMode != Map3DMode.SATELLITE
+ ) {
+ Text("Satellite")
+ }
+ Button(
+ onClick = { mapMode = Map3DMode.HYBRID },
+ modifier = Modifier.weight(1f),
+ enabled = mapMode != Map3DMode.HYBRID
+ ) {
+ Text("Hybrid")
+ }
+ }
+
+ Button(
+ onClick = { isRestricted = !isRestricted },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(if (isRestricted) "Clear Restriction" else "Restrict to Area")
+ }
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
new file mode 100644
index 00000000..4bcd24c8
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
@@ -0,0 +1,147 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.graphics.Color
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.google.android.gms.maps3d.Popover
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.gms.maps3d.model.popoverOptions
+import com.google.android.gms.maps3d.model.popoverStyle
+import com.google.android.gms.maps3d.model.popoverShadow
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.MarkerConfig
+import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
+
+class PopoversActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ PopoversScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PopoversScreen() {
+ val context = LocalContext.current
+ var googleMap3DInstance by remember { mutableStateOf(null) }
+ var activePopover by remember { mutableStateOf(null) }
+
+ // Camera centered on Devils Tower
+ val devilsTowerCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 44.589994
+ longitude = -104.715326
+ altitude = 1508.9
+ }
+ heading = 1.0
+ tilt = 75.0
+ range = 1635.0
+ roll = 0.0
+ }
+ }
+
+ // Sample marker that will trigger the popover
+ val marker = remember {
+ MarkerConfig(
+ key = "popover_marker",
+ position = latLngAltitude {
+ latitude = 44.59054845363309
+ longitude = -104.715177415273
+ altitude = 10.0
+ },
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH,
+ label = "Click me for Popover",
+ isExtruded = true,
+ isDrawnWhenOccluded = true,
+ onClick = { nativeMarker ->
+ googleMap3DInstance?.let { map ->
+ val textView = android.widget.TextView(context).apply {
+ text = "This is a Popover anchored to a marker!"
+ setPadding(32, 16, 32, 16)
+ setTextColor(Color.BLACK)
+ setBackgroundColor(Color.WHITE)
+ }
+ val newPopover = map.addPopover(popoverOptions {
+ positionAnchor = nativeMarker
+ altitudeMode = AltitudeMode.ABSOLUTE
+ content = textView
+ autoCloseEnabled = true
+ autoPanEnabled = false
+ popoverStyle = popoverStyle {
+ backgroundColor = Color.WHITE
+ borderRadius = 16f
+ shadow = popoverShadow {
+ color = Color.DKGRAY
+ radius = 8f
+ offsetX = 4f
+ offsetY = 4f
+ }
+ }
+ })
+
+ activePopover?.remove()
+ activePopover = newPopover
+ activePopover?.show()
+ }
+ }
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = devilsTowerCamera,
+ markers = listOf(marker),
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = { instance ->
+ googleMap3DInstance = instance
+ instance.setMap3DClickListener { _, _ ->
+ activePopover?.remove()
+ activePopover = null
+ }
+ }
+ )
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
index 99b598c4..429b236c 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
@@ -149,58 +149,9 @@ fun BasicMapSample() {
}
}
-@Composable
-fun HelloMapSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Hello Map Sample Placeholder\nTODO: Implement basic map initialization.")
- }
-}
-
-@Composable
-fun CameraControlsSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Camera Controls Sample Placeholder\nTODO: Implement camera movements and animations.")
- }
-}
-
-@Composable
-fun MapInteractionsSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Map Interactions Sample Placeholder\nTODO: Implement click listeners and UI interactions.")
- }
-}
-
-@Composable
-fun MarkersSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Markers Sample Placeholder\nTODO: Implement adding and customizing 3D markers.")
- }
-}
-
@Composable
fun ModelsSample() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Models Sample Placeholder\nTODO: Implement loading 3D models (glTF).")
}
}
-
-@Composable
-fun PolygonsSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Polygons Sample Placeholder\nTODO: Implement drawing polygons.")
- }
-}
-
-@Composable
-fun PolylinesSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Polylines Sample Placeholder\nTODO: Implement drawing polylines.")
- }
-}
-
-@Composable
-fun PopoversSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Popovers Sample Placeholder\nTODO: Implement showing interactive popovers.")
- }
-}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index c830dbb3..35101d4e 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -29,6 +29,7 @@ import com.google.android.gms.maps3d.model.Camera
import com.google.android.gms.maps3d.model.CameraRestriction
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.maps.android.compose3d.utils.toValidCamera
+import com.google.maps.android.compose3d.utils.toValidCameraRestriction
/**
* A declarative Compose wrapper for the Google Maps 3D SDK [Map3DView].
@@ -97,7 +98,7 @@ fun GoogleMap3D(
// Sync hoisted state with the imperative map instance
googleMap3D.setCamera(camera.toValidCamera())
- googleMap3D.setCameraRestriction(cameraRestriction)
+ googleMap3D.setCameraRestriction(cameraRestriction.toValidCameraRestriction())
googleMap3D.setMapMode(mapMode)
state.syncMarkers(googleMap3D, markers)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
index 7a8c5a2e..c326f9bc 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
@@ -18,10 +18,12 @@ package com.google.maps.android.compose3d.utils
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.CameraRestriction
import com.google.android.gms.maps3d.model.FlyAroundOptions
import com.google.android.gms.maps3d.model.FlyToOptions
import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.cameraRestriction
import com.google.android.gms.maps3d.model.flyAroundOptions
import com.google.android.gms.maps3d.model.flyToOptions
import com.google.android.gms.maps3d.model.latLngAltitude
@@ -73,6 +75,61 @@ fun Camera?.toValidCamera(): Camera {
}
}
+/**
+ * Converts a nullable CameraRestriction object into a valid CameraRestriction object.
+ * Provides safe defaults for null parameters to prevent crashes in the native SDK.
+ * Ensures min <= max for altitude, heading, and tilt.
+ *
+ * @receiver The nullable CameraRestriction object to validate.
+ * @return A valid CameraRestriction object, or null if the input was null.
+ */
+fun CameraRestriction?.toValidCameraRestriction(): CameraRestriction? {
+ val source = this ?: return null
+
+ val minAlt = source.minAltitude ?: altitudeRange.start
+ val maxAlt = source.maxAltitude ?: altitudeRange.endInclusive
+ val (finalMinAlt, finalMaxAlt) = if (minAlt > maxAlt) {
+ Pair(maxAlt, minAlt)
+ } else {
+ Pair(minAlt, maxAlt)
+ }
+
+ val minHead = source.minHeading ?: headingRange.start
+ val maxHead = source.maxHeading ?: headingRange.endInclusive
+ val (finalMinHead, finalMaxHead) = if (minHead > maxHead) {
+ Pair(maxHead, minHead)
+ } else {
+ Pair(minHead, maxHead)
+ }
+
+ val minT = source.minTilt ?: tiltRange.start
+ val maxT = source.maxTilt ?: tiltRange.endInclusive
+ val (finalMinT, finalMaxT) = if (minT > maxT) {
+ Pair(maxT, minT)
+ } else {
+ Pair(minT, maxT)
+ }
+
+ if (source.minAltitude == null || source.maxAltitude == null ||
+ source.minHeading == null || source.maxHeading == null ||
+ source.minTilt == null || source.maxTilt == null ||
+ finalMinAlt != source.minAltitude || finalMaxAlt != source.maxAltitude ||
+ finalMinHead != source.minHeading || finalMaxHead != source.maxHeading ||
+ finalMinT != source.minTilt || finalMaxT != source.maxTilt) {
+
+ return cameraRestriction {
+ bounds = source.bounds
+ minAltitude = finalMinAlt
+ maxAltitude = finalMaxAlt
+ minHeading = finalMinHead
+ maxHeading = finalMaxHead
+ minTilt = finalMinT
+ maxTilt = finalMaxT
+ }
+ }
+ return source
+}
+
/**
* Coerces the latitude, longitude, and altitude of a LatLngAltitude object
* to be within their valid ranges. Longitude is clamped, not wrapped here.
From 7c9054d7d248adcd4a261ecbf65cfefc52e8331a Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 14:12:59 -0600
Subject: [PATCH 21/29] feat(maps3d-compose): Implement Models sample and
refactor demo architecture
- Moved BasicMapSample to its own file for better modularity.
- Implemented ModelsActivity to demonstrate loading 3D models and orchestrating cinematic camera animations.
- Registered ModelsActivity in the manifest and updated MainActivity to launch it.
- Added unit tests in UtilitiesTest.kt for CameraRestriction validation.
- Removed old placeholders from SampleScreens.kt.
- Included pre-existing changes in PolylinesActivity and deletion of properties file.
---
.../maps3dkotlin/models/ModelsActivity.kt | 4 +-
gradle/gradle-daemon-jvm.properties | 12 --
.../src/main/AndroidManifest.xml | 8 +
.../maps3dcomposedemo/BasicMapSample.kt | 157 +++++++++++++++
.../CameraAnimationsActivity.kt | 182 +++++++++++++++++
.../example/maps3dcomposedemo/MainActivity.kt | 12 +-
.../maps3dcomposedemo/ModelsActivity.kt | 190 ++++++++++++++++++
.../maps3dcomposedemo/PolylinesActivity.kt | 13 +-
.../maps3dcomposedemo/SampleScreens.kt | 114 +----------
.../maps/android/compose3d/utils/Utilities.kt | 4 +
.../maps/android/compose3d/UtilitiesTest.kt | 93 +++++++++
11 files changed, 650 insertions(+), 139 deletions(-)
delete mode 100644 gradle/gradle-daemon-jvm.properties
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
create mode 100644 maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt
index 47c5f6cd..e5d034c9 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt
@@ -20,7 +20,6 @@ import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.example.maps3d.common.awaitCameraUpdate
import com.example.maps3d.common.toCameraUpdate
-import com.example.maps3d.common.toValidCamera
import com.example.maps3dcommon.R
import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity
import com.google.android.gms.maps3d.GoogleMap3D
@@ -35,10 +34,9 @@ import com.google.android.gms.maps3d.model.orientation
import com.google.android.gms.maps3d.model.vector3D
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
deleted file mode 100644
index 6c1139ec..00000000
--- a/gradle/gradle-daemon-jvm.properties
+++ /dev/null
@@ -1,12 +0,0 @@
-#This file is generated by updateDaemonJvm
-toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
-toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
-toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
-toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
-toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
-toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
-toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
-toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
-toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
-toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
-toolchainVersion=21
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index ed56e1b0..b3d27512 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -68,6 +68,14 @@
android:name=".MapOptionsActivity"
android:exported="true"
android:label="Map Options" />
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt
new file mode 100644
index 00000000..a4bea885
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt
@@ -0,0 +1,157 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.graphics.Color
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.CollisionBehavior
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.MarkerConfig
+import com.google.maps.android.compose3d.PolylineConfig
+
+@Composable
+fun BasicMapSample() {
+ // Hoist the camera state
+ var cameraState by remember {
+ mutableStateOf(
+ camera {
+ center = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 0.0
+ }
+ heading = 0.0
+ tilt = 60.0
+ range = 3000.0
+ roll = 0.0
+ }
+ )
+ }
+
+ var mapMode by remember { mutableIntStateOf(Map3DMode.SATELLITE) }
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Sample markers
+ val markers = remember {
+ listOf(
+ MarkerConfig(
+ key = "golden_gate",
+ position = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 1.0
+ },
+ label = "Golden Gate Bridge",
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH,
+ isDrawnWhenOccluded = true,
+ collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL
+ )
+ )
+ }
+
+ // Sample polyline
+ val polylines = remember {
+ listOf(
+ PolylineConfig(
+ key = "sample_line",
+ points = listOf(
+ latLngAltitude { latitude = 37.8199; longitude = -122.4783; altitude = 0.0 },
+ latLngAltitude { latitude = 37.8299; longitude = -122.4883; altitude = 0.0 }
+ ),
+ color = Color.RED,
+ width = 10f,
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ )
+ )
+ }
+
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ ) {
+ GoogleMap3D(
+ camera = cameraState,
+ markers = markers,
+ polylines = polylines,
+ mapMode = mapMode,
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = {
+ // Map is ready, we could perform additional setup here if needed
+ },
+ onMapSteady = {
+ isMapSteady = true
+ }
+ )
+
+ // UI Controls
+ FloatingActionButton(
+ onClick = {
+ mapMode = if (mapMode == Map3DMode.SATELLITE) {
+ Map3DMode.HYBRID
+ } else {
+ Map3DMode.SATELLITE
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ ) {
+ Text(text = if (mapMode == Map3DMode.SATELLITE) "Hybrid" else "Satellite")
+ }
+
+ FloatingActionButton(
+ onClick = {
+ // Reset camera
+ cameraState = camera {
+ center = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 0.0
+ }
+ heading = 0.0
+ tilt = 60.0
+ range = 2000.0
+ roll = 0.0
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(16.dp)
+ ) {
+ Text(text = "Reset")
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt
new file mode 100644
index 00000000..e53343e8
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt
@@ -0,0 +1,182 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.flyAroundOptions
+import com.google.android.gms.maps3d.model.flyToOptions
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+class CameraAnimationsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ CameraAnimationsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CameraAnimationsScreen() {
+ var mapInstance by remember { mutableStateOf(null) }
+ var statusText by remember { mutableStateOf("Idle") }
+ val coroutineScope = rememberCoroutineScope()
+
+ val newYork = latLngAltitude { latitude = 40.7128; longitude = -74.0060; altitude = 0.0 }
+ val sf = latLngAltitude { latitude = 37.7749; longitude = -122.4194; altitude = 0.0 }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = camera {
+ center = newYork
+ range = 10000.0
+ tilt = 45.0
+ },
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = { googleMap3D ->
+ mapInstance = googleMap3D
+
+ googleMap3D.setOnMapSteadyListener { isSteady ->
+ if (isSteady) {
+ statusText = "Steady"
+ }
+ }
+ }
+ )
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(16.dp)
+ .background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "Status: $statusText", color = Color.White)
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Button(onClick = {
+ mapInstance?.let { map ->
+ statusText = "Animating to SF"
+ map.flyCameraTo(
+ flyToOptions {
+ endCamera = camera {
+ center = sf
+ range = 5000.0
+ tilt = 60.0
+ }
+ durationInMillis = 5000
+ }
+ )
+ coroutineScope.launch {
+ map.awaitCameraAnimation()
+ statusText = "Animation Ended (SF)"
+ }
+ }
+ }) {
+ Text("Fly to SF")
+ }
+
+ Button(onClick = {
+ mapInstance?.let { map ->
+ statusText = "Orbiting"
+ map.flyCameraAround(
+ flyAroundOptions {
+ rounds = 1.0
+ durationInMillis = 10000
+ }
+ )
+ coroutineScope.launch {
+ map.awaitCameraAnimation()
+ statusText = "Orbit Ended"
+ }
+ }
+ }) {
+ Text("Orbit")
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Button(onClick = {
+ mapInstance?.let { map ->
+ map.stopCameraAnimation()
+ statusText = "Animation Stopped"
+ }
+ }) {
+ Text("Stop")
+ }
+ }
+ }
+ }
+}
+
+// Helper extensions
+suspend fun com.google.android.gms.maps3d.GoogleMap3D.awaitCameraAnimation() = suspendCancellableCoroutine { continuation ->
+ setCameraAnimationEndListener {
+ setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ setCameraAnimationEndListener(null)
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index 116a2a5c..99a15746 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -97,7 +97,11 @@ fun CatalogScreen() {
context.startActivity(Intent(context, MarkersActivity::class.java))
}
}
- item { SampleItem("Models") { selectedSample = "models" } }
+ item {
+ SampleItem("Models") {
+ context.startActivity(Intent(context, ModelsActivity::class.java))
+ }
+ }
item {
SampleItem("Polygons") {
context.startActivity(Intent(context, PolygonsActivity::class.java))
@@ -118,12 +122,16 @@ fun CatalogScreen() {
context.startActivity(Intent(context, MapOptionsActivity::class.java))
}
}
+ item {
+ SampleItem("Camera Animations") {
+ context.startActivity(Intent(context, CameraAnimationsActivity::class.java))
+ }
+ }
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
when (selectedSample) {
"basic" -> BasicMapSample()
- "models" -> ModelsSample()
}
FloatingActionButton(
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
new file mode 100644
index 00000000..f367e1b5
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
@@ -0,0 +1,190 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.flyAroundOptions
+import com.google.android.gms.maps3d.model.flyToOptions
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.ModelConfig
+import com.google.maps.android.compose3d.ModelScale
+import com.google.maps.android.compose3d.utils.awaitCameraAnimation
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class ModelsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ ModelsScreen()
+ }
+ }
+}
+
+@Composable
+private fun ModelsScreen() {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+
+ val initialCamera = camera {
+ center = latLngAltitude {
+ latitude = 47.133971
+ longitude = 11.333161
+ altitude = 2200.0
+ }
+ heading = 221.0
+ tilt = 25.0
+ range = 30000.0
+ }
+
+ var cameraState by remember { mutableStateOf(initialCamera) }
+ var mapInstance by remember { mutableStateOf(null) }
+ var animationJob by remember { mutableStateOf(null) }
+
+ val models = remember {
+ listOf(
+ ModelConfig(
+ key = "plane_model",
+ position = latLngAltitude {
+ latitude = 47.133971
+ longitude = 11.333161
+ altitude = 2200.0
+ },
+ url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb",
+ altitudeMode = AltitudeMode.ABSOLUTE,
+ scale = ModelScale.Uniform(0.05f),
+ heading = 41.5,
+ tilt = -90.0,
+ roll = 0.0
+ )
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = cameraState,
+ models = models,
+ mapMode = Map3DMode.SATELLITE,
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = { googleMap3D ->
+ mapInstance = googleMap3D
+
+ // Start animation sequence when map is ready
+ animationJob = coroutineScope.launch {
+ runAnimationSequence(googleMap3D)
+ }
+ }
+ )
+
+ // UI Controls
+ FloatingActionButton(
+ onClick = {
+ animationJob?.cancel()
+ animationJob = coroutineScope.launch {
+ mapInstance?.let { map ->
+ // Fly back to initial camera
+ map.flyCameraTo(
+ flyToOptions {
+ endCamera = initialCamera
+ durationInMillis = 2000
+ }
+ )
+ map.awaitCameraAnimation()
+ runAnimationSequence(map)
+ }
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(16.dp)
+ ) {
+ Text(text = "Reset")
+ }
+
+ FloatingActionButton(
+ onClick = {
+ animationJob?.cancel()
+ animationJob = null
+ Toast.makeText(context, "Animation Stopped", Toast.LENGTH_SHORT).show()
+ },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ ) {
+ Text(text = "Stop")
+ }
+ }
+}
+
+private suspend fun runAnimationSequence(googleMap3D: com.google.android.gms.maps3d.GoogleMap3D) {
+ delay(1500)
+
+ val camera = camera {
+ center = latLngAltitude {
+ latitude = 47.133971
+ longitude = 11.333161
+ altitude = 2200.0
+ }
+ heading = 221.4
+ tilt = 75.0
+ range = 700.0
+ }
+
+ // Fly to the plane model
+ googleMap3D.flyCameraTo(
+ flyToOptions {
+ endCamera = camera
+ durationInMillis = 3500
+ }
+ )
+ googleMap3D.awaitCameraAnimation()
+
+ delay(500)
+
+ // Fly around the plane model
+ googleMap3D.flyCameraAround(
+ flyAroundOptions {
+ center = camera
+ durationInMillis = 3500
+ rounds = 0.5
+ }
+ )
+ googleMap3D.awaitCameraAnimation()
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
index f0dff896..bc7f4674 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
@@ -16,27 +16,23 @@ package com.example.maps3dcomposedemo
import android.graphics.Color
import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import kotlinx.coroutines.launch
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.google.android.gms.maps3d.model.AltitudeMode
@@ -44,11 +40,8 @@ import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
-import com.google.maps.android.compose3d.Map3DRegistry
import com.google.maps.android.compose3d.PolylineConfig
-import com.google.maps.android.compose3d.toPolylineOptions
-import kotlinx.coroutines.delay
-import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.launch
/**
* Activity that demonstrates the use of polylines on a 3D map using Compose.
@@ -118,7 +111,7 @@ fun PolylinesScreen() {
color = Color.RED,
width = 7f,
altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
- zIndex = 5,
+ zIndex = 10,
outerColor = Color.BLACK,
outerWidth = 13f,
drawsOccludedSegments = true,
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
index 429b236c..9ee3b019 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
@@ -24,6 +24,7 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -33,6 +34,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.CollisionBehavior
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
@@ -40,118 +42,6 @@ import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
import com.google.maps.android.compose3d.PolylineConfig
-@Composable
-fun BasicMapSample() {
- // Hoist the camera state
- var cameraState by remember {
- mutableStateOf(
- camera {
- center = latLngAltitude {
- latitude = 37.8199
- longitude = -122.4783
- altitude = 0.0
- }
- heading = 0.0
- tilt = 60.0
- range = 2000.0
- roll = 0.0
- }
- )
- }
- var mapMode by remember { mutableStateOf(Map3DMode.SATELLITE) }
- var isMapSteady by remember { mutableStateOf(false) }
- // Sample markers
- val markers = remember {
- listOf(
- MarkerConfig(
- key = "golden_gate",
- position = latLngAltitude {
- latitude = 37.8199
- longitude = -122.4783
- altitude = 0.0
- },
- label = "Golden Gate Bridge",
- altitudeMode = AltitudeMode.CLAMP_TO_GROUND
- )
- )
- }
- // Sample polyline
- val polylines = remember {
- listOf(
- PolylineConfig(
- key = "sample_line",
- points = listOf(
- latLngAltitude { latitude = 37.8199; longitude = -122.4783; altitude = 0.0 },
- latLngAltitude { latitude = 37.8299; longitude = -122.4883; altitude = 0.0 }
- ),
- color = Color.RED,
- width = 10f,
- altitudeMode = AltitudeMode.CLAMP_TO_GROUND
- )
- )
- }
-
- Box(modifier = Modifier.fillMaxSize().semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }) {
- GoogleMap3D(
- camera = cameraState,
- markers = markers,
- polylines = polylines,
- mapMode = mapMode,
- modifier = Modifier.fillMaxSize(),
- onMapReady = {
- // Map is ready, we could perform additional setup here if needed
- },
- onMapSteady = {
- isMapSteady = true
- }
- )
-
- // UI Controls
- FloatingActionButton(
- onClick = {
- mapMode = if (mapMode == Map3DMode.SATELLITE) {
- Map3DMode.HYBRID
- } else {
- Map3DMode.SATELLITE
- }
- },
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(16.dp)
- ) {
- Text(text = if (mapMode == Map3DMode.SATELLITE) "Hybrid" else "Satellite")
- }
-
- FloatingActionButton(
- onClick = {
- // Reset camera
- cameraState = camera {
- center = latLngAltitude {
- latitude = 37.8199
- longitude = -122.4783
- altitude = 0.0
- }
- heading = 0.0
- tilt = 60.0
- range = 2000.0
- roll = 0.0
- }
- },
- modifier = Modifier
- .align(Alignment.BottomStart)
- .padding(16.dp)
- ) {
- Text(text = "Reset")
- }
- }
-}
-
-@Composable
-fun ModelsSample() {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Models Sample Placeholder\nTODO: Implement loading 3D models (glTF).")
- }
-}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
index c326f9bc..4f11db69 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
@@ -28,6 +28,8 @@ import com.google.android.gms.maps3d.model.flyAroundOptions
import com.google.android.gms.maps3d.model.flyToOptions
import com.google.android.gms.maps3d.model.latLngAltitude
import java.util.Locale
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
@@ -541,3 +543,5 @@ fun haversineDistance(p1: LatLng, p2: LatLng): Double {
return r * c
}
+
+
diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt
new file mode 100644
index 00000000..731dea83
--- /dev/null
+++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.compose3d
+
+import com.google.android.gms.maps3d.model.cameraRestriction
+import com.google.maps.android.compose3d.utils.altitudeRange
+import com.google.maps.android.compose3d.utils.headingRange
+import com.google.maps.android.compose3d.utils.tiltRange
+import com.google.maps.android.compose3d.utils.toValidCameraRestriction
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class UtilitiesTest {
+
+ @Test
+ fun testToValidCameraRestriction_null() {
+ val restriction = null
+ assertNull(restriction.toValidCameraRestriction())
+ }
+
+ @Test
+ fun testToValidCameraRestriction_valid() {
+ val restriction = cameraRestriction {
+ minAltitude = 10.0
+ maxAltitude = 100.0
+ minHeading = 0.0
+ maxHeading = 180.0
+ minTilt = 0.0
+ maxTilt = 45.0
+ }
+ val valid = restriction.toValidCameraRestriction()
+
+ assertEquals(10.0, valid?.minAltitude)
+ assertEquals(100.0, valid?.maxAltitude)
+ assertEquals(0.0, valid?.minHeading)
+ assertEquals(180.0, valid?.maxHeading)
+ assertEquals(0.0, valid?.minTilt)
+ assertEquals(45.0, valid?.maxTilt)
+ }
+
+ @Test
+ fun testToValidCameraRestriction_defaults() {
+ val restriction = cameraRestriction {
+ // Leave fields null
+ }
+ val valid = restriction.toValidCameraRestriction()
+
+ assertEquals(altitudeRange.start, valid?.minAltitude)
+ assertEquals(altitudeRange.endInclusive, valid?.maxAltitude)
+ assertEquals(headingRange.start, valid?.minHeading)
+ assertEquals(headingRange.endInclusive, valid?.maxHeading)
+ assertEquals(tiltRange.start, valid?.minTilt)
+ assertEquals(tiltRange.endInclusive, valid?.maxTilt)
+ }
+
+ @Test
+ fun testToValidCameraRestriction_swapped() {
+ val restriction = cameraRestriction {
+ minAltitude = 100.0
+ maxAltitude = 10.0
+ minHeading = 180.0
+ maxHeading = 0.0
+ minTilt = 45.0
+ maxTilt = 0.0
+ }
+ val valid = restriction.toValidCameraRestriction()
+
+ assertEquals(10.0, valid?.minAltitude)
+ assertEquals(100.0, valid?.maxAltitude)
+ assertEquals(0.0, valid?.minHeading)
+ assertEquals(180.0, valid?.maxHeading)
+ assertEquals(0.0, valid?.minTilt)
+ assertEquals(45.0, valid?.maxTilt)
+ }
+}
From 82305b803e5930e5eb14eb0e35a20bba6138f71d Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 14:42:48 -0600
Subject: [PATCH 22/29] Refactor Basic Map sample to launch as a separate
activity
- Refactor "Basic Map with Marker & Polyline" sample to launch BasicMapActivity instead of using inline state.
- Create BasicMapActivity and register it in the manifest.
- Delete empty SampleScreens.kt file.
- Simplify polyline in PolylinesActivity to fix visual test failure.
- Increase timeout and add delay in Maps3DVisualTest for map interactions.
- Add log in MapInteractionsActivity for debugging clicks.
- Fix ktlint violations and clean up unused imports across the project.
---
.../maps3djava/markers/MarkersActivity.java | 2 +-
maps3d-compose-demo/build.gradle.kts | 10 ++
.../maps3dcomposedemo/BaseVisualTest.kt | 8 +-
.../maps3dcomposedemo/Maps3DVisualTest.kt | 114 +++++++--------
.../src/main/AndroidManifest.xml | 4 +
...{BasicMapSample.kt => BasicMapActivity.kt} | 63 +++++++--
.../CameraAnimationsActivity.kt | 30 ++--
.../CameraControlsActivity.kt | 16 +--
.../com/example/maps3dcomposedemo/Data.kt | 2 +-
.../maps3dcomposedemo/HelloMapActivity.kt | 17 +--
.../example/maps3dcomposedemo/MainActivity.kt | 130 ++++++++----------
.../MapInteractionsActivity.kt | 17 ++-
.../maps3dcomposedemo/MapOptionsActivity.kt | 22 +--
.../maps3dcomposedemo/MarkersActivity.kt | 45 +++---
.../maps3dcomposedemo/ModelsActivity.kt | 20 +--
.../maps3dcomposedemo/PolygonsActivity.kt | 28 ++--
.../maps3dcomposedemo/PolylinesActivity.kt | 32 ++---
.../maps3dcomposedemo/PopoversActivity.kt | 44 +++---
.../maps3dcomposedemo/SampleScreens.kt | 47 -------
maps3d-compose/build.gradle.kts | 10 ++
.../maps/android/compose3d/DataModels.kt | 9 +-
.../maps/android/compose3d/GoogleMap3D.kt | 4 +-
.../maps/android/compose3d/Map3DRegistry.kt | 4 +-
.../maps/android/compose3d/Map3DState.kt | 50 +++----
.../google/maps/android/compose3d/Mappers.kt | 1 -
.../android/compose3d/utils/CameraUpdate.kt | 8 +-
.../android/compose3d/utils/GeoMathUtils.kt | 4 +-
.../maps/android/compose3d/utils/Utilities.kt | 31 ++---
.../maps/android/compose3d/MappersTest.kt | 95 ++++++++++---
.../maps/android/compose3d/UtilitiesTest.kt | 6 +-
.../java/snippets/PolygonSnippets.java | 2 +-
.../example/snippets/kotlin/MapActivity.kt | 4 +-
.../example/snippets/kotlin/TrackedMap3D.kt | 8 +-
.../kotlin/snippets/MarkerSnippets.kt | 6 +-
34 files changed, 455 insertions(+), 438 deletions(-)
rename maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/{BasicMapSample.kt => BasicMapActivity.kt} (75%)
delete mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java
index d93ab3bc..6419ead5 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java
@@ -495,7 +495,7 @@ private void advanceTour(GoogleMap3D map) {
.thenComposeAsync(isSteady -> {
// If the user tapped "Stop" while we were waiting, gracefully exit the chain.
if (!isTourActive)
- return CompletableFuture.completedFuture((Void) null);
+ return CompletableFuture.completedFuture(null);
FlyAroundOptions orbitOptions = new FlyAroundOptions(camera, 5000L, 1.0);
return com.example.maps3djava.common.MapUtils.awaitCameraAnimation(map, orbitOptions);
diff --git a/maps3d-compose-demo/build.gradle.kts b/maps3d-compose-demo/build.gradle.kts
index 072f44fd..d5468624 100644
--- a/maps3d-compose-demo/build.gradle.kts
+++ b/maps3d-compose-demo/build.gradle.kts
@@ -19,6 +19,16 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets.gradle.plugin)
+ id("com.diffplug.spotless") version "6.25.0"
+}
+
+configure {
+ kotlin {
+ target("**/*.kt")
+ ktlint().editorConfigOverride(mapOf("indent_size" to "4", "ktlint_function_naming_ignore_when_annotated_with" to "Composable"))
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
}
android {
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
index 284a1e96..4d4614b8 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt
@@ -38,7 +38,7 @@ abstract class BaseVisualTest {
val key = BuildConfig.GEMINI_API_KEY
assertTrue(
"GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.",
- key != "YOUR_GEMINI_API_KEY"
+ key != "YOUR_GEMINI_API_KEY",
)
key
}
@@ -50,10 +50,10 @@ abstract class BaseVisualTest {
val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath)
assertTrue("Failed to decode screenshot file: $filename", bitmap != null)
-
+
println("Screenshot saved to device: ${screenshotFile.absolutePath}")
println("To pull: adb pull ${screenshotFile.absolutePath}")
-
+
return bitmap
}
@@ -65,6 +65,6 @@ abstract class BaseVisualTest {
*/
protected fun waitForMapRendering(timeoutSeconds: Long = 30) {
val found = uiDevice.wait(Until.hasObject(By.desc("MapSteady")), timeoutSeconds * 1000)
- assertTrue("Map did not become steady within ${timeoutSeconds} seconds", found)
+ assertTrue("Map did not become steady within $timeoutSeconds seconds", found)
}
}
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 16f490b4..26e5ff60 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -16,17 +16,16 @@
package com.example.maps3dcomposedemo
+import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Before
-import android.content.Intent
-import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Test
import org.junit.runner.RunWith
-import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class Maps3DVisualTest : BaseVisualTest() {
@@ -50,12 +49,12 @@ class Maps3DVisualTest : BaseVisualTest() {
// Define the verification prompt for Gemini
val prompt = """
- Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
Check the image against the following criteria:
1. Confirm that the title 'Maps 3D Compose Samples' is visible.
2. Confirm that there are multiple list items visible (e.g., "Basic Map with Marker & Polyline", "Hello Map", etc.).
-
- If all elements are present and look reasonable for a list of samples, reply with "PASSED".
+
+ If all elements are present and look reasonable for a list of samples, reply with "PASSED".
If any element is missing or incorrect, please detail the discrepancy.
""".trimIndent()
@@ -67,7 +66,7 @@ class Maps3DVisualTest : BaseVisualTest() {
// Assert on Gemini's response
assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
)
}
@@ -79,7 +78,7 @@ class Maps3DVisualTest : BaseVisualTest() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-
+
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
@@ -91,12 +90,12 @@ class Maps3DVisualTest : BaseVisualTest() {
// Define the verification prompt for Gemini
val prompt = """
- Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
Check the image against the following criteria:
1. Confirm that a 3D map view is visible.
2. Confirm that the Delicate Arch itself is clearly visible and a prominent part of the scene (it should look like a large freestanding rock arch).
-
- If all elements are present and the Delicate Arch is clearly visible, reply with "PASSED".
+
+ If all elements are present and the Delicate Arch is clearly visible, reply with "PASSED".
If any element is missing or incorrect, please detail the discrepancy.
""".trimIndent()
@@ -108,7 +107,7 @@ class Maps3DVisualTest : BaseVisualTest() {
// Assert on Gemini's response
assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
)
}
@@ -119,10 +118,10 @@ class Maps3DVisualTest : BaseVisualTest() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-
+
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
-
+
// Wait for the map to render and tiles to load
waitForMapRendering(60)
@@ -131,12 +130,12 @@ class Maps3DVisualTest : BaseVisualTest() {
// Define the verification prompt for Gemini
val prompt = """
- Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
+ Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly.
Check the image against the following criteria:
1. Confirm that a 3D map view is visible.
2. Confirm that the Space Needle or surrounding Seattle urban area (like stadiums/arenas) is visible and prominent.
-
- If all elements are present and look reasonable for a 3D map of Seattle, reply with "PASSED".
+
+ If all elements are present and look reasonable for a 3D map of Seattle, reply with "PASSED".
If any element is missing or incorrect, please detail the discrepancy.
""".trimIndent()
@@ -148,7 +147,7 @@ class Maps3DVisualTest : BaseVisualTest() {
// Assert on Gemini's response
assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
)
}
@@ -160,20 +159,23 @@ class Maps3DVisualTest : BaseVisualTest() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-
+
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
-
+
// Wait for the map to render and tiles to load
waitForMapRendering(60)
-
+
+ // Wait a bit more to ensure map is interactive
+ android.os.SystemClock.sleep(2000)
+
// Strategy 1: Click the center of the screen and surrounding points
val screenWidth = uiDevice.displayWidth
val screenHeight = uiDevice.displayHeight
-
+
val centerX = screenWidth / 2
val centerY = screenHeight / 2
-
+
// Click center and a few points around it to increase chances
uiDevice.click(centerX, centerY)
android.os.SystemClock.sleep(500)
@@ -184,24 +186,24 @@ class Maps3DVisualTest : BaseVisualTest() {
uiDevice.click(centerX + 50, centerY - 50)
android.os.SystemClock.sleep(500)
uiDevice.click(centerX - 50, centerY + 50)
-
+
// Wait for the click info card to update with text containing "Clicked"
val textUpdated = uiDevice.wait(
Until.hasObject(By.descContains("Clicked")),
- 5000
+ 10000,
)
assertTrue("Card text did not update after click", textUpdated)
-
+
// Verify that the text contains coordinates or place ID
val cardObject = uiDevice.findObject(By.descContains("Clicked"))
val description = cardObject.contentDescription
println("Card text: $description")
-
+
assertTrue(
"Card text should contain 'Location' or 'Place ID'",
- description.contains("Location") || description.contains("Place ID")
+ description.contains("Location") || description.contains("Place ID"),
)
-
+
// Capture a screenshot for visual confirmation
captureScreenshot("map_interactions_success.png")
}
@@ -215,34 +217,34 @@ class Maps3DVisualTest : BaseVisualTest() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-
+
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
-
+
// Wait for the map to render and tiles to load
waitForMapRendering(60)
-
+
// Capture a screenshot
val screenshotBitmap = captureScreenshot("markers_devils_tower.png")
-
+
// Define the verification prompt for Gemini
val prompt = """
Please act as a UI tester and analyze this screenshot.
1. Confirm that a 3D map view is visible.
2. Confirm that an alien icon or marker is visible on top of the prominent rock formation (Devils Tower).
-
- If the map is visible and the alien marker is seen on the tower, reply with "PASSED".
+
+ If the map is visible and the alien marker is seen on the tower, reply with "PASSED".
Otherwise, report what you see.
""".trimIndent()
-
+
// Analyze the image using Gemini
val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
println("Gemini's analysis: $geminiResponse")
-
+
// Assert on Gemini's response
assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
)
}
}
@@ -256,34 +258,34 @@ class Maps3DVisualTest : BaseVisualTest() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-
+
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
-
+
// Wait for the map to render and tiles to load
waitForMapRendering(60)
-
+
// Capture a screenshot
val screenshotBitmap = captureScreenshot("polylines_sanitas.png")
-
+
// Define the verification prompt for Gemini
val prompt = """
Please act as a UI tester and analyze this screenshot.
1. Confirm that a 3D map view is visible.
2. Confirm that a red polyline (line) is visible on the map, representing a trail.
-
- If the map is visible and the red polyline is seen, reply with "PASSED".
+
+ If the map is visible and the red polyline is seen, reply with "PASSED".
Otherwise, report what you see.
""".trimIndent()
-
+
// Analyze the image using Gemini
val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
println("Gemini's analysis: $geminiResponse")
-
+
// Assert on Gemini's response
assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
)
}
}
@@ -296,34 +298,34 @@ class Maps3DVisualTest : BaseVisualTest() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-
+
// Wait for the activity to be displayed
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
-
+
// Wait for the map to render and tiles to load
waitForMapRendering(60)
-
+
// Capture a screenshot
val screenshotBitmap = captureScreenshot("polygons_denver_zoo.png")
-
+
// Define the verification prompt for Gemini
val prompt = """
Please act as a UI tester and analyze this screenshot.
1. Confirm that a 3D map view is visible.
2. Confirm that a yellow translucent polygon with a green border is visible on the map.
-
- If the map is visible and the yellow polygon is seen, reply with "PASSED".
+
+ If the map is visible and the yellow polygon is seen, reply with "PASSED".
Otherwise, report what you see.
""".trimIndent()
-
+
// Analyze the image using Gemini
val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
println("Gemini's analysis: $geminiResponse")
-
+
// Assert on Gemini's response
assertTrue(
"Visual verification failed. Gemini response: $geminiResponse",
- geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
)
}
}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index b3d27512..694c2aa6 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -76,6 +76,10 @@
android:name=".ModelsActivity"
android:exported="true"
android:label="Models" />
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt
similarity index 75%
rename from maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt
rename to maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt
index a4bea885..9e486af3 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapSample.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt
@@ -17,10 +17,16 @@
package com.example.maps3dcomposedemo
import android.graphics.Color
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -42,6 +48,26 @@ import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
import com.google.maps.android.compose3d.PolylineConfig
+/**
+ * Activity that demonstrates a basic 3D map with a marker and a polyline using Compose.
+ */
+class BasicMapActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ BasicMapSample()
+ }
+ }
+ }
+ }
+}
+
@Composable
fun BasicMapSample() {
// Hoist the camera state
@@ -57,7 +83,7 @@ fun BasicMapSample() {
tilt = 60.0
range = 3000.0
roll = 0.0
- }
+ },
)
}
@@ -77,8 +103,8 @@ fun BasicMapSample() {
label = "Golden Gate Bridge",
altitudeMode = AltitudeMode.RELATIVE_TO_MESH,
isDrawnWhenOccluded = true,
- collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL
- )
+ collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL,
+ ),
)
}
@@ -88,19 +114,28 @@ fun BasicMapSample() {
PolylineConfig(
key = "sample_line",
points = listOf(
- latLngAltitude { latitude = 37.8199; longitude = -122.4783; altitude = 0.0 },
- latLngAltitude { latitude = 37.8299; longitude = -122.4883; altitude = 0.0 }
+ latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 0.0
+ },
+ latLngAltitude {
+ latitude = 37.8299
+ longitude = -122.4883
+ altitude = 0.0
+ },
),
color = Color.RED,
width = 10f,
- altitudeMode = AltitudeMode.CLAMP_TO_GROUND
- )
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
+ ),
)
}
- Box(modifier = Modifier
- .fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = cameraState,
@@ -113,7 +148,7 @@ fun BasicMapSample() {
},
onMapSteady = {
isMapSteady = true
- }
+ },
)
// UI Controls
@@ -127,11 +162,11 @@ fun BasicMapSample() {
},
modifier = Modifier
.align(Alignment.BottomEnd)
- .padding(16.dp)
+ .padding(16.dp),
) {
Text(text = if (mapMode == Map3DMode.SATELLITE) "Hybrid" else "Satellite")
}
-
+
FloatingActionButton(
onClick = {
// Reset camera
@@ -149,7 +184,7 @@ fun BasicMapSample() {
},
modifier = Modifier
.align(Alignment.BottomStart)
- .padding(16.dp)
+ .padding(16.dp),
) {
Text(text = "Reset")
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt
index e53343e8..64a53fe0 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt
@@ -58,7 +58,7 @@ class CameraAnimationsActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
CameraAnimationsScreen()
}
@@ -73,8 +73,16 @@ fun CameraAnimationsScreen() {
var statusText by remember { mutableStateOf("Idle") }
val coroutineScope = rememberCoroutineScope()
- val newYork = latLngAltitude { latitude = 40.7128; longitude = -74.0060; altitude = 0.0 }
- val sf = latLngAltitude { latitude = 37.7749; longitude = -122.4194; altitude = 0.0 }
+ val newYork = latLngAltitude {
+ latitude = 40.7128
+ longitude = -74.0060
+ altitude = 0.0
+ }
+ val sf = latLngAltitude {
+ latitude = 37.7749
+ longitude = -122.4194
+ altitude = 0.0
+ }
Box(modifier = Modifier.fillMaxSize()) {
GoogleMap3D(
@@ -86,13 +94,13 @@ fun CameraAnimationsScreen() {
modifier = Modifier.fillMaxSize(),
onMapReady = { googleMap3D ->
mapInstance = googleMap3D
-
+
googleMap3D.setOnMapSteadyListener { isSteady ->
if (isSteady) {
statusText = "Steady"
}
}
- }
+ },
)
Column(
@@ -101,13 +109,13 @@ fun CameraAnimationsScreen() {
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
.padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Status: $statusText", color = Color.White)
-
+
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly
+ horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = {
mapInstance?.let { map ->
@@ -120,7 +128,7 @@ fun CameraAnimationsScreen() {
tilt = 60.0
}
durationInMillis = 5000
- }
+ },
)
coroutineScope.launch {
map.awaitCameraAnimation()
@@ -138,7 +146,7 @@ fun CameraAnimationsScreen() {
flyAroundOptions {
rounds = 1.0
durationInMillis = 10000
- }
+ },
)
coroutineScope.launch {
map.awaitCameraAnimation()
@@ -152,7 +160,7 @@ fun CameraAnimationsScreen() {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly
+ horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = {
mapInstance?.let { map ->
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
index 3a461159..f64f348d 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt
@@ -22,22 +22,16 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.unit.dp
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
@@ -50,7 +44,7 @@ class CameraControlsActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
CameraControlsScreen()
}
@@ -62,7 +56,7 @@ class CameraControlsActivity : ComponentActivity() {
@Composable
fun CameraControlsScreen() {
var isMapSteady by remember { mutableStateOf(false) }
-
+
// Calibrated camera centered around Seattle (Space Needle area)
val seattleCamera = remember {
camera {
@@ -81,16 +75,14 @@ fun CameraControlsScreen() {
Box(
modifier = Modifier
.fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = seattleCamera,
modifier = Modifier.fillMaxSize(),
onMapSteady = {
isMapSteady = true
- }
+ },
)
-
-
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt
index 94486584..7a0f8468 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt
@@ -1551,4 +1551,4 @@ internal val sanitasLoop = """
40.0202120, -105.2976260
40.0201700, -105.2977960
40.0201510, -105.2978440
-""".trimIndent()
\ No newline at end of file
+""".trimIndent()
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
index 0791d927..89232a6d 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt
@@ -22,23 +22,16 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.unit.dp
-import com.google.android.gms.maps3d.GoogleMap3D as Map3D
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
@@ -51,7 +44,7 @@ class HelloMapActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
HelloMapScreen()
}
@@ -63,7 +56,7 @@ class HelloMapActivity : ComponentActivity() {
@Composable
fun HelloMapScreen() {
var isMapSteady by remember { mutableStateOf(false) }
-
+
val flatironsCamera = remember {
camera {
center = latLngAltitude {
@@ -81,16 +74,14 @@ fun HelloMapScreen() {
Box(
modifier = Modifier
.fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = flatironsCamera,
modifier = Modifier.fillMaxSize(),
onMapSteady = {
isMapSteady = true
- }
+ },
)
-
-
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index 99a15746..64c4521f 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -16,31 +16,25 @@
package com.example.maps3dcomposedemo
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
-import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import android.content.Intent
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -53,7 +47,7 @@ class MainActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
CatalogScreen()
}
@@ -65,83 +59,67 @@ class MainActivity : ComponentActivity() {
@Composable
fun CatalogScreen() {
val context = LocalContext.current
- var selectedSample by remember { mutableStateOf(null) }
-
- if (selectedSample == null) {
- LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
- item {
- Text(
- text = "Maps 3D Compose Samples",
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(16.dp)
- )
- }
- item { SampleItem("Basic Map with Marker & Polyline") { selectedSample = "basic" } }
- item {
- SampleItem("Hello Map") {
- context.startActivity(Intent(context, HelloMapActivity::class.java))
- }
- }
- item {
- SampleItem("Camera Controls") {
- context.startActivity(Intent(context, CameraControlsActivity::class.java))
- }
+ LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
+ item {
+ Text(
+ text = "Maps 3D Compose Samples",
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ item {
+ SampleItem("Basic Map with Marker & Polyline") {
+ context.startActivity(Intent(context, BasicMapActivity::class.java))
}
- item {
- SampleItem("Map Interactions") {
- context.startActivity(Intent(context, MapInteractionsActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Hello Map") {
+ context.startActivity(Intent(context, HelloMapActivity::class.java))
}
- item {
- SampleItem("Markers") {
- context.startActivity(Intent(context, MarkersActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Camera Controls") {
+ context.startActivity(Intent(context, CameraControlsActivity::class.java))
}
- item {
- SampleItem("Models") {
- context.startActivity(Intent(context, ModelsActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Map Interactions") {
+ context.startActivity(Intent(context, MapInteractionsActivity::class.java))
}
- item {
- SampleItem("Polygons") {
- context.startActivity(Intent(context, PolygonsActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Markers") {
+ context.startActivity(Intent(context, MarkersActivity::class.java))
}
- item {
- SampleItem("Polylines") {
- context.startActivity(Intent(context, PolylinesActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Models") {
+ context.startActivity(Intent(context, ModelsActivity::class.java))
}
- item {
- SampleItem("Popovers") {
- context.startActivity(Intent(context, PopoversActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Polygons") {
+ context.startActivity(Intent(context, PolygonsActivity::class.java))
}
- item {
- SampleItem("Map Options") {
- context.startActivity(Intent(context, MapOptionsActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Polylines") {
+ context.startActivity(Intent(context, PolylinesActivity::class.java))
}
- item {
- SampleItem("Camera Animations") {
- context.startActivity(Intent(context, CameraAnimationsActivity::class.java))
- }
+ }
+ item {
+ SampleItem("Popovers") {
+ context.startActivity(Intent(context, PopoversActivity::class.java))
}
}
- } else {
- Box(modifier = Modifier.fillMaxSize()) {
- when (selectedSample) {
- "basic" -> BasicMapSample()
+ item {
+ SampleItem("Map Options") {
+ context.startActivity(Intent(context, MapOptionsActivity::class.java))
}
-
- FloatingActionButton(
- onClick = { selectedSample = null },
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(16.dp)
- .statusBarsPadding()
- ) {
- Text("Back")
+ }
+ item {
+ SampleItem("Camera Animations") {
+ context.startActivity(Intent(context, CameraAnimationsActivity::class.java))
}
}
}
@@ -154,12 +132,12 @@ fun SampleItem(title: String, onClick: () -> Unit) {
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { onClick() },
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Text(
text = title,
modifier = Modifier.padding(16.dp),
- style = MaterialTheme.typography.bodyLarge
+ style = MaterialTheme.typography.bodyLarge,
)
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
index 649b24bb..e47f8341 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt
@@ -17,15 +17,14 @@
package com.example.maps3dcomposedemo
import android.os.Bundle
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Card
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -53,7 +52,7 @@ class MapInteractionsActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
MapInteractionsScreen()
}
@@ -86,7 +85,7 @@ fun MapInteractionsScreen() {
Box(
modifier = Modifier
.fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = calibratedCamera,
@@ -96,6 +95,7 @@ fun MapInteractionsScreen() {
map3dInstance = instance
// Set up click listener directly on the instance
instance.setMap3DClickListener { location, placeId ->
+ Log.d("MapInteractionsActivity", "Map clicked at ${location.latitude}, ${location.longitude}")
clickedInfo = if (placeId != null) {
"Clicked Place ID: $placeId\nAt: ${location.latitude}, ${location.longitude}"
} else {
@@ -105,24 +105,23 @@ fun MapInteractionsScreen() {
},
onMapSteady = {
isMapSteady = true
- }
+ },
)
-
-
// Click Info Card
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
- .padding(bottom = 32.dp) // Extra padding to avoid system bars
+ // Extra padding to avoid system bars
+ .padding(bottom = 32.dp),
) {
Text(
text = clickedInfo,
modifier = Modifier
.padding(16.dp)
.semantics { contentDescription = clickedInfo },
- style = MaterialTheme.typography.bodyMedium
+ style = MaterialTheme.typography.bodyMedium,
)
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt
index 8eed1853..ac99040d 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt
@@ -45,8 +45,8 @@ import androidx.compose.ui.unit.dp
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.cameraRestriction
-import com.google.android.gms.maps3d.model.latLngBounds
import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.gms.maps3d.model.latLngBounds
import com.google.maps.android.compose3d.GoogleMap3D
class MapOptionsActivity : ComponentActivity() {
@@ -57,7 +57,7 @@ class MapOptionsActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
MapOptionsScreen()
}
@@ -103,7 +103,7 @@ fun MapOptionsScreen() {
camera = devilsTowerCamera,
mapMode = mapMode,
cameraRestriction = if (isRestricted) restriction else null,
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
)
// Control Panel
@@ -113,32 +113,32 @@ fun MapOptionsScreen() {
.padding(16.dp)
.background(
color = Color.Black.copy(alpha = 0.6f),
- shape = RoundedCornerShape(16.dp)
+ shape = RoundedCornerShape(16.dp),
)
.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Map Options",
color = Color.White,
- style = MaterialTheme.typography.titleMedium
+ style = MaterialTheme.typography.titleMedium,
)
-
+
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { mapMode = Map3DMode.SATELLITE },
modifier = Modifier.weight(1f),
- enabled = mapMode != Map3DMode.SATELLITE
+ enabled = mapMode != Map3DMode.SATELLITE,
) {
Text("Satellite")
}
Button(
onClick = { mapMode = Map3DMode.HYBRID },
modifier = Modifier.weight(1f),
- enabled = mapMode != Map3DMode.HYBRID
+ enabled = mapMode != Map3DMode.HYBRID,
) {
Text("Hybrid")
}
@@ -146,7 +146,7 @@ fun MapOptionsScreen() {
Button(
onClick = { isRestricted = !isRestricted },
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
) {
Text(if (isRestricted) "Clear Restriction" else "Restrict to Area")
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
index 20ab05d6..83f058a6 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt
@@ -16,41 +16,34 @@
package com.example.maps3dcomposedemo
+import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps3d.Popover
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
-import android.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import com.google.android.gms.maps3d.model.markerOptions
import com.google.android.gms.maps3d.model.popoverOptions
-import com.google.android.gms.maps3d.Popover
-import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
+import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
class MarkersActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -60,7 +53,7 @@ class MarkersActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
MarkersScreen()
}
@@ -91,7 +84,7 @@ fun MarkersScreen() {
val context = LocalContext.current
var activePopover by remember { mutableStateOf(null) }
var googleMap3DInstance by remember { mutableStateOf(null) }
-
+
val alienMarker = remember {
MarkerConfig(
key = "alien",
@@ -113,26 +106,28 @@ fun MarkersScreen() {
setTextColor(Color.BLACK)
setBackgroundColor(Color.WHITE)
}
- val newPopover = map.addPopover(popoverOptions {
- positionAnchor = marker
- altitudeMode = AltitudeMode.ABSOLUTE
- content = textView
- autoCloseEnabled = true
- autoPanEnabled = false
- })
-
+ val newPopover = map.addPopover(
+ popoverOptions {
+ positionAnchor = marker
+ altitudeMode = AltitudeMode.ABSOLUTE
+ content = textView
+ autoCloseEnabled = true
+ autoPanEnabled = false
+ },
+ )
+
activePopover?.remove()
activePopover = newPopover
activePopover?.show()
}
- }
+ },
)
}
Box(
modifier = Modifier
.fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = devilsTowerCamera,
@@ -144,9 +139,7 @@ fun MarkersScreen() {
},
onMapReady = { instance ->
googleMap3DInstance = instance
- }
+ },
)
-
-
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
index f367e1b5..ed6a71b1 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
@@ -62,7 +62,7 @@ class ModelsActivity : ComponentActivity() {
private fun ModelsScreen() {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
-
+
val initialCamera = camera {
center = latLngAltitude {
latitude = 47.133971
@@ -92,8 +92,8 @@ private fun ModelsScreen() {
scale = ModelScale.Uniform(0.05f),
heading = 41.5,
tilt = -90.0,
- roll = 0.0
- )
+ roll = 0.0,
+ ),
)
}
@@ -105,12 +105,12 @@ private fun ModelsScreen() {
modifier = Modifier.fillMaxSize(),
onMapReady = { googleMap3D ->
mapInstance = googleMap3D
-
+
// Start animation sequence when map is ready
animationJob = coroutineScope.launch {
runAnimationSequence(googleMap3D)
}
- }
+ },
)
// UI Controls
@@ -124,7 +124,7 @@ private fun ModelsScreen() {
flyToOptions {
endCamera = initialCamera
durationInMillis = 2000
- }
+ },
)
map.awaitCameraAnimation()
runAnimationSequence(map)
@@ -133,7 +133,7 @@ private fun ModelsScreen() {
},
modifier = Modifier
.align(Alignment.BottomStart)
- .padding(16.dp)
+ .padding(16.dp),
) {
Text(text = "Reset")
}
@@ -146,7 +146,7 @@ private fun ModelsScreen() {
},
modifier = Modifier
.align(Alignment.BottomEnd)
- .padding(16.dp)
+ .padding(16.dp),
) {
Text(text = "Stop")
}
@@ -172,7 +172,7 @@ private suspend fun runAnimationSequence(googleMap3D: com.google.android.gms.map
flyToOptions {
endCamera = camera
durationInMillis = 3500
- }
+ },
)
googleMap3D.awaitCameraAnimation()
@@ -184,7 +184,7 @@ private suspend fun runAnimationSequence(googleMap3D: com.google.android.gms.map
center = camera
durationInMillis = 3500
rounds = 0.5
- }
+ },
)
googleMap3D.awaitCameraAnimation()
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
index 3b6f2c59..7d4f99ed 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt
@@ -20,8 +20,6 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.platform.LocalContext
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -31,18 +29,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import kotlinx.coroutines.launch
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
-import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.PolygonConfig
+import kotlinx.coroutines.launch
/**
* Activity that demonstrates the use of polygons on a 3D map using Compose.
@@ -58,7 +58,7 @@ class PolygonsActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
PolygonsScreen()
}
@@ -162,7 +162,8 @@ fun PolygonsScreen() {
key = "denver_zoo",
path = zooOutline,
innerPaths = listOf(zooHole),
- fillColor = Color.argb(70, 255, 255, 0), // Translucent yellow
+ // Translucent yellow
+ fillColor = Color.argb(70, 255, 255, 0),
strokeColor = Color.GREEN,
strokeWidth = 3f,
altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
@@ -171,7 +172,7 @@ fun PolygonsScreen() {
scope.launch {
Toast.makeText(context, "Zoo time!", Toast.LENGTH_SHORT).show()
}
- }
+ },
)
}
@@ -182,7 +183,7 @@ fun PolygonsScreen() {
39.7465307929639, -104.94370889409778
39.747031745033794, -104.9415078562927
39.74837320615968, -104.94194414397013
- 39.74812392425406, -104.94414971628434
+ 39.74812392425406, -104.94414971628434
""".trimIndent()
.lines()
.map { line -> line.split(",").map { it.trim().toDouble() } }
@@ -201,7 +202,8 @@ fun PolygonsScreen() {
PolygonConfig(
key = "museum_face_$index",
path = outline,
- fillColor = Color.argb(70, 255, 0, 255), // Semi-transparent magenta
+ // Semi-transparent magenta
+ fillColor = Color.argb(70, 255, 0, 255),
strokeColor = Color.MAGENTA,
strokeWidth = 3f,
altitudeMode = AltitudeMode.ABSOLUTE,
@@ -210,7 +212,7 @@ fun PolygonsScreen() {
scope.launch {
Toast.makeText(context, "Museum time!", Toast.LENGTH_SHORT).show()
}
- }
+ },
)
}
}
@@ -218,7 +220,7 @@ fun PolygonsScreen() {
Box(
modifier = Modifier
.fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = denverCamera,
@@ -227,7 +229,7 @@ fun PolygonsScreen() {
modifier = Modifier.fillMaxSize(),
onMapSteady = {
isMapSteady = true
- }
+ },
)
}
}
@@ -238,7 +240,7 @@ fun PolygonsScreen() {
*/
fun extrudePolygon(
basePoints: List,
- extrusionHeight: Double
+ extrusionHeight: Double,
): List> {
if (basePoints.size < 3) return emptyList()
if (extrusionHeight <= 0) return emptyList()
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
index bc7f4674..499f9f7a 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt
@@ -58,7 +58,7 @@ class PolylinesActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
PolylinesScreen()
}
@@ -72,7 +72,7 @@ fun PolylinesScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isMapSteady by remember { mutableStateOf(false) }
-
+
// Define the camera position centered around Mount Sanitas trailhead
val boulderCamera = remember {
camera {
@@ -103,54 +103,38 @@ fun PolylinesScreen() {
}
}
- // Create a polyline config with a stroked effect
+ // Create a polyline config
val polylineConfig = remember {
PolylineConfig(
key = "sanitas_loop",
points = trailPoints,
color = Color.RED,
- width = 7f,
+ width = 10f,
altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
zIndex = 10,
- outerColor = Color.BLACK,
- outerWidth = 13f,
drawsOccludedSegments = true,
onClick = { polyline ->
Log.d("PolylinesActivity", "Polyline clicked: $polyline")
scope.launch {
Toast.makeText(context, "Hiking time!", Toast.LENGTH_SHORT).show()
}
- }
- )
- }
-
- val bgPolylineConfig = remember {
- PolylineConfig(
- key = "sanitas_loop_background",
- points = trailPoints,
- color = Color.BLACK,
- width = 11f,
- altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
- zIndex = 4,
- outerColor = Color.BLACK,
- outerWidth = 13f,
- drawsOccludedSegments = true,
+ },
)
}
Box(
modifier = Modifier
.fillMaxSize()
- .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
) {
GoogleMap3D(
camera = boulderCamera,
mapMode = Map3DMode.HYBRID,
- polylines = listOf(bgPolylineConfig, polylineConfig),
+ polylines = listOf(polylineConfig),
modifier = Modifier.fillMaxSize(),
onMapSteady = {
isMapSteady = true
- }
+ },
)
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
index 4bcd24c8..eb1dad05 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
@@ -38,8 +38,8 @@ import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.android.gms.maps3d.model.popoverOptions
-import com.google.android.gms.maps3d.model.popoverStyle
import com.google.android.gms.maps3d.model.popoverShadow
+import com.google.android.gms.maps3d.model.popoverStyle
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
@@ -52,7 +52,7 @@ class PopoversActivity : ComponentActivity() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = MaterialTheme.colorScheme.background,
) {
PopoversScreen()
}
@@ -103,29 +103,31 @@ fun PopoversScreen() {
setTextColor(Color.BLACK)
setBackgroundColor(Color.WHITE)
}
- val newPopover = map.addPopover(popoverOptions {
- positionAnchor = nativeMarker
- altitudeMode = AltitudeMode.ABSOLUTE
- content = textView
- autoCloseEnabled = true
- autoPanEnabled = false
- popoverStyle = popoverStyle {
- backgroundColor = Color.WHITE
- borderRadius = 16f
- shadow = popoverShadow {
- color = Color.DKGRAY
- radius = 8f
- offsetX = 4f
- offsetY = 4f
+ val newPopover = map.addPopover(
+ popoverOptions {
+ positionAnchor = nativeMarker
+ altitudeMode = AltitudeMode.ABSOLUTE
+ content = textView
+ autoCloseEnabled = true
+ autoPanEnabled = false
+ popoverStyle = popoverStyle {
+ backgroundColor = Color.WHITE
+ borderRadius = 16f
+ shadow = popoverShadow {
+ color = Color.DKGRAY
+ radius = 8f
+ offsetX = 4f
+ offsetY = 4f
+ }
}
- }
- })
-
+ },
+ )
+
activePopover?.remove()
activePopover = newPopover
activePopover?.show()
}
- }
+ },
)
}
@@ -141,7 +143,7 @@ fun PopoversScreen() {
activePopover?.remove()
activePopover = null
}
- }
+ },
)
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
deleted file mode 100644
index 9ee3b019..00000000
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/SampleScreens.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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.example.maps3dcomposedemo
-
-import android.graphics.Color
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.unit.dp
-import com.google.android.gms.maps3d.model.AltitudeMode
-import com.google.android.gms.maps3d.model.CollisionBehavior
-import com.google.android.gms.maps3d.model.Map3DMode
-import com.google.android.gms.maps3d.model.camera
-import com.google.android.gms.maps3d.model.latLngAltitude
-import com.google.maps.android.compose3d.GoogleMap3D
-import com.google.maps.android.compose3d.MarkerConfig
-import com.google.maps.android.compose3d.PolylineConfig
-
-
-
-
diff --git a/maps3d-compose/build.gradle.kts b/maps3d-compose/build.gradle.kts
index 5e2cd44e..86c8f175 100644
--- a/maps3d-compose/build.gradle.kts
+++ b/maps3d-compose/build.gradle.kts
@@ -18,6 +18,16 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
+ id("com.diffplug.spotless") version "6.25.0"
+}
+
+configure {
+ kotlin {
+ target("**/*.kt")
+ ktlint().editorConfigOverride(mapOf("indent_size" to "4", "ktlint_function_naming_ignore_when_annotated_with" to "Composable"))
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
}
android {
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index 659f2a1d..edf19a0f 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -40,7 +40,7 @@ data class MarkerConfig(
val isExtruded: Boolean = false,
val isDrawnWhenOccluded: Boolean = false,
val collisionBehavior: Int = CollisionBehavior.REQUIRED,
- val onClick: ((Marker) -> Unit)? = null
+ val onClick: ((Marker) -> Unit)? = null,
)
/**
@@ -58,7 +58,7 @@ data class PolylineConfig(
val outerWidth: Float = 0f,
val drawsOccludedSegments: Boolean = false,
@get:WorkerThread
- val onClick: ((Polyline) -> Unit)? = null
+ val onClick: ((Polyline) -> Unit)? = null,
)
/**
@@ -73,7 +73,7 @@ data class PolygonConfig(
val strokeColor: Int,
val strokeWidth: Float,
val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
- val onClick: ((Polygon) -> Unit)? = null
+ val onClick: ((Polygon) -> Unit)? = null,
)
/**
@@ -96,6 +96,5 @@ data class ModelConfig(
val scale: ModelScale = ModelScale.Uniform(1.0f),
val heading: Double = 0.0,
val tilt: Double = 0.0,
- val roll: Double = 0.0
+ val roll: Double = 0.0,
)
-
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index 35101d4e..1b9f7e1f 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -67,7 +67,7 @@ fun GoogleMap3D(
@Map3DMode mapMode: Int = Map3DMode.SATELLITE,
options: Map3DOptions = Map3DOptions(),
onMapReady: (GoogleMap3D) -> Unit = {},
- onMapSteady: () -> Unit = {}
+ onMapSteady: () -> Unit = {},
) {
val state = remember { Map3DState() }
val hasCalledOnMapReady = remember { mutableStateOf(false) }
@@ -129,6 +129,6 @@ fun GoogleMap3D(
state.clear()
Map3DRegistry.clearInstance()
map3dView.onDestroy()
- }
+ },
)
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt
index cc7590e5..c1a2b697 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt
@@ -31,7 +31,7 @@ import com.google.android.gms.maps3d.GoogleMap3D
*/
object Map3DRegistry {
private var mapInstance: GoogleMap3D? = null
-
+
/**
* Tracks whether the map has been initialized and is ready for content.
* The SDK only calls `OnMapReadyListener` once.
@@ -61,7 +61,7 @@ object Map3DRegistry {
// We do not reset isMapReady here, as the underlying SDK instance might still be ready
// even if we detach from a specific view.
}
-
+
/**
* Marks the map as ready. Called when the `OnMapReadyListener` fires.
*/
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
index d0e340e9..65b37c63 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -76,23 +76,25 @@ class Map3DState {
}
private fun createMarker(map: GoogleMap3D, config: MarkerConfig): Marker? {
- val marker = map.addMarker(markerOptions {
- position = config.position.toValidLocation()
- altitudeMode = config.altitudeMode
- config.styleView?.let { setStyle(it) }
- label = config.label
- zIndex = config.zIndex
- isExtruded = config.isExtruded
- isDrawnWhenOccluded = config.isDrawnWhenOccluded
- collisionBehavior = config.collisionBehavior
- })
-
+ val marker = map.addMarker(
+ markerOptions {
+ position = config.position.toValidLocation()
+ altitudeMode = config.altitudeMode
+ config.styleView?.let { setStyle(it) }
+ label = config.label
+ zIndex = config.zIndex
+ isExtruded = config.isExtruded
+ isDrawnWhenOccluded = config.isDrawnWhenOccluded
+ collisionBehavior = config.collisionBehavior
+ },
+ )
+
config.onClick?.let { callback ->
marker?.setClickListener {
callback(marker)
}
}
-
+
return marker
}
@@ -131,7 +133,7 @@ class Map3DState {
}
}
- private fun createPolyline(map: GoogleMap3D, config: PolylineConfig): Polyline? {
+ private fun createPolyline(map: GoogleMap3D, config: PolylineConfig): Polyline {
val polyline = map.addPolyline(config.toPolylineOptions())
config.onClick?.let { callback ->
polyline.setClickListener {
@@ -176,15 +178,17 @@ class Map3DState {
}
}
- private fun createPolygon(map: GoogleMap3D, config: PolygonConfig): Polygon? {
- val polygon = map.addPolygon(polygonOptions {
- this.path = config.path.map { it.toValidLocation() }
- innerPaths = config.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) }
- fillColor = config.fillColor
- strokeColor = config.strokeColor
- strokeWidth = config.strokeWidth.toDouble()
- altitudeMode = config.altitudeMode
- })
+ private fun createPolygon(map: GoogleMap3D, config: PolygonConfig): Polygon {
+ val polygon = map.addPolygon(
+ polygonOptions {
+ this.path = config.path.map { it.toValidLocation() }
+ innerPaths = config.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) }
+ fillColor = config.fillColor
+ strokeColor = config.strokeColor
+ strokeWidth = config.strokeWidth.toDouble()
+ altitudeMode = config.altitudeMode
+ },
+ )
config.onClick?.let { callback ->
polygon.setClickListener {
callback(polygon)
@@ -228,7 +232,7 @@ class Map3DState {
}
}
- private fun createModel(map: GoogleMap3D, config: ModelConfig): Model? {
+ private fun createModel(map: GoogleMap3D, config: ModelConfig): Model {
return map.addModel(config.toModelOptions())
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
index f6c57ce6..30efad18 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt
@@ -75,4 +75,3 @@ fun ModelConfig.toModelOptions() = modelOptions {
}
}
}
-
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt
index 5a98083a..f05e8553 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt
@@ -71,13 +71,13 @@ fun FlyAroundOptions.toCameraUpdate(): CameraUpdate {
fun FlyToOptions.toValidFlyToOptions(): FlyToOptions {
return this.copy(
- endCamera = this.endCamera.toValidCamera()
+ endCamera = this.endCamera.toValidCamera(),
)
}
fun FlyAroundOptions.toValidFlyAroundOptions(): FlyAroundOptions {
return this.copy(
- center = this.center.toValidCamera()
+ center = this.center.toValidCamera(),
)
}
@@ -98,7 +98,7 @@ fun FlyAroundOptions.toValidFlyAroundOptions(): FlyAroundOptions {
suspend fun awaitCameraUpdate(
controller: GoogleMap3D,
cameraUpdate: CameraUpdate,
- cameraChangedListener: OnCameraAnimationEndListener? = null
+ cameraChangedListener: OnCameraAnimationEndListener? = null,
) = suspendCancellableCoroutine { continuation ->
// No need to wait if the update is a move
if (cameraUpdate is CameraUpdate.Move) {
@@ -124,7 +124,7 @@ suspend fun awaitCameraUpdate(
/**
* Suspends the coroutine until the current camera animation is finished.
- *
+ *
* In a 3D environment, this is essential for sequencing cinematic movements.
*/
suspend fun GoogleMap3D.awaitCameraAnimation() = suspendCancellableCoroutine { continuation ->
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt
index 39046273..80724515 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt
@@ -25,7 +25,9 @@ object GeoMathUtils {
* Replaces expensive haversine math inside the render loop with a precomputed distance array lookup.
*/
fun getInterpolatedPoint(
- distance: Double, path: List, cumulativeDistances: DoubleArray
+ distance: Double,
+ path: List,
+ cumulativeDistances: DoubleArray,
): LatLng {
if (distance <= 0.0) return path.first()
if (distance >= cumulativeDistances.last()) return path.last()
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
index 4f11db69..266ce469 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt
@@ -28,8 +28,6 @@ import com.google.android.gms.maps3d.model.flyAroundOptions
import com.google.android.gms.maps3d.model.flyToOptions
import com.google.android.gms.maps3d.model.latLngAltitude
import java.util.Locale
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
@@ -117,8 +115,8 @@ fun CameraRestriction?.toValidCameraRestriction(): CameraRestriction? {
source.minTilt == null || source.maxTilt == null ||
finalMinAlt != source.minAltitude || finalMaxAlt != source.maxAltitude ||
finalMinHead != source.minHeading || finalMaxHead != source.maxHeading ||
- finalMinT != source.minTilt || finalMaxT != source.maxTilt) {
-
+ finalMinT != source.minTilt || finalMaxT != source.maxTilt
+ ) {
return cameraRestriction {
bounds = source.bounds
minAltitude = finalMinAlt
@@ -263,7 +261,7 @@ fun Number.toCompassDirection(): String {
"N", "NNE", "NE", "ENE",
"E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW",
- "W", "WNW", "NW", "NNW"
+ "W", "WNW", "NW", "NNW",
)
val headingDegrees = this.toDouble()
@@ -312,7 +310,7 @@ fun FlyAroundOptions.copy(
center: Camera? = null,
durationInMillis: Long? = null,
rounds: Double? = null,
-) : FlyAroundOptions {
+): FlyAroundOptions {
val objectToCopy = this
return flyAroundOptions {
@@ -325,7 +323,7 @@ fun FlyAroundOptions.copy(
fun FlyToOptions.copy(
endCamera: Camera? = null,
durationInMillis: Long? = null,
-) : FlyToOptions {
+): FlyToOptions {
val objectToCopy = this
return flyToOptions {
@@ -378,7 +376,8 @@ fun Camera.toCameraString(): String {
heading = ${camera.heading.format(0)}
tilt = ${camera.tilt.format(0)}
range = ${camera.range.format(0)}
- }""".trimIndent()
+ }
+ """.trimIndent()
}
/**
@@ -406,7 +405,7 @@ internal fun Double?.format(decimalPlaces: Int): String {
/**
* Smooths a path of LatLng points using Chaikin's algorithm.
- *
+ *
* Chaikin's algorithm works by cutting corners. Each iteration replaces each
* internal point with two points, each 1/4 and 3/4 along the edge between the
* previous and next points.
@@ -431,13 +430,13 @@ fun List.smoothPath(iterations: Int = 1): List {
// Point at 1/4 of the way
val q = LatLng(
p0.latitude * 0.75 + p1.latitude * 0.25,
- p0.longitude * 0.75 + p1.longitude * 0.25
+ p0.longitude * 0.75 + p1.longitude * 0.25,
)
// Point at 3/4 of the way
val r = LatLng(
p0.latitude * 0.25 + p1.latitude * 0.75,
- p0.longitude * 0.25 + p1.longitude * 0.75
+ p0.longitude * 0.25 + p1.longitude * 0.75,
)
nextPath.add(q)
@@ -466,15 +465,15 @@ fun calculateHeading(from: LatLng, to: LatLng): Double {
val dLon = lon2 - lon1
val y = sin(dLon) * cos(lat2)
val x = cos(lat1) * sin(lat2) -
- sin(lat1) * cos(lat2) * cos(dLon)
-
+ sin(lat1) * cos(lat2) * cos(dLon)
+
val bearing = Math.toDegrees(atan2(y, x))
return (bearing + 360.0) % 360.0
}
/**
* Simplifies a path of LatLng points using the Ramer-Douglas-Peucker algorithm.
- *
+ *
* This algorithm reduces the number of points in a curve that is approximated
* by a series of points, while preserving the overall shape.
*
@@ -538,10 +537,8 @@ fun haversineDistance(p1: LatLng, p2: LatLng): Double {
val dLon = lon2 - lon1
val a = sin(dLat / 2).pow(2.0) +
- cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2.0)
+ cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2.0)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return r * c
}
-
-
diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt
index d3bc7c39..44a83d05 100644
--- a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt
+++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt
@@ -22,8 +22,16 @@ class MappersTest {
@Test
fun testPolylineConfigToPolylineOptions() {
val points = listOf(
- latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 },
- latLngAltitude { latitude = 4.0; longitude = 5.0; altitude = 6.0 }
+ latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 3.0
+ },
+ latLngAltitude {
+ latitude = 4.0
+ longitude = 5.0
+ altitude = 6.0
+ },
)
val config = PolylineConfig(
key = "test_polyline",
@@ -34,7 +42,7 @@ class MappersTest {
zIndex = 1,
outerColor = Color.BLACK,
outerWidth = 2f,
- drawsOccludedSegments = true
+ drawsOccludedSegments = true,
)
val options = config.toPolylineOptions()
@@ -54,7 +62,11 @@ class MappersTest {
@Test
fun testMarkerConfigToMarkerOptions() {
- val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val position = latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 3.0
+ }
val config = MarkerConfig(
key = "test_marker",
position = position,
@@ -63,7 +75,7 @@ class MappersTest {
zIndex = 2,
isExtruded = true,
isDrawnWhenOccluded = true,
- collisionBehavior = CollisionBehavior.REQUIRED
+ collisionBehavior = CollisionBehavior.REQUIRED,
)
val options = config.toMarkerOptions()
@@ -80,13 +92,33 @@ class MappersTest {
@Test
fun testPolygonConfigToPolygonOptions() {
val path = listOf(
- latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 0.0 },
- latLngAltitude { latitude = 3.0; longitude = 4.0; altitude = 0.0 },
- latLngAltitude { latitude = 5.0; longitude = 6.0; altitude = 0.0 }
+ latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 0.0
+ },
+ latLngAltitude {
+ latitude = 3.0
+ longitude = 4.0
+ altitude = 0.0
+ },
+ latLngAltitude {
+ latitude = 5.0
+ longitude = 6.0
+ altitude = 0.0
+ },
)
val hole = listOf(
- latLngAltitude { latitude = 1.5; longitude = 2.5; altitude = 0.0 },
- latLngAltitude { latitude = 2.0; longitude = 3.0; altitude = 0.0 }
+ latLngAltitude {
+ latitude = 1.5
+ longitude = 2.5
+ altitude = 0.0
+ },
+ latLngAltitude {
+ latitude = 2.0
+ longitude = 3.0
+ altitude = 0.0
+ },
)
val config = PolygonConfig(
key = "test_polygon",
@@ -95,7 +127,7 @@ class MappersTest {
fillColor = Color.YELLOW,
strokeColor = Color.GREEN,
strokeWidth = 3f,
- altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
)
val options = config.toPolygonOptions()
@@ -116,7 +148,11 @@ class MappersTest {
@Test
fun testModelConfigToModelOptions() {
- val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val position = latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 3.0
+ }
val config = ModelConfig(
key = "test_model",
position = position,
@@ -125,7 +161,7 @@ class MappersTest {
scale = ModelScale.Uniform(2.0f),
heading = 10.0,
tilt = 20.0,
- roll = 30.0
+ roll = 30.0,
)
val options = config.toModelOptions()
@@ -144,7 +180,11 @@ class MappersTest {
@Test
fun testModelConfigToModelOptionsWithPerAxisScale() {
- val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val position = latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 3.0
+ }
val config = ModelConfig(
key = "test_model_per_axis",
position = position,
@@ -153,7 +193,7 @@ class MappersTest {
scale = ModelScale.PerAxis(1.0f, 2.0f, 3.0f),
heading = 10.0,
tilt = 20.0,
- roll = 30.0
+ roll = 30.0,
)
val options = config.toModelOptions()
@@ -163,16 +203,21 @@ class MappersTest {
assertEquals(2.0, options.scale.y, 0.0)
assertEquals(3.0, options.scale.z, 0.0)
}
+
@Test
fun testPolylineConfigToPolylineOptions_defaults() {
val points = listOf(
- latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 3.0
+ },
)
val config = PolylineConfig(
key = "test_polyline_defaults",
points = points,
color = Color.RED,
- width = 5f
+ width = 5f,
)
val options = config.toPolylineOptions()
@@ -189,10 +234,14 @@ class MappersTest {
@Test
fun testMarkerConfigToMarkerOptions_defaults() {
- val position = latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 3.0 }
+ val position = latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 3.0
+ }
val config = MarkerConfig(
key = "test_marker_defaults",
- position = position
+ position = position,
)
val options = config.toMarkerOptions()
@@ -210,14 +259,18 @@ class MappersTest {
@Test
fun testPolygonConfigToPolygonOptions_defaults() {
val path = listOf(
- latLngAltitude { latitude = 1.0; longitude = 2.0; altitude = 0.0 }
+ latLngAltitude {
+ latitude = 1.0
+ longitude = 2.0
+ altitude = 0.0
+ },
)
val config = PolygonConfig(
key = "test_polygon_defaults",
path = path,
fillColor = Color.YELLOW,
strokeColor = Color.GREEN,
- strokeWidth = 3f
+ strokeWidth = 3f,
)
val options = config.toPolygonOptions()
diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt
index 731dea83..96a94620 100644
--- a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt
+++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt
@@ -47,7 +47,7 @@ class UtilitiesTest {
maxTilt = 45.0
}
val valid = restriction.toValidCameraRestriction()
-
+
assertEquals(10.0, valid?.minAltitude)
assertEquals(100.0, valid?.maxAltitude)
assertEquals(0.0, valid?.minHeading)
@@ -62,7 +62,7 @@ class UtilitiesTest {
// Leave fields null
}
val valid = restriction.toValidCameraRestriction()
-
+
assertEquals(altitudeRange.start, valid?.minAltitude)
assertEquals(altitudeRange.endInclusive, valid?.maxAltitude)
assertEquals(headingRange.start, valid?.minHeading)
@@ -82,7 +82,7 @@ class UtilitiesTest {
maxTilt = 0.0
}
val valid = restriction.toValidCameraRestriction()
-
+
assertEquals(10.0, valid?.minAltitude)
assertEquals(100.0, valid?.maxAltitude)
assertEquals(0.0, valid?.minHeading)
diff --git a/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java b/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java
index 72f12ed4..1cc77260 100644
--- a/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java
+++ b/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java
@@ -170,7 +170,7 @@ public void addPolygonWithHole() {
// Core logic: create a Hole object and set InnerPaths (Optional)
Hole innerHole = new Hole(innerPoints);
- options.setInnerPaths(Arrays.asList(innerHole));
+ options.setInnerPaths(List.of(innerHole));
Polygon polygon = map.addPolygon(options);
// [START_EXCLUDE]
diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt
index 85c08d8a..dfc6e489 100644
--- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt
+++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt
@@ -56,7 +56,7 @@ class MapActivity : AppCompatActivity() {
val snippetList = SnippetRegistry.getSnippetGroups().flatMap { it.items }
var currentIndex = snippetList.indexOfFirst { it.title == snippetTitle && (groupTitle == null || it.groupTitle == groupTitle) }
- val printPoseBtn = findViewById(R.id.snapshot_button).apply {
+ findViewById(R.id.snapshot_button).apply {
setOnClickListener {
if (::googleMap3D.isInitialized) {
val cam = googleMap3D.getCamera() ?: return@setOnClickListener
@@ -91,7 +91,7 @@ class MapActivity : AppCompatActivity() {
}
}
- val replayBtn = findViewById(R.id.reset_view_button).apply {
+ findViewById(R.id.reset_view_button).apply {
setOnClickListener {
if (currentIndex >= 0) {
val item = snippetList[currentIndex]
diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt
index 6bd12de6..d7637e0c 100644
--- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt
+++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt
@@ -34,25 +34,25 @@ class TrackedMap3D(
return marker
}
- fun addPolyline(options: PolylineOptions): Polyline? {
+ fun addPolyline(options: PolylineOptions): Polyline {
val polyline = delegate.addPolyline(options)
if (polyline != null) items.add(polyline)
return polyline
}
- fun addPolygon(options: PolygonOptions): Polygon? {
+ fun addPolygon(options: PolygonOptions): Polygon {
val polygon = delegate.addPolygon(options)
if (polygon != null) items.add(polygon)
return polygon
}
- fun addModel(options: ModelOptions): Model? {
+ fun addModel(options: ModelOptions): Model {
val model = delegate.addModel(options)
if (model != null) items.add(model)
return model
}
- fun addPopover(options: PopoverOptions): Popover? {
+ fun addPopover(options: PopoverOptions): Popover {
val popover = delegate.addPopover(options)
if (popover != null) items.add(popover)
return popover
diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt
index 429420b6..b9cb2ba9 100644
--- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt
+++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt
@@ -63,7 +63,7 @@ class MarkerSnippets(private val context: Context, private val map: TrackedMap3D
// MarkerOptions uses label, not title.
}
- val marker = map.addMarker(options)
+ map.addMarker(options)
// [START_EXCLUDE]
map.flyCameraTo(
flyToOptions {
@@ -107,7 +107,7 @@ class MarkerSnippets(private val context: Context, private val map: TrackedMap3D
isDrawnWhenOccluded = true
}
- val marker = map.addMarker(options)
+ map.addMarker(options)
// [START_EXCLUDE]
map.flyCameraTo(
flyToOptions {
@@ -207,7 +207,7 @@ class MarkerSnippets(private val context: Context, private val map: TrackedMap3D
)
}
- val marker = map.addMarker(options)
+ map.addMarker(options)
// [START_EXCLUDE]
map.flyCameraTo(
flyToOptions {
From b7a499dd8b14cff2b86b7263fd3f558de2c90423 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Tue, 14 Apr 2026 15:13:37 -0600
Subject: [PATCH 23/29] feat(maps3d-compose): Add declarative Popover support
and update documentation
- Implement `PopoverConfig` in `DataModels.kt` to support declarative popovers in Compose.
- Add `popovers` parameter and `onMapClick` callback to `GoogleMap3D` composable to enable state-driven popover management and map interaction.
- Implement `syncPopovers` in `Map3DState.kt` to handle the mapping between Compose configurations and native SDK Popover instances.
- Refactor `PopoversActivity` in `maps3d-compose-demo` to demonstrate the declarative API, setting `autoPanEnabled` and `autoCloseEnabled` to false for predictable, state-driven UI behavior.
- Add `README.md` files to both `maps3d-compose` and `maps3d-compose-demo` modules to clarify their purpose as reference implementations.
- Relocate and expand `compose_api_coverage.md` into a comprehensive checklist tracking specific SDK classes and listeners, highlighting unsupported features like `Map3DViewUiController`.
- Update root `README.md` to document the new modules with explicit warnings about their experimental nature.
---
README.md | 10 ++
maps3d-compose-demo/README.md | 22 +++++
.../maps3dcomposedemo/ModelsActivity.kt | 3 +
.../maps3dcomposedemo/PopoversActivity.kt | 74 +++++++--------
maps3d-compose/README.md | 17 ++++
maps3d-compose/compose_api_coverage.md | 93 +++++++++++++++++++
.../maps/android/compose3d/DataModels.kt | 16 ++++
.../maps/android/compose3d/GoogleMap3D.kt | 9 ++
.../maps/android/compose3d/Map3DState.kt | 73 ++++++++++++++-
9 files changed, 276 insertions(+), 41 deletions(-)
create mode 100644 maps3d-compose-demo/README.md
create mode 100644 maps3d-compose/README.md
create mode 100644 maps3d-compose/compose_api_coverage.md
diff --git a/README.md b/README.md
index 4c80b808..849eaede 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,16 @@ The [Maps3DSamples/ApiDemos/common](Maps3DSamples/ApiDemos/common) module contai
It also has [Map3dViewModel](Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/Map3dViewModel.kt) which demonstrates how to
create an abstract base class for view models needing to interact with the Maps3DView via a GoogleMap3D.
+## Experimental Compose Wrapper
+
+In addition to the advanced sample, this repository contains an experimental, declarative Compose wrapper for the Maps 3D SDK:
+
+* **[maps3d-compose](maps3d-compose)**: Provides the `GoogleMap3D` composable and supports declarative state management for markers, polylines, polygons, models, and popovers.
+* **[maps3d-compose-demo](maps3d-compose-demo)**: Contains sample activities demonstrating how to use the wrapper.
+
+> [!WARNING]
+> These modules are a **Work In Progress (WIP) experiment** and serve as a **reference implementation**. They are not intended for production use.
+
## Requirements
To run the samples, you will need:
diff --git a/maps3d-compose-demo/README.md b/maps3d-compose-demo/README.md
new file mode 100644
index 00000000..8e125460
--- /dev/null
+++ b/maps3d-compose-demo/README.md
@@ -0,0 +1,22 @@
+# Maps 3D Compose Demo
+
+This module is a sample application that demonstrates how to use the `maps3d-compose` wrapper library.
+
+> [!WARNING]
+> **Status**: This application serves as a **reference implementation** for using the experimental Compose wrapper. It is a WIP experiment.
+
+## What's Inside
+
+You can find several sample activities demonstrating different features of the 3D Map in Compose:
+- **Basic Map**: A simple map with camera controls.
+- **Map Options**: Demonstrates changing map modes (Satellite/Hybrid) and applying camera restrictions.
+- **Models**: Shows how to add and interact with 3D models (glTF) on the map, including click listeners.
+- **Popovers**: Demonstrates the declarative Popover API, rendering Compose content inside native map popovers.
+
+## How to Run
+
+You can install and run this demo on a connected device or emulator using:
+```bash
+./gradlew :maps3d-compose-demo:installDebug
+```
+Then launch any of the activities via the launcher or ADB.
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
index ed6a71b1..12e35d33 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt
@@ -93,6 +93,9 @@ private fun ModelsScreen() {
heading = 41.5,
tilt = -90.0,
roll = 0.0,
+ onClick = {
+ Toast.makeText(context, "Clicked on Airplane!", Toast.LENGTH_SHORT).show()
+ },
),
)
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
index eb1dad05..a496ff3e 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
@@ -16,13 +16,17 @@
package com.example.maps3dcomposedemo
-import android.graphics.Color
import android.os.Bundle
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.ui.graphics.Color
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
@@ -42,6 +46,7 @@ import com.google.android.gms.maps3d.model.popoverShadow
import com.google.android.gms.maps3d.model.popoverStyle
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
+import com.google.maps.android.compose3d.PopoverConfig
import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
class PopoversActivity : ComponentActivity() {
@@ -63,9 +68,8 @@ class PopoversActivity : ComponentActivity() {
@Composable
fun PopoversScreen() {
- val context = LocalContext.current
- var googleMap3DInstance by remember { mutableStateOf(null) }
- var activePopover by remember { mutableStateOf(null) }
+ var popovers by remember { mutableStateOf(emptyList()) }
+ var isMapSteady by remember { mutableStateOf(false) }
// Camera centered on Devils Tower
val devilsTowerCamera = remember {
@@ -95,38 +99,28 @@ fun PopoversScreen() {
label = "Click me for Popover",
isExtruded = true,
isDrawnWhenOccluded = true,
- onClick = { nativeMarker ->
- googleMap3DInstance?.let { map ->
- val textView = android.widget.TextView(context).apply {
- text = "This is a Popover anchored to a marker!"
- setPadding(32, 16, 32, 16)
- setTextColor(Color.BLACK)
- setBackgroundColor(Color.WHITE)
- }
- val newPopover = map.addPopover(
- popoverOptions {
- positionAnchor = nativeMarker
- altitudeMode = AltitudeMode.ABSOLUTE
- content = textView
- autoCloseEnabled = true
- autoPanEnabled = false
- popoverStyle = popoverStyle {
- backgroundColor = Color.WHITE
- borderRadius = 16f
- shadow = popoverShadow {
- color = Color.DKGRAY
- radius = 8f
- offsetX = 4f
- offsetY = 4f
- }
+ onClick = {
+ popovers = listOf(
+ PopoverConfig(
+ key = "popover_1",
+ positionAnchorKey = "popover_marker",
+ autoPanEnabled = false,
+ autoCloseEnabled = false,
+ content = {
+ Surface(
+ color = Color.White,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.padding(8.dp)
+ ) {
+ Text(
+ text = "This is a Popover anchored to a marker!",
+ modifier = Modifier.padding(16.dp),
+ color = Color.Black
+ )
}
- },
+ }
)
-
- activePopover?.remove()
- activePopover = newPopover
- activePopover?.show()
- }
+ )
},
)
}
@@ -135,15 +129,15 @@ fun PopoversScreen() {
GoogleMap3D(
camera = devilsTowerCamera,
markers = listOf(marker),
+ popovers = popovers,
mapMode = Map3DMode.HYBRID,
modifier = Modifier.fillMaxSize(),
- onMapReady = { instance ->
- googleMap3DInstance = instance
- instance.setMap3DClickListener { _, _ ->
- activePopover?.remove()
- activePopover = null
- }
+ onMapSteady = {
+ isMapSteady = true
},
+ onMapClick = {
+ popovers = emptyList()
+ }
)
}
}
diff --git a/maps3d-compose/README.md b/maps3d-compose/README.md
new file mode 100644
index 00000000..51445948
--- /dev/null
+++ b/maps3d-compose/README.md
@@ -0,0 +1,17 @@
+# Maps 3D Compose Wrapper (Experimental)
+
+This library provides a Jetpack Compose wrapper for the Google Maps 3D SDK for Android.
+
+> [!WARNING]
+> **Status**: This implementation is a **Work In Progress (WIP) experiment** and serves as a **reference implementation**. It is not intended for production use. APIs may change significantly in the future.
+
+## What's Inside
+
+This module contains the source code for the Compose wrapper:
+- **`GoogleMap3D`**: The main Composable function to display a 3D map.
+- **Declarative State**: Support for adding Markers, Polylines, Polygons, 3D Models, and Popovers via standard Compose state lists.
+- **Camera Hoisting**: Ability to hoist the camera state for animations and position tracking.
+
+## Current Coverage
+
+For a detailed list of supported features and missing APIs, see [compose_api_coverage.md](./compose_api_coverage.md).
diff --git a/maps3d-compose/compose_api_coverage.md b/maps3d-compose/compose_api_coverage.md
new file mode 100644
index 00000000..76e20617
--- /dev/null
+++ b/maps3d-compose/compose_api_coverage.md
@@ -0,0 +1,93 @@
+# Compose API Coverage Checklist
+
+This document tracks the coverage of the Maps 3D SDK APIs in the experimental Compose wrapper.
+
+> [!WARNING]
+> This implementation is a **Work In Progress (WIP) experiment** and serves as a **reference implementation**. It is not intended for production use.
+
+## Not Yet Exposed Functionality
+
+The following features and listeners from the Maps 3D SDK are not yet supported or exposed in this experimental Compose wrapper:
+
+| Feature / Class | Status | Reference / Notes |
+| :--- | :--- | :--- |
+| **Map3DViewUiController** | Not Supported | Gestures and UI settings controller. |
+| **OnCameraChangedListener** | Not Supported | Camera event listener. |
+| **OnFirstSceneListener** | Not Supported | Scene loaded event listener. |
+| **OnPlaceClickListener** | Not Supported | Place click event listener. |
+| **Anchorable** | Not Supported | Interface for anchorable objects. |
+| **BoundingBox** | Not Supported | Spatial bounding box. |
+| **DrawingState** | Not Supported | State of drawing operations. |
+| **Glyph** | Not Supported | Text rendering elements. |
+| **MarkerView / MarkerViewOptions** | Not Supported | View-based markers. |
+| **PinConfiguration** | Not Supported | Pin styling configuration. |
+| **PinView** | Not Supported | Custom pin views. |
+| **VisibilityState** | Not Supported | Visibility state tracking. |
+
+## Coverage Status
+
+| Feature / Class | Status | Reference / Notes |
+| :--- | :--- | :--- |
+| **Map3DOptions** | Supported | Passed to `GoogleMap3D` in [`GoogleMap3D.kt:L69`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L69). |
+| **GoogleMap3D api calls** | Partial | Some exposed via parameters, others require native instance via `onMapReady`. |
+| **Map3DViewUiController** | Not Supported | Not yet exposed in the Compose wrapper. |
+| **OnCameraAnimationEndListener** | Supported via Native | Used in sample extensions, not exposed as parameter. |
+| **OnCameraChangedListener** | Not Supported | Not yet exposed. |
+| **OnFirstSceneListener** | Not Supported | Not yet exposed. |
+| **OnMap3DClickListener** | Supported | Exposed as `onMapClick` in `GoogleMap3D`. |
+| **OnMap3DViewReadyCallback** | Handled Internally | Used in [`GoogleMap3D.kt:L84`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L84) to initialize. |
+| **OnMarkerClickListener** | Handled Internally | Exposed as `onClick` in `MarkerConfig`. |
+| **OnModelClickListener** | Handled Internally | Exposed as `onClick` in `ModelConfig`. |
+| **OnPlaceClickListener** | Not Supported | Not yet exposed. |
+| **OnPolygonClickListener** | Handled Internally | Exposed as `onClick` in `PolygonConfig`. |
+| **OnPolylineClickListener** | Handled Internally | Exposed as `onClick` in `PolylineConfig`. |
+| **AltitudeMode** | Supported | Defined in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
+| **Anchorable** | Not Supported | Implied by marker anchoring but not explicitly exposed. |
+| **BoundingBox** | Not Supported | Not yet exposed. |
+| **Camera** | Supported | Hoisted in `GoogleMap3D` [`GoogleMap3D.kt:L60`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L60). |
+| **CameraRestriction** | Supported | Passed to `GoogleMap3D` [`GoogleMap3D.kt:L67`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L67). |
+| **CollisionBehavior** | Supported | Used in `MarkerConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
+| **DrawingState** | Not Supported | Not yet exposed. |
+| **FlyAround / FlyTo** | Supported via Native | Used in samples via native instance, not declarative. |
+| **Glyph** | Not Supported | Not yet exposed. |
+| **Hole** | Supported | Used in `PolygonConfig` in `Map3DState.kt`. |
+| **ImageView** | Handled Internally | Used for Popover content rendering. |
+| **Marker / MarkerOptions** | Supported | Via `MarkerConfig` in [`DataModels.kt:L35`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L35). |
+| **MarkerView / MarkerViewOptions** | Not Supported | Not yet exposed. |
+| **Model / ModelOptions** | Supported | Via `ModelConfig` in [`DataModels.kt:L93`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L93). |
+| **Orientation** | Supported | Used in `ModelConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
+| **PinConfiguration** | Not Supported | Not yet exposed. |
+| **PinView** | Not Supported | Not yet exposed. |
+| **Polygon / PolygonOptions** | Supported | Via `PolygonConfig` in [`DataModels.kt:L70`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L70). |
+| **Polyline / PolylineOptions** | Supported | Via `PolylineConfig` in [`DataModels.kt:L52`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L52). |
+| **OnMapReady** | Supported | Callback in `GoogleMap3D` [`GoogleMap3D.kt:L70`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L70). |
+| **OnMapSteady** | Supported | Callback in `GoogleMap3D` [`GoogleMap3D.kt:L71`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L71). |
+| **Popover / PopoverContentsView...**| Supported | Via `PopoverConfig` in [`DataModels.kt:L109`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L109). |
+| **Vector3D** | Supported | Used for scale in [`Map3DState.kt`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt). |
+| **VisibilityState** | Not Supported | Not yet exposed. |
+| **GoogleMap3D Composable...** | Supported | Lifecycle handling implemented in [`GoogleMap3D.kt`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt). |
+| **Core state management...** | Partial | Map properties supported, gestures might need `Map3DViewUiController`. |
+| **Camera animation...** | Supported via Native | `flyCameraTo` and `flyCameraAround` used in samples. |
+
+## References
+
+### Implementation in `maps3d-compose`
+
+- **Core Composable**: [`GoogleMap3D.kt:L59`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L59)
+- **State Management (`Map3DState.kt`)**:
+ - `syncMarkers`: [`Map3DState.kt:L51`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L51)
+ - `syncPolylines`: [`Map3DState.kt:L109`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L109)
+ - `syncPolygons`: [`Map3DState.kt:L154`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L154)
+ - `syncModels`: [`Map3DState.kt:L208`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L208)
+ - `syncPopovers`: [`Map3DState.kt:L253`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L253)
+
+### Demonstrations in `maps3d-compose-demo`
+
+- **Basic Map & Camera**: [`BasicMapActivity.kt:L140`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt#L140)
+- **Map Options & Restrictions**: [`MapOptionsActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt#L102)
+- **Camera Animations**: [`CameraAnimationsActivity.kt:L88`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt#L88)
+- **Markers**: [`MarkersActivity.kt:L89`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt#L89)
+- **Polylines**: [`PolylinesActivity.kt:L108`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt#L108)
+- **Polygons**: [`PolygonsActivity.kt:L161`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt#L161)
+- **3D Models**: [`ModelsActivity.kt:L83`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt#L83)
+- **Popovers**: [`PopoversActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt#L102)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index edf19a0f..4e2771da 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -17,12 +17,14 @@
package com.google.maps.android.compose3d
import androidx.annotation.WorkerThread
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.CollisionBehavior
import com.google.android.gms.maps3d.model.ImageView
import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.android.gms.maps3d.model.Marker
+import com.google.android.gms.maps3d.model.Model
import com.google.android.gms.maps3d.model.Polygon
import com.google.android.gms.maps3d.model.Polyline
@@ -97,4 +99,18 @@ data class ModelConfig(
val heading: Double = 0.0,
val tilt: Double = 0.0,
val roll: Double = 0.0,
+ val onClick: ((Model) -> Unit)? = null,
+)
+
+/**
+ * Data class representing a Popover to be added to the 3D map.
+ */
+@Immutable
+data class PopoverConfig(
+ val key: String,
+ val positionAnchorKey: String,
+ val content: @Composable () -> Unit,
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
+ val autoCloseEnabled: Boolean = true,
+ val autoPanEnabled: Boolean = true,
)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index 1b9f7e1f..6e87166e 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -63,11 +63,13 @@ fun GoogleMap3D(
polylines: List = emptyList(),
polygons: List = emptyList(),
models: List = emptyList(),
+ popovers: List = emptyList(),
cameraRestriction: CameraRestriction? = null,
@Map3DMode mapMode: Int = Map3DMode.SATELLITE,
options: Map3DOptions = Map3DOptions(),
onMapReady: (GoogleMap3D) -> Unit = {},
onMapSteady: () -> Unit = {},
+ onMapClick: (() -> Unit)? = null,
) {
val state = remember { Map3DState() }
val hasCalledOnMapReady = remember { mutableStateOf(false) }
@@ -105,6 +107,13 @@ fun GoogleMap3D(
state.syncPolylines(googleMap3D, polylines)
state.syncPolygons(googleMap3D, polygons)
state.syncModels(googleMap3D, models)
+ state.syncPopovers(map3dView.context, googleMap3D, popovers)
+
+ onMapClick?.let { callback ->
+ googleMap3D.setMap3DClickListener { _, _ ->
+ callback()
+ }
+ }
}
if (Map3DRegistry.isMapReady) {
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
index 65b37c63..cd7a6e8c 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -17,6 +17,9 @@
package com.google.maps.android.compose3d
import com.google.android.gms.maps3d.GoogleMap3D
+import android.content.Context
+import androidx.compose.ui.platform.ComposeView
+import com.google.android.gms.maps3d.Popover
import com.google.android.gms.maps3d.model.Hole
import com.google.android.gms.maps3d.model.Marker
import com.google.android.gms.maps3d.model.Model
@@ -24,6 +27,7 @@ import com.google.android.gms.maps3d.model.Polygon
import com.google.android.gms.maps3d.model.Polyline
import com.google.android.gms.maps3d.model.markerOptions
import com.google.android.gms.maps3d.model.polygonOptions
+import com.google.android.gms.maps3d.model.popoverOptions
import com.google.maps.android.compose3d.utils.toValidLocation
/**
@@ -39,6 +43,7 @@ class Map3DState {
private val polylines = mutableMapOf>()
private val polygons = mutableMapOf>()
private val models = mutableMapOf>()
+ private val popovers = mutableMapOf>()
/**
* Synchronizes the markers on the map with the provided list of configurations.
@@ -233,7 +238,71 @@ class Map3DState {
}
private fun createModel(map: GoogleMap3D, config: ModelConfig): Model {
- return map.addModel(config.toModelOptions())
+ val model = map.addModel(config.toModelOptions())
+ config.onClick?.let { callback ->
+ model.setClickListener {
+ callback(model)
+ }
+ }
+ return model
+ }
+
+ /**
+ * Synchronizes the popovers on the map with the provided list of configurations.
+ */
+ fun syncPopovers(context: Context, map: GoogleMap3D, popoverConfigs: List) {
+ val keysToRemove = popovers.keys.toMutableSet()
+
+ popoverConfigs.forEach { config ->
+ keysToRemove.remove(config.key)
+ val existing = popovers[config.key]
+
+ if (existing != null) {
+ val (oldConfig, popover) = existing
+ if (oldConfig != config) {
+ // Config changed, recreate
+ popover.remove()
+ val newPopover = createPopover(context, map, config)
+ if (newPopover != null) {
+ popovers[config.key] = Pair(config, newPopover)
+ }
+ }
+ } else {
+ // New popover
+ val newPopover = createPopover(context, map, config)
+ if (newPopover != null) {
+ popovers[config.key] = Pair(config, newPopover)
+ }
+ }
+ }
+
+ keysToRemove.forEach { key ->
+ popovers[key]?.second?.remove()
+ popovers.remove(key)
+ }
+ }
+
+ private fun createPopover(context: Context, map: GoogleMap3D, config: PopoverConfig): Popover? {
+ val marker = markers[config.positionAnchorKey]?.second ?: return null
+
+ val composeView = ComposeView(context).apply {
+ setContent {
+ config.content()
+ }
+ }
+
+ val popover = map.addPopover(
+ popoverOptions {
+ positionAnchor = marker
+ altitudeMode = config.altitudeMode
+ content = composeView
+ autoCloseEnabled = config.autoCloseEnabled
+ autoPanEnabled = config.autoPanEnabled
+ }
+ )
+
+ popover.show()
+ return popover
}
/**
@@ -248,5 +317,7 @@ class Map3DState {
polygons.clear()
models.values.forEach { it.second.remove() }
models.clear()
+ popovers.values.forEach { it.second.remove() }
+ popovers.clear()
}
}
From 8b33e3710ec73ccd16f63dc5c424aef9632fe460 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:16:55 -0600
Subject: [PATCH 24/29] feat: Implement OnCameraChangedListener and add Whiskey
Compass demo (#40)
* feat: Implement OnCameraChangedListener and add Whiskey Compass demo
- Exposed onCameraChanged callback in GoogleMap3D composable.
- Added WhiskeyCompass composable in demo app.
- Added CameraChangedActivity to demonstrate camera changes with compass, tilt scale, and range scale.
- Updated compose_api_coverage.md.
* docs: Add code reference link for OnCameraChangedListener in coverage doc
* refactor: Move camera changed demo widgets to widgets package
- Moved WhiskeyCompass.kt to widgets package.
- Extracted TiltScale and RangeScale to separate files in widgets package.
- Updated CameraChangedActivity to use widgets from the new package.
---
.../src/main/AndroidManifest.xml | 4 +
.../CameraChangedActivity.kt | 140 +++++++++
.../example/maps3dcomposedemo/MainActivity.kt | 5 +
.../maps3dcomposedemo/widgets/RangeScale.kt | 26 ++
.../maps3dcomposedemo/widgets/TiltScale.kt | 81 +++++
.../widgets/WhiskeyCompass.kt | 294 ++++++++++++++++++
maps3d-compose/compose_api_coverage.md | 4 +-
.../maps/android/compose3d/GoogleMap3D.kt | 5 +
8 files changed, 557 insertions(+), 2 deletions(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index 694c2aa6..98577059 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -80,6 +80,10 @@
android:name=".BasicMapActivity"
android:exported="true"
android:label="Basic Map" />
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt
new file mode 100644
index 00000000..518d25bb
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.example.maps3dcomposedemo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.example.maps3dcomposedemo.widgets.WhiskeyCompass
+import com.example.maps3dcomposedemo.widgets.TiltScale
+import com.example.maps3dcomposedemo.widgets.RangeScale
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlin.math.roundToInt
+
+class CameraChangedActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ CameraChangedScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CameraChangedScreen() {
+ // Camera centered on San Francisco
+ val initialCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 37.7749
+ longitude = -122.4194
+ altitude = 0.0
+ }
+ heading = 0.0
+ tilt = 45.0
+ range = 2000.0
+ roll = 0.0
+ }
+ }
+
+ // Use a flow to hold the camera state, as requested
+ val cameraFlow = remember { MutableStateFlow(initialCamera) }
+ val currentCamera by cameraFlow.collectAsState()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = initialCamera,
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onCameraChanged = { camera ->
+ cameraFlow.value = camera
+ }
+ )
+
+ val heading = currentCamera.heading?.toFloat() ?: 0f
+ val tilt = currentCamera.tilt?.toFloat() ?: 0f
+ val range = currentCamera.range?.toFloat() ?: 0f
+
+ // Overlay the Whiskey Compass at the top
+ WhiskeyCompass(
+ heading = heading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter)
+ .padding(top = 48.dp), // Padding for edge-to-edge only at top
+ backgroundColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
+ )
+
+ // Overlay Tilt Scale on the right
+ TiltScale(
+ tilt = tilt,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 16.dp)
+ )
+
+ // Overlay Range Scale at the bottom
+ RangeScale(
+ range = range,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 32.dp)
+ )
+ }
+}
+
+
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
index 64c4521f..4e93bea8 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt
@@ -122,6 +122,11 @@ fun CatalogScreen() {
context.startActivity(Intent(context, CameraAnimationsActivity::class.java))
}
}
+ item {
+ SampleItem("Camera Changed Listener (Whiskey Compass)") {
+ context.startActivity(Intent(context, CameraChangedActivity::class.java))
+ }
+ }
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt
new file mode 100644
index 00000000..fc7af21f
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt
@@ -0,0 +1,26 @@
+package com.example.maps3dcomposedemo.widgets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlin.math.roundToInt
+
+@Composable
+fun RangeScale(range: Float, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f))
+ .padding(8.dp)
+ ) {
+ Text(
+ text = "Range: ${range.roundToInt()} m",
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt
new file mode 100644
index 00000000..0dee2f11
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt
@@ -0,0 +1,81 @@
+package com.example.maps3dcomposedemo.widgets
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun TiltScale(tilt: Float, modifier: Modifier = Modifier) {
+ // User requested: 90 = straight down, 0 = horizontal
+ // SDK: 0 = straight down, 90 = horizontal
+ // So displayedTilt = 90 - sdkTilt
+ val displayedTilt = 90f - tilt
+ val textMeasurer = rememberTextMeasurer()
+ val onPrimaryColor = MaterialTheme.colorScheme.onPrimaryContainer
+
+ Box(
+ modifier = modifier
+ .height(300.dp)
+ .width(80.dp)
+ .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f))
+ .clipToBounds(),
+ contentAlignment = Alignment.Center
+ ) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val centerLineY = size.height / 2f
+ val centerLineX = size.width / 2f
+
+ // Pixels per degree
+ val pixelsPerDegree = 5f
+
+ // Draw a fixed center line (pointer)
+ drawLine(
+ color = Color.Red,
+ start = Offset(0f, centerLineY),
+ end = Offset(size.width, centerLineY),
+ strokeWidth = 2f
+ )
+
+ // Draw scrolling ticks
+ translate(top = centerLineY + (displayedTilt * pixelsPerDegree)) {
+ for (i in 0..90 step 5) {
+ val yPos = -i * pixelsPerDegree
+
+ val isMajor = i % 15 == 0
+ val tickLength = if (isMajor) 15f else 8f
+
+ drawLine(
+ color = onPrimaryColor,
+ start = Offset(centerLineX - tickLength, yPos),
+ end = Offset(centerLineX + tickLength, yPos),
+ strokeWidth = if (isMajor) 2f else 1f
+ )
+
+ if (isMajor) {
+ val measuredText = textMeasurer.measure(i.toString(), style = TextStyle(color = onPrimaryColor, fontSize = 12.sp))
+ drawText(
+ textLayoutResult = measuredText,
+ topLeft = Offset(centerLineX + 20f, yPos - measuredText.size.height / 2f)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
new file mode 100644
index 00000000..9619b356
--- /dev/null
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
@@ -0,0 +1,294 @@
+package com.example.maps3dcomposedemo.widgets
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlin.math.roundToInt
+
+/**
+ * A composable that displays a customizable aviation-style "whiskey" compass.
+ * This version renders a flat, scrollable strip with ticks, degree labels, and cardinal directions.
+ *
+ * @param heading The current heading in degrees (0-360).
+ * @param modifier The modifier to be applied to the compass.
+ * @param stripHeight The total visual height of the compass strip.
+ * @param backgroundColor The background color of the compass strip.
+ * @param tickColor The color of the tick marks on the rotating dial.
+ * @param lubberLineColor The color of the central indicator line.
+ * @param pixelsPerDegree Controls the horizontal spacing between degree markers on the dial.
+ *
+ * @param showDegreeLabels Whether to display numeric degree labels on the strip.
+ * @param degreeLabelInterval Interval for numeric degree labels (e.g., every 15 degrees).
+ * @param degreeLabelTextStyle TextStyle for the numeric degree labels on the strip.
+ * @param degreeLabelVerticalOffset Vertical offset of degree labels from the bottom of the ticks.
+ *
+ * @param showCardinalLabels Whether to display cardinal direction labels (N, NE, E, etc.) on the strip.
+ * @param cardinalLabelTextStyle TextStyle for the cardinal direction labels on the strip.
+ * @param cardinalLabelVerticalOffset Vertical offset of cardinal labels from the top of the ticks.
+ *
+ * @param majorTickHeight Height of the major tick marks.
+ * @param minorTickHeight Height of the minor tick marks.
+ * @param majorTickStrokeWidth Stroke width for major ticks.
+ * @param minorTickStrokeWidth Stroke width for minor ticks.
+ * @param lubberLineStrokeWidth Stroke width for the lubber line.
+ */
+@Composable
+fun WhiskeyCompass(
+ heading: Float,
+ modifier: Modifier = Modifier,
+ stripHeight: Dp = 80.dp,
+ backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
+ tickColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
+ lubberLineColor: Color = Color.Red,
+ pixelsPerDegree: Float = 10f,
+
+ showDegreeLabels: Boolean = true,
+ degreeLabelInterval: Int = 15,
+ degreeLabelTextStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(textAlign = TextAlign.Center),
+ degreeLabelVerticalOffset: Dp = 4.dp,
+
+ showCardinalLabels: Boolean = true,
+ cardinalLabelInterval: Int = 45, // Added for clarity
+ cardinalLabelTextStyle: TextStyle = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
+ cardinalLabelVerticalOffset: Dp = 4.dp,
+
+ majorTickHeight: Dp = 25.dp,
+ minorTickHeight: Dp = 15.dp,
+ majorTickStrokeWidth: Dp = 2.dp,
+ minorTickStrokeWidth: Dp = 1.dp,
+ lubberLineStrokeWidth: Dp = 2.dp
+) {
+ val textMeasurer = rememberTextMeasurer()
+ val density = LocalDensity.current
+
+ // Normalize heading to the 0-360 range to prevent offset issues
+ val normalizedHeading = (heading % 360f + 360f) % 360f
+
+ // Memoize measured cardinal labels for performance
+ val measuredCardinalLabels = remember(cardinalLabelTextStyle, density) {
+ with(density) {
+ mapOf(
+ 0 to textMeasurer.measure("N", style = cardinalLabelTextStyle),
+ 45 to textMeasurer.measure("NE", style = cardinalLabelTextStyle),
+ 90 to textMeasurer.measure("E", style = cardinalLabelTextStyle),
+ 135 to textMeasurer.measure("SE", style = cardinalLabelTextStyle),
+ 180 to textMeasurer.measure("S", style = cardinalLabelTextStyle),
+ 225 to textMeasurer.measure("SW", style = cardinalLabelTextStyle),
+ 270 to textMeasurer.measure("W", style = cardinalLabelTextStyle),
+ 315 to textMeasurer.measure("NW", style = cardinalLabelTextStyle)
+ )
+ }
+ }
+
+
+ Box(
+ modifier = modifier
+ .height(stripHeight)
+ .background(backgroundColor)
+ .clipToBounds(),
+ contentAlignment = Alignment.Center
+ ) {
+ // Canvas 1: Scrolling Compass Strip (ticks, degree labels, cardinal labels)
+ Canvas(modifier = Modifier.matchParentSize()) {
+ val majorTickHeightPx = majorTickHeight.toPx()
+ val minorTickHeightPx = minorTickHeight.toPx()
+ val majorTickStrokeWidthPx = majorTickStrokeWidth.toPx()
+ val minorTickStrokeWidthPx = minorTickStrokeWidth.toPx()
+ val degreeLabelVerticalOffsetPx = degreeLabelVerticalOffset.toPx()
+ val cardinalLabelVerticalOffsetPx = cardinalLabelVerticalOffset.toPx()
+
+ val canvasWidth = size.width
+ val canvasCenterY = center.y
+
+ val xOffset = center.x - (normalizedHeading * pixelsPerDegree)
+ val tickCenterY = canvasCenterY
+
+ translate(left = xOffset) {
+ for (repetition in -1..1) {
+ val repetitionBaseDegree = repetition * 360
+ for (degreeInRepetition in 0 until 360) {
+ val absoluteDegree = repetitionBaseDegree + degreeInRepetition
+ val xPos = absoluteDegree * pixelsPerDegree
+
+ val visibilityMargin = canvasWidth
+ if (xPos < -xOffset + canvasWidth + visibilityMargin && xPos > -xOffset - visibilityMargin) {
+
+ val isMajorTickEquivalent = degreeInRepetition % 10 == 0
+ val isMinorTickEquivalent = degreeInRepetition % 5 == 0 && !isMajorTickEquivalent
+
+ if (isMajorTickEquivalent) {
+ val tickTopY = tickCenterY - majorTickHeightPx / 2f
+ val tickBottomY = tickCenterY + majorTickHeightPx / 2f
+ drawLine(
+ color = tickColor,
+ start = Offset(x = xPos, y = tickTopY),
+ end = Offset(x = xPos, y = tickBottomY),
+ strokeWidth = majorTickStrokeWidthPx
+ )
+
+ if (showCardinalLabels && measuredCardinalLabels.containsKey(degreeInRepetition)) {
+ val measuredText = measuredCardinalLabels.getValue(degreeInRepetition)
+ drawText(
+ textLayoutResult = measuredText,
+ topLeft = Offset(
+ x = xPos - measuredText.size.width / 2f,
+ y = tickTopY - measuredText.size.height - cardinalLabelVerticalOffsetPx
+ )
+ )
+ }
+ }
+ else if (isMinorTickEquivalent) {
+ val tickTopY = tickCenterY - minorTickHeightPx / 2f
+ val tickBottomY = tickCenterY + minorTickHeightPx / 2f
+ drawLine(
+ color = tickColor,
+ start = Offset(x = xPos, y = tickTopY),
+ end = Offset(x = xPos, y = tickBottomY),
+ strokeWidth = minorTickStrokeWidthPx
+ )
+ }
+
+ if (showDegreeLabels && degreeInRepetition % degreeLabelInterval == 0) {
+ val tickBottomY = tickCenterY + (if (isMajorTickEquivalent) majorTickHeightPx else if (isMinorTickEquivalent) minorTickHeightPx else 0f) / 2f
+ val labelText = degreeInRepetition.toString()
+ val measuredText = textMeasurer.measure(labelText, style = degreeLabelTextStyle)
+ drawText(
+ textLayoutResult = measuredText,
+ topLeft = Offset(
+ x = xPos - measuredText.size.width / 2f,
+ y = tickBottomY + degreeLabelVerticalOffsetPx
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Canvas 2: Fixed Lubber Line
+ Canvas(modifier = Modifier.matchParentSize()) {
+ val lubberLineVisualPadding = stripHeight.toPx() * 0.05f
+ val currentLubberLineStrokeWidthPx = lubberLineStrokeWidth.toPx()
+ drawLine(
+ color = lubberLineColor,
+ start = Offset(x = center.x, y = lubberLineVisualPadding),
+ end = Offset(x = center.x, y = size.height - lubberLineVisualPadding),
+ strokeWidth = currentLubberLineStrokeWidthPx
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF222222)
+@Composable
+private fun FlatWhiskeyCompassPreview() {
+ val exampleHeadings = listOf(
+ 0f, 10f, 22.5f, 45f, 168f, 270f, 358f
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.DarkGray)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("Default Flat Compass Strip", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ WhiskeyCompass(
+ heading = 45f,
+ modifier = Modifier.fillMaxWidth(),
+ stripHeight = 100.dp,
+ pixelsPerDegree = 8f
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text("Customized Labels & Ticks", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ WhiskeyCompass(
+ heading = 123f,
+ modifier = Modifier.fillMaxWidth(),
+ stripHeight = 120.dp,
+ backgroundColor = Color(0xFF1A237E),
+ tickColor = Color(0xFFB0BEC5),
+ lubberLineColor = Color(0xFFFFD600),
+ pixelsPerDegree = 12f,
+ degreeLabelInterval = 10,
+ degreeLabelTextStyle = MaterialTheme.typography.bodySmall.copy(color = Color(0xFF81D4FA)),
+ cardinalLabelTextStyle = MaterialTheme.typography.labelLarge.copy(color = Color.White, fontWeight = FontWeight.Bold),
+ majorTickHeight = 30.dp,
+ minorTickHeight = 18.dp,
+ degreeLabelVerticalOffset = 6.dp,
+ cardinalLabelVerticalOffset = 6.dp,
+ majorTickStrokeWidth = 2.5.dp
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text("No Cardinal Labels", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ WhiskeyCompass(
+ heading = 210f,
+ modifier = Modifier.fillMaxWidth(),
+ stripHeight = 70.dp,
+ showCardinalLabels = false,
+ pixelsPerDegree = 6f
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text("No Degree Labels", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ WhiskeyCompass(
+ heading = 300f,
+ modifier = Modifier.fillMaxWidth(),
+ stripHeight = 70.dp,
+ showDegreeLabels = false,
+ pixelsPerDegree = 6f
+ )
+
+
+ exampleHeadings.forEach { currentHeading ->
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = "Test Heading: ${currentHeading.roundToInt()}°",
+ color = Color.LightGray,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp
+ )
+ WhiskeyCompass(
+ heading = currentHeading,
+ modifier = Modifier.fillMaxWidth(),
+ stripHeight = 90.dp,
+ pixelsPerDegree = 7f,
+ degreeLabelInterval = 30
+ )
+ }
+ }
+}
diff --git a/maps3d-compose/compose_api_coverage.md b/maps3d-compose/compose_api_coverage.md
index 76e20617..0ab3e42c 100644
--- a/maps3d-compose/compose_api_coverage.md
+++ b/maps3d-compose/compose_api_coverage.md
@@ -12,7 +12,6 @@ The following features and listeners from the Maps 3D SDK are not yet supported
| Feature / Class | Status | Reference / Notes |
| :--- | :--- | :--- |
| **Map3DViewUiController** | Not Supported | Gestures and UI settings controller. |
-| **OnCameraChangedListener** | Not Supported | Camera event listener. |
| **OnFirstSceneListener** | Not Supported | Scene loaded event listener. |
| **OnPlaceClickListener** | Not Supported | Place click event listener. |
| **Anchorable** | Not Supported | Interface for anchorable objects. |
@@ -32,7 +31,7 @@ The following features and listeners from the Maps 3D SDK are not yet supported
| **GoogleMap3D api calls** | Partial | Some exposed via parameters, others require native instance via `onMapReady`. |
| **Map3DViewUiController** | Not Supported | Not yet exposed in the Compose wrapper. |
| **OnCameraAnimationEndListener** | Supported via Native | Used in sample extensions, not exposed as parameter. |
-| **OnCameraChangedListener** | Not Supported | Not yet exposed. |
+| **OnCameraChangedListener** | Supported | Exposed as `onCameraChanged` in `GoogleMap3D` in [`GoogleMap3D.kt:L73`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L73). |
| **OnFirstSceneListener** | Not Supported | Not yet exposed. |
| **OnMap3DClickListener** | Supported | Exposed as `onMapClick` in `GoogleMap3D`. |
| **OnMap3DViewReadyCallback** | Handled Internally | Used in [`GoogleMap3D.kt:L84`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L84) to initialize. |
@@ -91,3 +90,4 @@ The following features and listeners from the Maps 3D SDK are not yet supported
- **Polygons**: [`PolygonsActivity.kt:L161`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt#L161)
- **3D Models**: [`ModelsActivity.kt:L83`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt#L83)
- **Popovers**: [`PopoversActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt#L102)
+- **Camera Changed Listener**: [`CameraChangedActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index 6e87166e..52ff307b 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -70,6 +70,7 @@ fun GoogleMap3D(
onMapReady: (GoogleMap3D) -> Unit = {},
onMapSteady: () -> Unit = {},
onMapClick: (() -> Unit)? = null,
+ onCameraChanged: (Camera) -> Unit = {},
) {
val state = remember { Map3DState() }
val hasCalledOnMapReady = remember { mutableStateOf(false) }
@@ -92,6 +93,10 @@ fun GoogleMap3D(
}
}
+ googleMap3D.setCameraChangedListener { camera ->
+ onCameraChanged(camera)
+ }
+
fun applyUpdates() {
if (!hasCalledOnMapReady.value) {
onMapReady(googleMap3D)
From 82e618bde601d5144e65df45bab5d32dd1d182e5 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:21:15 -0600
Subject: [PATCH 25/29] feat: Implement PinConfiguration support and add visual
test (#41)
* feat: Implement OnCameraChangedListener and add Whiskey Compass demo
- Exposed onCameraChanged callback in GoogleMap3D composable.
- Added WhiskeyCompass composable in demo app.
- Added CameraChangedActivity to demonstrate camera changes with compass, tilt scale, and range scale.
- Updated compose_api_coverage.md.
* docs: Add code reference link for OnCameraChangedListener in coverage doc
* refactor: Move camera changed demo widgets to widgets package
- Moved WhiskeyCompass.kt to widgets package.
- Extracted TiltScale and RangeScale to separate files in widgets package.
- Updated CameraChangedActivity to use widgets from the new package.
* feat: Implement PinConfiguration support and add visual test
* docs: Update API coverage for PinConfiguration
* docs: Clean up compose coverage file
* docs: Remove supported items from Not Yet Exposed list
* docs: Remove PinView from coverage checklist
* feat: Implement OnPlaceClickListener support and add visual test
* docs: Remove unsupported APIs from coverage checklist
---
.../maps3dcomposedemo/Maps3DVisualTest.kt | 117 +++++++++++++
.../src/main/AndroidManifest.xml | 8 +
.../CustomMarkersActivity.kt | 158 ++++++++++++++++++
.../example/maps3dcomposedemo/MainActivity.kt | 10 ++
.../maps3dcomposedemo/PlaceClickActivity.kt | 100 +++++++++++
.../maps3dcomposedemo/PopoversActivity.kt | 24 +--
maps3d-compose/compose_api_coverage.md | 47 ++----
.../maps/android/compose3d/DataModels.kt | 22 +++
.../maps/android/compose3d/GoogleMap3D.kt | 11 +-
.../maps/android/compose3d/Map3DState.kt | 41 ++++-
10 files changed, 487 insertions(+), 51 deletions(-)
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CustomMarkersActivity.kt
create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PlaceClickActivity.kt
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 26e5ff60..15d1bc36 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -329,4 +329,121 @@ class Maps3DVisualTest : BaseVisualTest() {
)
}
}
+
+ @Test
+ fun verifyCustomMarkersRenders() {
+ runBlocking {
+ // Launch CustomMarkersActivity directly
+ val intent = Intent(context, CustomMarkersActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("custom_markers_screenshot.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that multiple markers with different styles are visible around the rock formation (Devils Tower).
+ 3. Specifically look for:
+ - A red pin.
+ - A marker with text "DT".
+ - A green pin with a blue circle.
+ - A marker with an alien icon.
+
+ If the map is visible and these custom styled markers are seen, reply with "PASSED".
+ Otherwise, report what you see.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis: $geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
+ )
+ }
+ }
+
+ @Test
+ fun verifyPlaceClick() {
+ runBlocking {
+ // Launch PlaceClickActivity directly
+ val intent = Intent(context, PlaceClickActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Capture a screenshot to find the place mark
+ val screenshotBitmap = captureScreenshot("place_click_search.png")
+
+ // Define the prompt for Gemini to find coordinates
+ val promptFind = """
+ Analyze this screenshot of a 3D map.
+ You should see a landmark called "Devils Tower" near the center.
+ Find the label or marker for "Devils Tower".
+ Return its center coordinates as a JSON object: {"x": , "y": } where x and y are normalized coordinates between 0.0 and 1.0 (0.0 is top/left, 1.0 is bottom/right).
+ Return ONLY the JSON object, nothing else.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, promptFind, geminiApiKey)
+ println("Gemini's coordinate response: $geminiResponse")
+
+ // Parse JSON and click
+ try {
+ val jsonStr = geminiResponse?.substringAfter("{")?.substringBeforeLast("}")?.let { "{$it}" } ?: ""
+ val json = org.json.JSONObject(jsonStr)
+ val x = json.getDouble("x")
+ val y = json.getDouble("y")
+
+ val clickX = (x * uiDevice.displayWidth).toInt()
+ val clickY = (y * uiDevice.displayHeight).toInt()
+
+ uiDevice.click(clickX, clickY)
+ } catch (e: Exception) {
+ org.junit.Assert.fail("Failed to parse coordinates from Gemini response: $geminiResponse. Error: ${e.message}")
+ }
+
+ // Wait for UI to update
+ kotlinx.coroutines.delay(2000)
+
+ // Capture another screenshot to verify the click
+ val verifyBitmap = captureScreenshot("place_click_verification.png")
+
+ // Define the verification prompt
+ val promptVerify = """
+ Please act as a UI tester and analyze this screenshot.
+ Confirm that the text "Clicked Place:" followed by an ID is visible at the bottom of the screen.
+
+ If the text is seen, reply with "PASSED".
+ Otherwise, report what you see.
+ """.trimIndent()
+
+ val verifyResponse = helper.analyzeImage(verifyBitmap, promptVerify, geminiApiKey)
+ println("Gemini's verification response: $verifyResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: $verifyResponse",
+ verifyResponse?.contains("PASSED", ignoreCase = true) == true,
+ )
+ }
+ }
}
diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml
index 98577059..c49875d0 100644
--- a/maps3d-compose-demo/src/main/AndroidManifest.xml
+++ b/maps3d-compose-demo/src/main/AndroidManifest.xml
@@ -52,6 +52,14 @@
+
+
+ clickedPlaceId = placeId
+ }
+ }
+ }
+}
+
+@Composable
+fun PlaceClickScreen(clickedPlaceId: String, onPlaceClick: (String) -> Unit) {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ val empireStateCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 44.589994
+ longitude = -104.715326
+ altitude = 1508.9
+ }
+ tilt = 60.0
+ heading = 1.0
+ range = 5000.0
+ roll = 0.0
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ GoogleMap3D(
+ camera = empireStateCamera,
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ onPlaceClick = onPlaceClick,
+ )
+
+ if (clickedPlaceId.isNotEmpty()) {
+ Text(
+ text = "Clicked Place: $clickedPlaceId",
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .background(Color.White)
+ .padding(8.dp)
+ .semantics { contentDescription = "ClickedPlaceText" },
+ )
+ }
+ }
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
index a496ff3e..0cc416a2 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt
@@ -17,37 +17,31 @@
package com.example.maps3dcomposedemo
import android.os.Bundle
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.ui.graphics.Color
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import com.google.android.gms.maps3d.Popover
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
-import com.google.android.gms.maps3d.model.popoverOptions
-import com.google.android.gms.maps3d.model.popoverShadow
-import com.google.android.gms.maps3d.model.popoverStyle
import com.google.maps.android.compose3d.GoogleMap3D
import com.google.maps.android.compose3d.MarkerConfig
import com.google.maps.android.compose3d.PopoverConfig
-import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D
class PopoversActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -110,16 +104,16 @@ fun PopoversScreen() {
Surface(
color = Color.White,
shape = RoundedCornerShape(8.dp),
- modifier = Modifier.padding(8.dp)
+ modifier = Modifier.padding(8.dp),
) {
Text(
text = "This is a Popover anchored to a marker!",
modifier = Modifier.padding(16.dp),
- color = Color.Black
+ color = Color.Black,
)
}
- }
- )
+ },
+ ),
)
},
)
@@ -137,7 +131,7 @@ fun PopoversScreen() {
},
onMapClick = {
popovers = emptyList()
- }
+ },
)
}
}
diff --git a/maps3d-compose/compose_api_coverage.md b/maps3d-compose/compose_api_coverage.md
index 0ab3e42c..edd1c6b8 100644
--- a/maps3d-compose/compose_api_coverage.md
+++ b/maps3d-compose/compose_api_coverage.md
@@ -5,31 +5,11 @@ This document tracks the coverage of the Maps 3D SDK APIs in the experimental Co
> [!WARNING]
> This implementation is a **Work In Progress (WIP) experiment** and serves as a **reference implementation**. It is not intended for production use.
-## Not Yet Exposed Functionality
-
-The following features and listeners from the Maps 3D SDK are not yet supported or exposed in this experimental Compose wrapper:
-
-| Feature / Class | Status | Reference / Notes |
-| :--- | :--- | :--- |
-| **Map3DViewUiController** | Not Supported | Gestures and UI settings controller. |
-| **OnFirstSceneListener** | Not Supported | Scene loaded event listener. |
-| **OnPlaceClickListener** | Not Supported | Place click event listener. |
-| **Anchorable** | Not Supported | Interface for anchorable objects. |
-| **BoundingBox** | Not Supported | Spatial bounding box. |
-| **DrawingState** | Not Supported | State of drawing operations. |
-| **Glyph** | Not Supported | Text rendering elements. |
-| **MarkerView / MarkerViewOptions** | Not Supported | View-based markers. |
-| **PinConfiguration** | Not Supported | Pin styling configuration. |
-| **PinView** | Not Supported | Custom pin views. |
-| **VisibilityState** | Not Supported | Visibility state tracking. |
-
## Coverage Status
| Feature / Class | Status | Reference / Notes |
| :--- | :--- | :--- |
| **Map3DOptions** | Supported | Passed to `GoogleMap3D` in [`GoogleMap3D.kt:L69`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L69). |
-| **GoogleMap3D api calls** | Partial | Some exposed via parameters, others require native instance via `onMapReady`. |
-| **Map3DViewUiController** | Not Supported | Not yet exposed in the Compose wrapper. |
| **OnCameraAnimationEndListener** | Supported via Native | Used in sample extensions, not exposed as parameter. |
| **OnCameraChangedListener** | Supported | Exposed as `onCameraChanged` in `GoogleMap3D` in [`GoogleMap3D.kt:L73`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L73). |
| **OnFirstSceneListener** | Not Supported | Not yet exposed. |
@@ -37,37 +17,45 @@ The following features and listeners from the Maps 3D SDK are not yet supported
| **OnMap3DViewReadyCallback** | Handled Internally | Used in [`GoogleMap3D.kt:L84`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L84) to initialize. |
| **OnMarkerClickListener** | Handled Internally | Exposed as `onClick` in `MarkerConfig`. |
| **OnModelClickListener** | Handled Internally | Exposed as `onClick` in `ModelConfig`. |
-| **OnPlaceClickListener** | Not Supported | Not yet exposed. |
+| **OnPlaceClickListener** | Supported | Exposed as `onPlaceClick` in `GoogleMap3D`. |
| **OnPolygonClickListener** | Handled Internally | Exposed as `onClick` in `PolygonConfig`. |
| **OnPolylineClickListener** | Handled Internally | Exposed as `onClick` in `PolylineConfig`. |
| **AltitudeMode** | Supported | Defined in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
-| **Anchorable** | Not Supported | Implied by marker anchoring but not explicitly exposed. |
-| **BoundingBox** | Not Supported | Not yet exposed. |
| **Camera** | Supported | Hoisted in `GoogleMap3D` [`GoogleMap3D.kt:L60`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L60). |
| **CameraRestriction** | Supported | Passed to `GoogleMap3D` [`GoogleMap3D.kt:L67`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L67). |
| **CollisionBehavior** | Supported | Used in `MarkerConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
-| **DrawingState** | Not Supported | Not yet exposed. |
| **FlyAround / FlyTo** | Supported via Native | Used in samples via native instance, not declarative. |
-| **Glyph** | Not Supported | Not yet exposed. |
+| **Glyph** | Supported | Via `GlyphConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
| **Hole** | Supported | Used in `PolygonConfig` in `Map3DState.kt`. |
| **ImageView** | Handled Internally | Used for Popover content rendering. |
| **Marker / MarkerOptions** | Supported | Via `MarkerConfig` in [`DataModels.kt:L35`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L35). |
-| **MarkerView / MarkerViewOptions** | Not Supported | Not yet exposed. |
| **Model / ModelOptions** | Supported | Via `ModelConfig` in [`DataModels.kt:L93`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L93). |
| **Orientation** | Supported | Used in `ModelConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
-| **PinConfiguration** | Not Supported | Not yet exposed. |
-| **PinView** | Not Supported | Not yet exposed. |
+| **PinConfiguration** | Supported | Via `PinConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). |
| **Polygon / PolygonOptions** | Supported | Via `PolygonConfig` in [`DataModels.kt:L70`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L70). |
| **Polyline / PolylineOptions** | Supported | Via `PolylineConfig` in [`DataModels.kt:L52`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L52). |
| **OnMapReady** | Supported | Callback in `GoogleMap3D` [`GoogleMap3D.kt:L70`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L70). |
| **OnMapSteady** | Supported | Callback in `GoogleMap3D` [`GoogleMap3D.kt:L71`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L71). |
| **Popover / PopoverContentsView...**| Supported | Via `PopoverConfig` in [`DataModels.kt:L109`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L109). |
| **Vector3D** | Supported | Used for scale in [`Map3DState.kt`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt). |
-| **VisibilityState** | Not Supported | Not yet exposed. |
| **GoogleMap3D Composable...** | Supported | Lifecycle handling implemented in [`GoogleMap3D.kt`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt). |
| **Core state management...** | Partial | Map properties supported, gestures might need `Map3DViewUiController`. |
| **Camera animation...** | Supported via Native | `flyCameraTo` and `flyCameraAround` used in samples. |
+## Not Yet Exposed Functionality
+
+The following features and listeners from the Maps 3D SDK are not yet supported or exposed in this experimental Compose wrapper:
+
+| Feature / Class | Status | Reference / Notes |
+| :--- | :--- | :--- |
+| **Map3DViewUiController** | Not Supported | Gestures and UI settings controller. |
+| **Anchorable** | Not Supported | Interface for anchorable objects. |
+| **BoundingBox** | Not Supported | Spatial bounding box. |
+| **DrawingState** | Not Supported | State of drawing operations. |
+| **MarkerView / MarkerViewOptions** | Not Supported | View-based markers. |
+| **PinView** | Not Supported | Custom pin views. |
+| **VisibilityState** | Not Supported | Visibility state tracking. |
+
## References
### Implementation in `maps3d-compose`
@@ -86,6 +74,7 @@ The following features and listeners from the Maps 3D SDK are not yet supported
- **Map Options & Restrictions**: [`MapOptionsActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt#L102)
- **Camera Animations**: [`CameraAnimationsActivity.kt:L88`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt#L88)
- **Markers**: [`MarkersActivity.kt:L89`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt#L89)
+- **Custom Markers**: [`CustomMarkersActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CustomMarkersActivity.kt)
- **Polylines**: [`PolylinesActivity.kt:L108`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt#L108)
- **Polygons**: [`PolygonsActivity.kt:L161`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt#L161)
- **3D Models**: [`ModelsActivity.kt:L83`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt#L83)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
index 4e2771da..39ad04d3 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt
@@ -28,6 +28,27 @@ import com.google.android.gms.maps3d.model.Model
import com.google.android.gms.maps3d.model.Polygon
import com.google.android.gms.maps3d.model.Polyline
+/**
+ * Sealed class representing the glyph (icon/text) inside a pin marker.
+ */
+sealed class GlyphConfig {
+ data class Color(val color: Int) : GlyphConfig()
+ data class Text(val text: String, val color: Int? = null) : GlyphConfig()
+ data class Circle(val color: Int? = null) : GlyphConfig()
+ data class Image(val imageResId: Int, val color: Int? = null) : GlyphConfig()
+}
+
+/**
+ * Data class representing the configuration of a pin marker.
+ */
+@Immutable
+data class PinConfig(
+ val scale: Float? = null,
+ val backgroundColor: Int? = null,
+ val borderColor: Int? = null,
+ val glyph: GlyphConfig? = null,
+)
+
/**
* Data class representing a Marker to be added to the 3D map.
*/
@@ -42,6 +63,7 @@ data class MarkerConfig(
val isExtruded: Boolean = false,
val isDrawnWhenOccluded: Boolean = false,
val collisionBehavior: Int = CollisionBehavior.REQUIRED,
+ val pinConfig: PinConfig? = null,
val onClick: ((Marker) -> Unit)? = null,
)
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index 52ff307b..f39a4c3c 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -70,6 +70,7 @@ fun GoogleMap3D(
onMapReady: (GoogleMap3D) -> Unit = {},
onMapSteady: () -> Unit = {},
onMapClick: (() -> Unit)? = null,
+ onPlaceClick: ((String) -> Unit)? = null,
onCameraChanged: (Camera) -> Unit = {},
) {
val state = remember { Map3DState() }
@@ -114,9 +115,13 @@ fun GoogleMap3D(
state.syncModels(googleMap3D, models)
state.syncPopovers(map3dView.context, googleMap3D, popovers)
- onMapClick?.let { callback ->
- googleMap3D.setMap3DClickListener { _, _ ->
- callback()
+ if (onMapClick != null || onPlaceClick != null) {
+ googleMap3D.setMap3DClickListener { _, placeId ->
+ if (placeId != null) {
+ onPlaceClick?.invoke(placeId)
+ } else {
+ onMapClick?.invoke()
+ }
}
}
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
index cd7a6e8c..6588ee4f 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt
@@ -16,13 +16,16 @@
package com.google.maps.android.compose3d
-import com.google.android.gms.maps3d.GoogleMap3D
import android.content.Context
+import android.graphics.Color
import androidx.compose.ui.platform.ComposeView
+import com.google.android.gms.maps3d.GoogleMap3D
import com.google.android.gms.maps3d.Popover
+import com.google.android.gms.maps3d.model.Glyph
import com.google.android.gms.maps3d.model.Hole
import com.google.android.gms.maps3d.model.Marker
import com.google.android.gms.maps3d.model.Model
+import com.google.android.gms.maps3d.model.PinConfiguration
import com.google.android.gms.maps3d.model.Polygon
import com.google.android.gms.maps3d.model.Polyline
import com.google.android.gms.maps3d.model.markerOptions
@@ -91,6 +94,36 @@ class Map3DState {
isExtruded = config.isExtruded
isDrawnWhenOccluded = config.isDrawnWhenOccluded
collisionBehavior = config.collisionBehavior
+
+ config.pinConfig?.let { pin ->
+ val builder = PinConfiguration.builder()
+ pin.scale?.let { builder.setScale(it) }
+ pin.backgroundColor?.let { builder.setBackgroundColor(it) }
+ pin.borderColor?.let { builder.setBorderColor(it) }
+
+ pin.glyph?.let { glyphConfig ->
+ val glyph = when (glyphConfig) {
+ is GlyphConfig.Color -> Glyph.fromColor(glyphConfig.color)
+ is GlyphConfig.Text -> {
+ val g = Glyph.fromText(glyphConfig.text)
+ glyphConfig.color?.let { g.color = it }
+ g
+ }
+ is GlyphConfig.Circle -> {
+ val g = Glyph.fromCircle()
+ glyphConfig.color?.let { g.color = it }
+ g
+ }
+ is GlyphConfig.Image -> {
+ val g = Glyph.fromColor(glyphConfig.color ?: Color.WHITE)
+ g.setImage(com.google.android.gms.maps3d.model.ImageView(glyphConfig.imageResId))
+ g
+ }
+ }
+ builder.setGlyph(glyph)
+ }
+ setStyle(builder.build())
+ }
},
)
@@ -284,7 +317,7 @@ class Map3DState {
private fun createPopover(context: Context, map: GoogleMap3D, config: PopoverConfig): Popover? {
val marker = markers[config.positionAnchorKey]?.second ?: return null
-
+
val composeView = ComposeView(context).apply {
setContent {
config.content()
@@ -298,9 +331,9 @@ class Map3DState {
content = composeView
autoCloseEnabled = config.autoCloseEnabled
autoPanEnabled = config.autoPanEnabled
- }
+ },
)
-
+
popover.show()
return popover
}
From a3a42dc996f0ba209b9ec7c846d9913bd6ff7cc8 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:25:29 -0600
Subject: [PATCH 26/29] docs: Update API coverage with PlaceClickActivity demo
---
maps3d-compose/compose_api_coverage.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/maps3d-compose/compose_api_coverage.md b/maps3d-compose/compose_api_coverage.md
index edd1c6b8..b07fa580 100644
--- a/maps3d-compose/compose_api_coverage.md
+++ b/maps3d-compose/compose_api_coverage.md
@@ -80,3 +80,4 @@ The following features and listeners from the Maps 3D SDK are not yet supported
- **3D Models**: [`ModelsActivity.kt:L83`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt#L83)
- **Popovers**: [`PopoversActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt#L102)
- **Camera Changed Listener**: [`CameraChangedActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt)
+- **Place Clicks**: [`PlaceClickActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PlaceClickActivity.kt)
From 162b245f501897357fffcbb040ed21dd95ec3edd Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:18:23 -0600
Subject: [PATCH 27/29] feat: address review comments, refactor
getMap3DViewAsync, and add rotation test
- Refactor `GoogleMap3D` and `ThreeDMap` to avoid calling `getMap3DViewAsync` on every recomposition by moving it to the `factory` block of `AndroidView`.
- Use `rememberUpdatedState` in `GoogleMap3D` for callbacks to prevent stale captures.
- Add `verifyRotationMaintainsMap` visual test to verify map rendering after device rotation.
- Remove reference files in `visual-test-examples` directory as requested.
- Move Spotless plugin to versions catalog and upgrade several dependencies (Places to 5.2.0, Spotless to 8.4.0, Ktor to 3.4.2, etc.).
- Clean up `truth` library usage across modules, favoring `google-truth`.
- Fix Spotless formatting warnings in `CameraChangedActivity.kt` and `WhiskeyCompass.kt`.
---
...otlin-compiler-17720919123303405947.salive | 0
.../ApiDemos/java-app/build.gradle.kts | 2 +-
.../ApiDemos/kotlin-app/build.gradle.kts | 2 +-
.../scenarios/ThreeDMap.kt | 18 ++-
gradle/libs.versions.toml | 11 +-
maps3d-compose-demo/build.gradle.kts | 2 +-
.../maps3dcomposedemo/Maps3DVisualTest.kt | 51 ++++++++
.../CameraChangedActivity.kt | 34 ++----
.../maps3dcomposedemo/widgets/RangeScale.kt | 4 +-
.../maps3dcomposedemo/widgets/TiltScale.kt | 18 +--
.../widgets/WhiskeyCompass.kt | 63 ++++++----
.../maps/android/compose3d/GoogleMap3D.kt | 97 ++++++++-------
visual-test-examples/BaseVisualTest.kt | 70 -----------
visual-test-examples/ClusteringVisualTest.kt | 112 ------------------
.../IconGeneratorVisualTest.kt | 103 ----------------
visual-test-examples/KmlVisualTest.kt | 75 ------------
.../RendererVisualTestBase.kt | 97 ---------------
visual-testing/build.gradle.kts | 2 +-
18 files changed, 188 insertions(+), 573 deletions(-)
create mode 100644 .kotlin/sessions/kotlin-compiler-17720919123303405947.salive
delete mode 100644 visual-test-examples/BaseVisualTest.kt
delete mode 100644 visual-test-examples/ClusteringVisualTest.kt
delete mode 100644 visual-test-examples/IconGeneratorVisualTest.kt
delete mode 100644 visual-test-examples/KmlVisualTest.kt
delete mode 100644 visual-test-examples/RendererVisualTestBase.kt
diff --git a/.kotlin/sessions/kotlin-compiler-17720919123303405947.salive b/.kotlin/sessions/kotlin-compiler-17720919123303405947.salive
new file mode 100644
index 00000000..e69de29b
diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
index c40b263e..19f427fb 100644
--- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
@@ -134,7 +134,7 @@ dependencies {
testImplementation(libs.json) // "org.json:json:20251224"
testImplementation(libs.robolectric) // "org.robolectric:robolectric:4.16.1"
testImplementation(libs.androidx.core) // "androidx.test:core:1.7.0"
- testImplementation(libs.truth) // "com.google.truth:truth:1.4.5"
+ testImplementation(libs.google.truth) // "com.google.truth:truth:1.4.5"
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(project(":Maps3DSamples:ApiDemos:common"))
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
index 60963ffa..08387045 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
@@ -140,7 +140,7 @@ dependencies {
testImplementation(libs.json) // "org.json:json:20251224"
testImplementation(libs.robolectric) // "org.robolectric:robolectric:4.16.1"
testImplementation(libs.androidx.core) // "androidx.test:core:1.7.0"
- testImplementation(libs.truth) // "com.google.truth:truth:1.4.5"
+ testImplementation(libs.google.truth) // "com.google.truth:truth:1.4.5"
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.uiautomator)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt
index f51aacd3..2a53a7db 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt
@@ -15,6 +15,8 @@
package com.example.advancedmaps3dsamples.scenarios
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.maps3d.GoogleMap3D
@@ -28,20 +30,23 @@ internal fun ThreeDMap(
viewModel: ScenariosViewModel,
modifier: Modifier = Modifier,
) {
+ // Use rememberUpdatedState to avoid capturing stale callbacks if they change
+ val currentOnMapSteadyChange by rememberUpdatedState { isSteady: Boolean ->
+ viewModel.onMapSteadyChange(isSteady)
+ }
+
AndroidView(
modifier = modifier,
factory = { context ->
val map3dView = Map3DView(context = context, options = options)
map3dView.onCreate(null)
- map3dView
- },
- update = { map3dView ->
+
map3dView.getMap3DViewAsync(
object : OnMap3DViewReadyCallback {
override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
viewModel.setGoogleMap3D(googleMap3D)
googleMap3D.setOnMapSteadyListener { isSceneSteady ->
- viewModel.onMapSteadyChange(isSceneSteady)
+ currentOnMapSteadyChange(isSceneSteady)
}
}
@@ -50,6 +55,11 @@ internal fun ThreeDMap(
}
}
)
+
+ map3dView
+ },
+ update = { _ ->
+ // No-op, updates are handled via viewModel and imperative calls on the stored instance
},
onRelease = { _ ->
// Clean up resources if needed
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 64e6441b..dc0c8af9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,16 +22,17 @@ material = "1.13.0"
playServicesBase = "18.10.0"
playServicesMaps3d = "0.2.0"
-places = "5.1.1"
+places = "5.2.0"
secretsGradlePlugin = "2.0.1"
truth = "1.4.5"
uiautomator = "2.3.0"
+spotless = "8.4.0"
kotlinxDatetime = "0.7.1"
-kotlinxSerialization = "1.10.0"
-ktor = "3.4.1"
+kotlinxSerialization = "1.11.0"
+ktor = "3.4.2"
hilt = "2.59.2"
-ksp = "2.3.2"
+ksp = "2.3.6"
mapsUtilsKtx = "6.0.1"
androidx-core-ktx = "1.8.9"
@@ -63,7 +64,6 @@ play-services-base = { module = "com.google.android.gms:play-services-base", ver
play-services-maps3d = { module = "com.google.android.gms:play-services-maps3d", version.ref = "playServicesMaps3d" }
places = { module = "com.google.android.libraries.places:places", version.ref = "places" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
-truth = { module = "com.google.truth:truth", version.ref = "truth" }
google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
@@ -89,3 +89,4 @@ hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
diff --git a/maps3d-compose-demo/build.gradle.kts b/maps3d-compose-demo/build.gradle.kts
index d5468624..28cbd953 100644
--- a/maps3d-compose-demo/build.gradle.kts
+++ b/maps3d-compose-demo/build.gradle.kts
@@ -19,7 +19,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets.gradle.plugin)
- id("com.diffplug.spotless") version "6.25.0"
+ alias(libs.plugins.spotless)
}
configure {
diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
index 15d1bc36..86eae31d 100644
--- a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
+++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt
@@ -446,4 +446,55 @@ class Maps3DVisualTest : BaseVisualTest() {
)
}
}
+
+ @Test
+ fun verifyRotationMaintainsMap() = runBlocking {
+ // Launch PolylinesActivity directly
+ val intent = Intent(context, PolylinesActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ try {
+ // Rotate device to landscape
+ uiDevice.setOrientationLeft()
+
+ // Wait for rotation to complete and map to render again
+ kotlinx.coroutines.delay(5000) // Wait for rotation animation
+ waitForMapRendering(60)
+
+ // Capture a screenshot in landscape
+ val screenshotBitmap = captureScreenshot("polylines_landscape.png")
+
+ // Define the verification prompt for Gemini
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot of a 3D map in landscape mode.
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that a red polyline is still visible on the map.
+
+ If the map is visible and the red polyline is seen, reply with "PASSED".
+ Otherwise, report what you see.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis (Landscape): $geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed in landscape. Gemini response: $geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true,
+ )
+ } finally {
+ // Restore orientation
+ uiDevice.setOrientationNatural()
+ kotlinx.coroutines.delay(2000) // Wait for it to settle
+ }
+ }
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt
index 518d25bb..256eb929 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt
@@ -20,42 +20,27 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.drawscope.translate
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.drawText
-import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.google.android.gms.maps3d.model.Camera
-import com.google.android.gms.maps3d.model.Map3DMode
-import com.example.maps3dcomposedemo.widgets.WhiskeyCompass
-import com.example.maps3dcomposedemo.widgets.TiltScale
import com.example.maps3dcomposedemo.widgets.RangeScale
+import com.example.maps3dcomposedemo.widgets.TiltScale
+import com.example.maps3dcomposedemo.widgets.WhiskeyCompass
+import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.maps.android.compose3d.GoogleMap3D
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlin.math.roundToInt
class CameraChangedActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -102,7 +87,7 @@ fun CameraChangedScreen() {
modifier = Modifier.fillMaxSize(),
onCameraChanged = { camera ->
cameraFlow.value = camera
- }
+ },
)
val heading = currentCamera.heading?.toFloat() ?: 0f
@@ -115,8 +100,9 @@ fun CameraChangedScreen() {
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
- .padding(top = 48.dp), // Padding for edge-to-edge only at top
- backgroundColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
+ // Padding for edge-to-edge only at top
+ .padding(top = 48.dp),
+ backgroundColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f),
)
// Overlay Tilt Scale on the right
@@ -124,7 +110,7 @@ fun CameraChangedScreen() {
tilt = tilt,
modifier = Modifier
.align(Alignment.CenterEnd)
- .padding(end = 16.dp)
+ .padding(end = 16.dp),
)
// Overlay Range Scale at the bottom
@@ -132,9 +118,7 @@ fun CameraChangedScreen() {
range = range,
modifier = Modifier
.align(Alignment.BottomCenter)
- .padding(bottom = 32.dp)
+ .padding(bottom = 32.dp),
)
}
}
-
-
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt
index fc7af21f..3c1c7652 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt
@@ -15,12 +15,12 @@ fun RangeScale(range: Float, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f))
- .padding(8.dp)
+ .padding(8.dp),
) {
Text(
text = "Range: ${range.roundToInt()} m",
color = MaterialTheme.colorScheme.onPrimaryContainer,
- style = MaterialTheme.typography.bodyMedium
+ style = MaterialTheme.typography.bodyMedium,
)
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt
index 0dee2f11..358e5344 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt
@@ -35,43 +35,43 @@ fun TiltScale(tilt: Float, modifier: Modifier = Modifier) {
.width(80.dp)
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f))
.clipToBounds(),
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val centerLineY = size.height / 2f
val centerLineX = size.width / 2f
-
+
// Pixels per degree
val pixelsPerDegree = 5f
-
+
// Draw a fixed center line (pointer)
drawLine(
color = Color.Red,
start = Offset(0f, centerLineY),
end = Offset(size.width, centerLineY),
- strokeWidth = 2f
+ strokeWidth = 2f,
)
// Draw scrolling ticks
translate(top = centerLineY + (displayedTilt * pixelsPerDegree)) {
for (i in 0..90 step 5) {
val yPos = -i * pixelsPerDegree
-
+
val isMajor = i % 15 == 0
val tickLength = if (isMajor) 15f else 8f
-
+
drawLine(
color = onPrimaryColor,
start = Offset(centerLineX - tickLength, yPos),
end = Offset(centerLineX + tickLength, yPos),
- strokeWidth = if (isMajor) 2f else 1f
+ strokeWidth = if (isMajor) 2f else 1f,
)
-
+
if (isMajor) {
val measuredText = textMeasurer.measure(i.toString(), style = TextStyle(color = onPrimaryColor, fontSize = 12.sp))
drawText(
textLayoutResult = measuredText,
- topLeft = Offset(centerLineX + 20f, yPos - measuredText.size.height / 2f)
+ topLeft = Offset(centerLineX + 20f, yPos - measuredText.size.height / 2f),
)
}
}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
index 9619b356..0916f272 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
@@ -77,7 +77,8 @@ fun WhiskeyCompass(
degreeLabelVerticalOffset: Dp = 4.dp,
showCardinalLabels: Boolean = true,
- cardinalLabelInterval: Int = 45, // Added for clarity
+ // Added for clarity
+ cardinalLabelInterval: Int = 45,
cardinalLabelTextStyle: TextStyle = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
cardinalLabelVerticalOffset: Dp = 4.dp,
@@ -85,14 +86,14 @@ fun WhiskeyCompass(
minorTickHeight: Dp = 15.dp,
majorTickStrokeWidth: Dp = 2.dp,
minorTickStrokeWidth: Dp = 1.dp,
- lubberLineStrokeWidth: Dp = 2.dp
+ lubberLineStrokeWidth: Dp = 2.dp,
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
// Normalize heading to the 0-360 range to prevent offset issues
val normalizedHeading = (heading % 360f + 360f) % 360f
-
+
// Memoize measured cardinal labels for performance
val measuredCardinalLabels = remember(cardinalLabelTextStyle, density) {
with(density) {
@@ -104,18 +105,17 @@ fun WhiskeyCompass(
180 to textMeasurer.measure("S", style = cardinalLabelTextStyle),
225 to textMeasurer.measure("SW", style = cardinalLabelTextStyle),
270 to textMeasurer.measure("W", style = cardinalLabelTextStyle),
- 315 to textMeasurer.measure("NW", style = cardinalLabelTextStyle)
+ 315 to textMeasurer.measure("NW", style = cardinalLabelTextStyle),
)
}
}
-
Box(
modifier = modifier
.height(stripHeight)
.background(backgroundColor)
.clipToBounds(),
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
// Canvas 1: Scrolling Compass Strip (ticks, degree labels, cardinal labels)
Canvas(modifier = Modifier.matchParentSize()) {
@@ -141,7 +141,6 @@ fun WhiskeyCompass(
val visibilityMargin = canvasWidth
if (xPos < -xOffset + canvasWidth + visibilityMargin && xPos > -xOffset - visibilityMargin) {
-
val isMajorTickEquivalent = degreeInRepetition % 10 == 0
val isMinorTickEquivalent = degreeInRepetition % 5 == 0 && !isMajorTickEquivalent
@@ -152,7 +151,7 @@ fun WhiskeyCompass(
color = tickColor,
start = Offset(x = xPos, y = tickTopY),
end = Offset(x = xPos, y = tickBottomY),
- strokeWidth = majorTickStrokeWidthPx
+ strokeWidth = majorTickStrokeWidthPx,
)
if (showCardinalLabels && measuredCardinalLabels.containsKey(degreeInRepetition)) {
@@ -161,32 +160,39 @@ fun WhiskeyCompass(
textLayoutResult = measuredText,
topLeft = Offset(
x = xPos - measuredText.size.width / 2f,
- y = tickTopY - measuredText.size.height - cardinalLabelVerticalOffsetPx
- )
+ y = tickTopY - measuredText.size.height - cardinalLabelVerticalOffsetPx,
+ ),
)
}
- }
- else if (isMinorTickEquivalent) {
+ } else if (isMinorTickEquivalent) {
val tickTopY = tickCenterY - minorTickHeightPx / 2f
val tickBottomY = tickCenterY + minorTickHeightPx / 2f
drawLine(
color = tickColor,
start = Offset(x = xPos, y = tickTopY),
end = Offset(x = xPos, y = tickBottomY),
- strokeWidth = minorTickStrokeWidthPx
+ strokeWidth = minorTickStrokeWidthPx,
)
}
if (showDegreeLabels && degreeInRepetition % degreeLabelInterval == 0) {
- val tickBottomY = tickCenterY + (if (isMajorTickEquivalent) majorTickHeightPx else if (isMinorTickEquivalent) minorTickHeightPx else 0f) / 2f
+ val tickBottomY = tickCenterY + (
+ if (isMajorTickEquivalent) {
+ majorTickHeightPx
+ } else if (isMinorTickEquivalent) {
+ minorTickHeightPx
+ } else {
+ 0f
+ }
+ ) / 2f
val labelText = degreeInRepetition.toString()
val measuredText = textMeasurer.measure(labelText, style = degreeLabelTextStyle)
drawText(
textLayoutResult = measuredText,
topLeft = Offset(
x = xPos - measuredText.size.width / 2f,
- y = tickBottomY + degreeLabelVerticalOffsetPx
- )
+ y = tickBottomY + degreeLabelVerticalOffsetPx,
+ ),
)
}
}
@@ -203,7 +209,7 @@ fun WhiskeyCompass(
color = lubberLineColor,
start = Offset(x = center.x, y = lubberLineVisualPadding),
end = Offset(x = center.x, y = size.height - lubberLineVisualPadding),
- strokeWidth = currentLubberLineStrokeWidthPx
+ strokeWidth = currentLubberLineStrokeWidthPx,
)
}
}
@@ -213,7 +219,13 @@ fun WhiskeyCompass(
@Composable
private fun FlatWhiskeyCompassPreview() {
val exampleHeadings = listOf(
- 0f, 10f, 22.5f, 45f, 168f, 270f, 358f
+ 0f,
+ 10f,
+ 22.5f,
+ 45f,
+ 168f,
+ 270f,
+ 358f,
)
Column(
@@ -223,14 +235,14 @@ private fun FlatWhiskeyCompassPreview() {
.padding(16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Default Flat Compass Strip", color = Color.White, style = MaterialTheme.typography.titleMedium)
WhiskeyCompass(
heading = 45f,
modifier = Modifier.fillMaxWidth(),
stripHeight = 100.dp,
- pixelsPerDegree = 8f
+ pixelsPerDegree = 8f,
)
Spacer(modifier = Modifier.height(16.dp))
@@ -250,7 +262,7 @@ private fun FlatWhiskeyCompassPreview() {
minorTickHeight = 18.dp,
degreeLabelVerticalOffset = 6.dp,
cardinalLabelVerticalOffset = 6.dp,
- majorTickStrokeWidth = 2.5.dp
+ majorTickStrokeWidth = 2.5.dp,
)
Spacer(modifier = Modifier.height(16.dp))
@@ -260,7 +272,7 @@ private fun FlatWhiskeyCompassPreview() {
modifier = Modifier.fillMaxWidth(),
stripHeight = 70.dp,
showCardinalLabels = false,
- pixelsPerDegree = 6f
+ pixelsPerDegree = 6f,
)
Spacer(modifier = Modifier.height(16.dp))
@@ -270,24 +282,23 @@ private fun FlatWhiskeyCompassPreview() {
modifier = Modifier.fillMaxWidth(),
stripHeight = 70.dp,
showDegreeLabels = false,
- pixelsPerDegree = 6f
+ pixelsPerDegree = 6f,
)
-
exampleHeadings.forEach { currentHeading ->
Spacer(Modifier.height(12.dp))
Text(
text = "Test Heading: ${currentHeading.roundToInt()}°",
color = Color.LightGray,
fontFamily = FontFamily.Monospace,
- fontSize = 12.sp
+ fontSize = 12.sp,
)
WhiskeyCompass(
heading = currentHeading,
modifier = Modifier.fillMaxWidth(),
stripHeight = 90.dp,
pixelsPerDegree = 7f,
- degreeLabelInterval = 30
+ degreeLabelInterval = 30,
)
}
}
diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
index f39a4c3c..84387ae5 100644
--- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
+++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt
@@ -17,8 +17,10 @@
package com.google.maps.android.compose3d
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.maps3d.GoogleMap3D
@@ -75,74 +77,87 @@ fun GoogleMap3D(
) {
val state = remember { Map3DState() }
val hasCalledOnMapReady = remember { mutableStateOf(false) }
+ val googleMap3DState = remember { mutableStateOf(null) }
+
+ // Use rememberUpdatedState to avoid capturing stale lambdas in the async callback
+ val currentOnMapSteady by rememberUpdatedState(onMapSteady)
+ val currentOnCameraChanged by rememberUpdatedState(onCameraChanged)
+ val currentOnMapReady by rememberUpdatedState(onMapReady)
+ val currentOnMapClick by rememberUpdatedState(onMapClick)
+ val currentOnPlaceClick by rememberUpdatedState(onPlaceClick)
AndroidView(
modifier = modifier,
factory = { context ->
val map3dView = Map3DView(context, options)
map3dView.onCreate(null)
- map3dView
- },
- update = { map3dView ->
+
map3dView.getMap3DViewAsync(object : OnMap3DViewReadyCallback {
override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ googleMap3DState.value = googleMap3D
Map3DRegistry.setInstance(googleMap3D)
googleMap3D.setOnMapSteadyListener { isSteady ->
if (isSteady) {
- onMapSteady()
+ currentOnMapSteady()
}
}
googleMap3D.setCameraChangedListener { camera ->
- onCameraChanged(camera)
+ currentOnCameraChanged(camera)
}
- fun applyUpdates() {
- if (!hasCalledOnMapReady.value) {
- onMapReady(googleMap3D)
- hasCalledOnMapReady.value = true
- }
-
- // Sync hoisted state with the imperative map instance
- googleMap3D.setCamera(camera.toValidCamera())
- googleMap3D.setCameraRestriction(cameraRestriction.toValidCameraRestriction())
- googleMap3D.setMapMode(mapMode)
-
- state.syncMarkers(googleMap3D, markers)
- state.syncPolylines(googleMap3D, polylines)
- state.syncPolygons(googleMap3D, polygons)
- state.syncModels(googleMap3D, models)
- state.syncPopovers(map3dView.context, googleMap3D, popovers)
-
- if (onMapClick != null || onPlaceClick != null) {
- googleMap3D.setMap3DClickListener { _, placeId ->
- if (placeId != null) {
- onPlaceClick?.invoke(placeId)
- } else {
- onMapClick?.invoke()
- }
+ if (currentOnMapClick != null || currentOnPlaceClick != null) {
+ googleMap3D.setMap3DClickListener { _, placeId ->
+ if (placeId != null) {
+ currentOnPlaceClick?.invoke(placeId)
+ } else {
+ currentOnMapClick?.invoke()
}
}
}
-
- if (Map3DRegistry.isMapReady) {
- // Map was already ready (e.g. reused instance), apply updates immediately
- applyUpdates()
- } else {
- // First time initialization, must wait for listener
- googleMap3D.setOnMapReadyListener {
- googleMap3D.setOnMapReadyListener(null) // Clear it immediately
- Map3DRegistry.markReady()
- applyUpdates()
- }
- }
}
override fun onError(error: Exception) {
throw error
}
})
+
+ map3dView
+ },
+ update = { map3dView ->
+ val googleMap3D = googleMap3DState.value
+ if (googleMap3D != null) {
+ fun applyUpdates() {
+ if (!hasCalledOnMapReady.value) {
+ currentOnMapReady(googleMap3D)
+ hasCalledOnMapReady.value = true
+ }
+
+ // Sync hoisted state with the imperative map instance
+ googleMap3D.setCamera(camera.toValidCamera())
+ googleMap3D.setCameraRestriction(cameraRestriction.toValidCameraRestriction())
+ googleMap3D.setMapMode(mapMode)
+
+ state.syncMarkers(googleMap3D, markers)
+ state.syncPolylines(googleMap3D, polylines)
+ state.syncPolygons(googleMap3D, polygons)
+ state.syncModels(googleMap3D, models)
+ state.syncPopovers(map3dView.context, googleMap3D, popovers)
+ }
+
+ if (Map3DRegistry.isMapReady) {
+ // Map was already ready (e.g. reused instance), apply updates immediately
+ applyUpdates()
+ } else {
+ // First time initialization, must wait for listener
+ googleMap3D.setOnMapReadyListener {
+ googleMap3D.setOnMapReadyListener(null) // Clear it immediately
+ Map3DRegistry.markReady()
+ applyUpdates()
+ }
+ }
+ }
},
onRelease = { map3dView ->
state.clear()
diff --git a/visual-test-examples/BaseVisualTest.kt b/visual-test-examples/BaseVisualTest.kt
deleted file mode 100644
index a41125cd..00000000
--- a/visual-test-examples/BaseVisualTest.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * 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.utils.demo
-
-import android.app.Instrumentation
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.uiautomator.UiDevice
-import com.google.maps.android.visualtesting.GeminiVisualTestHelper
-import org.junit.Assert.assertTrue
-import java.io.File
-
-abstract class BaseVisualTest {
-
- protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
- protected val uiDevice = UiDevice.getInstance(instrumentation)
- protected val context: Context = instrumentation.targetContext
- protected val helper = GeminiVisualTestHelper()
-
- protected val geminiApiKey: String by lazy {
- val key = BuildConfig.GEMINI_API_KEY
- assertTrue(
- "GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.",
- key != "YOUR_GEMINI_API_KEY"
- )
- key
- }
-
- protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap {
- val screenshotFile = File(context.cacheDir, filename)
- val screenshotTaken = uiDevice.takeScreenshot(screenshotFile)
- assertTrue("Failed to take screenshot: $filename", screenshotTaken)
-
- val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath)
- assertTrue("Failed to decode screenshot file: $filename", bitmap != null)
- return bitmap
- }
-
- /**
- * Waits for the map to render.
- * Since MapView content (tiles, markers) is rendered on a GL surface and not exposed as
- * accessibility nodes, we cannot rely on UiAutomator looking for text/markers.
- * We use a stable delay to ensure rendering is complete.
- */
- protected fun waitForMapRendering(seconds: Long = 3) {
- // Optional: Wait for map container if possible
- // uiDevice.wait(Until.hasObject(By.descContains("Google Map")), 5000)
-
- try {
- java.util.concurrent.TimeUnit.SECONDS.sleep(seconds)
- } catch (e: InterruptedException) {
- e.printStackTrace()
- }
- }
-}
diff --git a/visual-test-examples/ClusteringVisualTest.kt b/visual-test-examples/ClusteringVisualTest.kt
deleted file mode 100644
index 032eeef3..00000000
--- a/visual-test-examples/ClusteringVisualTest.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * 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.utils.demo
-
-import android.util.Log
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.Until
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.util.concurrent.TimeUnit
-
-@RunWith(AndroidJUnit4::class)
-class ClusteringVisualTest : BaseVisualTest() {
-
- @Before
- fun setup() {
- // Launch the app
- val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
- context.startActivity(intent)
- uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
- }
-
- @Test
- fun naturalLanguageClickTest() = runBlocking {
- // Use a natural language prompt to perform the click action
- helper.performActionFromPrompt("Click the CLUSTERING button", uiDevice, geminiApiKey)
-
- // Wait for the clustering screen to load and map to render
- TimeUnit.SECONDS.sleep(5)
-
- // Capture a screenshot to verify the result of the action
- val screenshotBitmap = captureScreenshot("natural_lang_click_screenshot.png")
-
- // --- Perform a visual assertion on the new screen ---
- val prompt = "Does this image show a map with several markers clustered together? Answer only YES or NO."
- val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
-
- println("Gemini's analysis after natural language click: $geminiResponse")
- assertTrue(
- "Visual verification failed. Gemini did not confirm the presence of a map with clusters.",
- geminiResponse?.contains("YES", ignoreCase = true) == true
- )
- }
-
- @Test
- fun verifyClusteringScreenContent() = runBlocking {
- // Wait for the app to load and find the "Clustering" button
- val clusteringButton = uiDevice.wait(Until.findObject(By.text("CLUSTERING")), 10000)
-
- if (clusteringButton == null) {
- // Dump window hierarchy to logcat for debugging
- val outputStream = ByteArrayOutputStream()
- uiDevice.dumpWindowHierarchy(outputStream)
- Log.e("ClusteringVisualTest", "Could not find clustering button. UI Hierarchy:\n${outputStream.toString("UTF-8")}")
-
- // Take a screenshot for visual inspection
- val screenshotFile = File(context.cacheDir, "test_failure_screenshot.png")
- uiDevice.takeScreenshot(screenshotFile)
- Log.e("ClusteringVisualTest", "Debug screenshot saved to device cache.")
- }
-
- assertNotNull("Clustering button not found. Check logcat for UI hierarchy dump and debug screenshot.", clusteringButton)
- clusteringButton.click()
-
- // Wait for the clustering screen to load and map to render
- TimeUnit.SECONDS.sleep(5)
-
- // Capture a screenshot
- val screenshotBitmap = captureScreenshot("clustering_screenshot.png")
-
- // --- STEP 2: Define your verification prompt ---
- val prompt = """
- Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. Check the image against the following three acceptance criteria:
- Geographic Bounds: Confirm the map is centered on North London and Hertfordshire, specifically showing landmarks like St Albans, Enfield, and the M25 ring road.
- Primary Cluster: Verify the presence of either a Red cluster marker labeled '200+' or a Green cluster marker labeled '100+' located centrally over the North London area.
- Secondary Cluster: Verify the presence of a Blue cluster marker labeled '10+' located to the southwest of the green marker.
- If all three elements are present and legible, just confirm that the visual test has PASSED. If any element is missing or incorrect, please detail the discrepancy.
- """.trimIndent()
-
- // --- STEP 3: Analyze the image using Gemini ---
- val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
-
- // --- STEP 4: Assert on Gemini's response ---
- println("Gemini's analysis: $geminiResponse")
- // Example assertion: Check if Gemini confirms the presence of clusters
- assertTrue(
- "PASSED",
- geminiResponse!!.contains("PASSED", ignoreCase = true)
- )
- }
-}
\ No newline at end of file
diff --git a/visual-test-examples/IconGeneratorVisualTest.kt b/visual-test-examples/IconGeneratorVisualTest.kt
deleted file mode 100644
index 1d5e2f6a..00000000
--- a/visual-test-examples/IconGeneratorVisualTest.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * 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.utils.demo
-
-import android.content.Intent
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.Until
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-
-private val prompt = """
- Task: Act as a Visual QA system to verify an Android application screenshot. Compare the
- provided image against the following technical specifications. Your goal is to determine if the
- rendering is correct.
-
- 1. Map Verification
- Confirm that a map is visible in the background. The map should be centered on the Sydney,
- Australia area. Major landmarks or labels like Sydney, Hornsby, and Cronulla should be visible.
- Be flexible with minor labels as they may change, but the general geographic layout must match the reference.
- 2. Marker Content and Styling
- There must be exactly six markers on the screen. Verify each of the following:
-
- * Marker A: Text is "Default". Background is white. Orientation is standard horizontal.
- * Marker B: Text is "Custom color". Background is cyan/light blue. Orientation is standard horizontal.
- * Marker C: Text is "Rotated 90 degrees". Background is red. Both the bubble and the text are rotated 90 degrees clockwise.
- * Marker D: Text is "Rotate=90, ContentRotate=-90". Background is purple. The bubble is rotated 90 degrees clockwise, but the text inside remains horizontal.
- * Marker E: Text is "ContentRotate=90". Background is green. The bubble is horizontal, but the text inside is rotated 90 degrees clockwise.
- * Marker F: Text is "Mixing different fonts". Background is orange. The word "Mixing" must be in italics. The words "different fonts" must be in bold.
-
- 3. Spatial Grid Verification
- Verify that the markers are positioned correctly relative to one another in a rough grid layout:
-
- * Top-Left: The Orange marker ("Mixing different fonts").
- * Top-Right: The Green marker ("ContentRotate=90").
- * Middle-Left: The Red marker ("Rotated 90 degrees").
- * Middle-Center: The White marker ("Default").
- * Bottom-Left: The Purple marker ("Rotate=90, ContentRotate=-90").
- * Bottom-Right: The Cyan marker ("Custom color").
-
- 4. Output Requirements
- Provide your response in the following format:
-
- * Status: [PASSED or FAILED]
- * Justification: Provide a concise explanation for the classification. If FAILED, list the
- specific markers or map elements that do not meet the criteria.
-""".trimIndent()
-
-@RunWith(AndroidJUnit4::class)
-class IconGeneratorVisualTest : BaseVisualTest() {
-
- @Before
- fun setup() {
- // Launch the app
- val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
- context.startActivity(intent)
- uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
- }
-
- @Test
- fun testIconMarkers() = runBlocking {
- // Launch IconGeneratorDemoActivity directly
- val intent = Intent(context, IconGeneratorDemoActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
- }
- context.startActivity(intent)
- uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
-
- // Wait for map rendering (Markers are bitmaps, so we can't search for text "Default")
- waitForMapRendering(5)
-
- // Capture a screenshot
- val screenshotBitmap = captureScreenshot("clustering_screenshot.png")
-
- // --- STEP 3: Analyze the image using Gemini ---
- val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
-
- // --- STEP 4: Assert on Gemini's response ---
- println("Gemini's analysis: $geminiResponse")
-
- requireNotNull(geminiResponse) { "Gemini response was null, check API key or network connection." }
- assertTrue(
- "Gemini validation failed. Response: $geminiResponse",
- geminiResponse.contains("PASSED", ignoreCase = true)
- )
- }
-}
diff --git a/visual-test-examples/KmlVisualTest.kt b/visual-test-examples/KmlVisualTest.kt
deleted file mode 100644
index 9ac5415c..00000000
--- a/visual-test-examples/KmlVisualTest.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * 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.utils.demo
-
-import android.content.Intent
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.Until
-import com.google.common.truth.Truth.assertWithMessage
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
-import org.junit.Test
-import org.junit.runner.RunWith
-import kotlin.time.Duration.Companion.seconds
-
-@RunWith(AndroidJUnit4::class)
-class KmlVisualTest : BaseVisualTest() {
- @Test
- fun verifyKmlLayerOverlay() = runBlocking {
- // Launch KmlDemoActivity directly
- val intent = Intent(context, KmlDemoActivity::class.java)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(intent)
- uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 15000)
-
- // Wait for the KML screen to load and map to render
- delay(10.seconds)
-
- // Capture a screenshot
- val screenshotBitmap = captureScreenshot("kml_screenshot.png")
-
- // --- STEP 2: Define your verification prompt ---
- val prompt = """
- Task: Analyze the provided image and verify it against the following three strict criteria.
- Criteria Checklist:
- Location: The image must display a map of the Googleplex (look for text labels such as "Googleplex", "Amphitheatre Pkwy", or "Charleston Rd").
- Subject Matter: The map must feature highlighted building footprints (polygonal shapes overlaying the buildings).
- Color Palette: The building footprints must explicitly include all four of the following colors: Blue, Red, Green, and Yellow.
-
- Decision Logic:
- If ALL criteria are met, the test passes.
- If ANY criterion is not met, the test fails.
-
- Required Output Format:
- Provide your response in the following format:
- Test Result: [PASS / FAIL]
- Verification Details:
- Location Check: [State if Googleplex is confirmed]
- Footprint Check: [State if footprints are visible]
- Color Check: [List the colors found]
- Failure Explanation: [If FAIL, you must explain exactly which specific criterion was not met. If PASS, write "None".]
- """.trimIndent()
-
- // --- STEP 3: Analyze the image using Gemini ---
- val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
-
- // --- STEP 4: Assert on Gemini's response ---
- assertWithMessage("Gemini's analysis failed: $geminiResponse").that(geminiResponse).contains("Test Result: PASS")
- assertWithMessage("Gemini's analysis failed: $geminiResponse").that(geminiResponse).doesNotContain("Test Result: FAIL")
- }
-}
diff --git a/visual-test-examples/RendererVisualTestBase.kt b/visual-test-examples/RendererVisualTestBase.kt
deleted file mode 100644
index 006ffd77..00000000
--- a/visual-test-examples/RendererVisualTestBase.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * 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.utils.demo
-
-import android.content.Intent
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.Until
-import org.junit.Assert.assertTrue
-import java.util.concurrent.TimeUnit
-
-abstract class RendererVisualTestBase : BaseVisualTest() {
-
- protected fun launchActivity() {
- val intent = Intent(context, RendererDemoActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
- }
- context.startActivity(intent)
- uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
- // Wait for map initialization
- TimeUnit.SECONDS.sleep(3)
- }
-
- protected suspend fun clickButton(label: String) {
- // Ensure bottom sheet is expanded enough to find the button
- expandBottomSheet()
-
- // Try to find by text first for speed
- val element = uiDevice.findObject(By.textContains(label))
- if (element != null) {
- element.click()
- } else {
- // Fallback to AI if standard selector fails
- helper.performActionFromPrompt("Click the $label button or chip", uiDevice, geminiApiKey)
- }
-
- // Wait for action to settle (bottom sheet collapse, map render)
- TimeUnit.SECONDS.sleep(2)
- }
-
- protected fun expandBottomSheet() {
- val bottomSheet = uiDevice.findObject(By.res(context.packageName, "bottom_sheet"))
- if (bottomSheet != null) {
- val startY = uiDevice.displayHeight - 100
- val endY = uiDevice.displayHeight / 2
- uiDevice.swipe(uiDevice.displayWidth / 2, startY, uiDevice.displayWidth / 2, endY, 10)
- TimeUnit.SECONDS.sleep(1)
- }
- }
-
- protected fun collapseBottomSheet() {
- val bottomSheet = uiDevice.findObject(By.res(context.packageName, "bottom_sheet"))
- if (bottomSheet != null) {
- val startY = uiDevice.displayHeight - 200
- val endY = uiDevice.displayHeight - 100
- uiDevice.swipe(uiDevice.displayWidth / 2, startY, uiDevice.displayWidth / 2, endY, 10)
- TimeUnit.SECONDS.sleep(1)
- }
- }
-
- protected suspend fun verifyMapContent(description: String) {
- val screenshotBitmap = captureScreenshot("visual_test_${System.currentTimeMillis()}.png")
-
- val prompt = """
- Analyze this screenshot of a map app.
- Check if the following condition is met:
- "$description"
-
- Also check if the bottom sheet is collapsed (showing only the top "peek" area with "Load File"/"Clear" buttons and "Presets" header, but NOT the full list of chips).
-
- CRITICAL: Start your response with YES if BOTH the condition is met AND the bottom sheet is collapsed.
- Start your response with NO if ANY condition is not met.
- Then explain your reasoning.
- """.trimIndent()
-
- val response = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
- println("Gemini Verification for '$description': $response")
-
- assertTrue(
- "Visual verification failed. Response: $response",
- response?.trim()?.startsWith("YES", ignoreCase = true) == true
- )
- }
-}
diff --git a/visual-testing/build.gradle.kts b/visual-testing/build.gradle.kts
index 8b6ef90e..75a649f4 100644
--- a/visual-testing/build.gradle.kts
+++ b/visual-testing/build.gradle.kts
@@ -60,7 +60,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
- testImplementation(libs.truth)
+ testImplementation(libs.google.truth)
// Dependencies for GeminiVisualTestHelper
implementation(libs.ktor.client.core)
From 3e8e60d6d225abea8776044631e0743e9228588d Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:34:20 -0600
Subject: [PATCH 28/29] refactor: Apply Spotless and reformat WhiskeyCompass
- Improve formatting of the `WhiskeyCompass` composable.
- Migrated Spotless plugin to use version catalog alias in build files.
- Increased the rendering margin in `WhiskeyCompass` to prevent visual pop-in.
---
...otlin-compiler-17720919123303405947.salive | 0
build.gradle.kts | 3 +
.../widgets/WhiskeyCompass.kt | 61 +++++++++++++------
maps3d-compose/build.gradle.kts | 2 +-
snippets/build.gradle.kts | 2 +-
5 files changed, 46 insertions(+), 22 deletions(-)
delete mode 100644 .kotlin/sessions/kotlin-compiler-17720919123303405947.salive
create mode 100644 build.gradle.kts
diff --git a/.kotlin/sessions/kotlin-compiler-17720919123303405947.salive b/.kotlin/sessions/kotlin-compiler-17720919123303405947.salive
deleted file mode 100644
index e69de29b..00000000
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 00000000..793378a9
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ alias(libs.plugins.spotless) apply false
+}
diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
index 0916f272..ef3ed41e 100644
--- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
+++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt
@@ -79,7 +79,10 @@ fun WhiskeyCompass(
showCardinalLabels: Boolean = true,
// Added for clarity
cardinalLabelInterval: Int = 45,
- cardinalLabelTextStyle: TextStyle = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
+ cardinalLabelTextStyle: TextStyle = MaterialTheme.typography.labelMedium.copy(
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ ),
cardinalLabelVerticalOffset: Dp = 4.dp,
majorTickHeight: Dp = 25.dp,
@@ -139,10 +142,10 @@ fun WhiskeyCompass(
val absoluteDegree = repetitionBaseDegree + degreeInRepetition
val xPos = absoluteDegree * pixelsPerDegree
- val visibilityMargin = canvasWidth
- if (xPos < -xOffset + canvasWidth + visibilityMargin && xPos > -xOffset - visibilityMargin) {
+ if (xPos < -xOffset + canvasWidth + canvasWidth && xPos > -xOffset - canvasWidth) {
val isMajorTickEquivalent = degreeInRepetition % 10 == 0
- val isMinorTickEquivalent = degreeInRepetition % 5 == 0 && !isMajorTickEquivalent
+ val isMinorTickEquivalent =
+ degreeInRepetition % 5 == 0 && !isMajorTickEquivalent
if (isMajorTickEquivalent) {
val tickTopY = tickCenterY - majorTickHeightPx / 2f
@@ -154,8 +157,12 @@ fun WhiskeyCompass(
strokeWidth = majorTickStrokeWidthPx,
)
- if (showCardinalLabels && measuredCardinalLabels.containsKey(degreeInRepetition)) {
- val measuredText = measuredCardinalLabels.getValue(degreeInRepetition)
+ if (showCardinalLabels && measuredCardinalLabels.containsKey(
+ degreeInRepetition
+ )
+ ) {
+ val measuredText =
+ measuredCardinalLabels.getValue(degreeInRepetition)
drawText(
textLayoutResult = measuredText,
topLeft = Offset(
@@ -176,17 +183,16 @@ fun WhiskeyCompass(
}
if (showDegreeLabels && degreeInRepetition % degreeLabelInterval == 0) {
- val tickBottomY = tickCenterY + (
- if (isMajorTickEquivalent) {
- majorTickHeightPx
- } else if (isMinorTickEquivalent) {
- minorTickHeightPx
- } else {
- 0f
- }
- ) / 2f
+ val tickBottomY = tickCenterY + (if (isMajorTickEquivalent) {
+ majorTickHeightPx
+ } else if (isMinorTickEquivalent) {
+ minorTickHeightPx
+ } else {
+ 0f
+ }) / 2f
val labelText = degreeInRepetition.toString()
- val measuredText = textMeasurer.measure(labelText, style = degreeLabelTextStyle)
+ val measuredText =
+ textMeasurer.measure(labelText, style = degreeLabelTextStyle)
drawText(
textLayoutResult = measuredText,
topLeft = Offset(
@@ -237,7 +243,11 @@ private fun FlatWhiskeyCompassPreview() {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- Text("Default Flat Compass Strip", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Default Flat Compass Strip",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium
+ )
WhiskeyCompass(
heading = 45f,
modifier = Modifier.fillMaxWidth(),
@@ -246,7 +256,11 @@ private fun FlatWhiskeyCompassPreview() {
)
Spacer(modifier = Modifier.height(16.dp))
- Text("Customized Labels & Ticks", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Customized Labels & Ticks",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium
+ )
WhiskeyCompass(
heading = 123f,
modifier = Modifier.fillMaxWidth(),
@@ -257,7 +271,10 @@ private fun FlatWhiskeyCompassPreview() {
pixelsPerDegree = 12f,
degreeLabelInterval = 10,
degreeLabelTextStyle = MaterialTheme.typography.bodySmall.copy(color = Color(0xFF81D4FA)),
- cardinalLabelTextStyle = MaterialTheme.typography.labelLarge.copy(color = Color.White, fontWeight = FontWeight.Bold),
+ cardinalLabelTextStyle = MaterialTheme.typography.labelLarge.copy(
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ ),
majorTickHeight = 30.dp,
minorTickHeight = 18.dp,
degreeLabelVerticalOffset = 6.dp,
@@ -266,7 +283,11 @@ private fun FlatWhiskeyCompassPreview() {
)
Spacer(modifier = Modifier.height(16.dp))
- Text("No Cardinal Labels", color = Color.White, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "No Cardinal Labels",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium
+ )
WhiskeyCompass(
heading = 210f,
modifier = Modifier.fillMaxWidth(),
diff --git a/maps3d-compose/build.gradle.kts b/maps3d-compose/build.gradle.kts
index 86c8f175..27cc602d 100644
--- a/maps3d-compose/build.gradle.kts
+++ b/maps3d-compose/build.gradle.kts
@@ -18,7 +18,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
- id("com.diffplug.spotless") version "6.25.0"
+ alias(libs.plugins.spotless)
}
configure {
diff --git a/snippets/build.gradle.kts b/snippets/build.gradle.kts
index 950cdc10..f5ce5c09 100644
--- a/snippets/build.gradle.kts
+++ b/snippets/build.gradle.kts
@@ -20,7 +20,7 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.secrets.gradle.plugin) apply false
- id("com.diffplug.spotless") version "6.25.0"
+ alias(libs.plugins.spotless)
}
subprojects {
From 4bc531103d578c83fc200ae33c047c7bc79ced2e Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:57:24 -0600
Subject: [PATCH 29/29] fix: apply missing timeout changes to
GeminiVisualTestHelper
---
.../maps/android/visualtesting/GeminiVisualTestHelper.kt | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt
index 83ead3ef..38fe55a3 100644
--- a/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt
+++ b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt
@@ -46,9 +46,9 @@ class GeminiVisualTestHelper {
private val client = HttpClient(CIO) {
install(HttpTimeout) {
- requestTimeoutMillis = 60000
- connectTimeoutMillis = 60000
- socketTimeoutMillis = 60000
+ requestTimeoutMillis = 60_000
+ connectTimeoutMillis = 60_000
+ socketTimeoutMillis = 60_000
}
}