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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .gemini/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Gemini Code Assist Configuration
# See: https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github

# Feature settings
have_fun: false

code_review:
disable: false
comment_severity_threshold: MEDIUM
max_review_comments: -1

pull_request_opened:
summary: true
code_review: true
include_drafts: true

# Files to ignore in Gemini analysis
ignore_patterns:
- "**/*.bin"
- "**/*.exe"
- "**/build/**"
- "**/.gradle/**"
- "**/secrets.properties"
276 changes: 276 additions & 0 deletions .gemini/skills/android-maps3d-sdk/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
---
name: android-maps3d-sdk
description: Guide for integrating the Google Maps 3D SDK into an Android Jetpack Compose application. Use when users ask to add Maps 3D, 3D maps, or Map3DView to their Android app in Compose.
---

# Android Maps 3D SDK Integration

You are an expert Android developer specializing in Jetpack Compose and modern Android architecture. Follow these instructions carefully to integrate the `play-services-maps3d` library into the user's Android application.

We should start with a few questions about how the developer want to use `Maps3DView`.

Are they using or planning on using Jetpack Compose?

Are they using or planning on using dependency injection (such as Hilt or Koin)?

## 1. Setup Dependencies

First, add the necessary versions and libraries to your `libs.versions.toml` file:

```toml
[versions]
playServicesMaps3d = "0.2.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be worth adding a note asking to verify if this is the latest version, since this is subject to change.

lifecycleRuntimeKtx = "2.8.5"

[libraries]
play-services-maps3d = { group = "com.google.android.gms", name = "play-services-maps3d", version.ref = "playServicesMaps3d" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
```

Then, add the dependencies to the app-level `build.gradle.kts` file.

```kotlin
dependencies {
// Google Maps 3D SDK
implementation(libs.play.services.maps3d)

// Lifecycle Runtime KTX for Coroutine interop
implementation(libs.androidx.lifecycle.runtime.ktx)
}
```

## 2. Setup the Secrets Gradle Plugin

Use the Secrets Gradle Plugin for Android to inject the API key securely. In app-level `build.gradle.kts`:

```kotlin
plugins {
alias(libs.plugins.secrets.gradle.plugin)
}

secrets {
propertiesFileName = "secrets.properties"
defaultPropertiesFileName = "local.defaults.properties"
}
```

In `AndroidManifest.xml`, add the required permissions and reference the injected API key meta-data:

```xml
<manifest ...>
<!-- Required for Google Maps 3D -->
<uses-permission android:name="android.permission.INTERNET" />

<application ...>
<!-- Google Maps 3D API Key injected by Secrets Gradle Plugin -->
<!-- Note the specific name for Maps 3D -->
<meta-data
android:name="com.google.android.geo.maps3d.API_KEY"
android:value="${MAPS3D_API_KEY}" />
...
</application>
</manifest>
```

Add the API Key to `secrets.properties`:

```properties
MAPS3D_API_KEY=YOUR_API_KEY
```

## 3. Implement the Map3D Container Composable

If the user is working in a Jetpack Compose app or is creating a Compose app, We can use an
`AndroidView` to bridge between the View-based `Map3DView` and Jetpack Compose.

```kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.Map3DMode
import com.google.android.gms.maps.model.Map3DOptions
import com.google.android.gms.maps.Map3DView
import com.google.android.gms.maps.GoogleMap3D
import com.google.android.gms.maps.OnMap3DViewReadyCallback

@Composable
fun Map3DContainer(
modifier: Modifier = Modifier,
options: Map3DOptions
) {
// 1. Hoist State: Remember the map object
var googleMap by remember { mutableStateOf<GoogleMap3D?>(null) }

Box(modifier = modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
Map3DView(context, options).apply {
// Manually call onCreate.
onCreate(null)
}
},
update = { view ->
view.getMap3DViewAsync(
object : OnMap3DViewReadyCallback {
override fun onMap3DViewReady(map3D: GoogleMap3D) {
googleMap = map3D // Capture the controller
}
override fun onError(e: Exception) {
googleMap = null
throw e
}
}
)
},
onRelease = { view ->
googleMap = null
view.onDestroy()
}
)
}
}
```

## 4. Best Practices & Guidelines
* **Double-Wait Pattern:** Triggering animations from Compose buttons requires the **Double-Wait** pattern (`awaitCameraAnimation` + `awaitSteady`) to ensure peak visual quality.
* **Coroutine Bridging:** Animations in the 3D SDK are fire-and-forget. Use an `awaitCameraAnimation(map: GoogleMap3D)` suspend wrapper function using `suspendCancellableCoroutine` for structured concurrency:

```kotlin
suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
map.setCameraAnimationEndListener {
map.setCameraAnimationEndListener(null) // Cleanup listener to avoid leaks
if (continuation.isActive) {
continuation.resume(Unit)
}
}
continuation.invokeOnCancellation {
map.setCameraAnimationEndListener(null)
}
}
```

* **Lifecycle:** You must pass lifecycle events down to `Map3DView`. In Compose, `factory` block takes care of instantiation and `onRelease` handles cleanup (`onDestroy()`). Ensure `onCreate` is called in the factory block.
* *Critical Note:* The underlying `GoogleMap3D` engine instance is effectively created once per application lifecycle. If your `AndroidView` Composable leaves the composition and later returns (creating a new `Map3DView`), the underlying 3D engine may still retain previously added objects (like Polygons) from the destroyed view. You must manually clear or track your objects to avoid duplicates across recompositions or Navigation transitions.
* **Initialization & Adding Objects:** Do **not** attempt to set the camera or add 3D objects (like Polygons) immediately after the `GoogleMap3D` reference is ready. The renderer needs time to warm up.
* **Initial Camera:** Always set the initial camera position declaratively via `Map3DOptions` (passed into your container view) rather than imperatively moving the camera after the map loads. This avoids dizzying "flight" animations from coordinate `(0,0)` on startup.
* **Adding Objects:** Only inject geometries into the scene after the map has signaled it is fully ready and stable. Typically, this means waiting for an `onMapSteady` callback.
* **Updating Map Objects:** When updating an existing Map Object (e.g., `Polygon`, `Polyline`), do **not** use `remove()` and re-add a new one, as this causes flickering. Instead, use `getId()` from the existing object and pass it to a new `PolygonOptions` (or equivalent) builder, then call `addPolygon()` with those new options on the same `GoogleMap3D` instance. The SDK uses the matching ID to update the existing object gracefully without flickering.
* **Nullable Camera Properties:** The 3D SDK's `Camera` object has 6 degrees of freedom. Properties like `heading`, `tilt`, `roll`, and `range` are returned as `Double?` (nullable) since the renderer does not always guarantee a value for every property. Handle these nulls defensively when extracting camera telemetry, especially when persisting position data.
* **Parameter Validation:** The Maps 3D library will throw exceptions and crash if passed out-of-bounds telemetry for camera movements or locations. Standardize a validation/coercion layer (e.g., returning a `toValidCamera()` extension object) covering:
* `latitude`: clamped to `[-90.0, 90.0]`
* `longitude`: clamped to `[-180.0, 180.0]`
* `tilt`: clamped to `[0.0, 90.0]`
* `range`: clamped to `[0.0, 63170000.0]`
* `heading`: wrapped to `[0.0, 360.0]`
* `roll`: wrapped to `[-360.0, 360.0]`
* `altitude`: clamped to `[0.0, MAX_ALTITUDE_METERS]`

**Example Extension:**
```kotlin
/** Helper to wrap cyclic values like heading and roll */
fun Double.wrapIn(lower: Double, upper: Double): Double {
val range = upper - lower
if (range <= 0) return this
val offset = this - lower
return lower + (offset - Math.floor(offset / range) * range)
}

/** Extension to sanitize camera telemetry before passing to engine */
fun Camera?.toValidCamera(): Camera {
val source = this ?: return Camera.DEFAULT_CAMERA
return camera {
center = latLngAltitude {
latitude = source.center.latitude.coerceIn(-90.0..90.0)
longitude = source.center.longitude.coerceIn(-180.0..180.0)
altitude = source.center.altitude.coerceIn(0.0..LatLngAltitude.MAX_ALTITUDE_METERS)
}
heading = source.heading?.toDouble()?.wrapIn(0.0, 360.0) ?: 0.0
tilt = source.tilt?.toDouble()?.coerceIn(0.0..90.0) ?: 60.0
roll = source.roll?.toDouble()?.wrapIn(-360.0, 360.0) ?: 0.0
range = source.range?.toDouble()?.coerceIn(0.0..63170000.0) ?: 1500.0
}
}
```

* **Immutable Updates (`copy` Extensions):** The 3D SDK builders (like `camera {}` or `latLngAltitude {}`) do not natively provide a `copy()` method like Kotlin data classes. To gracefully update a single property (like altitude) while retaining the rest of the object's complex state, implement custom `.copy()` extensions:

```kotlin
/** Extension to clone and modify a Camera */
fun Camera.copy(
center: LatLngAltitude? = null,
heading: Double? = null,
tilt: Double? = null,
range: Double? = null,
roll: Double? = null,
): Camera {
val objectToCopy = this
return camera {
this.center = center ?: objectToCopy.center
this.heading = heading ?: objectToCopy.heading
this.tilt = tilt ?: objectToCopy.tilt
this.range = range ?: objectToCopy.range
this.roll = roll ?: objectToCopy.roll
}
}

/** Extension to clone and modify a LatLngAltitude */
fun LatLngAltitude.copy(
latitude: Double? = null,
longitude: Double? = null,
altitude: Double? = null,
): LatLngAltitude {
val objectToCopy = this
return latLngAltitude {
this.latitude = latitude ?: objectToCopy.latitude
this.longitude = longitude ?: objectToCopy.longitude
this.altitude = altitude ?: objectToCopy.altitude
}
}
```

## 5. A Note on Initialization

Immediate Setup (onMap3DViewReady): Fails on cold starts because the viewport layout and binding matrix are not yet stable. Camera updates are completely ignored, and overlays may render offset.
OnMapReady & OnMapSteady Listeners: These callbacks are strictly edge-triggered. While they may fire on a cold start, they will skip execution entirely on a warm restore (e.g., returning to the Activity) because the view is already considered ready/steady. This leaves the user with a frozen camera state and missing overlays.
The Solution: Timer-Based Delay Workaround
Until the SDK introduces native Coroutine support (like an .awaitMap() extension) or synchronous state getters (like isMapReady), the most reliable workaround for both cold and warm starts is a timer-based delay. By intentionally deferring the initialization logic slightly, we bypass the brittle edge-triggered listeners entirely.

Kotlin Implementation (Preferred)
Use a coroutine with delay() inside your initialization flow:

```kotlin
// Ensure you are launching on the Main thread to interact with the Map3DView safely
lifecycleScope.launch {
// Wait for the viewport to fully inflate and bindings to stabilize.
// 500ms is a safe brute-force threshold to avoid edge-trigger races.
delay(500)

// Position camera and add overlays safely
setupMapElements()
}
```

Java Implementation
Use a standard Handler mapped to the Main Looper:

```java
new Handler(Looper.getMainLooper()).postDelayed(() -> {
// Wait for the viewport to fully inflate, then safely apply updates
setupMapElements();
}, 500);
```

[IMPORTANT] Even with the timer delay successfully ensuring your camera updates fire, you must still implement an isInitialized boolean latch
(or dynamically check if your layers exist) within setupMapElements(). Otherwise, you will endlessly stack duplicate markers, model nodes,
and polyline overlays on top of each other during every warm Activity re-entry.

## 6. Execution Steps
1. Add the 3D Maps SDK dependencies.
2. Setup the Secrets Gradle plugin if not already set.
3. Update `AndroidManifest.xml` with the specific `com.google.android.geo.maps3d.API_KEY` tag.
4. Create the `Map3DContainer` composable wrapped in `AndroidView`.
5. Inform the user how to add `MAPS3D_API_KEY` securely.
24 changes: 24 additions & 0 deletions .gemini/styleguide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Gemini Code Assist Style Guide: android-maps3d-samples

This guide defines the custom code review and generation rules for the `android-maps3d-samples` project.

## Jetpack Compose Guidelines
- **API Guidelines**: Strictly follow the [Jetpack Compose API guidelines](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md).
- **Naming**: Composable functions must be PascalCase.
- **State Management**: Lift state. Do not put complex `GoogleMap3D` objects directly into Composables if possible.
- **Modifiers**: The first optional parameter of any Composable should be `modifier: Modifier = Modifier`.

## Kotlin Style
- **Naming**: Use camelCase for variables and functions.
- **Documentation**: Provide KDoc for all public classes, properties, and functions.
- **Safety**: Use null-safe operators and avoid `!!`.

## Maps 3D Specifics
- **Secrets**: Never commit API keys. Ensure they are read from `secrets.properties` via `BuildConfig` or similar. Use the Secrets Gradle Plugin.
- **Maps 3D SDK Integration**:
- The SDK is currently View-based (`Map3DView`). Use it in Jetpack Compose by wrapping it inside an `AndroidView` composable.
- Implement the **Double-Wait** pattern (`awaitCameraAnimation` + `awaitSteady`) for cinematic animations in Compose to ensure peak visual quality before triggering UI changes.
- Animations are fire-and-forget. Use a wrapper suspend function (like `awaitCameraAnimation`) to enable structured concurrency.
- Be mindful of Z-ordering issues when over-layering Compose elements over the `AndroidView` which uses `SurfaceView` or `TextureView` internally.
- **Permissions**: Ensure `<uses-permission android:name="android.permission.INTERNET" />` is in `AndroidManifest.xml`.
- **API Key Metadata**: Requires `<meta-data android:name="com.google.android.geo.maps3d.API_KEY" android:value="${MAPS3D_API_KEY}" />` in `AndroidManifest.xml`.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ To run the samples, you will need:
- (for the advanced sample, copy `Maps3DSamples/advanced/local.defaults.properties` to `Maps3DSamples/advanced/secrets.properties` and set the value of `MAPS3D_API_KEY` to your API key.)
- All samples require up-to-date versions of the Android build tools and the Android support repository.

## Gemini Code Assist Integration

This repository includes custom instructions (Skills) for **Gemini Code Assist** to help generate code adhering to Maps3D best practices.

The skill is located in the `.gemini/skills/android-maps3d-sdk` directory. You should install this skill into your working environment following the instructions for your specific setup.

As a reference, you can view the [Gemini CLI documentation](https://geminicli.com/) for one way to use local skills. Please note that your specific AI agent or development environment may require different installation steps.

## Running the sample(s)

1. Download the samples by cloning this repository:
Expand Down
Loading