Skip to content

Commit ccbb8d0

Browse files
authored
docs: add Gemini Code Assist skill for Maps3D SDK (#30)
* docs: add Gemini Code Assist skill and instructions * docs: add note about Maps 3D SDK version verification * docs: add note about lifecycleRuntimeKtx version verification
1 parent fabe8c5 commit ccbb8d0

4 files changed

Lines changed: 333 additions & 0 deletions

File tree

.gemini/config.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Gemini Code Assist Configuration
2+
# See: https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github
3+
4+
# Feature settings
5+
have_fun: false
6+
7+
code_review:
8+
disable: false
9+
comment_severity_threshold: MEDIUM
10+
max_review_comments: -1
11+
12+
pull_request_opened:
13+
summary: true
14+
code_review: true
15+
include_drafts: true
16+
17+
# Files to ignore in Gemini analysis
18+
ignore_patterns:
19+
- "**/*.bin"
20+
- "**/*.exe"
21+
- "**/build/**"
22+
- "**/.gradle/**"
23+
- "**/secrets.properties"
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
---
2+
name: android-maps3d-sdk
3+
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.
4+
---
5+
6+
# Android Maps 3D SDK Integration
7+
8+
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.
9+
10+
We should start with a few questions about how the developer want to use `Maps3DView`.
11+
12+
Are they using or planning on using Jetpack Compose?
13+
14+
Are they using or planning on using dependency injection (such as Hilt or Koin)?
15+
16+
## 1. Setup Dependencies
17+
18+
First, add the necessary versions and libraries to your `libs.versions.toml` file:
19+
20+
```toml
21+
[versions]
22+
# NOTE: Verify this is the latest version of the Maps 3D SDK, as it is subject to change.
23+
playServicesMaps3d = "0.2.0"
24+
# NOTE: Verify this is the latest version of lifecycle-runtime-ktx.
25+
lifecycleRuntimeKtx = "2.8.5"
26+
27+
[libraries]
28+
play-services-maps3d = { group = "com.google.android.gms", name = "play-services-maps3d", version.ref = "playServicesMaps3d" }
29+
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
30+
```
31+
32+
Then, add the dependencies to the app-level `build.gradle.kts` file.
33+
34+
```kotlin
35+
dependencies {
36+
// Google Maps 3D SDK
37+
implementation(libs.play.services.maps3d)
38+
39+
// Lifecycle Runtime KTX for Coroutine interop
40+
implementation(libs.androidx.lifecycle.runtime.ktx)
41+
}
42+
```
43+
44+
## 2. Setup the Secrets Gradle Plugin
45+
46+
Use the Secrets Gradle Plugin for Android to inject the API key securely. In app-level `build.gradle.kts`:
47+
48+
```kotlin
49+
plugins {
50+
alias(libs.plugins.secrets.gradle.plugin)
51+
}
52+
53+
secrets {
54+
propertiesFileName = "secrets.properties"
55+
defaultPropertiesFileName = "local.defaults.properties"
56+
}
57+
```
58+
59+
In `AndroidManifest.xml`, add the required permissions and reference the injected API key meta-data:
60+
61+
```xml
62+
<manifest ...>
63+
<!-- Required for Google Maps 3D -->
64+
<uses-permission android:name="android.permission.INTERNET" />
65+
66+
<application ...>
67+
<!-- Google Maps 3D API Key injected by Secrets Gradle Plugin -->
68+
<!-- Note the specific name for Maps 3D -->
69+
<meta-data
70+
android:name="com.google.android.geo.maps3d.API_KEY"
71+
android:value="${MAPS3D_API_KEY}" />
72+
...
73+
</application>
74+
</manifest>
75+
```
76+
77+
Add the API Key to `secrets.properties`:
78+
79+
```properties
80+
MAPS3D_API_KEY=YOUR_API_KEY
81+
```
82+
83+
## 3. Implement the Map3D Container Composable
84+
85+
If the user is working in a Jetpack Compose app or is creating a Compose app, We can use an
86+
`AndroidView` to bridge between the View-based `Map3DView` and Jetpack Compose.
87+
88+
```kotlin
89+
import androidx.compose.foundation.layout.Box
90+
import androidx.compose.foundation.layout.fillMaxSize
91+
import androidx.compose.runtime.*
92+
import androidx.compose.ui.Modifier
93+
import androidx.compose.ui.viewinterop.AndroidView
94+
import com.google.android.gms.maps.model.LatLng
95+
import com.google.android.gms.maps.model.Map3DMode
96+
import com.google.android.gms.maps.model.Map3DOptions
97+
import com.google.android.gms.maps.Map3DView
98+
import com.google.android.gms.maps.GoogleMap3D
99+
import com.google.android.gms.maps.OnMap3DViewReadyCallback
100+
101+
@Composable
102+
fun Map3DContainer(
103+
modifier: Modifier = Modifier,
104+
options: Map3DOptions
105+
) {
106+
// 1. Hoist State: Remember the map object
107+
var googleMap by remember { mutableStateOf<GoogleMap3D?>(null) }
108+
109+
Box(modifier = modifier.fillMaxSize()) {
110+
AndroidView(
111+
modifier = Modifier.fillMaxSize(),
112+
factory = { context ->
113+
Map3DView(context, options).apply {
114+
// Manually call onCreate.
115+
onCreate(null)
116+
}
117+
},
118+
update = { view ->
119+
view.getMap3DViewAsync(
120+
object : OnMap3DViewReadyCallback {
121+
override fun onMap3DViewReady(map3D: GoogleMap3D) {
122+
googleMap = map3D // Capture the controller
123+
}
124+
override fun onError(e: Exception) {
125+
googleMap = null
126+
throw e
127+
}
128+
}
129+
)
130+
},
131+
onRelease = { view ->
132+
googleMap = null
133+
view.onDestroy()
134+
}
135+
)
136+
}
137+
}
138+
```
139+
140+
## 4. Best Practices & Guidelines
141+
* **Double-Wait Pattern:** Triggering animations from Compose buttons requires the **Double-Wait** pattern (`awaitCameraAnimation` + `awaitSteady`) to ensure peak visual quality.
142+
* **Coroutine Bridging:** Animations in the 3D SDK are fire-and-forget. Use an `awaitCameraAnimation(map: GoogleMap3D)` suspend wrapper function using `suspendCancellableCoroutine` for structured concurrency:
143+
144+
```kotlin
145+
suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation ->
146+
map.setCameraAnimationEndListener {
147+
map.setCameraAnimationEndListener(null) // Cleanup listener to avoid leaks
148+
if (continuation.isActive) {
149+
continuation.resume(Unit)
150+
}
151+
}
152+
continuation.invokeOnCancellation {
153+
map.setCameraAnimationEndListener(null)
154+
}
155+
}
156+
```
157+
158+
* **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.
159+
* *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.
160+
* **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.
161+
* **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.
162+
* **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.
163+
* **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.
164+
* **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.
165+
* **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:
166+
* `latitude`: clamped to `[-90.0, 90.0]`
167+
* `longitude`: clamped to `[-180.0, 180.0]`
168+
* `tilt`: clamped to `[0.0, 90.0]`
169+
* `range`: clamped to `[0.0, 63170000.0]`
170+
* `heading`: wrapped to `[0.0, 360.0]`
171+
* `roll`: wrapped to `[-360.0, 360.0]`
172+
* `altitude`: clamped to `[0.0, MAX_ALTITUDE_METERS]`
173+
174+
**Example Extension:**
175+
```kotlin
176+
/** Helper to wrap cyclic values like heading and roll */
177+
fun Double.wrapIn(lower: Double, upper: Double): Double {
178+
val range = upper - lower
179+
if (range <= 0) return this
180+
val offset = this - lower
181+
return lower + (offset - Math.floor(offset / range) * range)
182+
}
183+
184+
/** Extension to sanitize camera telemetry before passing to engine */
185+
fun Camera?.toValidCamera(): Camera {
186+
val source = this ?: return Camera.DEFAULT_CAMERA
187+
return camera {
188+
center = latLngAltitude {
189+
latitude = source.center.latitude.coerceIn(-90.0..90.0)
190+
longitude = source.center.longitude.coerceIn(-180.0..180.0)
191+
altitude = source.center.altitude.coerceIn(0.0..LatLngAltitude.MAX_ALTITUDE_METERS)
192+
}
193+
heading = source.heading?.toDouble()?.wrapIn(0.0, 360.0) ?: 0.0
194+
tilt = source.tilt?.toDouble()?.coerceIn(0.0..90.0) ?: 60.0
195+
roll = source.roll?.toDouble()?.wrapIn(-360.0, 360.0) ?: 0.0
196+
range = source.range?.toDouble()?.coerceIn(0.0..63170000.0) ?: 1500.0
197+
}
198+
}
199+
```
200+
201+
* **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:
202+
203+
```kotlin
204+
/** Extension to clone and modify a Camera */
205+
fun Camera.copy(
206+
center: LatLngAltitude? = null,
207+
heading: Double? = null,
208+
tilt: Double? = null,
209+
range: Double? = null,
210+
roll: Double? = null,
211+
): Camera {
212+
val objectToCopy = this
213+
return camera {
214+
this.center = center ?: objectToCopy.center
215+
this.heading = heading ?: objectToCopy.heading
216+
this.tilt = tilt ?: objectToCopy.tilt
217+
this.range = range ?: objectToCopy.range
218+
this.roll = roll ?: objectToCopy.roll
219+
}
220+
}
221+
222+
/** Extension to clone and modify a LatLngAltitude */
223+
fun LatLngAltitude.copy(
224+
latitude: Double? = null,
225+
longitude: Double? = null,
226+
altitude: Double? = null,
227+
): LatLngAltitude {
228+
val objectToCopy = this
229+
return latLngAltitude {
230+
this.latitude = latitude ?: objectToCopy.latitude
231+
this.longitude = longitude ?: objectToCopy.longitude
232+
this.altitude = altitude ?: objectToCopy.altitude
233+
}
234+
}
235+
```
236+
237+
## 5. A Note on Initialization
238+
239+
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.
240+
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.
241+
The Solution: Timer-Based Delay Workaround
242+
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.
243+
244+
Kotlin Implementation (Preferred)
245+
Use a coroutine with delay() inside your initialization flow:
246+
247+
```kotlin
248+
// Ensure you are launching on the Main thread to interact with the Map3DView safely
249+
lifecycleScope.launch {
250+
// Wait for the viewport to fully inflate and bindings to stabilize.
251+
// 500ms is a safe brute-force threshold to avoid edge-trigger races.
252+
delay(500)
253+
254+
// Position camera and add overlays safely
255+
setupMapElements()
256+
}
257+
```
258+
259+
Java Implementation
260+
Use a standard Handler mapped to the Main Looper:
261+
262+
```java
263+
new Handler(Looper.getMainLooper()).postDelayed(() -> {
264+
// Wait for the viewport to fully inflate, then safely apply updates
265+
setupMapElements();
266+
}, 500);
267+
```
268+
269+
[IMPORTANT] Even with the timer delay successfully ensuring your camera updates fire, you must still implement an isInitialized boolean latch
270+
(or dynamically check if your layers exist) within setupMapElements(). Otherwise, you will endlessly stack duplicate markers, model nodes,
271+
and polyline overlays on top of each other during every warm Activity re-entry.
272+
273+
## 6. Execution Steps
274+
1. Add the 3D Maps SDK dependencies.
275+
2. Setup the Secrets Gradle plugin if not already set.
276+
3. Update `AndroidManifest.xml` with the specific `com.google.android.geo.maps3d.API_KEY` tag.
277+
4. Create the `Map3DContainer` composable wrapped in `AndroidView`.
278+
5. Inform the user how to add `MAPS3D_API_KEY` securely.

.gemini/styleguide.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Gemini Code Assist Style Guide: android-maps3d-samples
2+
3+
This guide defines the custom code review and generation rules for the `android-maps3d-samples` project.
4+
5+
## Jetpack Compose Guidelines
6+
- **API Guidelines**: Strictly follow the [Jetpack Compose API guidelines](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md).
7+
- **Naming**: Composable functions must be PascalCase.
8+
- **State Management**: Lift state. Do not put complex `GoogleMap3D` objects directly into Composables if possible.
9+
- **Modifiers**: The first optional parameter of any Composable should be `modifier: Modifier = Modifier`.
10+
11+
## Kotlin Style
12+
- **Naming**: Use camelCase for variables and functions.
13+
- **Documentation**: Provide KDoc for all public classes, properties, and functions.
14+
- **Safety**: Use null-safe operators and avoid `!!`.
15+
16+
## Maps 3D Specifics
17+
- **Secrets**: Never commit API keys. Ensure they are read from `secrets.properties` via `BuildConfig` or similar. Use the Secrets Gradle Plugin.
18+
- **Maps 3D SDK Integration**:
19+
- The SDK is currently View-based (`Map3DView`). Use it in Jetpack Compose by wrapping it inside an `AndroidView` composable.
20+
- Implement the **Double-Wait** pattern (`awaitCameraAnimation` + `awaitSteady`) for cinematic animations in Compose to ensure peak visual quality before triggering UI changes.
21+
- Animations are fire-and-forget. Use a wrapper suspend function (like `awaitCameraAnimation`) to enable structured concurrency.
22+
- Be mindful of Z-ordering issues when over-layering Compose elements over the `AndroidView` which uses `SurfaceView` or `TextureView` internally.
23+
- **Permissions**: Ensure `<uses-permission android:name="android.permission.INTERNET" />` is in `AndroidManifest.xml`.
24+
- **API Key Metadata**: Requires `<meta-data android:name="com.google.android.geo.maps3d.API_KEY" android:value="${MAPS3D_API_KEY}" />` in `AndroidManifest.xml`.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ To run the samples, you will need:
5959
- (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.)
6060
- All samples require up-to-date versions of the Android build tools and the Android support repository.
6161

62+
## Gemini Code Assist Integration
63+
64+
This repository includes custom instructions (Skills) for **Gemini Code Assist** to help generate code adhering to Maps3D best practices.
65+
66+
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.
67+
68+
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.
69+
6270
## Running the sample(s)
6371

6472
1. Download the samples by cloning this repository:

0 commit comments

Comments
 (0)