-
Notifications
You must be signed in to change notification settings - Fork 4
doc: GMP Maps3D on Android Skill #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dkhawk
wants to merge
1
commit into
main
Choose a base branch
from
docs/add-skill
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.