diff --git a/codelab/codelab.md b/codelab/codelab.md
new file mode 100644
index 00000000..1ca57dc5
--- /dev/null
+++ b/codelab/codelab.md
@@ -0,0 +1,979 @@
+# Codelab: Aloha Explorer — Building with the 3D Maps SDK for Android
+
+Welcome to the future of mobile mapping! In this codelab, we are going to build **Aloha Explorer**, a 3D interactive tour of historic Iolani Palace in Honolulu.
+
+Using the **Google Maps 3D SDK for Android**, you will transform a standard flat map into an immersive 3D experience. We will start with a basic "Hello World" app and add:
+
+* **Cinematic Camera**: A smooth flight from space down to Honolulu.
+* **3D Markers**: Floating markers pinned to specific altitudes.
+* **3D Volumes**: An extruded polygon visualizing the palace grounds.
+* **3D Models**: A high-fidelity glTF model (a Balloon) floating above the scene.
+
+### What you’ll learn
+
+* **Initialization**: How to set up the `Map3DView` and handle its lifecycle.
+* **Camera Control**: How to orchestrate smooth animations using Coroutines.
+* **Altitude Modes**: Understanding `ABSOLUTE`, `RELATIVE_TO_GROUND`, `CLAMP_TO_GROUND`, and `RELATIVE_TO_MESH`.
+* **Extrusion**: Turning 2D footprints into 3D volumes.
+* **3D Models**: Loading and placing glTF assets.
+* **Interaction**: Handling click events on 3D objects.
+
+---
+
+## Prerequisites
+
+Before we start coding, ensure your environment is ready.
+
+1. **Enable the SDK**:
+ * Go to the [Google Cloud Console](https://console.cloud.google.com/marketplace/product/google/mapsandroid.googleapis.com).
+ * Select your project and click **Enable** to turn on the "Maps 3D SDK for Android".
+ * Make sure you have an API Key created in the "Credentials" section.
+
+2. **Add Your API Key**:
+ To authenticate with Google Maps Platform services, your app must provide an API key. We use the **Secrets Gradle Plugin** to do this securely.
+
+ > **[!NOTE] Why the Secrets Plugin?**
+ > Hardcoding an API key into your Android Manifest or source code is dangerous! If you check your code into GitHub, malicious bots can scrape your key!
+ >
+ > The Secrets Gradle Plugin extracts the key from a local text file and injects it into the build process securely without exposing it to version control.
+
+ The `starter` project comes with the Secrets plugin pre-configured for you! All you need to do is create a new file called `secrets.properties` in the root of your project and add:
+ ```properties
+ MAPS_API_KEY=YOUR_API_KEY_HERE
+ ```
+
+3. **Dependencies**:
+ Finally, you need to import the SDK itself. Open `gradle/libs.versions.toml` and uncomment the `playServicesMaps3d` line in the `[versions]` section and the `play-services-maps3d` line in the `[libraries]` section.
+
+ Then, open `app/build.gradle.kts` and uncomment the dependency:
+ ```kotlin
+ dependencies {
+ // ...
+ implementation(libs.play.services.maps3d)
+ }
+ ```
+ * Ensure the Maps 3D SDK dependency is also present in `dependencies { ... }`:
+ ```kotlin
+ dependencies {
+ implementation(libs.play.services.maps3d)
+ }
+ ```
+
+ * **Be sure to sync the project if you haven't already!**
+
+4. **Uncomment SDK-Dependent Files**:
+ The starter project contains several helper files (`HonoluluData.kt`, `Map3DLifecycleObserver.kt`, and `Utilities.kt`) that rely heavily on the Maps 3D SDK. They were temporarily commented out to prevent compilation errors before you synced the SDK dependency.
+ * Open `HonoluluData.kt`. Remove the `/*` at the top and the `*/` at the bottom to uncomment the file.
+ * Do the same for `Map3DLifecycleObserver.kt`.
+ * Do the same for `Utilities.kt`.
+
+---
+
+## 1. Setting the Stage (Layout & Init)
+
+Before we can explore the islands, we need to set up our "cockpit". We need a layout that gives the map full focus while keeping our controls accessible.
+
+### Step 1.1: The Layout
+
+Open `app/src/main/res/layout/activity_main.xml`. The layout is mostly provided for you in the starter project. It features a `ConstraintLayout` containing a placeholder `TextView` at the top and a scrollable row of control buttons at the bottom.
+
+To insert the 3D Map, find the `TextView` with the `id` of `@+id/map3dView` and replace the entire ` ` block with the `Map3DView` component below.
+
+**Key Changes:**
+1. **Map3DView**: We replace the placeholder with the actual SDK component. Notice all the `app:` attributes provided to configure the initial state of the camera!
+2. **Attributes**: We configure its boundaries, tilt limits, and the initial hybrid map mode.
+
+```xml
+
+
+```
+
+### Step 1.2: Project Skeleton
+
+Open `MainActivity.kt`. We will set up the class structure, state variables, and handle the "Edge-to-Edge" UI logic here. Note that all of our geographic constants and model URLs have been pre-defined for you in `HonoluluData.kt` to keep this class clean.
+
+**Tasks**:
+1. Setup **State Management** lists to track objects.
+2. Handle **Window Insets** in `onCreate`.
+
+```kotlin
+class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
+
+ private lateinit var map3DView: Map3DView
+ private var googleMap3D: GoogleMap3D? = null
+
+ // State Management: Track objects to remove them later
+ private val activeMarkers = mutableListOf()
+ private val activePolygons = mutableListOf()
+ private val activePolylines = mutableListOf()
+ private val activeModels = mutableListOf()
+ private val activePopovers = mutableListOf()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContentView(R.layout.activity_main)
+
+ // Handle Insets (Status Bar & Nav Bar)
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.map3dView)) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, 0)
+ insets
+ }
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.controls_scroll_view)) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val basePadding = (16 * resources.displayMetrics.density).toInt() // 16dp
+ v.setPadding(systemBars.left, basePadding, systemBars.right, systemBars.bottom + basePadding)
+ insets
+ }
+ }
+
+ // Helper to clear the map before adding new content
+ private fun resetMap() {
+ activeMarkers.forEach { it.remove() }; activeMarkers.clear()
+ activePolygons.forEach { it.remove() }; activePolygons.clear()
+ activePolylines.forEach { it.remove() }; activePolylines.clear()
+ activeModels.forEach { it.remove() }; activeModels.clear()
+ activePopovers.forEach { it.remove() }; activePopovers.clear()
+ }
+
+ // We'll implement onMap3DViewReady in the next step!
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ // TODO: Initialize map
+ }
+}
+```
+
+### Step 1.3: Initialize the Map
+
+Now separate the boilerplate from the logic. We will initialize the `Map3DView` and set up our callback.
+
+1. **Initialize View**: Add the `map3DView` setup code to the end of `onCreate`.
+2. **Handle Callback**: Implement `onMap3DViewReady` to receive the `GoogleMap3D` controller.
+3. **Setup Controls**: Configure the bottom sheet buttons.
+
+Add this logic to `MainActivity.kt`:
+
+```kotlin
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // ... previous code ...
+
+ // Initialize the Map3DView
+ map3DView = findViewById(R.id.map3dView)
+
+ // 1.4. Map Lifecycle
+ // Manually trigger onCreate (and unpack any saved state from rotation)
+ val mapState = savedStateRegistry.consumeRestoredStateForKey("map3d_state_provider")
+ map3DView.onCreate(mapState)
+
+ // Attach the automated Lifecycle Observer
+ lifecycle.addObserver(Map3DLifecycleObserver(map3DView, this))
+
+ map3DView.getMap3DViewAsync(this)
+ }
+
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ this@MainActivity.googleMap3D = googleMap3D
+
+ lifecycleScope.launch {
+ // Start from space!
+ startFromGlobalView(googleMap3D)
+
+ // Wire up buttons
+ setupButtons(googleMap3D)
+ }
+ }
+
+ // Helper: Start the camera from a global view
+ private fun startFromGlobalView(map: GoogleMap3D) {
+ map.setCamera(
+ camera {
+ center = latLngAltitude { latitude = 0.0; longitude = 0.0; altitude = 0.0 }
+ range = 25_000_000.0 // 25,000 km
+ tilt = 0.0
+ heading = 0.0
+ }
+ )
+ }
+```
+> **Note**: You will see red errors for `startFromGlobalView` and other helper functions. Don't worry! We will implement these in the next sections. The `setupButtons` wiring has been pre-provided at the bottom of your file.
+
+### Step 1.4: Map Lifecycle (The Modern Way)
+
+Unlike standard Android Views, the `Map3DView` contains a powerful 3D rendering engine that must be explicitly paused and resumed to preserve battery life and system resources.
+
+Normally, this would mean overriding `onResume`, `onPause`, `onDestroy`, and `onSaveInstanceState` in your `MainActivity` and manually forwarding those events to the map.
+
+**To avoid this messy boilerplate, we have pre-provided `Map3DLifecycleObserver.kt`.**
+
+This useful utility class implements `DefaultLifecycleObserver`. By calling `lifecycle.addObserver(Map3DLifecycleObserver(map3DView, this))` inside `onCreate`, the map will automatically listen to your Activity's lifecycle events, keeping your UI controller clean!
+
+---
+
+## 2. The Royal Flyover (Camera Mastery)
+
+Now that we’re in Hawaii, let's head to the historic **Iolani Palace**. Unlike 2D maps where you just "pan", 3D maps allow cinematic camera movements.
+
+### Objective
+We want to fly from space down to Honolulu, and then orbit the palace.
+
+### The Setup
+We use `flyToOptions` to define the flight.
+
+```kotlin
+private suspend fun flyToHonolulu(map: GoogleMap3D) {
+ // Duration using Kotlin Duration extension
+ val flyDuration = 5.seconds
+
+ map.flyCameraTo(
+ flyToOptions {
+ endCamera = camera {
+ center = HONOLULU
+ tilt = 45.0 // Angled view
+ range = 20000.0 // 20km away
+ }
+ durationInMillis = flyDuration.inWholeMilliseconds
+ }
+ )
+ // Wait...
+}
+```
+
+---
+
+## 3. Robustness (The Secret Sauce)
+
+Here is where many developers trip up. Animations take time. Network loading takes time. If you just chain commands together immediately, the camera might try to move before the map is ready.
+
+**The Solution: Suspending Helper Functions**
+
+> **[!NOTE] Deep Dive: The Magic of `suspendCancellableCoroutine`**
+> The Maps 3D SDK relies heavily on trailing callback functions (e.g. `setCameraAnimationEndListener`). If you want to fly to five different places sequentially, chaining five callbacks inside each other quickly turns into an unreadable "Pyramid of Doom" (affectionately known as Callback Hell).
+>
+> By wrapping these listeners inside Kotlin's `suspendCancellableCoroutine`, we temporarily pause the execution of our Kotlin code *without blocking the main UI thread*! Once the Maps SDK fires the callback indicating the flight is over, `continuation.resume(Unit)` allows our code to cleanly wake back up and proceed to the next line.
+>
+> **The Result:** We can write flat, sequential, and highly readable synchronous-looking code!
+
+Open `Utilities.kt` to see how we wrap the SDK's callback-based listeners into Kotlin Coroutine `suspend` functions. This lets us write linear, readable code like "Fly THERE, then wait, then Fly HERE".
+
+```kotlin
+// Pauses execution until the camera stops moving
+private suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setCameraAnimationEndListener {
+ map.setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ map.setCameraAnimationEndListener(null)
+ }
+}
+
+// Pauses execution until the map tiles are fully loaded and steady
+private suspend fun awaitMapSteady(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setOnMapSteadyListener { isSteady ->
+ if (isSteady) {
+ map.setOnMapSteadyListener(null) // Cleanup the listener
+ if (continuation.isActive) {
+ continuation.resume(Unit) // Resume the suspended coroutine
+ }
+ }
+ }
+
+ // Safety: If the coroutine is cancelled (e.g., user exits app), remove the listener.
+ continuation.invokeOnCancellation {
+ map.setOnMapSteadyListener(null)
+ }
+}
+```
+
+Now our flight plan looks like this:
+
+```kotlin
+ map.flyCameraTo(...)
+ awaitCameraAnimation(map) // Wait for flight
+ awaitMapSteady(map) // Wait for tiles to load
+ map.flyCameraAround(...) // Start orbit
+```
+
+---
+
+## 4. Sticking the Landing (Markers & Altitude)
+
+Objects in a 3D world need to know where they sit on the vertical "Z-axis".
+
+### The 3 Modes of Altitude
+1. **ABSOLUTE**: Relative to sea level (WGS84). Good for airplanes.
+2. **RELATIVE_TO_GROUND**: Relative to the terrain height. Good for things floating *above* the ground.
+3. **CLAMP_TO_GROUND**: Snaps to the terrain. Good for POIs.
+4. **RELATIVE_TO_MESH**: Relative to the actual 3D objects (buildings/trees). Good for placing things on rooftops.
+
+### Helper: `resetMap()` and `addMarker()`
+
+You noticed we call `resetMap()` at the start. This prevents "ghost objects"—markers from previous clicks staying on the map. We iterate through our tracking lists (`activeMarkers`, etc.), call `.remove()` on each object to clear it from the 3D engine, and then clear the list.
+
+We also use a custom `addMarker(options: MarkerOptions)` helper method. Instead of duplicating the `Toast` listener and the list-tracking logic for every single marker, this helper centralizes it:
+```kotlin
+private fun addMarker(markerOptions: MarkerOptions) {
+ googleMap3D?.addMarker(markerOptions)?.also { marker ->
+ marker.setClickListener {
+ lifecycleScope.launch(Dispatchers.Main) {
+ Toast.makeText(this@MainActivity, getString(R.string.toast_clicked, marker.label), Toast.LENGTH_SHORT).show()
+ }
+ }
+ activeMarkers.add(marker)
+ }
+}
+```
+
+
+```kotlin
+private fun addMarkers(map: GoogleMap3D) {
+ resetMap()
+
+ // 4.1. ABSOLUTE: Altitude is relative to the WGS84 ellipsoid (rough sea level).
+ addMarker(
+ markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude + 0.001
+ longitude = IOLANI_PALACE.longitude
+ altitude = 100.0 // 100 meters above sea level
+ }
+ altitudeMode = AltitudeMode.ABSOLUTE
+ label = getString(R.string.label_absolute)
+ isDrawnWhenOccluded = true
+ isExtruded = true // Draws a line to the ground
+ }
+ )
+
+ // 4.2. RELATIVE_TO_GROUND: Altitude is added to the terrain height at that point.
+ addMarker(
+ markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude
+ altitude = 50.0 // 50m above ground
+ }
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND
+ label = getString(R.string.label_relative)
+ isDrawnWhenOccluded = true
+ isExtruded = true
+ }
+ )
+
+ // 4.3. CLAMP_TO_GROUND: Snaps to the terrain.
+ addMarker(
+ markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude - 0.001
+ longitude = IOLANI_PALACE.longitude
+ altitude = 0.0 // Ignored
+ }
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ label = getString(R.string.label_clamped)
+ isDrawnWhenOccluded = true
+ isExtruded = true
+ }
+ )
+}
+
+
+```
+
+---
+
+## 5. Advanced Custom Markers
+
+Markers don't have to be just standard red pins. The Maps 3D SDK allows you to heavily customize their appearance using the `setStyle()` method on `MarkerOptions`.
+
+Here we'll add three new markers next to Iolani Palace to demonstrate:
+1. **A Styled Pin**: A customized standard pin with a specific color and scale.
+2. **An Image Glyph**: A marker that uses a standard Android Drawable resource (a Hibiscus flower).
+3. **A Text Glyph**: A marker that uses an emoji (`🌸`) scaled elegantly.
+
+Add the following function below `addMarkers`:
+
+```kotlin
+ private fun addCustomMarkers(map: GoogleMap3D) {
+ resetMap()
+
+ // 5.1. Styled Pin
+ addMarker(
+ markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude + 0.002
+ altitude = 0.0
+ }
+ label = getString(R.string.label_styled_pin)
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ setStyle(pinConfiguration {
+ backgroundColor = Color.BLUE
+ borderColor = Color.WHITE
+ scale = 1.5f
+ })
+ }
+ )
+
+ // 5.2. Image Glyph (Hibiscus)
+ addMarker(
+ markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude + 0.004
+ altitude = 0.0
+ }
+ label = getString(R.string.label_hibiscus)
+ isExtruded = true
+ isDrawnWhenOccluded = true
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ setStyle(ImageView(this@MainActivity).apply {
+ setImageResource(R.drawable.hibiscus)
+ })
+ }
+ )
+
+ // 5.3. Text Glyph
+ val glyphText = Glyph.fromColor(Color.YELLOW).apply {
+ setText("🌸")
+ }
+
+ addMarker(
+ markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude + 0.006
+ altitude = 0.0
+ }
+ label = getString(R.string.label_text_glyph)
+ isExtruded = true
+ isDrawnWhenOccluded = true
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ setStyle(pinConfiguration {
+ setGlyph(glyphText)
+ scale = 1.2f
+ backgroundColor = Color.BLUE
+ borderColor = Color.GREEN
+ })
+ }
+ )
+ }
+```
+
+---
+
+## 6. Highlighting History (Polygons & Extrusion)
+
+Let's highlight the Palace grounds. A flat polygon is okay, but a 3D volume is better.
+
+### Extrusion Algorithm
+Open `Utilities.kt` to review the `extrudePolygon` helper function. We can "extrude" a flat shape by:
+1. Taking the base coordinates.
+2. Duplicating them at a higher altitude (the "roof").
+3. Stitching the sides together with new polygons.
+
+```kotlin
+// Define the base (ground) shape of Iolani Palace.
+// Uses the constant defined in companion object
+val palaceBaseFace = IOLANI_PALACE_GEO.windowed(2, 2).map {
+ latLngAltitude { latitude = it[0]; longitude = it[1]; altitude = 0.0 }
+}.let { points -> points + points.first() }
+
+// Extrude!
+val extrudedPalace = extrudePolygon(palaceBaseFace, 35.0) // 35 meters tall
+```
+
+```kotlin
+// Helper function to create the 3D extrusion geometry
+private fun extrudePolygon(
+ basePoints: List,
+ extrusionHeight: Double
+): List> {
+ if (basePoints.size < 3) return emptyList()
+ if (extrusionHeight <= 0) return emptyList()
+
+ val baseAltitude = basePoints.first().altitude
+
+ // 1. Create top points
+ val topPoints = basePoints.map { basePoint ->
+ latLngAltitude {
+ latitude = basePoint.latitude
+ longitude = basePoint.longitude
+ altitude = baseAltitude + extrusionHeight
+ }
+ }
+
+ val faces = mutableListOf>()
+ faces.add(basePoints.toList()) // Bottom
+ faces.add(topPoints.toList().reversed()) // Top
+
+ // 2. Create side walls
+ 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
+}
+```
+
+> **[!TIP] Visualizing the Extrusion Math**
+> When stitching the vertical side walls, we create a single face polygon using four vertices: two from the "ground" (Base) and two directly above them (Top) at the extrusion altitude.
+>
+> ```text
+> p1Top -------- p2Top (Altitude: 35.0)
+> | |
+> | (Side Wall) |
+> | |
+> p1Base ------- p2Base (Altitude: 0.0)
+> ```
+> By looping this logic for every pair of points around the perimeter, we seamlessly form an unbroken 3D perimeter wall.
+
+When we add these faces to the map, we use `AltitudeMode.ABSOLUTE` to ensure the building keeps its shape perfectly.
+
+
+
+---
+
+## 7. Interaction (Touching the World)
+
+A map isn't an image; it's an interface. Let's make our markers interactive.
+
+```kotlin
+// 6.1. Tapping the Turf
+// Add click listener to each face
+palacePolygons.forEach { polygon ->
+ polygon.setClickListener {
+ lifecycleScope.launch(Dispatchers.Main) {
+ Toast.makeText(
+ this@MainActivity,
+ "The Royal Palace: A symbol of Hawaiian sovereignty.",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+}
+```
+
+## 8. Connecting the Dots (Polylines)
+
+While markers show discrete locations, polylines show paths. Let's draw a path from Iolani Palace to Waikiki Beach.
+
+Just like Polygons, Polylines are drawn point-by-point.
+
+```kotlin
+// Draw path to Waikiki
+activePolylines.add(map.addPolyline(
+ polylineOptions {
+ path = listOf(IOLANI_PALACE, WAIKIKI)
+ strokeWidth = 10.0
+ strokeColor = Color.BLUE
+ }
+))
+
+// Jump the camera to see the full path
+map.setCamera(camera {
+ center = latLngAltitude {
+ latitude = 21.2893
+ longitude = -157.8441
+ altitude = 0.0
+ }
+ heading = 0.0
+ tilt = 0.0
+ range = 8000.0
+})
+```
+
+---
+
+## 9. Up, Up, and Away (The Balloon)
+
+Finally, let's have some fun. We'll load a 3D asset (a glTF file) of a Hot Air Balloon and place it over Waikiki.
+
+### Adding a Model
+Models are just like markers, but with 3D geometry.
+
+```kotlin
+val balloon = map.addModel(
+ modelOptions {
+ position = latLngAltitude {
+ latitude = WAIKIKI.latitude
+ longitude = WAIKIKI.longitude
+ altitude = 20.0 // Floating above the beach
+ }
+ url = "https://.../balloon.glb"
+ scale = vector3D { x = 5.0; y = 5.0; z = 5.0 } // Make it big!
+ }
+)
+```
+
+And ensuring it's clickable:
+
+```kotlin
+balloon.setClickListener {
+ lifecycleScope.launch(Dispatchers.Main) {
+ Toast.makeText(this@MainActivity, "Clicked the Balloon!", Toast.LENGTH_SHORT).show()
+ }
+}
+
+
+
+---
+
+## 10. Popovers (Info Windows)
+
+Markers are great, but sometimes you need to show more information. **Popovers** are 2D views that "stick" to a 3D location. Unlike Markers, they always face the camera and can contain any Android View (button, text, image, etc.).
+
+### Creating a Popover
+
+In this example, we will attach a "Hello World" message to the Iolani Palace marker.
+
+1. **Create the View**: First, we create a standard Android `TextView` programmatically.
+2. **Configure the Popover**: We use `popoverOptions` to define its anchor point and style.
+
+```kotlin
+ private fun setupPopover(map: GoogleMap3D) {
+ resetMap()
+
+ // 9.1. Create a simple text view for the popover content
+ // In a real app, you could inflate this from XML using layoutInflater
+ val textView = TextView(this).apply {
+ text = "Welcome to Iolani Palace!\nA symbol of Hawaiian sovereignty."
+ setPadding(32, 16, 32, 16)
+ setTextColor(Color.BLACK)
+ setBackgroundColor(Color.WHITE)
+ }
+
+ // 9.2. Add a Popover attached to the same location
+ val popover = map.addPopover(popoverOptions {
+ positionAnchor = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude
+ altitude = 10.0
+ }
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH
+ content = textView
+ autoCloseEnabled = true // Close when user clicks elsewhere
+ autoPanEnabled = true // Move camera to ensure popover is visible
+ popoverStyle = popoverStyle {
+ backgroundColor = Color.WHITE
+ borderRadius = 16f
+ shadow = popoverShadow {
+ color = Color.DKGRAY
+ radius = 8f
+ offsetX = 4f
+ offsetY = 4f
+ }
+ }
+ })
+
+ // Track it
+ activePopovers.add(popover)
+
+ // Show immediately for demo purposes
+ popover.show()
+ }
+```
+
+Popovers bridge the gap between the 3D world and 2D information. They are perfect for labels, detailed info windows, or even interactive menus.
+
+
+
+---
+
+## 11. The Scenic Tour (Animating Between Markers)
+
+Let's spread our wings and explore the whole island! In this step, we'll scatter markers across Oahu's most famous landmarks and animate the camera flying point-to-point.
+
+1. Add the `btn_tour` and `btn_clear` buttons to your `activity_main.xml` layout, next to the other buttons.
+2. In `MainActivity.kt`, add the geographic constants for the landmarks to the `companion object`.
+3. Add a helper function `flyTour` to orchestrate the flight:
+
+```kotlin
+ private suspend fun flyTour(map: GoogleMap3D) {
+ val locations = listOf(
+ HONOLULU to "Honolulu",
+ DIAMOND_HEAD to "Diamond Head",
+ HANAUMA_BAY to "Hanauma Bay",
+ KOKO_HEAD to "Koko Head",
+ LANIKAI_BEACH to "Lanikai Beach",
+ MOUNT_KAALA to "Mount Ka'ala",
+ PEARL_HARBOR to "Pearl Harbor"
+ )
+
+ // Add all markers for the tour
+ resetMap()
+ locations.forEach { (location, name) ->
+ activeMarkers.add(map.addMarker(
+ markerOptions {
+ position = location
+ label = name
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ isExtruded = true
+ }
+ )!!)
+ }
+
+ // Fly to each location
+ for ((location, _) in locations) {
+ // TODO: CHALLENGE!
+ // 1. Tell the map to flyCameraTo this 'location'.
+ // (Hint: Use tilt = 45.0, range = 2500.0, heading = 0.0, durationInMillis = 3000L)
+ // 2. Wait for the animation to finish using awaitCameraAnimation(map)
+ // 3. Optional: Add a delay so the user can enjoy the view before flying to the next!
+
+ }
+ }
+```
+
+### Stopping Animations
+
+If the tour is running and the user clicks another button, we must cancel the existing animation. Notice in the solution code we wrap `lifecycleScope.launch` in a variable `currentAnimationJob` and call `currentAnimationJob?.cancel()` before starting any new action!
+
+Wire up the new buttons in `setupButtons`:
+
+```kotlin
+ findViewById(R.id.btn_tour).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ flyTour(map)
+ }
+ }
+
+ findViewById(R.id.btn_clear).setOnClickListener {
+ currentAnimationJob?.cancel()
+ resetMap()
+ }
+```
+
+---
+
+## 12. Bonus: Jetpack Compose
+
+Prefer **Jetpack Compose** over XML? The Maps 3D SDK is View-based, but is a perfect candidate for `AndroidView`.
+
+We have included a full reference implementation in `Map3DComposeActivity.kt` (in the solution code). Here is a complete guide to recreating it.
+
+### 1. Configure Options
+First, define how the map should initialize.
+
+```kotlin
+val map3DOptions = Map3DOptions(
+ centerLat = HONOLULU.latitude,
+ centerLng = HONOLULU.longitude,
+ centerAlt = 1000.0,
+ heading = 0.0,
+ range = 10_000_000.0, // Start from space
+ mapMode = Map3DMode.HYBRID
+)
+```
+
+### 2. The Composable Wrapper
+
+We'll build the `Map3DContainer` step-by-step.
+
+#### 2.1 State & AndroidView Skeleton
+
+First, we need to bridge the gap between Compose and the View system using `AndroidView`. We also need to hold the `GoogleMap3D` object in our Compose state.
+
+```kotlin
+@Composable
+fun Map3DContainer(
+ modifier: Modifier = Modifier,
+ options: Map3DOptions
+) {
+ // State to hold the map controller
+ var googleMap by remember { mutableStateOf(null) }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { context ->
+ // TODO: Initialize View
+ Map3DView(context, options)
+ }
+ )
+ }
+}
+```
+
+#### 2.2 Initializing the View (`factory`)
+
+The `factory` lambda is called *once* to create the View. We must manually initialize the `Map3DView`'s lifecycle here.
+
+```kotlin
+factory = { context ->
+ Map3DView(context, options).apply {
+ // Manually call onCreate. In a real app, wire this to LifecycleOwner.
+ onCreate(null)
+ }
+},
+```
+
+#### 2.3 Capturing the Map (`update`)
+
+The `update` lambda runs when the Composable recomposes. We use `getMap3DViewAsync` to extract the `GoogleMap3D` controller and save it to our state.
+
+```kotlin
+update = { view ->
+ view.getMap3DViewAsync(
+ object : OnMap3DViewReadyCallback {
+ override fun onMap3DViewReady(map3D: GoogleMap3D) {
+ googleMap = map3D
+ }
+ override fun onError(e: Exception) {
+ googleMap = null
+ throw e
+ }
+ }
+ )
+},
+```
+
+#### 2.4 Cleanup (`onRelease`)
+
+To prevent memory leaks, we must destroy the view when the Composable is removed from the screen.
+
+```kotlin
+onRelease = { view ->
+ googleMap = null
+ view.onDestroy()
+}
+```
+
+#### 2.5 Adding Interaction
+
+Finally, let's overlay a Button to trigger our cinematic flight. We use a `Job` to manage the animation cancellation.
+
+```kotlin
+// Add this state to track animations
+var animationJob by remember { mutableStateOf(null) }
+val coroutineScope = rememberCoroutineScope()
+
+// ... inside Box, after AndroidView ...
+
+Button(
+ // Enable only if map is ready and not currently animating
+ enabled = googleMap != null && animationJob == null,
+ onClick = {
+ animationJob?.cancel()
+ animationJob = coroutineScope.launch {
+ // 1. Fly to Honolulu
+ googleMap?.flyCameraTo(flyToOptions {
+ endCamera = camera {
+ center = HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ }
+ durationInMillis = 2000L
+ })
+ googleMap?.let { awaitCameraAnimation(it) }
+
+ // 2. Orbit
+ googleMap?.flyCameraAround(flyAroundOptions {
+ center = camera {
+ center = HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ }
+ rounds = 1.0
+ durationInMillis = 5000L
+ })
+ googleMap?.let { awaitCameraAnimation(it) }
+
+ animationJob = null
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 32.dp)
+) {
+ Text("Fly to Honolulu")
+}
+```
+
+### 3. The Activity Setup
+Finally, use `setContent` to display your Composable.
+
+```kotlin
+class Map3DComposeActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Map3DContainer(
+ modifier = Modifier.padding(innerPadding),
+ options = map3DOptions
+ )
+ }
+ }
+ }
+}
+```
+
+This pattern gives you the best of both worlds: the power of the 3D Maps SDK and the modern UI of Jetpack Compose. Check out `Map3DComposeActivity.kt` for the complete code!
+
+
+
+---
+
+## Finish & Next Steps
+
+Mahalo for completing the **Aloha Explorer** codelab! You’ve gone from a empty activity to a cinematic, interactive 3D experience.
+
+**You learned how to:**
+* Manage the `Map3DView` lifecycle.
+* Control the camera with Coroutines.
+* Master `AltitudeMode` for perfect placement.
+* Import and click 3D Models.
+
+**The world is no longer flat—go build something amazing!**
diff --git a/codelab/solution/.gitignore b/codelab/solution/.gitignore
new file mode 100644
index 00000000..45969d87
--- /dev/null
+++ b/codelab/solution/.gitignore
@@ -0,0 +1,16 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/secrets.properties
diff --git a/codelab/solution/app/.gitignore b/codelab/solution/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/codelab/solution/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/codelab/solution/app/build.gradle.kts b/codelab/solution/app/build.gradle.kts
new file mode 100644
index 00000000..b2eae8a2
--- /dev/null
+++ b/codelab/solution/app/build.gradle.kts
@@ -0,0 +1,93 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.secrets.gradle.plugin)
+}
+
+android {
+ namespace = "com.example.alohaexplorer"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ buildFeatures {
+ viewBinding = true
+ compose = true
+ buildConfig = true
+ }
+
+ defaultConfig {
+ applicationId = "com.example.alohaexplorer"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.material)
+ implementation(platform(libs.androidx.compose.bom))
+
+ debugImplementation(libs.androidx.ui.tooling)
+
+ testImplementation(libs.junit)
+
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+
+ implementation(libs.play.services.maps3d)
+}
+
+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.
+ defaultPropertiesFileName = "local.defaults.properties"
+}
+
+tasks.register("launchDebug") {
+ dependsOn("installDebug")
+ commandLine("adb", "shell", "am", "start", "-n", "com.example.alohaexplorer/.MainActivity")
+ doLast {
+ println("Launched com.example.alohaexplorer/.MainActivity")
+ }
+}
diff --git a/codelab/solution/app/proguard-rules.pro b/codelab/solution/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/codelab/solution/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/codelab/solution/app/src/androidTest/java/com/example/alohaexplorer/ExampleInstrumentedTest.kt b/codelab/solution/app/src/androidTest/java/com/example/alohaexplorer/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..a0a3fff9
--- /dev/null
+++ b/codelab/solution/app/src/androidTest/java/com/example/alohaexplorer/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.example.alohaexplorer
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.example.alohaexplorer", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/AndroidManifest.xml b/codelab/solution/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..afa3d86d
--- /dev/null
+++ b/codelab/solution/app/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/java/com/example/alohaexplorer/HonoluluData.kt b/codelab/solution/app/src/main/java/com/example/alohaexplorer/HonoluluData.kt
new file mode 100644
index 00000000..a25e4778
--- /dev/null
+++ b/codelab/solution/app/src/main/java/com/example/alohaexplorer/HonoluluData.kt
@@ -0,0 +1,107 @@
+package com.example.alohaexplorer
+
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+
+// Step 1: Honolulu
+val HONOLULU = latLngAltitude {
+ latitude = 21.3069
+ longitude = -157.8583
+ altitude = 0.0
+}
+
+// Step 2: Iolani Palace
+val IOLANI_PALACE = latLngAltitude {
+ latitude = 21.306740
+ longitude = -157.858803
+ altitude = 0.0
+}
+
+// Step 6: Waikiki Beach
+val WAIKIKI = latLngAltitude {
+ latitude = 21.2766
+ longitude = -157.8286
+ altitude = 0.0
+}
+
+val DIAMOND_HEAD = latLngAltitude {
+ latitude = 21.26194
+ longitude = -157.80556
+ altitude = 0.0
+}
+
+val KOKO_HEAD = latLngAltitude {
+ latitude = 21.270856
+ longitude = -157.694442
+ altitude = 0.0
+}
+
+val PEARL_HARBOR = latLngAltitude {
+ latitude = 21.31861
+ longitude = -157.92250
+ altitude = 0.0
+}
+
+val MOUNT_KAALA = latLngAltitude {
+ latitude = 21.50694
+ longitude = -158.14278
+ altitude = 0.0
+}
+
+val LANIKAI_BEACH = latLngAltitude {
+ latitude = 21.39309
+ longitude = -157.71546
+ altitude = 0.0
+}
+
+// Models must be loaded from a URL. Here we use Cloud Storage.
+const val BALLOON_MODEL_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/balloon-pin-BlXF32yD.glb"
+const val BALLOON_SCALE = 5.0
+
+// Step 5: Iolani Palace Geometry (Lat, Lng pairs)
+val IOLANI_PALACE_GEO = listOf(
+ 21.307180365, -157.858769898,
+ 21.306765552, -157.858390366,
+ 21.306476932, -157.858755146,
+ 21.306892995, -157.859134679,
+)
+
+val POLYGON_CAMERA = camera {
+ center = latLngAltitude {
+ latitude = 21.307051
+ longitude = -157.858546
+ altitude = 7.1
+ }
+ heading = 78.0
+ tilt = 53.0
+ range = 644.0
+}
+
+val MARKER_CAMERA = camera {
+ center = latLngAltitude {
+ latitude = 21.306648
+ longitude = -157.859336
+ altitude = 26.8
+ }
+ tilt = 68.0
+ range = 1024.0
+ heading = 61.0
+}
+
+val CUSTOM_MARKER_CAMERA = camera {
+ this.center = latLngAltitude {
+ this.latitude = 21.306370
+ this.longitude = -157.855474
+ this.altitude = 6.6
+ }
+ this.heading = 39.0
+ this.tilt = 68.0
+ this.range = 1399.0
+}
+
+val BALLOON_CAMERA = camera {
+ center = WAIKIKI
+ tilt = 60.0
+ range = 1000.0
+ heading = 0.0
+}
diff --git a/codelab/solution/app/src/main/java/com/example/alohaexplorer/MainActivity.kt b/codelab/solution/app/src/main/java/com/example/alohaexplorer/MainActivity.kt
new file mode 100644
index 00000000..81a0c049
--- /dev/null
+++ b/codelab/solution/app/src/main/java/com/example/alohaexplorer/MainActivity.kt
@@ -0,0 +1,631 @@
+package com.example.alohaexplorer
+
+import android.graphics.Color
+import android.os.Bundle
+import android.util.Log
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.lifecycleScope
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.Map3DView
+import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
+import com.google.android.gms.maps3d.Popover
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.Glyph
+import com.google.android.gms.maps3d.model.ImageView
+import com.google.android.gms.maps3d.model.Marker
+import com.google.android.gms.maps3d.model.MarkerOptions
+import com.google.android.gms.maps3d.model.Model
+import com.google.android.gms.maps3d.model.Polygon
+import com.google.android.gms.maps3d.model.Polyline
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.flyAroundOptions
+import com.google.android.gms.maps3d.model.flyToOptions
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.gms.maps3d.model.markerOptions
+import com.google.android.gms.maps3d.model.modelOptions
+import com.google.android.gms.maps3d.model.orientation
+import com.google.android.gms.maps3d.model.pinConfiguration
+import com.google.android.gms.maps3d.model.polygonOptions
+import com.google.android.gms.maps3d.model.polylineOptions
+import com.google.android.gms.maps3d.model.popoverOptions
+import com.google.android.gms.maps3d.model.popoverShadow
+import com.google.android.gms.maps3d.model.popoverStyle
+import com.google.android.gms.maps3d.model.vector3D
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * This is the main entry point for the "Aloha Explorer" 3D Maps Codelab.
+ *
+ * This Activity demonstrates the core concepts of the Google Maps Platform 3D SDK for Android:
+ * 1. **Initialization**: How to set up the `Map3DView` and handle its lifecycle.
+ * 2. **Camera Control**: How to move the camera programmatically (flyTo, flyAround).
+ * 3. **Adding Content**: How to add 3D Markers, Models, and shapes (Polygons, Polylines).
+ * 4. **Interaction**: How to handle click events on 3D objects.
+ * 5. **Coroutines**: How to use Kotlin Coroutines for smooth, asynchronous animation sequencing.
+ */
+class MainActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
+
+ // The main view component for the 3D Map.
+ private lateinit var map3DView: Map3DView
+
+ // The API object we use to interact with the map once it's ready.
+ private var googleMap3D: GoogleMap3D? = null
+
+ // **State Management**:
+ // We keep track of the objects we add to the map (Markers, Polygons, etc.) so we can
+ // remove them later. This prevents "ghost" objects from piling up if the user
+ // clicks buttons multiple times. We do this in the activity for simplicity, but recommend
+ // tracking state in a view model.
+
+ // TODO: handle rotation bug... :/
+ private val activeMarkers = mutableListOf()
+ private val activePolygons = mutableListOf()
+ private val activePolylines = mutableListOf()
+ private val activeModels = mutableListOf()
+ private val activePopovers = mutableListOf()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // Fix: Ensure the bundle can deserialize the Maps 3D SDK's Camera Parcelable on rotation
+ savedInstanceState?.classLoader = javaClass.classLoader
+ super.onCreate(savedInstanceState)
+
+ // Enables "Edge-to-Edge" mode, allowing the map to draw behind the system bars
+ // (status bar and navigation bar) for a more immersive experience.
+ enableEdgeToEdge()
+ setContentView(R.layout.activity_main)
+
+ setUpInsets()
+
+ // 1.3. Initialize Map3DView
+ map3DView = findViewById(R.id.map3dView)
+
+ // 1.4. Map Lifecycle
+ // Manually trigger onCreate (and unpack any saved state from rotation)
+ val mapState = savedStateRegistry.consumeRestoredStateForKey("map3d_state_provider")
+ map3DView.onCreate(mapState)
+
+ // Attach the automated Lifecycle Observer to gracefully handle
+ // onResume, onPause, onDestroy, and onSaveInstanceState.
+ lifecycle.addObserver(Map3DLifecycleObserver(map3DView, this))
+
+ // This starts the map loading process. onMap3DViewReady will be called when it's done.
+ map3DView.getMap3DViewAsync(this)
+ }
+
+ /**
+ * Called when the Map is fully loaded and ready to be used.
+ * This is where we can start interacting with the `googleMap3D` object.
+ */
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ this@MainActivity.googleMap3D = googleMap3D
+
+ lifecycleScope.launch {
+ // 1.3. Start from Global View
+ startFromGlobalView(googleMap3D)
+
+ // Setup UI Buttons
+ setupButtons(googleMap3D)
+ }
+ }
+
+ private fun setupPopover(map: GoogleMap3D) {
+ clearMap()
+
+ // 9.1. Create a simple text view for the popover content
+ val textView = TextView(this@MainActivity).apply {
+ text = getString(R.string.toast_palace)
+ setPadding(32, 16, 32, 16)
+ setTextColor(Color.BLACK)
+ setBackgroundColor(Color.WHITE)
+ }
+
+ // 9.2. Add a Popover attached to the same location
+ val popover = map.addPopover(popoverOptions {
+ positionAnchor = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude
+ altitude = 10.0
+ }
+ altitudeMode = AltitudeMode.RELATIVE_TO_MESH
+ content = textView
+ autoCloseEnabled = true // Close when user clicks elsewhere
+ autoPanEnabled = true // Move camera to ensure popover is visible
+ popoverStyle = popoverStyle {
+ backgroundColor = Color.WHITE
+ borderRadius = 16f
+ shadow = popoverShadow {
+ color = Color.DKGRAY
+ radius = 8f
+ offsetX = 4f
+ offsetY = 4f
+ }
+ }
+ })
+
+ // Track it
+ activePopovers.add(popover)
+
+ // Show immediately for demo purposes
+ popover.show()
+ }
+
+ private suspend fun flyTour(map: GoogleMap3D) {
+ val locations = listOf(
+ HONOLULU to "Honolulu",
+ DIAMOND_HEAD to "Diamond Head",
+ KOKO_HEAD to "Koko Head",
+ LANIKAI_BEACH to "Lanikai Beach",
+ MOUNT_KAALA to "Mount Ka'ala",
+ PEARL_HARBOR to "Pearl Harbor"
+ )
+
+ // Add all markers for the tour
+ clearMap()
+ locations.forEach { (location, name) ->
+ activeMarkers.add(map.addMarker(
+ markerOptions {
+ position = location
+ label = name
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ isExtruded = true
+ }
+ )!!)
+ }
+
+ // Fly to each location
+ for ((location, _) in locations) {
+ val camera = camera {
+ center = location
+ tilt = 45.0
+ range = 2500.0
+ heading = 0.0
+ }
+
+ flyCameraToAndWait(
+ map,
+ camera,
+ duration = 3.seconds)
+
+ // Wait for the scene to load
+ awaitMapSteady(map)
+
+ map.flyCameraAround(
+ flyAroundOptions {
+ center = camera
+ rounds = 1.0
+ durationInMillis = 3.seconds.inWholeMilliseconds
+ }
+ )
+
+ // Pause at each location
+ delay(1.5.seconds)
+ }
+ }
+
+ private fun startFromGlobalView(map: GoogleMap3D) {
+ // Initial Position: "Global View" aka Space View
+ map.setCamera(
+ camera {
+ center = latLngAltitude {
+ latitude = 21.3069
+ longitude = -157.8583
+ altitude = 0.0
+ }
+ tilt = 0.0 // Looking straight down
+ range = 5_000_000.0 // 5,000 km altitude for a wide view
+ }
+ )
+ }
+
+ private suspend fun flyToHonolulu(map: GoogleMap3D) {
+ // Duration for the animation
+ val flyDuration = 5.seconds
+ val orbitDuration = 10.seconds
+
+ map.flyCameraTo(
+ // **FlyToOptions**: Configures how the camera moves.
+ flyToOptions {
+ endCamera = camera {
+ center = HONOLULU
+ tilt = 45.0
+ range = 20_000.0 // Zoomed out a bit from the city
+ heading = 0.0
+ }
+ durationInMillis = flyDuration.inWholeMilliseconds
+ }
+ )
+ // Wait for the animation to finish
+ awaitCameraAnimation(map)
+ awaitMapSteady(map) // Wait for tiles to load and settle
+
+ map.flyCameraAround(
+ flyAroundOptions {
+ // **FlyAroundOptions**: Orbits around a specific point.
+ // We reuse the target parameters to be precise, or could read map.camera
+ center = camera {
+ center = HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ heading = 0.0
+ }
+ rounds = 1.0 // One full circle
+ durationInMillis = orbitDuration.inWholeMilliseconds
+ }
+ )
+ awaitCameraAnimation(map)
+ }
+
+ /**
+ * Clears all objects from the map.
+ * We iterate through our tracking lists and call .remove() on each object.
+ * This is crucial to avoid "leaking" objects on the map if the user clicks buttons repeatedly.
+ */
+ private fun clearMap() {
+ activeMarkers.forEach { it.remove() }
+ activeMarkers.clear()
+
+ activePolygons.forEach { it.remove() }
+ activePolygons.clear()
+
+ activePolylines.forEach { it.remove() }
+ activePolylines.clear()
+
+ activeModels.forEach { it.remove() }
+ activeModels.clear()
+
+ activePopovers.forEach { it.remove() }
+ activePopovers.clear()
+ }
+
+ private fun addPolygon(map: GoogleMap3D) {
+ clearMap()
+
+ // Define the base (ground) shape of Iolani Palace.
+ // Take the four corners around the place and build a rectangle around the base
+ val palaceBaseFace = IOLANI_PALACE_GEO.windowed(2, 2).map {
+ latLngAltitude { latitude = it[0]; longitude = it[1]; altitude = 0.0 }
+ }.let { points -> points + points.first() }
+
+ // **Extrusion**: Turn a 2D shape into a 3D building by duplicating the path upwards
+ // and connecting the edges.
+ val palaceCube = extrudePolygon(palaceBaseFace, 35.0) // 35 meters tall
+
+ // Add all faces (top, bottom, sides) to the map
+ palaceCube.forEach { facePoints ->
+ map.addPolygon(
+ polygonOptions {
+ path = facePoints
+ fillColor = Color.argb(100, 255, 215, 0) // Gold with transparency
+ strokeColor = Color.YELLOW
+ strokeWidth = 5.0
+ altitudeMode = AltitudeMode.ABSOLUTE
+ }
+ ).also { face ->
+ // Remember the polygon for clean up
+ activePolygons.add(face)
+
+ // 6.1. Tapping the Turf
+ // Add click listener to each face
+ face.setClickListener {
+ lifecycleScope.launch(Dispatchers.Main) {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.the_royal_palace),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ }
+ }
+
+ private fun setupPolylines(map: GoogleMap3D) {
+ clearMap()
+
+ // 7. Polylines
+ // Draw path to Waikiki
+ activePolylines.add(map.addPolyline(
+ polylineOptions {
+ path = listOf(IOLANI_PALACE, WAIKIKI)
+ strokeWidth = 10.0
+ strokeColor = Color.BLUE
+ }
+ ))
+ }
+
+ private fun setupBalloon(map: GoogleMap3D) {
+ clearMap()
+
+ // Add "Balloon" Model (glTF)
+ // Models are external 3D assets.
+ val balloon = map.addModel(
+ modelOptions {
+ position = latLngAltitude {
+ latitude = WAIKIKI.latitude
+ longitude = WAIKIKI.longitude
+ altitude = 20.0 // Float 20 meters above the beach
+ }
+ altitudeMode = AltitudeMode.ABSOLUTE
+ // **Orientation**: Rotate the model in 3D space.
+ orientation = orientation {
+ heading = 0.0 // North
+ tilt = 0.0 // Upright
+ roll = 0.0 // Level
+ }
+ url = BALLOON_MODEL_URL
+ // **Scale**: Resize the model. (x=5, y=5, z=5) makes it 5x larger.
+ scale = vector3D { x = BALLOON_SCALE; y = BALLOON_SCALE; z = BALLOON_SCALE }
+ }
+ )
+
+ // Add click listener
+ balloon.setClickListener {
+ lifecycleScope.launch(Dispatchers.Main) {
+ Toast.makeText(this@MainActivity,
+ getString(R.string.clicked_the_balloon), Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ // Track it so we can remove it later
+ activeModels.add(balloon)
+ }
+
+
+ /**
+ * Helper function for adding a clickable marker.
+ */
+ private fun addClickableMarker(map: GoogleMap3D, markerOptions: MarkerOptions) {
+ map.addMarker(markerOptions)?.also { marker ->
+ marker.setClickListener {
+ lifecycleScope.launch(Dispatchers.Main) {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.toast_clicked, marker.label),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ activeMarkers.add(marker)
+ }
+ }
+
+ /**
+ * Demonstrates adding 3D markers with different [AltitudeMode]s.
+ * Junior Dev Tip: Understanding `AltitudeMode` is key to placing objects correctly.
+ */
+ private fun addMarkers(map: GoogleMap3D) {
+ clearMap()
+
+ // 4.1. ABSOLUTE: Altitude is relative to the WGS84 ellipsoid (rough sea level).
+ // Good for aircraft, satellites, or data visualizations that ignore terrain.
+ addClickableMarker(map, markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude
+ altitude = 100.0 // 100 meters above sea level
+ }
+ altitudeMode = AltitudeMode.ABSOLUTE
+ label = getString(R.string.label_absolute)
+ isDrawnWhenOccluded = true
+ isExtruded = true // Draws a line to the ground
+ })
+
+ // 4.2. RELATIVE_TO_GROUND: Altitude is added to the terrain height at that point.
+ // Perfect for placing things "floating 50m above the ground".
+ addClickableMarker(map, markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude + 0.001
+ longitude = IOLANI_PALACE.longitude
+ altitude = 50.0 // 50m above whatever ground is here
+ }
+ altitudeMode = AltitudeMode.RELATIVE_TO_GROUND
+ label = getString(R.string.label_relative)
+ isDrawnWhenOccluded = true
+ isExtruded = true
+ })
+
+ // 4.3. CLAMP_TO_GROUND: Altitude is ignored; object snaps to the terrain/mesh.
+ // Best for map pins, POIs, or anything on the surface.
+ addClickableMarker(map, markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude - 0.001
+ longitude = IOLANI_PALACE.longitude
+ altitude = 0.0 // Ignored
+ }
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ label = getString(R.string.label_clamped)
+ isDrawnWhenOccluded = true
+ isExtruded = true
+ })
+ }
+
+ private fun addCustomMarkers(map: GoogleMap3D) {
+ clearMap()
+
+ // 4.4. Styled Pin
+ addClickableMarker(map, markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude + 0.002
+ altitude = 0.0
+ }
+ label = getString(R.string.label_styled_pin)
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ setStyle(pinConfiguration {
+ backgroundColor = Color.BLUE
+ borderColor = Color.WHITE
+ scale = 1.5f
+ })
+ })
+
+ // 4.5. Image Glyph (Hibiscus)
+ addClickableMarker(map, markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude + 0.004
+ altitude = 0.0
+ }
+ label = getString(R.string.label_hibiscus)
+ isExtruded = true
+ isDrawnWhenOccluded = true
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ setStyle(ImageView(R.drawable.hibiscus))
+ })
+
+ // 4.6. Text Glyph
+ val glyphText = Glyph.fromColor(Color.YELLOW).apply {
+ setText("🌸")
+ }
+
+ addClickableMarker(map, markerOptions {
+ position = latLngAltitude {
+ latitude = IOLANI_PALACE.latitude
+ longitude = IOLANI_PALACE.longitude + 0.006
+ altitude = 0.0
+ }
+ label = getString(R.string.label_text_glyph)
+ isExtruded = true
+ isDrawnWhenOccluded = true
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND
+ setStyle(pinConfiguration {
+ setGlyph(glyphText)
+ scale = 1.2f
+ backgroundColor = Color.BLUE
+ borderColor = Color.GREEN
+ })
+ })
+ }
+
+
+ private fun setUpInsets() {
+ // **Window Insets Handling**:
+ // Because we are in Edge-to-Edge mode, we must manually ensure our UI elements
+ // don't get covered by system bars.
+ // 1.2.1. Map: Needs padding at the TOP so the Google Logo/Compass don't overlap the status bar.
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.map3dView)) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, 0)
+ insets
+ }
+
+ // 1.2.2. Controls: Need padding at the BOTTOM so buttons aren't covered by the gesture/nav bar.
+ // We add an extra 16dp base padding for aesthetics.
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.controls_scroll_view)) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val basePadding = (16 * resources.displayMetrics.density).toInt() // 16dp
+ v.setPadding(
+ systemBars.left,
+ basePadding,
+ systemBars.right,
+ systemBars.bottom + basePadding
+ )
+ insets
+ }
+ }
+
+ // ------------------------------------------------------------------------
+ // PRE-PROVIDED BOILERPLATE (Kept out of the way)
+ // ------------------------------------------------------------------------
+
+ private var currentAnimationJob: Job? = null
+
+ /**
+ * Sets up click listeners for the UI buttons to trigger camera animations.
+ * Note how we wrap the calls in `lifecycleScope.launch`. This is because:
+ * 1. The `flyTo...` functions are `suspend` functions (they take time to complete).
+ * 2. We want them to run on the main thread (UI interactions).
+ */
+ private fun setupButtons(map: GoogleMap3D) {
+ findViewById(R.id.btn_fly_honolulu).setOnClickListener {
+ // Cancel any ongoing tour or flight
+ currentAnimationJob?.cancel()
+ // "Launch" starts a new coroutine.
+ currentAnimationJob = lifecycleScope.launch {
+ clearMap()
+ flyToHonolulu(map)
+ }
+ }
+
+ findViewById(R.id.btn_show_markers).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ addMarkers(map)
+ flyCameraToAndWait(map, MARKER_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_custom_markers).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ addCustomMarkers(map)
+ flyCameraToAndWait(map, CUSTOM_MARKER_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_polygons).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ addPolygon(map)
+ flyCameraToAndWait(map, POLYGON_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_polylines).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ setupPolylines(map)
+ flyCameraToAndWait(map, BALLOON_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_balloon).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ setupBalloon(map)
+ flyCameraToAndWait(map, BALLOON_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_popovers).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ setupPopover(map)
+ flyCameraToAndWait(map, MARKER_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_tour).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ flyTour(map)
+ }
+ }
+
+ findViewById(R.id.btn_clear).setOnClickListener {
+ currentAnimationJob?.cancel()
+ clearMap()
+ }
+ }
+
+ private suspend fun flyCameraToAndWait(map: GoogleMap3D, camera: Camera, duration: Duration = 2.seconds) {
+ map.flyCameraTo(options = flyToOptions {
+ endCamera = camera
+ durationInMillis = duration.inWholeMilliseconds
+ })
+
+ awaitCameraAnimation(map)
+ }
+
+}
diff --git a/codelab/solution/app/src/main/java/com/example/alohaexplorer/Map3DComposeActivity.kt b/codelab/solution/app/src/main/java/com/example/alohaexplorer/Map3DComposeActivity.kt
new file mode 100644
index 00000000..7c2b84be
--- /dev/null
+++ b/codelab/solution/app/src/main/java/com/example/alohaexplorer/Map3DComposeActivity.kt
@@ -0,0 +1,187 @@
+package com.example.alohaexplorer
+
+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.Button
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.Map3DOptions
+import com.google.android.gms.maps3d.Map3DView
+import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
+import com.google.android.gms.maps3d.model.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 kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+/**
+ * Configure the initial state of the 3D Map.
+ * This global configuration ensures the map starts where we want it (High above Honolulu).
+ */
+val map3DOptions = Map3DOptions(
+ defaultUiDisabled = true,
+ centerLat = HONOLULU.latitude,
+ centerLng = HONOLULU.longitude,
+ centerAlt = 1000.0,
+ heading = 0.0,
+ tilt = 0.0,
+ roll = 0.0,
+ range = 10_000_000.0, // Start with a "Global View" from space
+ minHeading = 0.0,
+ maxHeading = 360.0,
+ minTilt = 0.0,
+ maxTilt = 90.0,
+ bounds = null,
+ mapMode = Map3DMode.HYBRID,
+ mapId = null,
+)
+
+/**
+ * A minimalist activity to demonstrate how to integrate [Map3DView] with Jetpack Compose.
+ */
+class Map3DComposeActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Map3DContainer(
+ modifier = Modifier.padding(innerPadding),
+ options = map3DOptions
+ )
+ }
+ }
+ }
+}
+
+/**
+ * A Composable wrapper for [Map3DView].
+ *
+ * This function handles the interop between Compose's declarative UI and the usage of the
+ * classic Android View system required by the Maps3D SDK.
+ */
+@Composable
+fun Map3DContainer(
+ modifier: Modifier = Modifier,
+ options: Map3DOptions,
+) {
+ // We use a state holder to remember the GoogleMap3D object once it's initialized.
+ // This allows us to interact with the GoogleMap3D object later in response to UI events.
+ // In a more complex app, we would normally hold this state in a ViewModel or similar.
+ //
+ // https://github.com/googlemaps-samples/android-maps3d-samples/tree/main/Maps3DSamples/advanced
+ // presents a more complex reference sample.
+ var googleMap by remember { mutableStateOf(null) }
+
+ // CoroutineScope to launch suspending functions.
+ val coroutineScope = rememberCoroutineScope()
+
+ var animationJob by remember { mutableStateOf(null) }
+
+ Box(modifier = modifier.fillMaxSize()) {
+
+ // AndroidView is the bridge between Compose and Views.
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+
+ // factory: Called ONCE to create the View.
+ // We initialize the specific Map3DView here.
+ factory = { context ->
+ Map3DView(context, options).apply {
+ // We must manually call the lifecycle methods on the View.
+ // Ideally, you would wire this up to the LifecycleOwner, but for
+ // simplicity in this codelab, we just call onCreate here.
+ onCreate(null)
+ }
+ },
+
+ // update: Called when the Composable recomposes.
+ // We use this to attach listeners or update properties.
+ update = { view ->
+ view.getMap3DViewAsync(
+ object : OnMap3DViewReadyCallback {
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ // Save the googleMap3D for UI events.
+ googleMap = googleMap3D
+ }
+
+ override fun onError(error: Exception) {
+ googleMap = null
+ throw error
+ }
+ }
+ )
+ },
+
+ // onRelease: Called when this Composable is removed from the composition.
+ // We clean up the View here to prevent memory leaks and stop the renderer.
+ onRelease = { view ->
+ googleMap = null
+ view.onDestroy()
+ }
+ )
+
+ // A native Compose Button sitting on top of the AndroidView
+ Button(
+ // We can only click if the map is loaded, and we are not already animating
+ enabled = googleMap != null && animationJob == null,
+ onClick = {
+ animationJob?.cancel()
+
+ googleMap?.let { googleMap ->
+ // Launch a coroutine to run our cinematic sequence
+ animationJob = coroutineScope.launch {
+ googleMap.flyCameraTo(flyToOptions {
+ endCamera = camera {
+ center = HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ }
+ durationInMillis = 2000L
+ })
+
+ // Wait for the fly-to to finish
+ awaitCameraAnimation(googleMap)
+
+ googleMap.flyCameraAround(flyAroundOptions {
+ center = camera {
+ center = HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ }
+ rounds = 1.0
+ durationInMillis = 5000L
+ })
+
+ // Wait for the fly-around to finish
+ awaitCameraAnimation(googleMap)
+ animationJob = null
+ }
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 32.dp)
+ ) {
+ Text(stringResource(R.string.fly_to_honolulu))
+ }
+ }
+}
diff --git a/codelab/solution/app/src/main/java/com/example/alohaexplorer/Map3DLifecycleObserver.kt b/codelab/solution/app/src/main/java/com/example/alohaexplorer/Map3DLifecycleObserver.kt
new file mode 100644
index 00000000..a4794cdb
--- /dev/null
+++ b/codelab/solution/app/src/main/java/com/example/alohaexplorer/Map3DLifecycleObserver.kt
@@ -0,0 +1,46 @@
+package com.example.alohaexplorer
+
+import android.os.Bundle
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.savedstate.SavedStateRegistryOwner
+import com.google.android.gms.maps3d.Map3DView
+
+/**
+ * A lifecycle observer that automatically forwards Android Activity/Fragment
+ * lifecycle events to a managed [Map3DView].
+ */
+class Map3DLifecycleObserver(
+ private val map3DView: Map3DView,
+ private val savedStateRegistryOwner: SavedStateRegistryOwner
+) : DefaultLifecycleObserver {
+
+ // Note: onCreate is typically handled manually in the Activity because it requires
+ // the savedInstanceState Bundle, which DefaultLifecycleObserver doesn't inherently pass.
+ // However, we can hook into the SavedStateRegistry to automate onSaveInstanceState!
+
+ init {
+ // Automatically handle onSaveInstanceState via the AndroidX SavedStateRegistry
+ savedStateRegistryOwner.savedStateRegistry.registerSavedStateProvider(
+ "map3d_state_provider"
+ ) {
+ val bundle = Bundle()
+ map3DView.onSaveInstanceState(bundle)
+ bundle
+ }
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ map3DView.onResume()
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ map3DView.onPause()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ map3DView.onDestroy()
+ // Good citizenship: unregister the state provider when destroyed
+ savedStateRegistryOwner.savedStateRegistry.unregisterSavedStateProvider("map3d_state_provider")
+ }
+}
diff --git a/codelab/solution/app/src/main/java/com/example/alohaexplorer/Utilities.kt b/codelab/solution/app/src/main/java/com/example/alohaexplorer/Utilities.kt
new file mode 100644
index 00000000..e68c0225
--- /dev/null
+++ b/codelab/solution/app/src/main/java/com/example/alohaexplorer/Utilities.kt
@@ -0,0 +1,104 @@
+package com.example.alohaexplorer
+
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.latLngAltitude
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import kotlin.math.floor
+
+
+/**
+ * Extrudes a flat polygon (defined by basePoints, all at the same altitude)
+ * upwards by a given extrusionHeight to form a 3D prism (like a building).
+ *
+ * **Algorithm Explanation**:
+ * 1. **Bottom Face**: The original list of points.
+ * 2. **Top Face**: Same lat/lng as bottom, but altitude += height.
+ * 3. **Side Faces**: Quads connecting each segment of the bottom to the corresponding segment of the top.
+ *
+ * @param basePoints List of vertices for the base.
+ * @param extrusionHeight Height in meters to extrude upwards.
+ */
+fun extrudePolygon(
+ basePoints: List,
+ extrusionHeight: Double
+): List> {
+ if (basePoints.size < 3) return emptyList()
+ if (extrusionHeight <= 0) return emptyList()
+
+ val baseAltitude = basePoints.first().altitude
+
+ // 1. Create points for the top face
+ val topPoints = basePoints.map { basePoint ->
+ latLngAltitude {
+ latitude = basePoint.latitude
+ longitude = basePoint.longitude
+ altitude = baseAltitude + extrusionHeight
+ }
+ }
+
+ val faces = mutableListOf>()
+
+ // 2. Add bottom face
+ faces.add(basePoints.toList())
+
+ // 3. Add top face (must be reversed for correct "winding order" so it faces up)
+ faces.add(topPoints.toList().reversed())
+
+ // 4. Add side wall faces
+ for (i in basePoints.indices) {
+ val p1Base = basePoints[i]
+ val p2Base = basePoints[(i + 1) % basePoints.size] // Wrap around to start
+
+ val p1Top = topPoints[i]
+ val p2Top = topPoints[(i + 1) % basePoints.size]
+
+ // Define the quad for this side
+ val sideFace = listOf(p1Base, p2Base, p2Top, p1Top)
+ faces.add(sideFace)
+ }
+
+ return faces
+}
+
+/**
+ * **Coroutine Helper**: Awaiting Map Stability.
+ * Often you want to wait until the map has fully loaded (is "steady") before starting
+ * the next animation or interaction. This implementation wraps the callback-based
+ * `setOnMapSteadyListener` into a standard Kotlin `suspend` function.
+ */
+suspend fun awaitMapSteady(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setOnMapSteadyListener { isSteady ->
+ if (isSteady) {
+ map.setOnMapSteadyListener(null) // Cleanup the listener
+ if (continuation.isActive) {
+ continuation.resume(Unit) // Resume the suspended coroutine
+ }
+ }
+ }
+
+ // Safety: If the coroutine is cancelled (e.g., user exits app), remove the listener.
+ continuation.invokeOnCancellation {
+ map.setOnMapSteadyListener(null)
+ }
+}
+
+
+/**
+ * **Coroutine Helper**: Awaiting Camera Animation.
+ * Similar to `awaitMapSteady`, this pauses our code execution until the camera finishes its flight.
+ * This allows us to write strict sequences like: "Fly to A, THEN wait, THEN fly to B".
+ */
+suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setCameraAnimationEndListener {
+ map.setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ map.setCameraAnimationEndListener(null)
+ }
+}
diff --git a/codelab/solution/app/src/main/res/drawable-nodpi/hibiscus.png b/codelab/solution/app/src/main/res/drawable-nodpi/hibiscus.png
new file mode 100644
index 00000000..5e819df2
Binary files /dev/null and b/codelab/solution/app/src/main/res/drawable-nodpi/hibiscus.png differ
diff --git a/codelab/solution/app/src/main/res/drawable/controls_background.xml b/codelab/solution/app/src/main/res/drawable/controls_background.xml
new file mode 100644
index 00000000..fbe6ad52
--- /dev/null
+++ b/codelab/solution/app/src/main/res/drawable/controls_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/codelab/solution/app/src/main/res/drawable/ic_launcher_background.xml b/codelab/solution/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/codelab/solution/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelab/solution/app/src/main/res/drawable/ic_launcher_foreground.xml b/codelab/solution/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/codelab/solution/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/layout/activity_main.xml b/codelab/solution/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..707cf5ad
--- /dev/null
+++ b/codelab/solution/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/mipmap-hdpi/ic_launcher.png b/codelab/solution/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..b42f08aa
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/codelab/solution/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..021e9515
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-mdpi/ic_launcher.png b/codelab/solution/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..a5aacd58
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/codelab/solution/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..d93630d6
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/codelab/solution/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..ed089826
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/codelab/solution/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..95fc9448
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/codelab/solution/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..d40a0734
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/codelab/solution/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..abb934ce
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/codelab/solution/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..21eff98c
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/codelab/solution/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/codelab/solution/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..e246e530
Binary files /dev/null and b/codelab/solution/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/codelab/solution/app/src/main/res/values-night/themes.xml b/codelab/solution/app/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..d21fab09
--- /dev/null
+++ b/codelab/solution/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/values/colors.xml b/codelab/solution/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..c8524cd9
--- /dev/null
+++ b/codelab/solution/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/values/strings.xml b/codelab/solution/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..95b5f45b
--- /dev/null
+++ b/codelab/solution/app/src/main/res/values/strings.xml
@@ -0,0 +1,33 @@
+
+ Aloha Explorer
+
+ Clicked %1$s
+ Absolute (100m)
+ Relative (50m)
+ Clamped
+ Styled Pin
+ Text Glyph
+ Hibiscus Marker
+ Iolani Palace Polygon
+
+ Image Glyph
+ The Royal Palace: A symbol of Hawaiian sovereignty.
+ Clicked the Balloon!
+ Clicked the Balloon!
+ The Royal Palace: A symbol of Hawaiian sovereignty.
+ Fly to Honolulu
+
+
+ Camera
+ Markers
+ Custom Markers
+ Polygons
+ Polylines
+ Models
+ Popovers
+ Tour
+ Clear
+
+
+ Map3DView will go here
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/values/themes.xml b/codelab/solution/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..10278bd5
--- /dev/null
+++ b/codelab/solution/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/xml/backup_rules.xml b/codelab/solution/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..4df92558
--- /dev/null
+++ b/codelab/solution/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/main/res/xml/data_extraction_rules.xml b/codelab/solution/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/codelab/solution/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/solution/app/src/test/java/com/example/alohaexplorer/ExampleUnitTest.kt b/codelab/solution/app/src/test/java/com/example/alohaexplorer/ExampleUnitTest.kt
new file mode 100644
index 00000000..2f66be3c
--- /dev/null
+++ b/codelab/solution/app/src/test/java/com/example/alohaexplorer/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.example.alohaexplorer
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/codelab/solution/build.gradle.kts b/codelab/solution/build.gradle.kts
new file mode 100644
index 00000000..2050527c
--- /dev/null
+++ b/codelab/solution/build.gradle.kts
@@ -0,0 +1,14 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.secrets.gradle.plugin) apply false
+}
+
+tasks.register("installAndLaunch") {
+ description = "Installs and launches the demo app."
+ group = "install"
+ dependsOn("app:installDebug")
+ commandLine("adb", "shell", "am", "start", "-n", "com.example.alohaexplorer/.MainActivity")
+}
\ No newline at end of file
diff --git a/codelab/solution/gradle.properties b/codelab/solution/gradle.properties
new file mode 100644
index 00000000..4540b754
--- /dev/null
+++ b/codelab/solution/gradle.properties
@@ -0,0 +1,33 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.resvalues=true
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+android.enableAppCompileTimeRClass=false
+android.usesSdkInManifest.disallowed=false
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.r8.optimizedResourceShrinking=false
+android.builtInKotlin=false
+android.newDsl=false
\ No newline at end of file
diff --git a/codelab/solution/gradle/libs.versions.toml b/codelab/solution/gradle/libs.versions.toml
new file mode 100644
index 00000000..b7a4a5e3
--- /dev/null
+++ b/codelab/solution/gradle/libs.versions.toml
@@ -0,0 +1,48 @@
+[versions]
+compileSdk = "36"
+minSdk = "26"
+targetSdk = "36"
+
+agp = "9.1.0"
+coreKtx = "1.18.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+appcompat = "1.7.1"
+material = "1.13.0"
+activity = "1.13.0"
+constraintlayout = "2.2.1"
+activityCompose = "1.13.0"
+composeBom = "2026.03.01"
+kotlin = "2.3.20"
+lifecycleRuntimeKtx = "2.10.0"
+
+playServicesMaps3d = "0.2.0"
+secretsGradlePlugin = "2.0.1"
+
+[libraries]
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+
+play-services-maps3d = { group = "com.google.android.gms", name = "play-services-maps3d", version.ref = "playServicesMaps3d" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
+secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }
diff --git a/codelab/solution/gradle/wrapper/gradle-wrapper.jar b/codelab/solution/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..8bdaf60c
Binary files /dev/null and b/codelab/solution/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/codelab/solution/gradle/wrapper/gradle-wrapper.properties b/codelab/solution/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..41465649
--- /dev/null
+++ b/codelab/solution/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/codelab/solution/gradlew b/codelab/solution/gradlew
new file mode 100755
index 00000000..ef07e016
--- /dev/null
+++ b/codelab/solution/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# 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
+#
+# https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/codelab/solution/gradlew.bat b/codelab/solution/gradlew.bat
new file mode 100644
index 00000000..db3a6ac2
--- /dev/null
+++ b/codelab/solution/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/codelab/solution/local.defaults.properties b/codelab/solution/local.defaults.properties
new file mode 100644
index 00000000..0e5b2b44
--- /dev/null
+++ b/codelab/solution/local.defaults.properties
@@ -0,0 +1 @@
+MAPS3D_API_KEY=YOUR_API_KEY
diff --git a/codelab/solution/settings.gradle.kts b/codelab/solution/settings.gradle.kts
new file mode 100644
index 00000000..772a6a40
--- /dev/null
+++ b/codelab/solution/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ if (System.getenv("USE_MAVEN_LOCAL") == "true") {
+ mavenLocal()
+ }
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Aloha Explorer"
+include(":app")
diff --git a/codelab/starter/.gitignore b/codelab/starter/.gitignore
new file mode 100644
index 00000000..45969d87
--- /dev/null
+++ b/codelab/starter/.gitignore
@@ -0,0 +1,16 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/secrets.properties
diff --git a/codelab/starter/app/.gitignore b/codelab/starter/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/codelab/starter/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/codelab/starter/app/build.gradle.kts b/codelab/starter/app/build.gradle.kts
new file mode 100644
index 00000000..32e23d2e
--- /dev/null
+++ b/codelab/starter/app/build.gradle.kts
@@ -0,0 +1,80 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.secrets.gradle.plugin)
+}
+
+android {
+ namespace = "com.example.alohaexplorer"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "com.example.alohaexplorer"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.material)
+ implementation(platform(libs.androidx.compose.bom))
+
+ debugImplementation(libs.androidx.ui.tooling)
+
+ testImplementation(libs.junit)
+
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+
+ // TODO: Prerequisites - Add the Maps 3D SDK dependency
+ // implementation(libs.play.services.maps3d)
+}
+
+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.
+ defaultPropertiesFileName = "local.defaults.properties"
+}
\ No newline at end of file
diff --git a/codelab/starter/app/proguard-rules.pro b/codelab/starter/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/codelab/starter/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/codelab/starter/app/src/androidTest/java/com/example/alohaexplorer/ExampleInstrumentedTest.kt b/codelab/starter/app/src/androidTest/java/com/example/alohaexplorer/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..a0a3fff9
--- /dev/null
+++ b/codelab/starter/app/src/androidTest/java/com/example/alohaexplorer/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.example.alohaexplorer
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.example.alohaexplorer", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/AndroidManifest.xml b/codelab/starter/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..85789d60
--- /dev/null
+++ b/codelab/starter/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/java/com/example/alohaexplorer/HonoluluData.kt b/codelab/starter/app/src/main/java/com/example/alohaexplorer/HonoluluData.kt
new file mode 100644
index 00000000..68893298
--- /dev/null
+++ b/codelab/starter/app/src/main/java/com/example/alohaexplorer/HonoluluData.kt
@@ -0,0 +1,111 @@
+package com.example.alohaexplorer
+
+/* TODO: Prerequisites - Uncomment the rest of this file after synchronizing the Maps 3D SDK dependency!
+
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+
+// Step 1: Honolulu
+val HONOLULU = latLngAltitude {
+ latitude = 21.3069
+ longitude = -157.8583
+ altitude = 0.0
+}
+
+// Step 2: Iolani Palace
+val IOLANI_PALACE = latLngAltitude {
+ latitude = 21.306740
+ longitude = -157.858803
+ altitude = 0.0
+}
+
+// Step 6: Waikiki Beach
+val WAIKIKI = latLngAltitude {
+ latitude = 21.2766
+ longitude = -157.8286
+ altitude = 0.0
+}
+
+val DIAMOND_HEAD = latLngAltitude {
+ latitude = 21.26194
+ longitude = -157.80556
+ altitude = 0.0
+}
+
+val KOKO_HEAD = latLngAltitude {
+ latitude = 21.270856
+ longitude = -157.694442
+ altitude = 0.0
+}
+
+val PEARL_HARBOR = latLngAltitude {
+ latitude = 21.31861
+ longitude = -157.92250
+ altitude = 0.0
+}
+
+val MOUNT_KAALA = latLngAltitude {
+ latitude = 21.50694
+ longitude = -158.14278
+ altitude = 0.0
+}
+
+val LANIKAI_BEACH = latLngAltitude {
+ latitude = 21.39309
+ longitude = -157.71546
+ altitude = 0.0
+}
+
+// Models must be loaded from a URL. Here we use Cloud Storage.
+const val BALLOON_MODEL_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/balloon-pin-BlXF32yD.glb"
+const val BALLOON_SCALE = 5.0
+
+// Step 5: Iolani Palace Geometry (Lat, Lng pairs)
+val IOLANI_PALACE_GEO = listOf(
+ 21.307180365, -157.858769898,
+ 21.306765552, -157.858390366,
+ 21.306476932, -157.858755146,
+ 21.306892995, -157.859134679,
+)
+
+val POLYGON_CAMERA = camera {
+ center = latLngAltitude {
+ latitude = 21.307051
+ longitude = -157.858546
+ altitude = 7.1
+ }
+ heading = 78.0
+ tilt = 53.0
+ range = 644.0
+}
+
+val MARKER_CAMERA = camera {
+ center = latLngAltitude {
+ latitude = 21.306648
+ longitude = -157.859336
+ altitude = 26.8
+ }
+ tilt = 68.0
+ range = 1024.0
+ heading = 61.0
+}
+
+val CUSTOM_MARKER_CAMERA = camera {
+ this.center = latLngAltitude {
+ this.latitude = 21.306370
+ this.longitude = -157.855474
+ this.altitude = 6.6
+ }
+ this.heading = 39.0
+ this.tilt = 68.0
+ this.range = 1399.0
+}
+
+val BALLOON_CAMERA = camera {
+ center = WAIKIKI
+ tilt = 60.0
+ range = 1000.0
+ heading = 0.0
+}
+
+*/
diff --git a/codelab/starter/app/src/main/java/com/example/alohaexplorer/MainActivity.kt b/codelab/starter/app/src/main/java/com/example/alohaexplorer/MainActivity.kt
new file mode 100644
index 00000000..4c1b1673
--- /dev/null
+++ b/codelab/starter/app/src/main/java/com/example/alohaexplorer/MainActivity.kt
@@ -0,0 +1,306 @@
+package com.example.alohaexplorer
+
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import kotlinx.coroutines.Job
+
+/**
+ * This is the main entry point for the "Aloha Explorer" 3D Maps Codelab.
+ */
+class MainActivity : AppCompatActivity() /*, TODO: Step 1.3 - Implement OnMap3DViewReadyCallback */ {
+
+ private var fortuneIndex = 0
+ // TODO: Step 1.3 - Define Map3DView and GoogleMap3D
+ // private lateinit var map3DView: Map3DView
+ // private var googleMap3D: GoogleMap3D? = null
+
+ // TODO: Step 1.2 - State Management
+ // private val activeMarkers = mutableListOf()
+ // private val activePolygons = mutableListOf()
+ // private val activePolylines = mutableListOf()
+ // private val activeModels = mutableListOf()
+ // private val activePopovers = mutableListOf()
+
+ private var currentAnimationJob: Job? = null
+
+ companion object {
+ // Constants are now located in HonoluluData.kt
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // Fix: Ensure the bundle can deserialize the Maps 3D SDK's Camera Parcelable on rotation
+ savedInstanceState?.classLoader = javaClass.classLoader
+ super.onCreate(savedInstanceState)
+ // Enables "Edge-to-Edge" mode, allowing the map to draw behind the system bars
+ enableEdgeToEdge()
+ setContentView(R.layout.activity_main)
+
+ // **Window Insets Handling**:
+ // Because we are in Edge-to-Edge mode, we must manually ensure our UI elements
+ // don't get covered by system bars.
+ // 1. Map: Needs padding at the TOP so the Google Logo/Compass don't overlap the status bar.
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.map3dView)) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, 0)
+ insets
+ }
+
+ // 2. Controls: Need padding at the BOTTOM so buttons aren't covered by the gesture/nav bar.
+ ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.controls_scroll_view)) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val basePadding = (16 * resources.displayMetrics.density).toInt() // 16dp
+ v.setPadding(systemBars.left, basePadding, systemBars.right, systemBars.bottom + basePadding)
+ insets
+ }
+
+ // TODO: Step 1.3 - Initialize Map3DView
+
+ // TODO: Step 1.4 - Map Lifecycle
+
+ // TODO: Step 1.5 - Configure the UI buttons
+ // setupButtons()
+ }
+
+ // TODO: Step 1.3 - Implement onMap3DViewReady
+ /*
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ }
+ */
+
+
+
+ // TODO: Step 1.3 - Start from Global View
+ /*
+ private fun startFromGlobalView(map: GoogleMap3D) {
+ // TODO: Step 1.3 - Set the Camera
+ }
+ */
+
+ // TODO: Step 2 - Fly to Honolulu
+ /*
+ private suspend fun flyToHonolulu(map: GoogleMap3D) {
+ // TODO: Step 2 - Position the Camera high above Honolulu
+ // Wait for the animation to finish
+ // Wait for tiles to load and settle
+ // Wait for the animation to finish
+ }
+ */
+
+ // TODO: Step 2 - Helper to fly to markers (for other buttons)
+ /*
+ private suspend fun flyToMarkers(map: GoogleMap3D) {
+ // Fly to the Iolani Palace
+ // Wait for the animation to finish
+ }
+ */
+
+ /*
+ private suspend fun flyToBalloon(map: GoogleMap3D) {
+ // Fly to the Balloon on Waikiki Beach
+ // Wait for the animation to finish
+ }
+ */
+
+ // See Utilities.kt for Robustness helpers (awaitMapSteady, awaitCameraAnimation)
+
+ // TODO: Step 4 (and others) - Reset Map Helper
+ /*
+ private fun resetMap() {
+ activeMarkers.forEach { it.remove() }
+ activeMarkers.clear()
+
+ activePolygons.forEach { it.remove() }
+ activePolygons.clear()
+
+ activePolylines.forEach { it.remove() }
+ activePolylines.clear()
+
+ activeModels.forEach { it.remove() }
+ activeModels.clear()
+
+ activePopovers.forEach { it.remove() }
+ activePopovers.clear()
+ }
+ */
+
+ // TODO: Step 4 - Add Markers
+ /*
+ private fun addMarkers(map: GoogleMap3D) {
+ resetMap()
+
+ // 1. Add ABSOLUTE Marker
+
+ // 2. Add RELATIVE_TO_GROUND Marker
+
+ // 3. Add CLAMP_TO_GROUND Marker
+ }
+ */
+
+ // TODO: Step 5 - Advanced Custom Markers
+ /*
+ private fun addCustomMarkers(map: GoogleMap3D) {
+ // Implementation provided in Advanced Custom Markers step
+ }
+ */
+
+ // TODO: Step 6 - Add Polygon
+ /*
+ private fun addPolygon(map: GoogleMap3D) {
+ resetMap()
+
+ // Define the base (ground) shape of Iolani Palace
+
+ // Extrude!
+
+ // Add all faces to the map
+
+ // Step 6: Tapping the Turf
+ }
+ */
+
+ // See Utilities.kt for extrudePolygon Algorithm
+ // TODO: Step 8 - Setup Polylines
+ /*
+ private fun setupPolylines(map: GoogleMap3D) {
+ resetMap()
+
+ // Draw path to Waikiki
+
+ // Jump the camera to the Polyline to see it
+ }
+ */
+
+ // TODO: Step 9 - Setup Balloon Model
+ /*
+ private fun setupBalloon(map: GoogleMap3D) {
+ resetMap()
+
+ // Add "Balloon" Model (glTF)
+
+ // Setup click listener
+ }
+ */
+
+ // TODO: Step 10 - Setup Popover
+ /*
+ private fun setupPopover(map: GoogleMap3D) {
+ resetMap()
+
+ // 1. Add a marker to serve as a visual anchor reference
+
+ // 2. Create a simple text view for the popover content
+
+ // 3. Add a Popover attached to the same location
+
+ // 4. Show popover on marker click
+ }
+ */
+
+ // TODO: Step 11 - Scenic Tour Challenge
+ /*
+ private suspend fun flyTour(map: GoogleMap3D) {
+ // Define Locations
+
+ // Add all markers for the tour
+
+ // Fly to each location (CHALLENGE!)
+ // 1. Tell the map to flyCameraTo this 'location'.
+ // 2. Wait for the animation to finish using awaitCameraAnimation(map)
+ // 3. Optional: Add a delay so the user can enjoy the view before flying to the next!
+ }
+ */
+
+ // TODO: Step 1.2 - Uncomment the setupButtons boilerplate after adding Maps 3D SDK dependencies
+ /*
+
+ /**
+ * Sets up click listeners for the UI buttons to trigger camera animations.
+ * Note how we wrap the calls in `lifecycleScope.launch`. This is because:
+ * 1. The `flyTo...` functions are `suspend` functions (they take time to complete).
+ * 2. We want them to run on the main thread (UI interactions).
+ */
+ private fun setupButtons(map: GoogleMap3D) {
+ findViewById(R.id.btn_fly_honolulu).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ resetMap()
+ flyToHonolulu(map)
+ }
+ }
+
+ findViewById(R.id.btn_show_markers).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ addMarkers(map)
+ flyCameraToAndWait(map, MARKER_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_custom_markers).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ addCustomMarkers(map)
+ flyCameraToAndWait(map, CUSTOM_MARKER_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_polygons).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ addPolygon(map)
+ flyCameraToAndWait(map, POLYGON_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_polylines).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ setupPolylines(map)
+ flyCameraToAndWait(map, BALLOON_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_balloon).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ setupBalloon(map)
+ flyCameraToAndWait(map, BALLOON_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_show_popovers).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ setupPopover(map)
+ flyCameraToAndWait(map, MARKER_CAMERA)
+ }
+ }
+
+ findViewById(R.id.btn_tour).setOnClickListener {
+ currentAnimationJob?.cancel()
+ currentAnimationJob = lifecycleScope.launch {
+ flyTour(map)
+ }
+ }
+
+ findViewById(R.id.btn_clear).setOnClickListener {
+ currentAnimationJob?.cancel()
+ resetMap()
+ }
+ }
+
+ private suspend fun flyCameraToAndWait(map: GoogleMap3D, camera: Camera, duration: Duration = 2.seconds) {
+ map.flyCameraTo(options = flyToOptions {
+ endCamera = camera
+ durationInMillis = duration.inWholeMilliseconds
+ })
+ // See Utilities.kt
+ awaitCameraAnimation(map)
+ }
+ */
+
+
+}
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/java/com/example/alohaexplorer/Map3DComposeActivity.kt b/codelab/starter/app/src/main/java/com/example/alohaexplorer/Map3DComposeActivity.kt
new file mode 100644
index 00000000..5ff2d93c
--- /dev/null
+++ b/codelab/starter/app/src/main/java/com/example/alohaexplorer/Map3DComposeActivity.kt
@@ -0,0 +1,162 @@
+package com.example.alohaexplorer
+
+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.Button
+// import androidx.compose.material3.Scaffold
+// import androidx.compose.material3.Text
+// import androidx.compose.runtime.Composable
+// import androidx.compose.runtime.getValue
+// import androidx.compose.runtime.mutableStateOf
+// import androidx.compose.runtime.remember
+// import androidx.compose.runtime.rememberCoroutineScope
+// import androidx.compose.runtime.setValue
+// import androidx.compose.ui.Alignment
+// import androidx.compose.ui.Modifier
+// import androidx.compose.ui.unit.dp
+// import androidx.compose.ui.viewinterop.AndroidView
+// import com.google.android.gms.maps3d.GoogleMap3D
+// import com.google.android.gms.maps3d.Map3DMode
+// import com.google.android.gms.maps3d.Map3DOptions
+// import com.google.android.gms.maps3d.Map3DView
+// import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
+// import com.google.android.gms.maps3d.model.camera
+// import com.google.android.gms.maps3d.model.flyAroundOptions
+// import com.google.android.gms.maps3d.model.flyToOptions
+// import com.google.android.gms.maps3d.model.latLngAltitude
+// import kotlinx.coroutines.Job
+// import kotlinx.coroutines.launch
+// import kotlinx.coroutines.suspendCancellableCoroutine
+// import kotlin.coroutines.resume
+
+// TODO: Step 12 - Jetpack Compose
+/*
+class Map3DComposeActivity : ComponentActivity() {
+
+ private val map3DOptions = Map3DOptions(
+ centerLat = MainActivity.HONOLULU.latitude,
+ centerLng = MainActivity.HONOLULU.longitude,
+ centerAlt = 1000.0,
+ heading = 0.0,
+ range = 10_000_000.0, // Start from space
+ mapMode = Map3DMode.HYBRID
+ )
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Map3DContainer(
+ modifier = Modifier.padding(innerPadding),
+ options = map3DOptions
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun Map3DContainer(
+ modifier: Modifier = Modifier,
+ options: Map3DOptions
+) {
+ // State to hold the map controller
+ var googleMap by remember { mutableStateOf(null) }
+
+ // Animation State
+ var animationJob by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+
+ Box(modifier = modifier.fillMaxSize()) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { context ->
+ Map3DView(context, options).apply {
+ // Manually call onCreate. In a real app, wire this to LifecycleOwner.
+ onCreate(null)
+ }
+ },
+ update = { view ->
+ view.getMap3DViewAsync(
+ object : OnMap3DViewReadyCallback {
+ override fun onMap3DViewReady(map3D: GoogleMap3D) {
+ googleMap = map3D
+ // No automatic startFromGlobalView here, we wait for button click
+ }
+ override fun onError(e: Exception) {
+ googleMap = null
+ throw e
+ }
+ }
+ )
+ // Forward Log lifecycle events if possible, or use a LifecycleEventObserver container
+ view.onResume()
+ },
+ onRelease = { view ->
+ googleMap = null
+ view.onDestroy()
+ }
+ )
+
+ Button(
+ // Enable only if map is ready and not currently animating
+ enabled = googleMap != null && animationJob == null,
+ onClick = {
+ animationJob?.cancel()
+ animationJob = coroutineScope.launch {
+ val map = googleMap ?: return@launch
+
+ // 1. Fly to Honolulu
+ map.flyCameraTo(flyToOptions {
+ endCamera = camera {
+ center = MainActivity.HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ }
+ durationInMillis = 2000L
+ })
+ awaitCameraAnimation(map)
+
+ // 2. Orbit
+ map.flyCameraAround(flyAroundOptions {
+ center = camera {
+ center = MainActivity.HONOLULU
+ tilt = 45.0
+ range = 20000.0
+ }
+ rounds = 1.0
+ durationInMillis = 5000L
+ })
+ awaitCameraAnimation(map)
+
+ animationJob = null
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 32.dp)
+ ) {
+ Text("Fly to Honolulu")
+ }
+ }
+}
+
+// Re-use our robust await function, or copy it here if utils are separate
+private suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setCameraAnimationEndListener {
+ map.setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+ continuation.invokeOnCancellation {
+ map.setCameraAnimationEndListener(null)
+ }
+}
+*/
diff --git a/codelab/starter/app/src/main/java/com/example/alohaexplorer/Map3DLifecycleObserver.kt b/codelab/starter/app/src/main/java/com/example/alohaexplorer/Map3DLifecycleObserver.kt
new file mode 100644
index 00000000..f247c3fe
--- /dev/null
+++ b/codelab/starter/app/src/main/java/com/example/alohaexplorer/Map3DLifecycleObserver.kt
@@ -0,0 +1,50 @@
+package com.example.alohaexplorer
+
+/* TODO: Prerequisites - Uncomment the rest of this file after synchronizing the Maps 3D SDK dependency!
+
+import android.os.Bundle
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.savedstate.SavedStateRegistryOwner
+import com.google.android.gms.maps3d.Map3DView
+
+/**
+ * A lifecycle observer that automatically forwards Android Activity/Fragment
+ * lifecycle events to a managed [Map3DView].
+ */
+class Map3DLifecycleObserver(
+ private val map3DView: Map3DView,
+ private val savedStateRegistryOwner: SavedStateRegistryOwner
+) : DefaultLifecycleObserver {
+
+ // Note: onCreate is typically handled manually in the Activity because it requires
+ // the savedInstanceState Bundle, which DefaultLifecycleObserver doesn't inherently pass.
+ // However, we can hook into the SavedStateRegistry to automate onSaveInstanceState!
+
+ init {
+ // Automatically handle onSaveInstanceState via the AndroidX SavedStateRegistry
+ savedStateRegistryOwner.savedStateRegistry.registerSavedStateProvider(
+ "map3d_state_provider"
+ ) {
+ val bundle = Bundle()
+ map3DView.onSaveInstanceState(bundle)
+ bundle
+ }
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ map3DView.onResume()
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ map3DView.onPause()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ map3DView.onDestroy()
+ // Good citizenship: unregister the state provider when destroyed
+ savedStateRegistryOwner.savedStateRegistry.unregisterSavedStateProvider("map3d_state_provider")
+ }
+}
+
+*/
diff --git a/codelab/starter/app/src/main/java/com/example/alohaexplorer/Utilities.kt b/codelab/starter/app/src/main/java/com/example/alohaexplorer/Utilities.kt
new file mode 100644
index 00000000..3eb32af6
--- /dev/null
+++ b/codelab/starter/app/src/main/java/com/example/alohaexplorer/Utilities.kt
@@ -0,0 +1,108 @@
+package com.example.alohaexplorer
+
+/* TODO: Prerequisites - Uncomment the rest of this file after synchronizing the Maps 3D SDK dependency!
+
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.latLngAltitude
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import kotlin.math.floor
+
+
+/**
+ * Extrudes a flat polygon (defined by basePoints, all at the same altitude)
+ * upwards by a given extrusionHeight to form a 3D prism (like a building).
+ *
+ * **Algorithm Explanation**:
+ * 1. **Bottom Face**: The original list of points.
+ * 2. **Top Face**: Same lat/lng as bottom, but altitude += height.
+ * 3. **Side Faces**: Quads connecting each segment of the bottom to the corresponding segment of the top.
+ *
+ * @param basePoints List of vertices for the base.
+ * @param extrusionHeight Height in meters to extrude upwards.
+ */
+fun extrudePolygon(
+ basePoints: List,
+ extrusionHeight: Double
+): List> {
+ if (basePoints.size < 3) return emptyList()
+ if (extrusionHeight <= 0) return emptyList()
+
+ val baseAltitude = basePoints.first().altitude
+
+ // 1. Create points for the top face
+ val topPoints = basePoints.map { basePoint ->
+ latLngAltitude {
+ latitude = basePoint.latitude
+ longitude = basePoint.longitude
+ altitude = baseAltitude + extrusionHeight
+ }
+ }
+
+ val faces = mutableListOf>()
+
+ // 2. Add bottom face
+ faces.add(basePoints.toList())
+
+ // 3. Add top face (must be reversed for correct "winding order" so it faces up)
+ faces.add(topPoints.toList().reversed())
+
+ // 4. Add side wall faces
+ for (i in basePoints.indices) {
+ val p1Base = basePoints[i]
+ val p2Base = basePoints[(i + 1) % basePoints.size] // Wrap around to start
+
+ val p1Top = topPoints[i]
+ val p2Top = topPoints[(i + 1) % basePoints.size]
+
+ // Define the quad for this side
+ val sideFace = listOf(p1Base, p2Base, p2Top, p1Top)
+ faces.add(sideFace)
+ }
+
+ return faces
+}
+
+/**
+ * **Coroutine Helper**: Awaiting Map Stability.
+ * Often you want to wait until the map has fully loaded (is "steady") before starting
+ * the next animation or interaction. This implementation wraps the callback-based
+ * `setOnMapSteadyListener` into a standard Kotlin `suspend` function.
+ */
+suspend fun awaitMapSteady(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setOnMapSteadyListener { isSteady ->
+ if (isSteady) {
+ map.setOnMapSteadyListener(null) // Cleanup the listener
+ if (continuation.isActive) {
+ continuation.resume(Unit) // Resume the suspended coroutine
+ }
+ }
+ }
+
+ // Safety: If the coroutine is cancelled (e.g., user exits app), remove the listener.
+ continuation.invokeOnCancellation {
+ map.setOnMapSteadyListener(null)
+ }
+}
+
+
+/**
+ * **Coroutine Helper**: Awaiting Camera Animation.
+ * Similar to `awaitMapSteady`, this pauses our code execution until the camera finishes its flight.
+ * This allows us to write strict sequences like: "Fly to A, THEN wait, THEN fly to B".
+ */
+suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
+ map.setCameraAnimationEndListener {
+ map.setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ map.setCameraAnimationEndListener(null)
+ }
+}
+
+*/
diff --git a/codelab/starter/app/src/main/res/drawable-nodpi/hibiscus.png b/codelab/starter/app/src/main/res/drawable-nodpi/hibiscus.png
new file mode 100644
index 00000000..c07c370e
Binary files /dev/null and b/codelab/starter/app/src/main/res/drawable-nodpi/hibiscus.png differ
diff --git a/codelab/starter/app/src/main/res/drawable/ic_launcher_background.xml b/codelab/starter/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/codelab/starter/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelab/starter/app/src/main/res/drawable/ic_launcher_foreground.xml b/codelab/starter/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/codelab/starter/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/layout/activity_main.xml b/codelab/starter/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..df65d875
--- /dev/null
+++ b/codelab/starter/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/mipmap-hdpi/ic_launcher.png b/codelab/starter/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..b42f08aa
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/codelab/starter/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..021e9515
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-mdpi/ic_launcher.png b/codelab/starter/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..a5aacd58
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/codelab/starter/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..d93630d6
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/codelab/starter/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..ed089826
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/codelab/starter/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..95fc9448
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/codelab/starter/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..d40a0734
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/codelab/starter/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..abb934ce
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/codelab/starter/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..21eff98c
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/codelab/starter/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/codelab/starter/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..e246e530
Binary files /dev/null and b/codelab/starter/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/codelab/starter/app/src/main/res/values-night/themes.xml b/codelab/starter/app/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..d21fab09
--- /dev/null
+++ b/codelab/starter/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/values/arrays.xml b/codelab/starter/app/src/main/res/values/arrays.xml
new file mode 100644
index 00000000..c5312cbe
--- /dev/null
+++ b/codelab/starter/app/src/main/res/values/arrays.xml
@@ -0,0 +1,21 @@
+
+
+
+ - Your journey to mapping mastery begins now. Hook up that SDK!
+ - A world of possibilities awaits. The SDK is your key.
+ - Don\'t delay, integrate today! The codelab beckons.
+ - The best way to predict the future is to create it. Start with the SDK.
+ - Great maps are built one SDK integration at a time. You\'re next!
+ - Unlock the power of location. The SDK is your first step.
+ - Success is not final, failure is not fatal: it is the courage to continue that counts. And that starts with the SDK.
+ - The map to your success is in the SDK. Follow the codelab!
+ - Every great app starts with a single line of code. Make it an SDK call.
+ - The journey of a thousand miles begins with a single map tile. Integrate the SDK.
+ - Your destiny is in your hands, and the SDK is in your project.
+ - Seek and you shall find… especially with the Maps SDK.
+ - Innovation awaits. The SDK is your canvas.
+ - Don\'t just build an app, build an experience. The SDK helps.
+ - The future is location-aware. Get started with the SDK.
+ - Your users will thank you for the amazing maps. The SDK is the secret.
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/values/colors.xml b/codelab/starter/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..c8524cd9
--- /dev/null
+++ b/codelab/starter/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/values/strings.xml b/codelab/starter/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..95b5f45b
--- /dev/null
+++ b/codelab/starter/app/src/main/res/values/strings.xml
@@ -0,0 +1,33 @@
+
+ Aloha Explorer
+
+ Clicked %1$s
+ Absolute (100m)
+ Relative (50m)
+ Clamped
+ Styled Pin
+ Text Glyph
+ Hibiscus Marker
+ Iolani Palace Polygon
+
+ Image Glyph
+ The Royal Palace: A symbol of Hawaiian sovereignty.
+ Clicked the Balloon!
+ Clicked the Balloon!
+ The Royal Palace: A symbol of Hawaiian sovereignty.
+ Fly to Honolulu
+
+
+ Camera
+ Markers
+ Custom Markers
+ Polygons
+ Polylines
+ Models
+ Popovers
+ Tour
+ Clear
+
+
+ Map3DView will go here
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/values/themes.xml b/codelab/starter/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..10278bd5
--- /dev/null
+++ b/codelab/starter/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/xml/backup_rules.xml b/codelab/starter/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..4df92558
--- /dev/null
+++ b/codelab/starter/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/main/res/xml/data_extraction_rules.xml b/codelab/starter/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/codelab/starter/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/codelab/starter/app/src/test/java/com/example/alohaexplorer/ExampleUnitTest.kt b/codelab/starter/app/src/test/java/com/example/alohaexplorer/ExampleUnitTest.kt
new file mode 100644
index 00000000..2f66be3c
--- /dev/null
+++ b/codelab/starter/app/src/test/java/com/example/alohaexplorer/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.example.alohaexplorer
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/codelab/starter/build.gradle.kts b/codelab/starter/build.gradle.kts
new file mode 100644
index 00000000..c6ed6178
--- /dev/null
+++ b/codelab/starter/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.secrets.gradle.plugin) apply false
+}
+
+tasks.register("installAndLaunch") {
+ description = "Installs and launches the demo app."
+ group = "install"
+ dependsOn("app:installDebug")
+ commandLine("adb", "shell", "am", "start", "-n", "com.example.alohaexplorer/.MainActivity")
+}
\ No newline at end of file
diff --git a/codelab/starter/gradle.properties b/codelab/starter/gradle.properties
new file mode 100644
index 00000000..4540b754
--- /dev/null
+++ b/codelab/starter/gradle.properties
@@ -0,0 +1,33 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.resvalues=true
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+android.enableAppCompileTimeRClass=false
+android.usesSdkInManifest.disallowed=false
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.r8.optimizedResourceShrinking=false
+android.builtInKotlin=false
+android.newDsl=false
\ No newline at end of file
diff --git a/codelab/starter/gradle/libs.versions.toml b/codelab/starter/gradle/libs.versions.toml
new file mode 100644
index 00000000..faa79689
--- /dev/null
+++ b/codelab/starter/gradle/libs.versions.toml
@@ -0,0 +1,50 @@
+[versions]
+compileSdk = "36"
+minSdk = "26"
+targetSdk = "36"
+
+agp = "9.1.0"
+coreKtx = "1.18.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+appcompat = "1.7.1"
+material = "1.13.0"
+activity = "1.13.0"
+constraintlayout = "2.2.1"
+activityCompose = "1.13.0"
+composeBom = "2026.03.01"
+kotlin = "2.3.20"
+lifecycleRuntimeKtx = "2.10.0"
+
+# TODO: Prerequisites - Add the Maps 3D SDK version
+# playServicesMaps3d = "0.2.0"
+secretsGradlePlugin = "2.0.1"
+
+[libraries]
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+
+# TODO: Prerequisites - Add the Maps 3D SDK library
+# play-services-maps3d = { group = "com.google.android.gms", name = "play-services-maps3d", version.ref = "playServicesMaps3d" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
+secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }
diff --git a/codelab/starter/gradle/wrapper/gradle-wrapper.jar b/codelab/starter/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..8bdaf60c
Binary files /dev/null and b/codelab/starter/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/codelab/starter/gradle/wrapper/gradle-wrapper.properties b/codelab/starter/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..59e27526
--- /dev/null
+++ b/codelab/starter/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+#Tue Jan 06 17:22:18 MST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/codelab/starter/gradlew b/codelab/starter/gradlew
new file mode 100755
index 00000000..ef07e016
--- /dev/null
+++ b/codelab/starter/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# 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
+#
+# https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/codelab/starter/gradlew.bat b/codelab/starter/gradlew.bat
new file mode 100644
index 00000000..db3a6ac2
--- /dev/null
+++ b/codelab/starter/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/codelab/starter/local.defaults.properties b/codelab/starter/local.defaults.properties
new file mode 100644
index 00000000..2ac1f030
--- /dev/null
+++ b/codelab/starter/local.defaults.properties
@@ -0,0 +1 @@
+MAPS3D_API_KEY=DEFAULT_API_KEY
\ No newline at end of file
diff --git a/codelab/starter/settings.gradle.kts b/codelab/starter/settings.gradle.kts
new file mode 100644
index 00000000..772a6a40
--- /dev/null
+++ b/codelab/starter/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ if (System.getenv("USE_MAVEN_LOCAL") == "true") {
+ mavenLocal()
+ }
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Aloha Explorer"
+include(":app")