diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7802de9b..364c27b1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -52,4 +52,4 @@ jobs:
distribution: 'adopt'
java-version: '21'
- name: Build advanced
- run: cd Maps3DSamples/ApiDemos && ./gradlew buildDebugPreBundle
\ No newline at end of file
+ run: cd Maps3DSamples/advanced && ./gradlew buildDebugPreBundle
\ No newline at end of file
diff --git a/Maps3DSamples/ApiDemos/java-app/README.md b/Maps3DSamples/ApiDemos/java-app/README.md
new file mode 100644
index 00000000..ed45ffa2
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/README.md
@@ -0,0 +1,20 @@
+# β Java Samples Catalog
+
+This directory contains the Java samples using traditional Android Views for the Android Maps 3D SDK.
+
+## π Sample Status
+
+| Feature | Status | Source Code |
+| :--- | :--- | :--- |
+| **Hello Map** | β
Done | [HelloMapActivity.java](src/main/java/com/example/maps3djava/hellomap/HelloMapActivity.java) |
+| **Polylines** | β
Done | [PolylinesActivity.java](src/main/java/com/example/maps3djava/polylines/PolylinesActivity.java) |
+| **Map Interactions** | β
Done | [MapInteractionsActivity.java](src/main/java/com/example/maps3djava/mapinteractions/MapInteractionsActivity.java) |
+| **Popovers** | β
Done | [PopoversActivity.java](src/main/java/com/example/maps3djava/popovers/PopoversActivity.java) |
+| **Camera Controls** | β
Done | [CameraControlsActivity.java](src/main/java/com/example/maps3djava/cameracontrols/CameraControlsActivity.java) |
+| **Polygons** | β
Done | [PolygonsActivity.java](src/main/java/com/example/maps3djava/polygons/PolygonsActivity.java) |
+| **Models** | β
Done | [ModelsActivity.java](src/main/java/com/example/maps3djava/models/ModelsActivity.java) |
+| **Markers** | β
Done | [MarkersActivity.java](src/main/java/com/example/maps3djava/markers/MarkersActivity.java) |
+
+---
+> [!NOTE]
+> These samples are view-based and serve as a reference for Java developers.
diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
index 19f427fb..a3132ef5 100644
--- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts
@@ -88,6 +88,7 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY"
}
buildTypes {
@@ -147,12 +148,11 @@ dependencies {
}
secrets {
- // Optionally specify a different file name containing your secrets.
- // The plugin defaults to "local.properties"
- propertiesFileName = "secrets.properties"
-
- // A properties file containing default secret values. This file can be
- // checked in version control.
+ // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI
+ val secretsFile = rootProject.file("secrets.properties")
+ if (secretsFile.exists()) {
+ propertiesFileName = "secrets.properties"
+ }
defaultPropertiesFileName = "local.defaults.properties"
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/README.md b/Maps3DSamples/ApiDemos/kotlin-app/README.md
new file mode 100644
index 00000000..e2757baa
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/kotlin-app/README.md
@@ -0,0 +1,20 @@
+# π§± Kotlin + Views Samples Catalog
+
+This directory contains the Kotlin samples using traditional Android Views for the Android Maps 3D SDK.
+
+## π Sample Status
+
+| Feature | Status | Source Code |
+| :--- | :--- | :--- |
+| **Hello Map** | β
Done | [HelloMapActivity.kt](src/main/java/com/example/maps3dkotlin/hellomap/HelloMapActivity.kt) |
+| **Polylines** | β
Done | [PolylinesActivity.kt](src/main/java/com/example/maps3dkotlin/polylines/PolylinesActivity.kt) |
+| **Map Interactions** | β
Done | [MapInteractionsActivity.kt](src/main/java/com/example/maps3dkotlin/mapinteractions/MapInteractionsActivity.kt) |
+| **Popovers** | β
Done | [PopoversActivity.kt](src/main/java/com/example/maps3dkotlin/popovers/PopoversActivity.kt) |
+| **Camera Controls** | β
Done | [CameraControlsActivity.kt](src/main/java/com/example/maps3dkotlin/cameracontrols/CameraControlsActivity.kt) |
+| **Polygons** | β
Done | [PolygonsActivity.kt](src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt) |
+| **Models** | β
Done | [ModelsActivity.kt](src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt) |
+| **Markers** | β
Done | [MarkersActivity.kt](src/main/java/com/example/maps3dkotlin/markers/MarkersActivity.kt) |
+
+---
+> [!NOTE]
+> These samples are view-based and serve as a reference for non-Compose applications.
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
index 08387045..ef50b6e0 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts
@@ -91,6 +91,7 @@ android {
versionName = "1.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY"
}
buildTypes {
@@ -155,12 +156,11 @@ dependencies {
}
secrets {
- // Optionally specify a different file name containing your secrets.
- // The plugin defaults to "local.properties"
- propertiesFileName = "secrets.properties"
-
- // A properties file containing default secret values. This file can be
- // checked in version control.
+ // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI
+ val secretsFile = rootProject.file("secrets.properties")
+ if (secretsFile.exists()) {
+ propertiesFileName = "secrets.properties"
+ }
defaultPropertiesFileName = "local.defaults.properties"
}
diff --git a/Maps3DSamples/ComposeDemos/app/README.md b/Maps3DSamples/ComposeDemos/app/README.md
new file mode 100644
index 00000000..f09a64b8
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/README.md
@@ -0,0 +1,34 @@
+# π Jetpack Compose Samples Catalog
+
+This directory contains the Compose samples for the Android Maps 3D SDK. We use a state-driven approach and lean on the `maps3d-compose` library.
+
+## π Sample Status
+
+| Feature | Status | Source Code | Screenshot | Description |
+| :--- | :--- | :--- | :--- | :--- |
+| **Basic Map** | β
Done | [HelloMapActivity.kt](src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt) |
| Displays a basic 3D map with standard satellite imagery and initial camera placement. |
+| **Polylines** | β
Done | [PolylinesActivity.kt](src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt) |
| Demonstrates drawing 3D polylines on the map, including custom colors and widths. |
+| **Map Interactions** | π§ Skeleton | [MapInteractionsActivity.kt](src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt) | | Will demonstrate handling click and drag events on map objects. |
+| **Popovers** | β
Done | [PopoversActivity.kt](src/main/java/com/example/composedemos/popovers/PopoversActivity.kt) |
| Shows how to display interactive popover overlays at specific coordinates on the map. |
+| **Camera Controls** | β
Done | [CameraControlsActivity.kt](src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt) |
| Demonstrates manual control of the camera center, heading, tilt, and range using UI controls. |
+| **Polygons** | β
Done | [PolygonsActivity.kt](src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt) |
| Demonstrates drawing 3D polygons with fill colors and outlines on the map. |
+| **Models** | β
Done | [ModelsActivity.kt](src/main/java/com/example/composedemos/models/ModelsActivity.kt) |
| Shows how to load and place custom 3D models (gLTF) on the map with position, scale, and orientation. |
+| **Markers** | β
Done | [MarkersActivity.kt](src/main/java/com/example/composedemos/markers/MarkersActivity.kt) |
| Demonstrates adding 2D markers with custom icons and anchor points to the 3D map. |
+| **Camera Restrictions** | β
Done | [CameraRestrictionsActivity.kt](src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt) |
| Shows how to restrict the camera range and center bounds to a specific area. |
+| **Flight Simulator** | π§ Skeleton | [FlightSimulatorActivity.kt](src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt) | | Will demonstrate a first-person camera view simulating flight. |
+| **Routes API** | β
Done | [RoutesActivity.kt](src/main/java/com/example/composedemos/routes/RoutesActivity.kt) |
| Demonstrates loading a route from file, rendering the polyline, and animating a 3D car model along the route using a flow-based engine. |
+| **Path Following** | π§ Skeleton | [PathFollowingActivity.kt](src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt) | | Will demonstrate camera following a path. |
+| **Path Styling** | π§ Skeleton | [PathStylingActivity.kt](src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt) | | Will demonstrate custom styling for paths. |
+| **Animating Models** | π§ Skeleton | [AnimatingModelsActivity.kt](src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt) | | Will demonstrate animating 3D models. |
+| **Place Search** | π§ Skeleton | [PlaceSearchActivity.kt](src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt) | | Will demonstrate programmatic place search. |
+| **Place Autocomplete** | π§ Skeleton | [PlaceAutocompleteActivity.kt](src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt) | | Will demonstrate place autocomplete widget. |
+| **Place Details** | β
Done | [PlaceDetailsActivity.kt](src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt) |
| Demonstrates loading place details using the modern Places UI Kit fragment (Fragment Interop) with a custom 'Boulder Nature Hippie' theme. |
+| **Advanced Camera Animation** | π§ Skeleton | [AdvancedCameraAnimationActivity.kt](src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt) | | Will demonstrate complex camera animations. |
+| **Data Visualization** | π§ Skeleton | [DataVisualizationActivity.kt](src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt) | | Will demonstrate visualizing data on 3D map. |
+| **Cloud Map Styling** | π§ Skeleton | [CloudStylingActivity.kt](src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt) | | Will demonstrate styling map via Cloud console. |
+| **Roadmap Mode** | π§ Skeleton | [RoadmapModeActivity.kt](src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt) | | Will demonstrate standard roadmap mode. |
+| **Field Of View** | π§ Skeleton | [FieldOfViewActivity.kt](src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt) | | Will demonstrate changing field of view. |
+
+---
+> [!NOTE]
+> Status `π§ Skeleton` means the activity exists and can be launched from the main list, but contains a TODO placeholder UI. We are actively implementing these following a TDD approach.
diff --git a/Maps3DSamples/ComposeDemos/app/build.gradle.kts b/Maps3DSamples/ComposeDemos/app/build.gradle.kts
new file mode 100644
index 00000000..6c9b741f
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/build.gradle.kts
@@ -0,0 +1,135 @@
+/*
+ * 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)
+ alias(libs.plugins.spotless)
+}
+
+configure {
+ kotlin {
+ target("**/*.kt")
+ ktlint().editorConfigOverride(mapOf("indent_size" to "4", "ktlint_function_naming_ignore_when_annotated_with" to "Composable"))
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+}
+
+android {
+ namespace = "com.example.composedemos"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "com.example.composedemos"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY"
+ 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)
+ implementation(libs.places)
+ implementation(libs.androidx.fragment.ktx)
+
+ // Maps Utils
+ implementation(libs.maps.utils.ktx)
+
+ // Material Icons Extended
+ implementation(libs.androidx.material.icons.extended)
+
+ // Lifecycle ViewModel Compose
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose")
+
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ 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 {
+ // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI
+ val secretsFile = rootProject.file("secrets.properties")
+ if (secretsFile.exists()) {
+ propertiesFileName = "secrets.properties"
+ }
+ defaultPropertiesFileName = "local.defaults.properties"
+}
+
+tasks.register("installAndLaunch") {
+ description = "Installs and launches the demo app."
+ group = "install"
+ dependsOn("installDebug")
+ commandLine("adb", "shell", "am", "start", "-n", "com.example.composedemos/.MainActivity")
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/BaseVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/BaseVisualTest.kt
new file mode 100644
index 00000000..4e40833e
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/BaseVisualTest.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.composedemos
+
+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.filesDir, 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)
+
+ android.util.Log.i("BaseVisualTest", "Screenshot saved to device: ${screenshotFile.absolutePath}")
+
+ 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/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraControlsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraControlsVisualTest.kt
new file mode 100644
index 00000000..cc2125df
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraControlsVisualTest.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.cameracontrols.CameraControlsActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CameraControlsVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyCameraControlsRenders() {
+ runBlocking {
+ // Launch CameraControlsActivity
+ val intent = Intent(context, CameraControlsActivity::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)
+
+ // Verify that a 3D map view is visible
+ // (Implicitly verified by waitForMapRendering)
+
+ // Verify that controls (e.g., a slider or text for "Heading") are visible
+ val foundHeading = uiDevice.wait(Until.hasObject(By.textContains("Heading")), 5000)
+ assertTrue("Heading control not found", foundHeading)
+
+ // Capture a screenshot for visual confirmation
+ captureScreenshot("camera_controls_screenshot.png")
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraRestrictionsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraRestrictionsVisualTest.kt
new file mode 100644
index 00000000..1a318034
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/CameraRestrictionsVisualTest.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.camerarestrictions.CameraRestrictionsActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CameraRestrictionsVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyCameraRestrictionsRenders() {
+ runBlocking {
+ // Launch CameraRestrictionsActivity
+ val intent = Intent(context, CameraRestrictionsActivity::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)
+
+ // Attempt to swipe/pan away from the center
+ val screenWidth = uiDevice.displayWidth
+ val screenHeight = uiDevice.displayHeight
+ uiDevice.swipe(screenWidth / 2, screenHeight / 2, 100, screenHeight / 2, 10) // Swipe left
+
+ // Wait a bit for any correction or settling
+ kotlinx.coroutines.delay(2000)
+
+ // Capture a screenshot
+ val screenshotBitmap = captureScreenshot("camera_restrictions_screenshot.png")
+
+ // Define the verification prompt for Gemini
+ // We ask if the Space Needle is still visible, implying the restriction worked.
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible.
+ 2. Confirm that the Space Needle (or the immediate surrounding area) is still visible and central in the view, despite an attempt to pan away.
+
+ If the map is visible and the Space Needle area is still centered, 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/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/HelloMapVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/HelloMapVisualTest.kt
new file mode 100644
index 00000000..8a6660a8
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/HelloMapVisualTest.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.hellomap.HelloMapActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HelloMapVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyHelloMapRenders() {
+ runBlocking {
+ // Launch HelloMapActivity
+ 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
+ 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 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 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/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MainActivityTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MainActivityTest.kt
new file mode 100644
index 00000000..4bdc56c4
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MainActivityTest.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.composedemos
+
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MainActivityTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun verifyTitleIsDisplayed() {
+ // Check that the catalog title is displayed
+ composeTestRule.onNodeWithText("Compose Demos Catalog").assertExists()
+ }
+
+ @Test
+ fun verifySampleItemsExist() {
+ // Check that at least some sample items are displayed
+ composeTestRule.onNodeWithText("Hello Map").assertExists()
+ composeTestRule.onNodeWithText("Polylines").assertExists()
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MapInteractionsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MapInteractionsVisualTest.kt
new file mode 100644
index 00000000..4ea6ecc4
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MapInteractionsVisualTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.mapinteractions.MapInteractionsActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MapInteractionsVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyMapInteractionsRenders() {
+ runBlocking {
+ // Launch MapInteractionsActivity directly
+ 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(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for the map to render and tiles to load
+ waitForMapRendering(60)
+
+ // Wait a bit to ensure map is interactive
+ println("Waiting 5 seconds for map to be interactive...")
+ kotlinx.coroutines.delay(5000)
+
+ // Strategy: 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
+
+ println("Clicking center and surrounding points...")
+ uiDevice.click(centerX, centerY)
+ kotlinx.coroutines.delay(500)
+ uiDevice.click(centerX + 50, centerY + 50)
+ kotlinx.coroutines.delay(500)
+ uiDevice.click(centerX - 50, centerY - 50)
+ kotlinx.coroutines.delay(500)
+ uiDevice.click(centerX + 50, centerY - 50)
+ kotlinx.coroutines.delay(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")),
+ 10000,
+ )
+ assertTrue("Card text did not update after click", textUpdated)
+
+ // Capture a screenshot for visual confirmation
+ captureScreenshot("map_interactions_screenshot.png")
+
+ // TODO: Implement Gemini verification if needed to check the look of the card.
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt
new file mode 100644
index 00000000..f70a11ee
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.markers.MarkersActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class MarkersVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyMarkersRenders() {
+ runBlocking {
+ // Launch MarkersActivity
+ 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(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_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 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, 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/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/ModelsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/ModelsVisualTest.kt
new file mode 100644
index 00000000..b37377c5
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/ModelsVisualTest.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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.models.ModelsActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ModelsVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyModelsRenders() {
+ runBlocking {
+ // Launch ModelsActivity
+ val intent = Intent(context, ModelsActivity::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("models_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 a 3D model of an airplane is visible on the map.
+
+ If the map is visible and the airplane model 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/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PlaceDetailsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PlaceDetailsVisualTest.kt
new file mode 100644
index 00000000..65b700d5
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PlaceDetailsVisualTest.kt
@@ -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.
+ */
+
+package com.example.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.placedetails.PlaceDetailsActivity
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PlaceDetailsVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyPlaceDetailsLoads() {
+ runBlocking {
+ // Launch PlaceDetailsActivity with Intent extra for state injection
+ val intent = Intent(context, PlaceDetailsActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ putExtra("place_name", "The Flatirons")
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // 1. Wait for the map to load (using fixed delay as steady callback can be flaky)
+ println("Waiting 2 seconds for map to load...")
+ delay(2000)
+
+ // 3. Wait for the Place Details fragment to load content (network call)
+ println("Waiting for Place Details to load...")
+ delay(15000)
+
+ // 4. Capture screenshot
+ val screenshotBitmap = captureScreenshot("place_details_screenshot.png")
+ delay(2000) // Wait for file to be fully written
+
+ // 5. Verify with Gemini - Stricter Prompt!
+ val prompt = """
+ The screen should show a 3D map in the background.
+ At the bottom, there should be a sheet or card containing Place Details.
+ You MUST verify that the text "Flatirons" is clearly visible in the Place Details card.
+ If you can see the text "Flatirons" in the card, reply with YES.
+ If the card is missing, blank, still loading, or shows a different place, reply with NO followed by a detailed description of what is visible on the screen and in the card area.
+ """.trimIndent()
+
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini result: ${geminiResponse?.trim()}")
+ assertTrue("Gemini verification failed: $geminiResponse", geminiResponse?.trim()?.contains("YES", ignoreCase = true) == true)
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolygonsVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolygonsVisualTest.kt
new file mode 100644
index 00000000..8ba057a1
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolygonsVisualTest.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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.polygons.PolygonsActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PolygonsVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyPolygonsRenders() {
+ runBlocking {
+ // Launch PolygonsActivity
+ 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_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 a yellow translucent polygon with a green border is visible on the map (representing the Denver Zoo).
+
+ 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/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolylinesVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolylinesVisualTest.kt
new file mode 100644
index 00000000..25c60c29
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PolylinesVisualTest.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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.polylines.PolylinesActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PolylinesVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyPolylinesRenders() {
+ runBlocking {
+ // Launch PolylinesActivity
+ 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_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 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,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt
new file mode 100644
index 00000000..d4587ae7
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.popovers.PopoversActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PopoversVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyPopoversRenders() {
+ runBlocking {
+ // Launch PopoversActivity
+ val intent = Intent(context, PopoversActivity::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 marker
+ val searchScreenshot = captureScreenshot("popovers_search.png")
+
+ // Define the prompt for Gemini to find coordinates
+ val promptFind = """
+ Analyze this screenshot of a 3D map.
+ You should see a marker or label with the text "Click me for Popover".
+ Find that marker or label.
+ 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(searchScreenshot, 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()
+
+ println("Clicking at ($clickX, $clickY) based on Gemini response")
+ uiDevice.click(clickX, clickY)
+ } catch (e: Exception) {
+ org.junit.Assert.fail("Failed to parse coordinates from Gemini response: $geminiResponse. Error: ${e.message}")
+ }
+
+ // Wait for the popover text to appear
+ val textFound = uiDevice.wait(
+ Until.hasObject(By.text("This is a Popover anchored to a marker!")),
+ 10000,
+ )
+ assertTrue("Popover text not found", textFound)
+
+ // Capture a screenshot for visual confirmation
+ captureScreenshot("popovers_screenshot.png")
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/RoutesVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/RoutesVisualTest.kt
new file mode 100644
index 00000000..5b73a4f8
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/RoutesVisualTest.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.example.composedemos
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.composedemos.routes.RoutesActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoutesVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyRoutesRenders() {
+ runBlocking {
+ // Fake shared prefs to avoid security warning dialog
+ val sharedPrefs = context.getSharedPreferences("route_prefs", android.content.Context.MODE_PRIVATE)
+ sharedPrefs.edit().putInt("warning_count", 2).commit()
+
+ // Launch RoutesActivity
+ val intent = Intent(context, RoutesActivity::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 map to load tiles and network call to complete
+ println("Waiting 20 seconds for route to load...")
+ kotlinx.coroutines.delay(20000)
+
+ // 1. Click "Fly Along" button to enter fly mode and show slider (defaults to Red Car)
+ val flyButton = uiDevice.wait(Until.hasObject(By.text("Fly Along")), 5000)
+ assertTrue("Fly Along button not found", flyButton)
+ uiDevice.findObject(By.text("Fly Along")).click()
+ kotlinx.coroutines.delay(2000) // Wait for slider to appear
+
+ // 3. Set progress to halfway point by clicking center of the slider
+ val slider = uiDevice.wait(Until.hasObject(By.desc("Progress Slider")), 5000)
+ assertTrue("Progress slider not found", slider)
+ val sliderObj = uiDevice.findObject(By.desc("Progress Slider"))
+ val bounds = sliderObj.visibleBounds
+ uiDevice.click(bounds.centerX(), bounds.centerY())
+ kotlinx.coroutines.delay(2000) // Wait for camera to jump to position
+
+ // Capture a screenshot for the catalog showing the red car at halfway point!
+ val screenshotBitmap = captureScreenshot("routes_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 a blue polyline (line) is visible on the map, representing a route.
+ 3. Confirm that a RED CAR 3D MODEL is clearly visible on or near the blue polyline.
+ 4. The route should be in Hawaii (Oahu area).
+
+ If and ONLY IF you can clearly see a red car model on or near the blue polyline, reply with "PASSED".
+ If you cannot see a red car model, reply with "FAILED: Red car model not visible".
+ Report what you see in detail.
+ """.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/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..1b9d47d8
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_controls_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_controls_screenshot.png
new file mode 100644
index 00000000..34327456
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_controls_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_restrictions_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_restrictions_screenshot.png
new file mode 100644
index 00000000..62c9af67
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/camera_restrictions_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/hello_map_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/hello_map_screenshot.png
new file mode 100644
index 00000000..20495194
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/hello_map_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/markers_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/markers_screenshot.png
new file mode 100644
index 00000000..97c0b591
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/markers_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/models_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/models_screenshot.png
new file mode 100644
index 00000000..5bfaf83b
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/models_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/place_details_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/place_details_screenshot.png
new file mode 100644
index 00000000..1cdc9f2c
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/place_details_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polygons_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polygons_screenshot.png
new file mode 100644
index 00000000..808e007a
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polygons_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polylines_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polylines_screenshot.png
new file mode 100644
index 00000000..00731970
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/polylines_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/popovers_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/popovers_screenshot.png
new file mode 100644
index 00000000..9a0ef6c7
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/popovers_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/routes_screenshot.png b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/routes_screenshot.png
new file mode 100644
index 00000000..ebfc2e94
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/routes_screenshot.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/ComposeDemosApplication.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/ComposeDemosApplication.kt
new file mode 100644
index 00000000..50fd018c
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/ComposeDemosApplication.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.composedemos
+
+import android.app.Application
+import com.google.android.libraries.places.api.Places
+
+class ComposeDemosApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+
+ // Initialize Places SDK
+ if (!Places.isInitialized()) {
+ Places.initializeWithNewPlacesApiEnabled(applicationContext, BuildConfig.MAPS3D_API_KEY)
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt
new file mode 100644
index 00000000..d0461f0f
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt
@@ -0,0 +1,237 @@
+/*
+ * 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.composedemos
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import com.example.composedemos.advancedcameraanimation.AdvancedCameraAnimationActivity
+import com.example.composedemos.animatingmodels.AnimatingModelsActivity
+import com.example.composedemos.cameracontrols.CameraControlsActivity
+import com.example.composedemos.camerarestrictions.CameraRestrictionsActivity
+import com.example.composedemos.cloudstyling.CloudStylingActivity
+import com.example.composedemos.datavisualization.DataVisualizationActivity
+import com.example.composedemos.fieldofview.FieldOfViewActivity
+import com.example.composedemos.flightsimulator.FlightSimulatorActivity
+import com.example.composedemos.hellomap.HelloMapActivity
+import com.example.composedemos.mapinteractions.MapInteractionsActivity
+import com.example.composedemos.markers.MarkersActivity
+import com.example.composedemos.models.ModelsActivity
+import com.example.composedemos.pathfollowing.PathFollowingActivity
+import com.example.composedemos.pathstyling.PathStylingActivity
+import com.example.composedemos.placeautocomplete.PlaceAutocompleteActivity
+import com.example.composedemos.placedetails.PlaceDetailsActivity
+import com.example.composedemos.placesearch.PlaceSearchActivity
+import com.example.composedemos.polygons.PolygonsActivity
+import com.example.composedemos.polylines.PolylinesActivity
+import com.example.composedemos.popovers.PopoversActivity
+import com.example.composedemos.roadmapmode.RoadmapModeActivity
+import com.example.composedemos.routes.RoutesActivity
+
+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() {
+ val context = LocalContext.current
+ LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
+ item {
+ Text(
+ text = "Compose Demos Catalog",
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+
+ // ApiDemos Parity
+ item { CategoryHeader("ApiDemos Parity") }
+ item {
+ SampleItem("Hello Map") {
+ context.startActivity(Intent(context, HelloMapActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Polylines") {
+ context.startActivity(Intent(context, PolylinesActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Map Interactions") {
+ context.startActivity(Intent(context, MapInteractionsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Popovers") {
+ context.startActivity(Intent(context, PopoversActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Camera Controls") {
+ context.startActivity(Intent(context, CameraControlsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Polygons") {
+ context.startActivity(Intent(context, PolygonsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Models") {
+ context.startActivity(Intent(context, ModelsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Markers") {
+ context.startActivity(Intent(context, MarkersActivity::class.java))
+ }
+ }
+
+ // Additional Catalog Requirements
+ item { CategoryHeader("Additional Catalog Requirements") }
+ item {
+ SampleItem("Camera Restrictions") {
+ context.startActivity(Intent(context, CameraRestrictionsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Flight Simulator") {
+ context.startActivity(Intent(context, FlightSimulatorActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Routes API") {
+ context.startActivity(Intent(context, RoutesActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Path Following") {
+ context.startActivity(Intent(context, PathFollowingActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Path Styling") {
+ context.startActivity(Intent(context, PathStylingActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Animating Models") {
+ context.startActivity(Intent(context, AnimatingModelsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Place Search") {
+ context.startActivity(Intent(context, PlaceSearchActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Place Autocomplete") {
+ context.startActivity(Intent(context, PlaceAutocompleteActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Place Details") {
+ context.startActivity(Intent(context, PlaceDetailsActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Advanced Camera Animation") {
+ context.startActivity(Intent(context, AdvancedCameraAnimationActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Data Visualization (Flood Fill)") {
+ context.startActivity(Intent(context, DataVisualizationActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Cloud Map Styling") {
+ context.startActivity(Intent(context, CloudStylingActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Roadmap Mode") {
+ context.startActivity(Intent(context, RoadmapModeActivity::class.java))
+ }
+ }
+ item {
+ SampleItem("Field Of View") {
+ context.startActivity(Intent(context, FieldOfViewActivity::class.java))
+ }
+ }
+ }
+}
+
+@Composable
+fun CategoryHeader(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.primary,
+ )
+}
+
+@Composable
+fun SampleItem(title: String, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .clickable { onClick() },
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ ) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
new file mode 100644
index 00000000..06b0d19a
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.advancedcameraanimation
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class AdvancedCameraAnimationActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Advanced Camera Animation sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt
new file mode 100644
index 00000000..2f56d49d
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/animatingmodels/AnimatingModelsActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.animatingmodels
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class AnimatingModelsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Animating Models sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt
new file mode 100644
index 00000000..4b24b679
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.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.composedemos.cameracontrols
+
+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.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+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 androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ CameraControlsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CameraControlsScreen() {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Hoisted camera states
+ var heading by remember { mutableStateOf(153.2f) }
+ var tilt by remember { mutableStateOf(75.0f) }
+ var range by remember { mutableStateOf(584.2f) }
+
+ // Recreate camera object when state changes
+ val dynamicCamera = remember(heading, tilt, range) {
+ camera {
+ center = latLngAltitude {
+ latitude = 47.620527586075134
+ longitude = -122.34935779313246
+ altitude = 155.2680153221576
+ }
+ this.heading = heading.toDouble()
+ this.tilt = tilt.toDouble()
+ this.range = range.toDouble()
+ roll = 0.0
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = dynamicCamera,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Camera Controls",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // 3. Controls Panel at the bottom
+ Card(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .padding(bottom = 32.dp)
+ .fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(text = "Heading: ${heading.toInt()}Β°", style = MaterialTheme.typography.bodyMedium)
+ Slider(
+ value = heading,
+ onValueChange = { heading = it },
+ valueRange = 0f..360f,
+ modifier = Modifier.semantics { contentDescription = "Heading Slider" },
+ )
+
+ Text(text = "Tilt: ${tilt.toInt()}Β°", style = MaterialTheme.typography.bodyMedium)
+ Slider(
+ value = tilt,
+ onValueChange = { tilt = it },
+ valueRange = 0f..90f,
+ modifier = Modifier.semantics { contentDescription = "Tilt Slider" },
+ )
+
+ Text(text = "Range: ${range.toInt()}m", style = MaterialTheme.typography.bodyMedium)
+ Slider(
+ value = range,
+ onValueChange = { range = it },
+ valueRange = 100f..5000f,
+ modifier = Modifier.semantics { contentDescription = "Range Slider" },
+ )
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt
new file mode 100644
index 00000000..b81264d3
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/camerarestrictions/CameraRestrictionsActivity.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.composedemos.camerarestrictions
+
+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.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.Card
+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 androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.cameraRestriction
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.gms.maps3d.model.latLngBounds
+import com.google.maps.android.compose3d.GoogleMap3D
+
+class CameraRestrictionsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ CameraRestrictionsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CameraRestrictionsScreen() {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ // Camera centered on Space Needle
+ val spaceNeedleCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 47.620527586075134
+ longitude = -122.34935779313246
+ altitude = 155.2680153221576
+ }
+ heading = 153.2
+ tilt = 75.0
+ range = 584.2
+ roll = 0.0
+ }
+ }
+
+ // Define a bounding box around the Space Needle
+ val seattleBounds = remember {
+ latLngBounds {
+ northEastLat = 47.63
+ northEastLng = -122.33
+ southWestLat = 47.61
+ southWestLng = -122.36
+ }
+ }
+
+ // Define camera restriction
+ val seattleRestriction = remember {
+ cameraRestriction {
+ bounds = seattleBounds
+ minAltitude = 100.0
+ maxAltitude = 2000.0
+ minHeading = 0.0
+ maxHeading = 360.0
+ minTilt = 0.0
+ maxTilt = 90.0
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen with camera restriction
+ GoogleMap3D(
+ camera = spaceNeedleCamera,
+ cameraRestriction = seattleRestriction,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Camera Restrictions",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // 3. Info Card
+ Card(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = "Camera is restricted to the Space Needle area (47.61..47.63, -122.36..-122.33) and altitude 100m..2000m.",
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt
new file mode 100644
index 00000000..802b7f50
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.cloudstyling
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class CloudStylingActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Cloud Map Styling sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt
new file mode 100644
index 00000000..175e67f7
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/datavisualization/DataVisualizationActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.datavisualization
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class DataVisualizationActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Data Visualization sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt
new file mode 100644
index 00000000..7eb586f9
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/fieldofview/FieldOfViewActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.fieldofview
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class FieldOfViewActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Field Of View sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt
new file mode 100644
index 00000000..29a5face
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/flightsimulator/FlightSimulatorActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.flightsimulator
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class FlightSimulatorActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Flight Simulator sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt
new file mode 100644
index 00000000..45caf24a
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.composedemos.hellomap
+
+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.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+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 androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ HelloMapScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HelloMapScreen() {
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ val delicateArchCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 38.74349839523953
+ longitude = -109.49930710001824
+ altitude = 1467.1204001315878
+ }
+ heading = 151.8340412978984
+ tilt = 68.31411315130784
+ range = 250.56850704659155
+ roll = 0.0
+ }
+ }
+
+ // Layering the elements to allow the map to show through the top bar
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = delicateArchCamera,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ // 75% opaque surface color
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ // Respect status bar / cutout area for padding the content
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Hello Map",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt
new file mode 100644
index 00000000..dddc866c
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/mapinteractions/MapInteractionsActivity.kt
@@ -0,0 +1,148 @@
+/*
+ * 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.composedemos.mapinteractions
+
+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.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.Card
+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 androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ MapInteractionsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MapInteractionsScreen() {
+ var isMapSteady by remember { mutableStateOf(false) }
+ var clickedInfo by remember { mutableStateOf("Click on the map to see details") }
+
+ // 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" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = calibratedCamera,
+ modifier = Modifier.fillMaxSize(),
+ onMapReady = { instance ->
+ instance.setMap3DClickListener { location, placeId ->
+ clickedInfo = if (placeId != null) {
+ "Clicked Place ID: $placeId"
+ } else {
+ "Clicked Location: ${location.latitude}, ${location.longitude}"
+ }
+ }
+ },
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Map Interactions",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // 3. Click Info Card
+ Card(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = clickedInfo,
+ modifier = Modifier
+ .padding(16.dp)
+ .semantics { contentDescription = clickedInfo },
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt
new file mode 100644
index 00000000..4c54d22a
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt
@@ -0,0 +1,174 @@
+/*
+ * 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.composedemos.markers
+
+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.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+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.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import com.example.composedemos.R
+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 com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.MarkerConfig
+import com.google.maps.android.compose3d.PopoverConfig
+
+class MarkersActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ MarkersScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MarkersScreen() {
+ var isMapSteady by remember { mutableStateOf(false) }
+ var popovers by remember { mutableStateOf(emptyList()) }
+
+ // 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 alienMarker = remember {
+ MarkerConfig(
+ key = "alien",
+ position = latLngAltitude {
+ latitude = 44.59054845363309
+ longitude = -104.715177415273
+ altitude = 10.0
+ },
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH,
+ styleView = ImageView(R.drawable.alien),
+ label = "Devil's Tower Alien",
+ isExtruded = true,
+ isDrawnWhenOccluded = true,
+ onClick = {
+ // State-driven popover creation! No direct map manipulation.
+ popovers = listOf(
+ PopoverConfig(
+ key = "alien_popover",
+ positionAnchorKey = "alien",
+ autoPanEnabled = false,
+ autoCloseEnabled = true,
+ content = {
+ Surface(
+ color = Color.White,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.padding(8.dp),
+ ) {
+ Text(
+ text = "They didn't just come to sculpt mashed potatoes.",
+ modifier = Modifier.padding(16.dp),
+ color = Color.Black,
+ )
+ }
+ },
+ ),
+ )
+ },
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = devilsTowerCamera,
+ mapMode = Map3DMode.HYBRID,
+ markers = listOf(alienMarker),
+ popovers = popovers,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ onMapClick = {
+ popovers = emptyList()
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Markers",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/models/ModelsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/models/ModelsActivity.kt
new file mode 100644
index 00000000..c4baf59f
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/models/ModelsActivity.kt
@@ -0,0 +1,250 @@
+/*
+ * 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.composedemos.models
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+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.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+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.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ ModelsScreen()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ModelsScreen() {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ var isMapSteady by remember { mutableStateOf(false) }
+
+ val initialCamera = remember {
+ 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,
+ onClick = {
+ Toast.makeText(context, "Clicked on Airplane!", Toast.LENGTH_SHORT).show()
+ },
+ ),
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ 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)
+ }
+ },
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Models",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // 3. UI Controls (FABs)
+ 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)
+ .padding(bottom = 32.dp), // Avoid system bars if visible
+ ) {
+ 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)
+ .padding(bottom = 32.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/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt
new file mode 100644
index 00000000..2d9e4825
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.pathfollowing
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class PathFollowingActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Path Following sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt
new file mode 100644
index 00000000..f999a024
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathstyling/PathStylingActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.pathstyling
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class PathStylingActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Path Styling sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt
new file mode 100644
index 00000000..e3e8f2d3
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placeautocomplete/PlaceAutocompleteActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.placeautocomplete
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class PlaceAutocompleteActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Place Autocomplete sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/Landmark.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/Landmark.kt
new file mode 100644
index 00000000..b7b1ef87
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/Landmark.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.composedemos.placedetails
+
+import com.google.android.gms.maps3d.model.LatLngAltitude
+
+/**
+ * A data class representing a landmark in the demo.
+ *
+ * @property id The unique Place ID for this landmark.
+ * @property name The human-readable name of the landmark.
+ * @property location The coordinates on the 3D map where the camera should point.
+ */
+data class Landmark(
+ val id: String,
+ val name: String,
+ val location: LatLngAltitude
+)
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/LandmarkList.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/LandmarkList.kt
new file mode 100644
index 00000000..a3edb646
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/LandmarkList.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.composedemos.placedetails
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Place
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * A composable that displays a list of landmarks.
+ *
+ * @param landmarks The list of landmarks to display.
+ * @param onLandmarkClick Callback invoked when a landmark is clicked.
+ * @param modifier The modifier to apply to the list.
+ */
+@Composable
+fun LandmarkList(
+ landmarks: List,
+ onLandmarkClick: (Landmark) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(modifier = modifier) {
+ Text(
+ text = "Locations",
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(16.dp)
+ )
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ items(landmarks) { landmark ->
+ LandmarkItem(
+ landmark = landmark,
+ onClick = { onLandmarkClick(landmark) }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+/**
+ * A composable that displays a single landmark item.
+ */
+@Composable
+private fun LandmarkItem(
+ landmark: Landmark,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Place,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(end = 16.dp)
+ )
+ Column {
+ Text(
+ text = landmark.name,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Boulder, CO",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt
new file mode 100644
index 00000000..80b08ad8
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placedetails/PlaceDetailsActivity.kt
@@ -0,0 +1,352 @@
+/*
+ * 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.composedemos.placedetails
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import androidx.activity.compose.setContent
+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.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MyLocation
+import androidx.compose.material3.BottomSheetScaffold
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberBottomSheetScaffoldState
+import androidx.compose.material3.rememberStandardBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.FragmentContainerView
+import androidx.fragment.app.commit
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.composedemos.R
+import com.google.android.gms.maps3d.model.Camera
+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.libraries.places.api.model.Place
+import com.google.android.libraries.places.widget.PlaceDetailsCompactFragment
+import com.google.android.libraries.places.widget.PlaceLoadListener
+import com.google.android.libraries.places.widget.model.Orientation
+import com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.utils.toValidCamera
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * A simple ViewModel to hold the selected place ID without using Hilt.
+ */
+class PlaceDetailsViewModel : ViewModel() {
+ companion object {
+ val initialCamera: Camera = camera {
+ center = latLngAltitude {
+ latitude = 39.982129291022446
+ longitude = -105.30156359691158
+ altitude = 2483.5 // Approx 8148 feet
+ }
+ heading = 26.0
+ tilt = 67.0
+ range = 2500.0
+ }.toValidCamera()
+ }
+
+ val landmarks: List = listOf(
+ Landmark(
+ id = "ChIJfXOTtWbsa4cRmW07qJRB6_8",
+ name = "The Flatirons",
+ location = latLngAltitude {
+ latitude = 39.9880
+ longitude = -105.2930
+ altitude = 2100.0
+ },
+ ),
+ Landmark(
+ id = "ChIJwd_EEkfsa4cRqy6eShKXFXY",
+ name = "Chautauqua Park",
+ location = latLngAltitude {
+ latitude = 39.9989
+ longitude = -105.2828
+ altitude = 1750.0
+ },
+ ),
+ Landmark(
+ id = "ChIJiTEGLibsa4cRepH7ZMFEcJ8",
+ name = "Pearl Street Mall",
+ location = latLngAltitude {
+ latitude = 40.0177
+ longitude = -105.2819
+ altitude = 1620.0
+ },
+ ),
+ Landmark(
+ id = "ChIJwR6cajTsa4cR2TH0qKTVKAM",
+ name = "University of Colorado Boulder",
+ location = latLngAltitude {
+ latitude = 40.0076
+ longitude = -105.2659
+ altitude = 1650.0
+ },
+ ),
+ Landmark(
+ id = "ChIJAfFnzszva4cR04sAt0lSm1g",
+ name = "Boulder Reservoir",
+ location = latLngAltitude {
+ latitude = 40.0780
+ longitude = -105.2220
+ altitude = 1580.0
+ },
+ ),
+ )
+
+ private val _placeId = MutableStateFlow(null)
+ val placeId: StateFlow = _placeId.asStateFlow()
+
+ private val _cameraState = MutableStateFlow(initialCamera)
+ val cameraState: StateFlow = _cameraState.asStateFlow()
+
+ fun setSelectedPlaceId(placeId: String?) {
+ _placeId.value = placeId
+ }
+
+ fun selectLandmark(landmark: Landmark) {
+ setSelectedPlaceId(landmark.id)
+ _cameraState.value = camera {
+ center = landmark.location
+ range = 1000.0
+ tilt = 45.0
+ }.toValidCamera()
+ }
+}
+
+class PlaceDetailsActivity : FragmentActivity() {
+ companion object {
+ private const val TAG = "PlaceDetailsActivity"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ val viewModel: PlaceDetailsViewModel = viewModel()
+
+ // Handle Intent extra for non-UI state injection (testing)
+ LaunchedEffect(Unit) {
+ intent.getStringExtra("place_name")?.let { name ->
+ delay(2000) // Delay a couple of seconds before showing
+ val landmark = viewModel.landmarks.find { it.name == name }
+ if (landmark != null) {
+ viewModel.selectLandmark(landmark)
+ }
+ }
+ }
+
+ MainScreen(viewModel)
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun MainScreen(viewModel: PlaceDetailsViewModel) {
+ val landmarks = viewModel.landmarks
+ val selectedPlaceId by viewModel.placeId.collectAsState()
+ val scope = rememberCoroutineScope()
+ val scaffoldState = rememberBottomSheetScaffoldState(
+ bottomSheetState = rememberStandardBottomSheetState(
+ initialValue = SheetValue.PartiallyExpanded,
+ ),
+ )
+ val sheetPeekHeight = 120.dp
+ val cameraState by viewModel.cameraState.collectAsState()
+
+ // Dismiss the place details overlay if the user fully expands the bottom sheet
+ LaunchedEffect(scaffoldState.bottomSheetState.currentValue) {
+ if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) {
+ viewModel.setSelectedPlaceId(null)
+ }
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ BottomSheetScaffold(
+ scaffoldState = scaffoldState,
+ sheetPeekHeight = sheetPeekHeight,
+ sheetContent = {
+ LandmarkList(
+ landmarks = landmarks,
+ onLandmarkClick = { landmark ->
+ Log.d(TAG, "LANDMARK_CLICKED: ${landmark.name}")
+ viewModel.selectLandmark(landmark)
+ scope.launch {
+ scaffoldState.bottomSheetState.partialExpand()
+ }
+ },
+ modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp),
+ )
+ },
+ ) { _ ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = cameraState,
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onPlaceClick = { placeId ->
+ Log.d(TAG, "Place clicked: placeId=$placeId")
+ viewModel.setSelectedPlaceId(placeId)
+ },
+ )
+ }
+ }
+
+ // Overlay stays on top of the scaffold
+ if (!selectedPlaceId.isNullOrEmpty()) {
+ PlaceDetailsOverlay(
+ placeId = selectedPlaceId!!,
+ onDismiss = { viewModel.setSelectedPlaceId(null) },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = sheetPeekHeight + 16.dp, start = 16.dp, end = 16.dp),
+ )
+ }
+ }
+ }
+
+ @Composable
+ fun PlaceDetailsOverlay(
+ placeId: String,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ ) {
+ val containerId = remember { View.generateViewId() }
+
+ LaunchedEffect(placeId) {
+ val fragment = supportFragmentManager.findFragmentById(containerId) as? PlaceDetailsCompactFragment
+ if (fragment != null) {
+ Log.d(TAG, "Updating existing fragment for new placeId: $placeId")
+ fragment.loadWithPlaceId(placeId)
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .widthIn(max = 600.dp)
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium),
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ FragmentContainerView(ctx).apply {
+ id = containerId
+
+ val newFragment = PlaceDetailsCompactFragment.newInstance(
+ PlaceDetailsCompactFragment.ALL_CONTENT,
+ Orientation.VERTICAL,
+ R.style.BoulderNatureHippieTheme, // Use our custom theme!
+ ).apply {
+ setPlaceLoadListener(object : PlaceLoadListener {
+ override fun onSuccess(place: Place) {
+ Log.d(TAG, "Place loaded: ${place.id}")
+ }
+
+ override fun onFailure(e: Exception) {
+ Log.e(TAG, "Place failed to load for ID: $placeId", e)
+ }
+ })
+ }
+
+ supportFragmentManager.commit {
+ replace(containerId, newFragment)
+ }
+
+ post { newFragment.loadWithPlaceId(placeId) }
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ FloatingActionButton(
+ onClick = onDismiss,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp),
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Dismiss",
+ )
+ }
+ }
+
+ androidx.compose.runtime.DisposableEffect(containerId) {
+ onDispose {
+ supportFragmentManager.findFragmentById(containerId)?.let {
+ supportFragmentManager.beginTransaction()
+ .remove(it)
+ .commitAllowingStateLoss()
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt
new file mode 100644
index 00000000..bdff996a
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/placesearch/PlaceSearchActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.placesearch
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class PlaceSearchActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Place Search sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt
new file mode 100644
index 00000000..c75df91b
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt
@@ -0,0 +1,297 @@
+/*
+ * 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.composedemos.polygons
+
+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.activity.enableEdgeToEdge
+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.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+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.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 androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.PolygonConfig
+import kotlinx.coroutines.launch
+
+class PolygonsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ PolygonsScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PolygonsScreen() {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ 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 = 1609.34 // Denver is a mile high!
+ }
+ heading = -68.0
+ tilt = 47.0
+ roll = 0.0
+ range = 2251.0
+ }
+ }
+
+ // Define the outline for the zoo
+ val zooOutline = remember {
+ """
+ 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 {
+ """
+ 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
+ val polygonConfig = remember {
+ PolygonConfig(
+ key = "denver_zoo",
+ path = zooOutline,
+ innerPaths = listOf(zooHole),
+ // Translucent yellow
+ fillColor = Color.argb(70, 255, 255, 0),
+ strokeColor = Color.GREEN,
+ strokeWidth = 3f,
+ 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,
+ // Semi-transparent magenta
+ fillColor = Color.argb(70, 255, 0, 255),
+ 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()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = denverCamera,
+ mapMode = Map3DMode.HYBRID,
+ polygons = listOf(polygonConfig) + museumPolygons,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Polygons",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+/**
+ * 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/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/Data.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/Data.kt
new file mode 100644
index 00000000..21396bae
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/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.composedemos.polylines
+
+/**
+ * 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()
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt
new file mode 100644
index 00000000..cdbdc2c6
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polylines/PolylinesActivity.kt
@@ -0,0 +1,166 @@
+/*
+ * 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.composedemos.polylines
+
+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.activity.enableEdgeToEdge
+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.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+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.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 androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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.PolylineConfig
+import kotlinx.coroutines.launch
+
+class PolylinesActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ 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 (defined in Data.kt)
+ 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 // Slight elevation to avoid clipping
+ }
+ }
+ }
+
+ // Create a polyline config (State-driven approach)
+ val polylineConfig = remember {
+ PolylineConfig(
+ key = "sanitas_loop",
+ points = trailPoints,
+ color = Color.RED,
+ width = 10f,
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND,
+ zIndex = 10,
+ drawsOccludedSegments = true,
+ onClick = { polyline ->
+ Log.d("PolylinesActivity", "Polyline clicked: $polyline")
+ scope.launch {
+ Toast.makeText(context, "Hiking time!", Toast.LENGTH_SHORT).show()
+ }
+ },
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = boulderCamera,
+ mapMode = Map3DMode.HYBRID,
+ polylines = listOf(polylineConfig),
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Polylines",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt
new file mode 100644
index 00000000..09290daa
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt
@@ -0,0 +1,171 @@
+/*
+ * 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.composedemos.popovers
+
+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.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+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.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+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.PopoverConfig
+
+class PopoversActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ PopoversScreen()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PopoversScreen() {
+ var popovers by remember { mutableStateOf(emptyList()) }
+ 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
+ }
+ }
+
+ // 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 = {
+ 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,
+ )
+ }
+ },
+ ),
+ )
+ },
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" },
+ ) {
+ // 1. Map fills the entire screen
+ GoogleMap3D(
+ camera = devilsTowerCamera,
+ markers = listOf(marker),
+ popovers = popovers,
+ mapMode = Map3DMode.HYBRID,
+ modifier = Modifier.fillMaxSize(),
+ onMapSteady = {
+ isMapSteady = true
+ },
+ onMapClick = {
+ popovers = emptyList()
+ },
+ )
+
+ // 2. Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = "Popovers",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt
new file mode 100644
index 00000000..9275f79e
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/roadmapmode/RoadmapModeActivity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.composedemos.roadmapmode
+
+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.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+class RoadmapModeActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "TODO: Roadmap Mode sample will be implemented here.",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteEngine.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteEngine.kt
new file mode 100644
index 00000000..e80ec6f3
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteEngine.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.composedemos.routes
+
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose3d.utils.GeoMathUtils
+import com.google.maps.android.compose3d.utils.calculateHeading
+import com.google.maps.android.compose3d.utils.haversineDistance
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+
+data class PositionAndHeading(
+ val position: LatLng,
+ val heading: Float
+)
+
+object RouteEngine {
+ fun calculatePositionAndHeading(
+ route: List,
+ cumulativeDistances: DoubleArray,
+ distance: Double,
+ lookaheadDistance: Double
+ ): PositionAndHeading {
+ val targetPos = GeoMathUtils.getInterpolatedPoint(distance, route, cumulativeDistances)
+
+ // Calculate lookahead position for heading
+ val lookaheadPos = GeoMathUtils.getInterpolatedPoint(distance + lookaheadDistance, route, cumulativeDistances)
+
+ val heading = if (targetPos == lookaheadPos && distance > 0.0) {
+ // If at the end, look back 1 meter to determine heading
+ val prevPos = GeoMathUtils.getInterpolatedPoint(distance - 1.0, route, cumulativeDistances)
+ calculateHeading(prevPos, targetPos).toFloat()
+ } else {
+ calculateHeading(targetPos, lookaheadPos).toFloat()
+ }
+
+ return PositionAndHeading(targetPos, heading)
+ }
+
+ fun getRouteTrackingFlow(
+ routeFlow: Flow>,
+ progressFlow: Flow,
+ lookaheadDistance: Double = 1000.0
+ ): Flow = combine(routeFlow, progressFlow) { route, progress ->
+ if (route.size < 2) return@combine PositionAndHeading(LatLng(0.0, 0.0), 0f)
+
+ val cumulativeDistances = DoubleArray(route.size)
+ cumulativeDistances[0] = 0.0
+ for (i in 1 until route.size) {
+ cumulativeDistances[i] = cumulativeDistances[i - 1] + haversineDistance(route[i - 1], route[i])
+ }
+ val totalDistance = cumulativeDistances.last()
+ val distance = totalDistance * progress.toDouble()
+
+ calculatePositionAndHeading(route, cumulativeDistances, distance, lookaheadDistance)
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteRepository.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteRepository.kt
new file mode 100644
index 00000000..54f77de5
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteRepository.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.composedemos.routes
+
+import com.google.android.gms.maps.model.LatLng
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.net.HttpURLConnection
+import java.net.URL
+
+data class RouteData(
+ val encodedPolyline: String,
+ val navPoints: List,
+)
+
+class RouteRepository {
+
+ suspend fun fetchRoute(
+ apiKey: String,
+ origin: LatLng,
+ dest: LatLng,
+ ): RouteData = withContext(Dispatchers.IO) {
+ val url = URL("https://routes.googleapis.com/directions/v2:computeRoutes")
+ val connection = url.openConnection() as HttpURLConnection
+ connection.requestMethod = "POST"
+ connection.setRequestProperty("Content-Type", "application/json")
+ connection.setRequestProperty("X-Goog-Api-Key", apiKey)
+ connection.setRequestProperty("X-Goog-FieldMask", "routes.polyline.encodedPolyline,routes.legs.steps.startLocation")
+ connection.doOutput = true
+
+ val requestBody = JSONObject().apply {
+ put(
+ "origin",
+ JSONObject().put(
+ "location",
+ JSONObject().put(
+ "latLng",
+ JSONObject().apply {
+ put("latitude", origin.latitude)
+ put("longitude", origin.longitude)
+ },
+ ),
+ ),
+ )
+ put(
+ "destination",
+ JSONObject().put(
+ "location",
+ JSONObject().put(
+ "latLng",
+ JSONObject().apply {
+ put("latitude", dest.latitude)
+ put("longitude", dest.longitude)
+ },
+ ),
+ ),
+ )
+ put("travelMode", "DRIVE")
+ }
+
+ OutputStreamWriter(connection.outputStream).use {
+ it.write(requestBody.toString())
+ it.flush()
+ }
+
+ val responseCode = connection.responseCode
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ val reader = BufferedReader(InputStreamReader(connection.inputStream))
+ val response = StringBuilder()
+ var line: String?
+ while (reader.readLine().also { line = it } != null) {
+ response.append(line)
+ }
+ reader.close()
+
+ val jsonResponse = JSONObject(response.toString())
+ val routes = jsonResponse.getJSONArray("routes")
+ if (routes.length() > 0) {
+ val route = routes.getJSONObject(0)
+ val polyline = route.getJSONObject("polyline")
+ val encodedPolyline = polyline.getString("encodedPolyline")
+
+ val navPoints = mutableListOf()
+ val legs = route.optJSONArray("legs")
+ if (legs != null && legs.length() > 0) {
+ val leg = legs.getJSONObject(0)
+ val steps = leg.optJSONArray("steps")
+ if (steps != null) {
+ for (i in 0 until steps.length()) {
+ val step = steps.getJSONObject(i)
+ val startLocation = step.optJSONObject("startLocation")
+ if (startLocation != null) {
+ val latLng = startLocation.getJSONObject("latLng")
+ navPoints.add(
+ LatLng(
+ latLng.getDouble("latitude"),
+ latLng.getDouble("longitude"),
+ ),
+ )
+ }
+ }
+ }
+ }
+ navPoints.add(dest) // Add destination as last point
+
+ RouteData(encodedPolyline, navPoints)
+ } else {
+ throw Exception("No route returned from the Maps API.")
+ }
+ } else {
+ val reader = BufferedReader(InputStreamReader(connection.errorStream))
+ val response = StringBuilder()
+ var line: String?
+ while (reader.readLine().also { line = it } != null) {
+ response.append(line)
+ }
+ reader.close()
+ throw Exception("API Error (HTTP $responseCode): $response")
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteViewModel.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteViewModel.kt
new file mode 100644
index 00000000..4b99131b
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RouteViewModel.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.example.composedemos.routes
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.PolyUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+sealed interface RouteUiState {
+ object Idle : RouteUiState
+ object Loading : RouteUiState
+ data class Success(
+ val decodedPolyline: List,
+ val navigationPoints: List,
+ ) : RouteUiState
+ data class Error(val message: String) : RouteUiState
+}
+
+class RouteViewModel(
+ private val routeRepository: RouteRepository = RouteRepository(),
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(RouteUiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun fetchRoute(apiKey: String, origin: LatLng, dest: LatLng) {
+ if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) {
+ _uiState.value = RouteUiState.Error("Invalid API Key. Please provide a real key.")
+ return
+ }
+
+ _uiState.value = RouteUiState.Loading
+
+ viewModelScope.launch {
+ try {
+ val routeData = routeRepository.fetchRoute(apiKey, origin, dest)
+
+ // Decode polyline on Default dispatcher (CPU heavy)
+ val decoded = withContext(Dispatchers.Default) {
+ PolyUtil.decode(routeData.encodedPolyline)
+ }
+
+ _uiState.value = RouteUiState.Success(decoded, routeData.navPoints)
+ } catch (e: Exception) {
+ _uiState.value = RouteUiState.Error(e.message ?: "Unknown Error")
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RoutesActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RoutesActivity.kt
new file mode 100644
index 00000000..7b5070d3
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/routes/RoutesActivity.kt
@@ -0,0 +1,764 @@
+/*
+ * 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.composedemos.routes
+
+import android.content.Context
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.requiredHeight
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.DirectionsCar
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.Place
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameMillis
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.content.edit
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.delay
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.composedemos.BuildConfig
+import com.example.composedemos.R
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Camera
+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 com.google.maps.android.compose3d.GoogleMap3D
+import com.google.maps.android.compose3d.MarkerConfig
+import com.google.maps.android.compose3d.ModelConfig
+import com.google.maps.android.compose3d.ModelScale
+import com.google.maps.android.compose3d.PolylineConfig
+import com.google.maps.android.compose3d.PopoverConfig
+import com.google.maps.android.compose3d.utils.haversineDistance
+import com.google.maps.android.compose3d.utils.toHeading
+import com.google.maps.android.compose3d.utils.toValidCamera
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlin.math.abs
+
+sealed interface RouteTracker {
+ val name: String
+
+ data object Marker : RouteTracker {
+ override val name = "Marker"
+ }
+
+ sealed class Model(
+ override val name: String,
+ val url: String,
+ val scale: Double,
+ val tilt: Double,
+ val hoverAltitude: Double,
+ val headingOffset: Double
+ ) : RouteTracker
+
+ data object RedCar : Model(
+ name = "Red Car",
+ url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb",
+ scale = 50.0,
+ tilt = -90.0,
+ hoverAltitude = 25.0,
+ headingOffset = 0.0
+ )
+
+ data object BananaCar : Model(
+ name = "Banana Car",
+ url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/banana_car.glb",
+ scale = 0.12,
+ tilt = -90.0,
+ hoverAltitude = 25.0,
+ headingOffset = 180.0
+ )
+}
+
+class RoutesActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Hide system tray (immersive mode)
+ val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ val viewModel: RouteViewModel = viewModel()
+ RouteSampleScreen(viewModel)
+ }
+ }
+ }
+ }
+}
+
+@OptIn(kotlinx.coroutines.FlowPreview::class)
+@Composable
+fun RouteSampleScreen(viewModel: RouteViewModel) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ val context = LocalContext.current
+ val sharedPrefs = remember { context.getSharedPreferences("route_prefs", Context.MODE_PRIVATE) }
+ val warningCount = remember { sharedPrefs.getInt("warning_count", 0) }
+
+ // Display Warnings
+ var displayWarning by remember { mutableStateOf(warningCount < 2) }
+
+ // User calibrated camera position
+ val initialCamera = remember {
+ camera {
+ center = latLngAltitude {
+ latitude = 21.348567086690373
+ longitude = -157.80396135489252
+ altitude = 0.0
+ }
+ heading = 38.685134043475976
+ tilt = 44.96296481747822
+ range = 29158.68678946048
+ roll = 0.0
+ }
+ }
+
+ // State-driven camera
+ var cameraState by remember { mutableStateOf(initialCamera) }
+ var polylines by remember { mutableStateOf(emptyList()) }
+
+ // Dynamic tracking state
+ var cameraRange by remember { mutableFloatStateOf(1500f) }
+ var baseSpeedMps by remember { mutableFloatStateOf(150f) }
+ var currentTracker by remember { mutableStateOf(RouteTracker.RedCar) }
+
+ // Playback controls state
+ var elapsedDistance by remember { mutableFloatStateOf(0f) }
+ var totalDistance by remember { mutableFloatStateOf(0f) }
+ var isPlaying by remember { mutableStateOf(false) }
+ var flyModeActive by remember { mutableStateOf(false) }
+ var cameraHeadingOffset by remember { mutableFloatStateOf(0f) }
+
+ val cameraFlow = remember { MutableSharedFlow(replay = 0, extraBufferCapacity = 1) }
+
+ LaunchedEffect(cameraFlow) {
+ cameraFlow
+ .debounce(1000)
+ .collect { camera ->
+ Log.d("CameraPosition", "Center: ${camera.center.latitude}, ${camera.center.longitude}, Heading: ${camera.heading}, Tilt: ${camera.tilt}, Range: ${camera.range}")
+ }
+ }
+
+ // 1. State flows for inputs to the engine
+ val routeFlow = remember { MutableStateFlow(emptyList()) }
+ val progressFlow = remember { MutableStateFlow(0f) }
+
+ // Automatically fetch route on start and update routeFlow
+ LaunchedEffect(Unit) {
+ val origin = LatLng(21.307043, -157.858984)
+ val dest = LatLng(21.390177, -157.719454)
+ viewModel.fetchRoute(BuildConfig.MAPS3D_API_KEY, origin, dest)
+ }
+
+ LaunchedEffect(uiState) {
+ if (uiState is RouteUiState.Success) {
+ val state = uiState as RouteUiState.Success
+ routeFlow.value = state.decodedPolyline
+
+ // Calculate total distance for progress calculation
+ val rawPath = state.decodedPolyline
+ if (rawPath.size >= 2) {
+ val cumulativeDistances = DoubleArray(rawPath.size)
+ cumulativeDistances[0] = 0.0
+ for (i in 1 until rawPath.size) {
+ cumulativeDistances[i] = cumulativeDistances[i - 1] + haversineDistance(rawPath[i - 1], rawPath[i])
+ }
+ totalDistance = cumulativeDistances.last().toFloat()
+ }
+
+ // Draw polyline
+ polylines = listOf(
+ PolylineConfig(
+ key = "route_line",
+ points = state.decodedPolyline.map { latLngAltitude { latitude = it.latitude; longitude = it.longitude; altitude = 0.0 } },
+ color = android.graphics.Color.BLUE,
+ width = 10f
+ )
+ )
+ }
+ }
+
+ // 2. The Engine Flow
+ val trackingFlow = remember(routeFlow) {
+ RouteEngine.getRouteTrackingFlow(routeFlow, progressFlow, 1000.0)
+ }
+
+ // 3. Collect the output state
+ val positionAndHeading by trackingFlow.collectAsState(initial = PositionAndHeading(LatLng(0.0, 0.0), 0f))
+
+ // 4. Update camera reactively during flight
+ LaunchedEffect(positionAndHeading, flyModeActive, cameraRange, cameraHeadingOffset) {
+ if (flyModeActive && positionAndHeading.position.latitude != 0.0) {
+ cameraState = camera {
+ center = latLngAltitude {
+ latitude = positionAndHeading.position.latitude
+ longitude = positionAndHeading.position.longitude
+ altitude = 0.0
+ }
+ heading = (positionAndHeading.heading.toDouble() + cameraHeadingOffset).toHeading()
+ tilt = 65.0
+ range = cameraRange.toDouble()
+ }.toValidCamera()
+ }
+ }
+
+ // 5. Derive the models list (Combiner)
+ val currentModels = remember(positionAndHeading, currentTracker) {
+ val redCarConfig = ModelConfig(
+ key = "red_car",
+ url = RouteTracker.RedCar.url,
+ position = if (currentTracker == RouteTracker.RedCar && positionAndHeading.position.latitude != 0.0) {
+ latLngAltitude {
+ latitude = positionAndHeading.position.latitude
+ longitude = positionAndHeading.position.longitude
+ altitude = RouteTracker.RedCar.hoverAltitude
+ }
+ } else {
+ latLngAltitude { latitude = 0.0; longitude = 0.0; altitude = 0.0 }
+ },
+ altitudeMode = if (currentTracker == RouteTracker.RedCar) AltitudeMode.RELATIVE_TO_GROUND else AltitudeMode.ABSOLUTE,
+ scale = if (currentTracker == RouteTracker.RedCar) ModelScale.Uniform(RouteTracker.RedCar.scale.toFloat()) else ModelScale.Uniform(0.001f),
+ heading = if (currentTracker == RouteTracker.RedCar) positionAndHeading.heading.toDouble() else 0.0,
+ tilt = if (currentTracker == RouteTracker.RedCar) RouteTracker.RedCar.tilt else 0.0,
+ roll = 0.0
+ )
+
+ val bananaCarConfig = ModelConfig(
+ key = "banana_car",
+ url = RouteTracker.BananaCar.url,
+ position = if (currentTracker == RouteTracker.BananaCar && positionAndHeading.position.latitude != 0.0) {
+ latLngAltitude {
+ latitude = positionAndHeading.position.latitude
+ longitude = positionAndHeading.position.longitude
+ altitude = RouteTracker.BananaCar.hoverAltitude
+ }
+ } else {
+ latLngAltitude { latitude = 0.0; longitude = 0.0; altitude = 0.0 }
+ },
+ altitudeMode = if (currentTracker == RouteTracker.BananaCar) AltitudeMode.RELATIVE_TO_GROUND else AltitudeMode.ABSOLUTE,
+ scale = if (currentTracker == RouteTracker.BananaCar) ModelScale.Uniform(RouteTracker.BananaCar.scale.toFloat()) else ModelScale.Uniform(0.001f),
+ heading = if (currentTracker == RouteTracker.BananaCar) positionAndHeading.heading.toDouble() else 0.0,
+ tilt = if (currentTracker == RouteTracker.BananaCar) RouteTracker.BananaCar.tilt else 0.0,
+ roll = 0.0
+ )
+
+ listOf(redCarConfig, bananaCarConfig)
+ }
+
+ // Derive markers list
+ val currentMarkers = remember(positionAndHeading, currentTracker) {
+ if (currentTracker == RouteTracker.Marker && positionAndHeading.position.latitude != 0.0) {
+ listOf(
+ MarkerConfig(
+ key = "route_marker",
+ position = latLngAltitude {
+ latitude = positionAndHeading.position.latitude
+ longitude = positionAndHeading.position.longitude
+ altitude = 0.0
+ },
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
+ styleView = ImageView(R.drawable.car)
+ )
+ )
+ } else {
+ emptyList()
+ }
+ }
+
+ // 6. Drive progress during animation
+ LaunchedEffect(isPlaying, totalDistance) {
+ if (!isPlaying || totalDistance <= 0f) return@LaunchedEffect
+ var lastFrameTime = withFrameMillis { it }
+ while (isPlaying) {
+ withFrameMillis { frameTime ->
+ val dtMs = frameTime - lastFrameTime
+ lastFrameTime = frameTime
+ val deltaDistance = baseSpeedMps * (dtMs / 1000.0).toFloat()
+ elapsedDistance += deltaDistance
+
+ if (elapsedDistance >= totalDistance) {
+ elapsedDistance = totalDistance
+ isPlaying = false
+ } else if (elapsedDistance <= 0f) {
+ elapsedDistance = 0f
+ isPlaying = false
+ }
+ progressFlow.value = elapsedDistance / totalDistance
+ }
+ }
+ }
+
+ // Sync progressFlow when elapsedDistance is changed manually via slider
+ LaunchedEffect(elapsedDistance, totalDistance) {
+ if (totalDistance > 0f) {
+ progressFlow.value = elapsedDistance / totalDistance
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // The Map Viewport Layer
+ GoogleMap3D(
+ camera = cameraState,
+ markers = currentMarkers,
+ models = currentModels,
+ polylines = polylines,
+ mapMode = Map3DMode.SATELLITE,
+ modifier = Modifier.fillMaxSize(),
+ onCameraChanged = { camera ->
+ cameraFlow.tryEmit(camera)
+ }
+ )
+
+ // Custom Translucent Top Bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f))
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Routes API",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ IconButton(onClick = {
+ currentTracker = when (currentTracker) {
+ RouteTracker.Marker -> RouteTracker.RedCar
+ RouteTracker.RedCar -> RouteTracker.BananaCar
+ RouteTracker.BananaCar -> RouteTracker.Marker
+ }
+ }) {
+ Icon(
+ imageVector = when (currentTracker) {
+ RouteTracker.Marker -> Icons.Default.Place
+ RouteTracker.RedCar -> Icons.Default.DirectionsCar
+ RouteTracker.BananaCar -> Icons.Default.Star
+ },
+ contentDescription = "Toggle Tracker Style",
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+
+ // Interactive Overlay UI
+ if (!flyModeActive) {
+ StandardControlsOverlay(uiState = uiState, onFlyClicked = {
+ flyModeActive = true
+ isPlaying = true
+ })
+ } else {
+ PlaybackControlsOverlay(
+ isPlaying = isPlaying,
+ onIsPlayingChange = { isPlaying = it },
+ elapsedDistance = elapsedDistance,
+ onElapsedDistanceChange = { elapsedDistance = it },
+ totalDistance = totalDistance,
+ onExitFlyMode = {
+ flyModeActive = false
+ isPlaying = false
+ cameraState = initialCamera // Reset camera
+ elapsedDistance = 0f
+ progressFlow.value = 0f
+ })
+ }
+
+ // Loading/Error Overlays
+ StateStatusOverlay(uiState = uiState)
+
+ // Camera Manipulator Overlays
+ if (uiState is RouteUiState.Success) {
+ CameraControlsOverlay(
+ flyModeActive = flyModeActive,
+ cameraRange = cameraRange,
+ onCameraRangeChange = { cameraRange = it },
+ baseSpeedMps = baseSpeedMps,
+ onBaseSpeedChange = { baseSpeedMps = it },
+ cameraHeadingOffset = cameraHeadingOffset,
+ onCameraHeadingChange = { cameraHeadingOffset = it })
+ }
+
+ // Dialogs
+ if (displayWarning) {
+ SecurityWarningDialog(
+ onDismiss = {
+ displayWarning = false
+ sharedPrefs.edit { putInt("warning_count", warningCount + 1) }
+ })
+ }
+ }
+}
+
+@Composable
+private fun BoxScope.StandardControlsOverlay(
+ uiState: RouteUiState, onFlyClicked: () -> Unit
+) {
+ Button(
+ onClick = onFlyClicked,
+ enabled = uiState is RouteUiState.Success,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(32.dp)
+ ) {
+ Text("Fly Along")
+ }
+}
+
+@Composable
+private fun BoxScope.PlaybackControlsOverlay(
+ isPlaying: Boolean,
+ onIsPlayingChange: (Boolean) -> Unit,
+ elapsedDistance: Float,
+ onElapsedDistanceChange: (Float) -> Unit,
+ totalDistance: Float,
+ onExitFlyMode: () -> Unit
+) {
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .wrapContentHeight()
+ .fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ tonalElevation = 4.dp
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = { onIsPlayingChange(!isPlaying) }) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = "Play/Pause"
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Slider(
+ value = elapsedDistance,
+ onValueChange = {
+ onElapsedDistanceChange(it)
+ },
+ valueRange = 0f..kotlin.math.max(1f, totalDistance),
+ modifier = Modifier.weight(1f).semantics { contentDescription = "Progress Slider" },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(onClick = onExitFlyMode) {
+ Icon(Icons.Default.Close, contentDescription = "Exit Fly Mode")
+ }
+ }
+ }
+}
+
+@Composable
+private fun BoxScope.StateStatusOverlay(uiState: RouteUiState) {
+ when (uiState) {
+ is RouteUiState.Loading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ is RouteUiState.Error -> {
+ Box(modifier = Modifier
+ .align(Alignment.Center)
+ .padding(32.dp)) {
+ Text(
+ text = uiState.message,
+ color = MaterialTheme.colorScheme.error,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ else -> { /* Do nothing */ }
+ }
+}
+
+@Composable
+private fun BoxScope.CameraControlsOverlay(
+ flyModeActive: Boolean,
+ cameraRange: Float,
+ onCameraRangeChange: (Float) -> Unit,
+ baseSpeedMps: Float,
+ onBaseSpeedChange: (Float) -> Unit,
+ cameraHeadingOffset: Float,
+ onCameraHeadingChange: (Float) -> Unit
+) {
+ FadingVerticalSlider(
+ value = cameraRange,
+ onValueChange = onCameraRangeChange,
+ valueRange = 200f..10000f,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 16.dp)
+ )
+
+ FadingVerticalSlider(
+ value = baseSpeedMps,
+ onValueChange = { onBaseSpeedChange(if (abs(it) < 150f) 0f else it) },
+ valueRange = -1500f..1500f,
+ drawCenterDeadZone = true,
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = 16.dp)
+ )
+
+ if (flyModeActive) {
+ FadingThumbWheel(
+ value = cameraHeadingOffset,
+ onValueChange = onCameraHeadingChange,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 48.dp)
+ .padding(horizontal = 16.dp)
+ )
+ }
+}
+
+@Composable
+private fun SecurityWarningDialog(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = { Icon(Icons.Filled.Warning, contentDescription = null) },
+ title = { Text("Security Warning") },
+ text = { Text("This sample makes a direct REST API call from a mobile client to the Google Maps Routes API. In a production application, doing this exposes your API key to malicious extraction.\n\nAlways proxy your Routes API requests through a secure backend server!") },
+ confirmButton = { TextButton(onClick = onDismiss) { Text("I Understand") } })
+}
+
+@Composable
+private fun FadingThumbWheel(
+ value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier
+) {
+ val currentValue by rememberUpdatedState(value)
+ val currentOnValueChange by rememberUpdatedState(onValueChange)
+
+ var sliderInteractionTime by androidx.compose.runtime.remember { mutableLongStateOf(System.currentTimeMillis()) }
+ var isSliderActive by androidx.compose.runtime.remember { mutableStateOf(true) }
+
+ LaunchedEffect(sliderInteractionTime) {
+ isSliderActive = true
+ delay(3000)
+ isSliderActive = false
+ }
+
+ val sliderAlpha by animateFloatAsState(
+ targetValue = if (isSliderActive) 0.9f else 0.3f,
+ animationSpec = tween(durationMillis = 500),
+ label = "sliderAlpha"
+ )
+
+ Box(
+ modifier = modifier
+ .width(250.dp)
+ .height(48.dp)
+ .alpha(sliderAlpha)
+ .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(24.dp))
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ awaitPointerEvent(PointerEventPass.Initial)
+ sliderInteractionTime = System.currentTimeMillis()
+ }
+ }
+ }
+ .pointerInput(Unit) {
+ var localValue = 0f
+ detectHorizontalDragGestures(
+ onDragStart = { localValue = currentValue },
+ onHorizontalDrag = { change, dragAmount ->
+ change.consume()
+ localValue += dragAmount * -0.2f
+ var wrapped = localValue
+ while (wrapped > 180f) wrapped -= 360f
+ while (wrapped <= -180f) wrapped += 360f
+ localValue = wrapped
+ currentOnValueChange(localValue)
+ })
+ }) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ repeat(11) {
+ Box(
+ modifier = Modifier
+ .width(2.dp)
+ .height(12.dp)
+ .background(Color.White.copy(alpha = 0.4f))
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .width(4.dp)
+ .height(24.dp)
+ .background(Color.Red, RoundedCornerShape(2.dp))
+ )
+
+ Text(
+ text = "${value.toInt()}Β°",
+ color = Color.White,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 2.dp)
+ )
+ }
+}
+
+@Composable
+private fun FadingVerticalSlider(
+ value: Float,
+ onValueChange: (Float) -> Unit,
+ valueRange: ClosedFloatingPointRange,
+ modifier: Modifier = Modifier,
+ drawCenterDeadZone: Boolean = false
+) {
+ var sliderInteractionTime by androidx.compose.runtime.remember { mutableLongStateOf(System.currentTimeMillis()) }
+ var isSliderActive by androidx.compose.runtime.remember { mutableStateOf(true) }
+
+ LaunchedEffect(sliderInteractionTime) {
+ isSliderActive = true
+ delay(3000)
+ isSliderActive = false
+ }
+
+ val sliderAlpha by animateFloatAsState(
+ targetValue = if (isSliderActive) 0.9f else 0.3f,
+ animationSpec = tween(durationMillis = 500),
+ label = "sliderAlpha"
+ )
+
+ Box(
+ modifier = modifier
+ .requiredWidth(48.dp)
+ .requiredHeight(300.dp)
+ .alpha(sliderAlpha)
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ awaitPointerEvent(PointerEventPass.Initial)
+ sliderInteractionTime = System.currentTimeMillis()
+ }
+ }
+ }) {
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = valueRange,
+ modifier = Modifier
+ .requiredWidth(300.dp)
+ .requiredHeight(48.dp)
+ .graphicsLayer {
+ rotationZ = 270f
+ transformOrigin = TransformOrigin(0.5f, 0.5f)
+ }
+ .align(Alignment.Center))
+
+ if (drawCenterDeadZone) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .requiredWidth(16.dp)
+ .requiredHeight(4.dp)
+ .background(Color.White, shape = CircleShape)
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/alien.png b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/alien.png
new file mode 100644
index 00000000..2c38fc9e
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/alien.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/car.png b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/car.png
new file mode 100644
index 00000000..894711cb
Binary files /dev/null and b/Maps3DSamples/ComposeDemos/app/src/main/res/drawable/car.png differ
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/values/strings.xml b/Maps3DSamples/ComposeDemos/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..a38f229c
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Compose Demos
+
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/res/values/themes.xml b/Maps3DSamples/ComposeDemos/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..3f3b01b5
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/main/res/values/themes.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
diff --git a/Maps3DSamples/ComposeDemos/app/src/test/java/com/example/composedemos/routes/RouteEngineTest.kt b/Maps3DSamples/ComposeDemos/app/src/test/java/com/example/composedemos/routes/RouteEngineTest.kt
new file mode 100644
index 00000000..0e980571
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/app/src/test/java/com/example/composedemos/routes/RouteEngineTest.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.composedemos.routes
+
+import com.google.android.gms.maps.model.LatLng
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class RouteEngineTest {
+
+ @Test
+ fun testInterpolationAtStart() {
+ val route = listOf(
+ LatLng(0.0, 0.0),
+ LatLng(1.0, 1.0)
+ )
+ val cumulativeDistances = doubleArrayOf(0.0, 157000.0) // Approx distance in meters for 1 deg
+
+ val result = RouteEngine.calculatePositionAndHeading(
+ route = route,
+ cumulativeDistances = cumulativeDistances,
+ distance = 0.0,
+ lookaheadDistance = 1000.0
+ )
+
+ // We expect to be at the start
+ assertEquals(0.0, result.position.latitude, 0.001)
+ assertEquals(0.0, result.position.longitude, 0.001)
+ // And heading towards (1,1) which is approx 45 degrees
+ assertEquals(45.0, result.heading.toDouble(), 5.0)
+ }
+
+ @Test
+ fun testGetRouteTrackingFlow() = runBlocking {
+ val routeFlow = MutableStateFlow(listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0)))
+ val progressFlow = MutableStateFlow(0f)
+
+ val trackingFlow = RouteEngine.getRouteTrackingFlow(routeFlow, progressFlow, 1000.0)
+
+ val result1 = trackingFlow.first()
+ assertEquals(0.0, result1.position.latitude, 0.001)
+ assertEquals(0.0, result1.position.longitude, 0.001)
+ assertEquals(45.0, result1.heading.toDouble(), 5.0)
+
+ // Emit new progress (halfway)
+ progressFlow.value = 0.5f
+ val result2 = trackingFlow.first()
+
+ // Halfway between (0,0) and (1,1) should be approx (0.5, 0.5)
+ assertEquals(0.5, result2.position.latitude, 0.1)
+ assertEquals(0.5, result2.position.longitude, 0.1)
+ }
+
+ @Test
+ fun testComplexRouteOrientation() {
+ val route = listOf(
+ LatLng(0.0, 0.0),
+ LatLng(1.0, 0.0), // Moving North (Heading 0)
+ LatLng(1.0, 1.0) // Moving East (Heading 90)
+ )
+ val cumulativeDistances = doubleArrayOf(0.0, 111000.0, 222000.0) // Approx 111km per degree
+
+ // Test on first segment (moving North)
+ val result1 = RouteEngine.calculatePositionAndHeading(
+ route = route,
+ cumulativeDistances = cumulativeDistances,
+ distance = 50000.0,
+ lookaheadDistance = 1000.0
+ )
+ assertEquals(0.0, result1.heading.toDouble(), 5.0)
+
+ // Test on second segment (moving East)
+ val result2 = RouteEngine.calculatePositionAndHeading(
+ route = route,
+ cumulativeDistances = cumulativeDistances,
+ distance = 160000.0,
+ lookaheadDistance = 1000.0
+ )
+ assertEquals(90.0, result2.heading.toDouble(), 5.0)
+
+ // Test at the end of the route
+ val result3 = RouteEngine.calculatePositionAndHeading(
+ route = route,
+ cumulativeDistances = cumulativeDistances,
+ distance = 222000.0,
+ lookaheadDistance = 1000.0
+ )
+ // We expect it to keep the heading of the last segment (90 degrees)
+ assertEquals(90.0, result3.heading.toDouble(), 5.0)
+ }
+}
diff --git a/Maps3DSamples/ComposeDemos/automation_script.py b/Maps3DSamples/ComposeDemos/automation_script.py
new file mode 100644
index 00000000..8f21b6a5
--- /dev/null
+++ b/Maps3DSamples/ComposeDemos/automation_script.py
@@ -0,0 +1,181 @@
+import subprocess
+import os
+import sys
+import re
+
+def run_command(cmd, cwd=None):
+ print(f"Running: {cmd}")
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd)
+ if result.returncode != 0:
+ print(f"Command failed with exit code {result.returncode}")
+ print(result.stderr)
+ return False, result.stdout
+ return True, result.stdout
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: python3 automation_script.py ")
+ sys.exit(1)
+
+ test_class = sys.argv[1]
+
+ # Workspace root
+ result = subprocess.run("git rev-parse --show-toplevel", shell=True, capture_output=True, text=True)
+ if result.returncode != 0:
+ print("Failed to find git workspace root.")
+ sys.exit(1)
+ workspace_root = result.stdout.strip()
+
+ # 1. Install and Run Test
+ # We use am instrument to keep the app installed so we can pull files with run-as.
+ print("Installing app...")
+ success, _ = run_command("./gradlew :Maps3DSamples:ComposeDemos:app:installDebug", cwd=workspace_root)
+ if not success: sys.exit(1)
+
+ print("Installing test app...")
+ success, _ = run_command("./gradlew :Maps3DSamples:ComposeDemos:app:installDebugAndroidTest", cwd=workspace_root)
+ if not success: sys.exit(1)
+
+ print("Running test...")
+ cmd = f"adb shell am instrument -w -e class com.example.composedemos.{test_class} com.example.composedemos.test/androidx.test.runner.AndroidJUnitRunner"
+ success, output = run_command(cmd, cwd=workspace_root)
+ print("Test Output:")
+ print(output)
+ if not success:
+ sys.exit(1)
+
+ print("Test passed. Pulling screenshot...")
+
+ # 2. Pull Screenshot
+ filename_mapping = {
+ "HelloMapVisualTest": "hello_map_screenshot.png",
+ "PolylinesVisualTest": "polylines_screenshot.png",
+ "MapInteractionsVisualTest": "map_interactions_screenshot.png",
+ "PopoversVisualTest": "popovers_screenshot.png",
+ "CameraControlsVisualTest": "camera_controls_screenshot.png",
+ "PlaceDetailsVisualTest": "place_details_screenshot.png",
+ "PolygonsVisualTest": "polygons_screenshot.png",
+ "ModelsVisualTest": "models_screenshot.png",
+ "MarkersVisualTest": "markers_screenshot.png",
+ "CameraRestrictionsVisualTest": "camera_restrictions_screenshot.png",
+ "RoutesVisualTest": "routes_screenshot.png",
+ }
+
+ filename = filename_mapping.get(test_class, f"{test_class.lower()}_screenshot.png")
+ local_path = filename
+
+ # Use run-as to read the file from the app's data directory and pipe it to a local file
+ cat_cmd = f"adb shell run-as com.example.composedemos cat files/{filename}"
+ print(f"Running: {cat_cmd}")
+ result = subprocess.run(cat_cmd, shell=True, capture_output=True, text=False)
+ if result.returncode != 0:
+ print(f"Failed to read screenshot via run-as. Error: {result.stderr.decode('utf-8')}")
+ sys.exit(1)
+
+ with open(local_path, "wb") as f:
+ f.write(result.stdout)
+ print(f"Pulled screenshot to {local_path}")
+
+ # 3. Scale Image using sips (macOS built-in)
+ # Get dimensions
+ dim_cmd = f"sips -g pixelWidth -g pixelHeight {local_path}"
+ success, dim_output = run_command(dim_cmd)
+ if not success:
+ print("Failed to get image dimensions.")
+ sys.exit(1)
+
+ # Parse width and height
+ try:
+ width = int(re.search(r"pixelWidth: (\d+)", dim_output).group(1))
+ height = int(re.search(r"pixelHeight: (\d+)", dim_output).group(1))
+ except AttributeError:
+ print(f"Failed to parse dimensions from output: {dim_output}")
+ sys.exit(1)
+
+ new_width = int(width * 0.5)
+ new_height = int(height * 0.5)
+
+ print(f"Scaling from {width}x{height} to {new_width}x{new_height}")
+ scale_cmd = f"sips -z {new_height} {new_width} {local_path}"
+ success, _ = run_command(scale_cmd)
+ if not success:
+ print("Failed to scale image.")
+ sys.exit(1)
+
+ # 4. Move to Source
+ # Target directory: Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots/
+ target_dir = f"{workspace_root}/Maps3DSamples/ComposeDemos/app/src/main/assets/screenshots"
+ os.makedirs(target_dir, exist_ok=True)
+
+ target_path = f"{target_dir}/{local_path}"
+ os.rename(local_path, target_path)
+ print(f"Screenshot saved to {target_path}")
+
+ # 5. Update Catalog
+ catalog_path = f"{workspace_root}/Maps3DSamples/ComposeDemos/app/README.md"
+
+ # Mapping from test class to Feature Name in table
+ mapping = {
+ "HelloMapVisualTest": "Basic Map",
+ "PolylinesVisualTest": "Polylines",
+ "MapInteractionsVisualTest": "Map Interactions",
+ "PopoversVisualTest": "Popovers",
+ "CameraControlsVisualTest": "Camera Controls",
+ "PolygonsVisualTest": "Polygons",
+ "ModelsVisualTest": "Models",
+ "MarkersVisualTest": "Markers",
+ "CameraRestrictionsVisualTest": "Camera Restrictions",
+ "RoutesVisualTest": "Routes API",
+ "PlaceDetailsVisualTest": "Place Details",
+ }
+
+ feature_name = mapping.get(test_class)
+ if feature_name:
+ with open(catalog_path, "r") as f:
+ catalog_content = f.read()
+
+ # Mapping to get activity file path
+ activity_mapping = {
+ "Basic Map": "hellomap/HelloMapActivity.kt",
+ "Polylines": "polylines/PolylinesActivity.kt",
+ "Map Interactions": "mapinteractions/MapInteractionsActivity.kt",
+ "Popovers": "popovers/PopoversActivity.kt",
+ "Camera Controls": "cameracontrols/CameraControlsActivity.kt",
+ "Polygons": "polygons/PolygonsActivity.kt",
+ "Models": "models/ModelsActivity.kt",
+ "Markers": "markers/MarkersActivity.kt",
+ "Camera Restrictions": "camerarestrictions/CameraRestrictionsActivity.kt",
+ "Routes API": "routes/RoutesActivity.kt",
+ "Place Details": "placedetails/PlaceDetailsActivity.kt",
+ }
+ activity_path = activity_mapping.get(feature_name)
+
+ image_link = f'
'
+
+ # Find the line for this feature and replace it
+ lines = catalog_content.split("\n")
+ updated = False
+ for i, line in enumerate(lines):
+ if f"| **{feature_name}** |" in line:
+ # Get the activity file basename
+ file_basename = activity_path.split("/")[-1]
+ # Split to get description if exists
+ parts = line.split("|")
+ description = parts[5].strip() if len(parts) > 5 else ""
+ # Construct new line preserving description
+ lines[i] = f"| **{feature_name}** | β
Done | [{file_basename}](src/main/java/com/example/composedemos/{activity_path}) | {image_link} | {description} |"
+ updated = True
+ break
+
+ if updated:
+ catalog_content = "\n".join(lines)
+ with open(catalog_path, "w") as f:
+ f.write(catalog_content)
+ print(f"Updated Compose Catalog README.md for {feature_name}")
+ else:
+ print(f"Feature {feature_name} not found in catalog.")
+ else:
+ print(f"No mapping found for {test_class} in catalog.")
+
+if __name__ == "__main__":
+ main()
diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts
index 93761b73..4ca67bda 100644
--- a/Maps3DSamples/advanced/app/build.gradle.kts
+++ b/Maps3DSamples/advanced/app/build.gradle.kts
@@ -94,6 +94,7 @@ android {
versionName = "1.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ manifestPlaceholders["MAPS3D_API_KEY"] = "DEFAULT_API_KEY"
}
buildTypes {
@@ -133,6 +134,7 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.google.truth)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
@@ -161,12 +163,11 @@ dependencies {
}
secrets {
- // Optionally specify a different file name containing your secrets.
- // The plugin defaults to "local.properties"
- propertiesFileName = "secrets.properties"
-
- // A properties file containing default secret values. This file can be
- // checked in version control.
+ // Only set propertiesFileName if the file exists to avoid FileNotFoundException in CI
+ val secretsFile = rootProject.file("secrets.properties")
+ if (secretsFile.exists()) {
+ propertiesFileName = "secrets.properties"
+ }
defaultPropertiesFileName = "local.defaults.properties"
}
diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/MainActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/MainActivityTest.kt
new file mode 100644
index 00000000..23704212
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/MainActivityTest.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.advancedmaps3dsamples
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * UI tests for [MainActivity].
+ */
+@RunWith(AndroidJUnit4::class)
+class MainActivityTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun testMainScreenIsDisplayed() {
+ // Verify that the activity launches and the main screen is displayed.
+ composeTestRule.onNodeWithTag("main_screen").assertIsDisplayed()
+
+ // Also verify that the activity is not finishing.
+ composeTestRule.activityRule.scenario.onActivity { activity ->
+ assertThat(activity.isFinishing).isFalse()
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivityTest.kt
new file mode 100644
index 00000000..a58b56c9
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivityTest.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.advancedmaps3dsamples.basicmap
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.example.advancedmaps3dsamples.basicmap.BasicComposeMapActivity
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * UI tests for [BasicComposeMapActivity].
+ */
+@RunWith(AndroidJUnit4::class)
+class BasicComposeMapActivityTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun testMapIsDisplayed() {
+ // Verify that the activity launches and the map view is displayed.
+ // We use the testTag we added to the AndroidView.
+ composeTestRule.onNodeWithTag("map3d_view").assertIsDisplayed()
+
+ // Also verify that the activity is not finishing.
+ composeTestRule.activityRule.scenario.onActivity { activity ->
+ assertThat(activity.isFinishing).isFalse()
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/route/RouteSampleActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/route/RouteSampleActivityTest.kt
new file mode 100644
index 00000000..7d468778
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/route/RouteSampleActivityTest.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.advancedmaps3dsamples.route
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * UI tests for [RouteSampleActivity].
+ */
+@RunWith(AndroidJUnit4::class)
+class RouteSampleActivityTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun testMapIsDisplayed() {
+ // Verify that the activity launches and the map view is displayed.
+ composeTestRule.onNodeWithTag("map3d_view").assertIsDisplayed()
+
+ // Also verify that the activity is not finishing.
+ composeTestRule.activityRule.scenario.onActivity { activity ->
+ assertThat(activity.isFinishing).isFalse()
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivityTest.kt b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivityTest.kt
new file mode 100644
index 00000000..14bca99a
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/androidTest/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivityTest.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.advancedmaps3dsamples.scenarios
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * UI tests for [ScenariosActivity].
+ */
+@RunWith(AndroidJUnit4::class)
+class ScenariosActivityTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun testScenarioPickerIsDisplayed() {
+ // Verify that the activity launches and the scenario picker is displayed.
+ composeTestRule.onNodeWithTag("scenario_picker").assertIsDisplayed()
+
+ // Also verify that the activity is not finishing.
+ composeTestRule.activityRule.scenario.onActivity { activity ->
+ assertThat(activity.isFinishing).isFalse()
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml
index 37cd046e..c573539a 100644
--- a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml
+++ b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml
@@ -54,6 +54,10 @@
android:name=".route.RouteSampleActivity"
android:exported="true"
/>
+
\ No newline at end of file
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt
index ad6645f1..cacda9af 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt
@@ -37,8 +37,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import com.example.advancedmaps3dsamples.basicmap.BasicComposeMapActivity
import com.example.advancedmaps3dsamples.route.RouteSampleActivity
import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity
import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme
@@ -48,6 +50,7 @@ data class MapSample(@StringRes val label: Int, val clazz: Class<*>)
private val samples =
listOf(
+ MapSample(R.string.map_sample_basic, BasicComposeMapActivity::class.java),
MapSample(R.string.map_sample_scenarios, ScenariosActivity::class.java),
MapSample(R.string.map_sample_route, RouteSampleActivity::class.java),
)
@@ -61,7 +64,7 @@ class MainActivity : ComponentActivity() {
setContent {
AdvancedMaps3DSamplesTheme {
Scaffold(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxSize().testTag("main_screen"),
topBar = {
CenterAlignedTopAppBar(
title = { Text(stringResource(R.string.map_sample_title)) }
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivity.kt
new file mode 100644
index 00000000..3a87888c
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/basicmap/BasicComposeMapActivity.kt
@@ -0,0 +1,112 @@
+package com.example.advancedmaps3dsamples.basicmap
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme
+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
+
+/**
+ * A minimal reference example demonstrating how to integrate the Google Maps 3D SDK
+ * into a Jetpack Compose application.
+ *
+ * This activity serves as a pedagogical example for junior developers to understand
+ * the core concepts of using Map3DView with Compose, including state hoisting,
+ * lifecycle management, and View interop.
+ */
+class BasicComposeMapActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ AdvancedMaps3DSamplesTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ // 1. STATE HOISTING (The Compose Bridge)
+ // We create a mutable state holder to keep a reference to the GoogleMap3D object.
+ // This allows Compose to react to changes when the map becomes ready and gives us
+ // a handle to interact with the map (e.g., add markers, move camera) later.
+ val map3DState = remember { mutableStateOf(null) }
+
+ // 2. VIEW INITIALIZATION
+ // We use `remember` to ensure the Map3DView is created ONLY ONCE when this
+ // Composable enters the composition. Without `remember`, a new Map3DView would
+ // be created on every recomposition, causing severe performance issues and losing state.
+ val map3DView = remember {
+ // Map3DOptions allows us to set the initial camera position and orientation.
+ val options = Map3DOptions(
+ centerLat = 21.350,
+ centerLng = -157.800,
+ centerAlt = 0.0,
+ tilt = 60.0,
+ range = 25000.0
+ )
+ Map3DView(context, options).apply {
+ // Map3DView loads its resources asynchronously. We must provide a callback
+ // to be notified when the map is fully loaded and ready for interaction.
+ getMap3DViewAsync(object : OnMap3DViewReadyCallback {
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ // Store the reference in our Compose state holder to "bridge" it.
+ map3DState.value = googleMap3D
+ }
+ override fun onError(error: Exception) {
+ // In a production app, handle this gracefully (e.g., show an error UI).
+ throw error
+ }
+ })
+ }
+ }
+
+ // 3. LIFECYCLE MANAGEMENT
+ // MapView (and Map3DView) are heavy components that manage native resources and rendering loops.
+ // They MUST be notified of the Activity lifecycle events to start/stop rendering,
+ // clean up memory, and respond to app backgrounding correctly.
+ // We use DisposableEffect to attach an observer to the Compose LifecycleOwner.
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_CREATE -> map3DView.onCreate(null)
+ Lifecycle.Event.ON_RESUME -> map3DView.onResume()
+ Lifecycle.Event.ON_PAUSE -> map3DView.onPause()
+ Lifecycle.Event.ON_DESTROY -> map3DView.onDestroy()
+ else -> {}
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+
+ // Clean up the observer when this Composable leaves the composition.
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ // 4. THE BRIDGE
+ // Since Map3DView is a traditional Android View, we use AndroidView to
+ // inflate and display it within our Jetpack Compose layout.
+ AndroidView(
+ modifier = Modifier.fillMaxSize().padding(innerPadding).testTag("map3d_view"),
+ factory = { map3DView }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt
index b179d54b..e597011f 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt
@@ -82,6 +82,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.advancedmaps3dsamples.BuildConfig
@@ -333,7 +334,7 @@ private fun Map3DViewport(
onMapReady: (GoogleMap3D) -> Unit, onMapCleared: () -> Unit
) {
val context = LocalContext.current
- AndroidView(modifier = Modifier.fillMaxSize(), factory = {
+ AndroidView(modifier = Modifier.fillMaxSize().testTag("map3d_view"), factory = {
val options = Map3DOptions(
centerLat = 21.350,
centerLng = -157.800,
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt
index d93bb2b9..d7f87b90 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt
@@ -54,6 +54,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -179,6 +180,7 @@ class ScenariosActivity : ComponentActivity() {
fun ScenarioPicker(modifier: Modifier, onScenarioClicked: (Scenario) -> Unit) {
Column(
modifier = modifier
+ .testTag("scenario_picker")
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
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 2a53a7db..224d4706 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
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.maps3d.GoogleMap3D
import com.google.android.gms.maps3d.Map3DOptions
@@ -36,7 +37,7 @@ internal fun ThreeDMap(
}
AndroidView(
- modifier = modifier,
+ modifier = modifier.testTag("map3d_view"),
factory = { context ->
val map3dView = Map3DView(context = context, options = options)
map3dView.onCreate(null)
diff --git a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml
index 2b72c48a..0a9ce978 100644
--- a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml
+++ b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml
@@ -20,6 +20,7 @@
3D Map Samples
Scenarios (video visuals)
Routes API Integration
+ Basic Map (Compose)