diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 00000000..a884532e --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,21 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Gemini Code Assist Configuration +# See: https://developers.google.com/gemini/docs/workspace/config +rules: + - id: ktx-idioms + name: Enforce Idiomatic KTX Kotlin Patterns + description: Enforces the use of coroutines and Flows over legacy Maps SDK callbacks. + severity: error diff --git a/.gemini/skills/android-maps-ktx/BUILD b/.gemini/skills/android-maps-ktx/BUILD new file mode 100644 index 00000000..d24bd34c --- /dev/null +++ b/.gemini/skills/android-maps-ktx/BUILD @@ -0,0 +1,17 @@ +load("//learning/gemini/agents/skill_rules:skills.bzl", "agent_skill") +load("//learning/gemini/agents/skills/skill_creator/scripts:skill_defs.bzl", "validate_skill_test") + +package(default_visibility = ["//visibility:public"]) + +agent_skill( + name = "android-maps-ktx", + srcs = [ + "SKILL.md", + "references/clustering-flows.md", + "references/collection-flows.md", + "references/gotchas.md", + "references/location-flows.md", + ], +) + +validate_skill_test(name = "validate_android_maps_ktx_skill_test") diff --git a/.gemini/skills/android-maps-ktx/OWNERS b/.gemini/skills/android-maps-ktx/OWNERS new file mode 100644 index 00000000..6f983dd8 --- /dev/null +++ b/.gemini/skills/android-maps-ktx/OWNERS @@ -0,0 +1,2 @@ +dkhawk@google.com +dkhawk diff --git a/.gemini/skills/android-maps-ktx/SKILL.md b/.gemini/skills/android-maps-ktx/SKILL.md new file mode 100644 index 00000000..63c88470 --- /dev/null +++ b/.gemini/skills/android-maps-ktx/SKILL.md @@ -0,0 +1,61 @@ +--- +name: android-maps-ktx +description: >- + Provides idiomatic Kotlin extension (KTX) patterns, reactive Flow event streams, + and multi-subscriber shareIn rules for Google Maps SDK for Android and its + Utility Library. Use when writing or refactoring Android map features with + coroutines/flows, streaming GPS location updates, handling marker cluster clicks, + managing styled overlay collections, or simulating adb coordinates. Don't use for + vanilla Java Google Maps development or web/iOS maps APIs. +--- + +# Android Maps KTX Agent Skill + +This skill provides verified patterns, gotchas, and reference implementations for developing with the **Android Maps KTX** and **Android Maps Utility KTX** libraries (`maps-ktx` and `maps-utils-ktx`). + +It guides you in writing concise, idiomatic Kotlin using coroutines and Flows to manage map lifecycles, viewport transitions, satellite location streams, marker cluster events, and custom styled overlay click collections. + +--- + +## πŸ“‹ Triggers & Scenarios + +### Use when: +- Implementing or refactoring Android map interfaces using **Kotlin coroutines** or **Kotlin Flows**. +- Consuming reactive satellite location updates (`LocationManager`) with automatic subscriber lifecycles. +- Listening to marker group click events or individual pin clicks using `ClusterManager` event streams. +- Managing click callbacks across distinct, styled sub-collections using `MarkerManager` or `CircleManager`. +- Creating pixel-perfect visual previews or automated `.gif` animations of map interactions using `adb` shell inputs. + +### Don't use for: +- Standard Java-based Google Maps SDK for Android development. +- Web Map developments (Maps JavaScript API) or iOS Maps integrations. +- General Android location services unrelated to Google Maps rendering or KTX. + +--- + +## 🎯 Idiomatic KTX Core Capabilities + +The Android Maps KTX libraries replace legacy callback-heavy interfaces with clean Kotlin-first abstractions. Refer to the following deep guides for implementations: + +### 1. Critical Gotchas & Stability Workarounds +- **The Single-Subscriber Invariant**: Learn why multiple direct subscribers to a KTX cold flow will hijack the listener slot or break on cancellation, and how to safely multicast using `.shareIn()`. +- **SystemUI Tuner Stability**: Learn how to set clean status bars in Android 15+ (SDK 37) without triggering fatal tuner repository crashes. +- **Mock Location Lifecycle**: Understand why mock providers must be created *before* activity subscription. +- πŸ“‚ **[Gotchas & Stability Reference](references/gotchas.md)** + +### 2. Reactive Location Streaming +- Cold flows for fine GPS location tracking and coarse cell/wifi tower tracking. +- Auto-cleanup of test location providers and lifecycle-safe subscriptions. +- adb coordinates injection for London (Fine GPS) and Paris (Coarse network) trajectories. +- πŸ“‚ **[Location Streams Reference](references/location-flows.md)** + +### 3. Marker Clustering Click Streams +- Subscribing to group cluster click viewport glides via `clusterClickEvents()`. +- Multicasting individual marker clicks safely across dual observers via `clusterItemClickEvents()` and `SharedFlow`. +- adb touch badges simulations. +- πŸ“‚ **[Clustering click flows Reference](references/clustering-flows.md)** + +### 4. Shape & Overlay Click Collections +- Propagating click events in clean, isolated sub-collections via KTX collection click extensions. +- Visual click confirmation rules (Persistent InfoWindows, interactive circle color-swapping on-device). +- πŸ“‚ **[Collection Click Flows Reference](references/collection-flows.md)** diff --git a/.gemini/skills/android-maps-ktx/references/clustering-flows.md b/.gemini/skills/android-maps-ktx/references/clustering-flows.md new file mode 100644 index 00000000..4f3e3654 --- /dev/null +++ b/.gemini/skills/android-maps-ktx/references/clustering-flows.md @@ -0,0 +1,91 @@ +# Marker Clustering Click Event Flows + +This reference provides implementation patterns and simulation inputs for consuming clustered marker click events via `maps-utils-ktx` Flows. + +--- + +## πŸš€ Kotlin KTX Flow Implementations + +The `ClusterManager` click extensions convert standard callback listeners (`setOnClusterClickListener`, `setOnClusterItemClickListener`) into clean, reactive Flows. + +### Complete Multi-Observer Multicast Click Flow Pattern +This pattern shows how to safely subscribe to group cluster click events (performing map zoom-ins) and multicast individual item click events concurrently across multiple observers without slot hijacking. + +```kotlin +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.ktx.utils.clustering.clusterClickEvents +import com.google.maps.android.ktx.utils.clustering.clusterItemClickEvents + +fun setupClusteringFlows( + map: GoogleMap, + clusterManager: ClusterManager, + scope: CoroutineScope, + logView: TextView +) { + map.setOnCameraIdleListener(clusterManager) + map.setOnMarkerClickListener(clusterManager) + + // 1. Group Cluster Click Flow (Single collector) + scope.launch { + clusterManager.clusterClickEvents().collect { cluster -> + // Zoom viewport directly onto clicked cluster coordinate KTX-style! + map.awaitAnimateCamera( + CameraUpdateFactory.newLatLngZoom(cluster.position, map.cameraPosition.zoom + 2f) + ) + logView.append("Cluster Clicked! Contains ${cluster.size} items.") + } + } + + // 2. Individual Cluster Item Click Flow (Multicast SharedFlow observer pattern) + val sharedItemClicks: SharedFlow = clusterManager.clusterItemClickEvents() + .shareIn( + scope = scope, + started = SharingStarted.WhileSubscribed(5000), + replay = 0 + ) + + // Observer 1 + scope.launch { + sharedItemClicks.collect { item -> + logView.append("Observer 1 collected item: '${item.title}'") + } + } + + // Observer 2 + scope.launch { + sharedItemClicks.collect { item -> + logView.append("Observer 2 collected item: '${item.title}'") + } + } +} +``` + +--- + +## πŸ§ͺ On-Device Click Simulation & Automation + +To automate marker cluster clicks on an emulator (e.g., for automated visual tests, screen recordings, or GIF generation), you can inject precise touch events using `adb shell input tap`. + +### Automated Click Sweep Sequence +Taps the cluster badge at the center of the screen to zoom into individual pins, and then executes a robust sweep in the center region to guarantee clicking one of the expanded pins: + +```bash +# 1. Launch Marker Cluster Click Flow view +adb shell "am start -W -n com.google.maps.android.ktx.demo/com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity --es EXTRA_SNIPPET_TITLE \"Marker Cluster Click Flow\"" +sleep 4 # Wait for base map tiles and clusters to fully render + +# 2. Tap the main Cluster Badge near map center to trigger clusterClicksSnippet +echo "Tapping Cluster Badge at map center..." +adb shell input tap 540 1200 +sleep 3 # Wait for KTX awaitAnimateCamera zoom transition to settle + +# 3. Sweep-tap around center coordinates to hit one of the newly expanded individual markers +echo "Simulating sweep-tap to trigger clusterItemClicksSnippet on an individual marker..." +for offset in 0 -60 60; do + for yoffset in 0 -60 60; do + adb shell input tap $((540 + offset)) $((1200 + yoffset)) + sleep 0.4 + done +done +sleep 2 +``` diff --git a/.gemini/skills/android-maps-ktx/references/collection-flows.md b/.gemini/skills/android-maps-ktx/references/collection-flows.md new file mode 100644 index 00000000..5b917fe4 --- /dev/null +++ b/.gemini/skills/android-maps-ktx/references/collection-flows.md @@ -0,0 +1,98 @@ +# Styled Shape & Overlay Click Flows + +This reference provides implementation patterns and on-device simulation coordinates for styled collection click handlers via `maps-utils-ktx` Flows. + +--- + +## πŸš€ Kotlin KTX Flow Implementations + +The Maps Utility Library provides `MarkerManager` and `CircleManager` to manage styled collections of overlays. KTX extensions provide clean `.clickEvents()` Flow abstractions over these collections. + +### Dual Collection Click Flows Example +This example demonstrates how to listen to clicks on custom markers (displaying interactive InfoWindows) and clickable circles (updating shape colors in real-time to visually confirm successful clicks). + +```kotlin +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.ktx.utils.collection.clickEvents + +fun setupCollectionFlows( + map: GoogleMap, + scope: CoroutineScope, + logView: TextView +) { + val centerPos = LatLng(51.150000, -0.150032) + map.moveCamera(CameraUpdateFactory.newLatLngZoom(centerPos, 13f)) + + // 1. Custom Marker Collection + val markerManager = MarkerManager(map) + val markerCollection = markerManager.newCollection() + val marker = markerCollection.addMarker( + MarkerOptions() + .position(LatLng(51.150000, -0.152000)) + .title("Unclustered Marker") + .snippet("Click to trigger KTX Flow") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE)) + ) + + scope.launch { + markerCollection.clickEvents().collect { clickedMarker -> + // Display standard InfoWindow above the clicked marker + clickedMarker.showInfoWindow() + logView.append("Marker Clicked via KTX Flow: '${clickedMarker.title}'") + } + } + + // 2. Custom Circle Collection + val circleManager = CircleManager(map) + val circleCollection = circleManager.newCollection() + val circle = circleCollection.addCircle( + CircleOptions() + .center(LatLng(51.154000, -0.148000)) + .radius(400.0) + .clickable(true) + .fillColor(android.graphics.Color.parseColor("#3F1A73E8")) // Light Blue + .strokeColor(android.graphics.Color.parseColor("#1A73E8")) + .strokeWidth(4f) + ) + + scope.launch { + circleCollection.clickEvents().collect { clickedCircle -> + // Change styling in real-time to provide persistent, clear visual feedback + clickedCircle.fillColor = android.graphics.Color.parseColor("#3FDF3A30") // Soft Red + clickedCircle.strokeColor = android.graphics.Color.parseColor("#DF3A30") + logView.append("Circle Clicked! Styling updated to RED.") + } + } +} +``` + +--- + +## πŸ§ͺ On-Device Click Simulation & Automation + +To automate overlay clicks on an emulator, you can inject precise touch events using `adb shell input tap`. + +### Automated Click Sequence +Taps the custom Azure marker at its map coordinate, and then executes a robust sweep around the clickable circle's coordinates: + +```bash +# 1. Launch Marker Collection Click Flow view +adb shell "am start -W -n com.google.maps.android.ktx.demo/com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity --es EXTRA_SNIPPET_TITLE \"Marker Collection Click Flow\"" +sleep 4 # Wait for map and overlays to render + +# 2. Tap the Custom Azure Marker to trigger marker click flow +echo "Tapping Custom Azure Marker..." +adb shell input tap 540 1650 +sleep 2.5 # Wait for InfoWindow to render + +# 3. Sweep-tap around the clickable circle's coordinates to trigger circle click flow +echo "Simulating sweep-tap to click Custom Circle..." +for offset in 0 -50 50; do + for yoffset in 0 -50 50; do + adb shell input tap $((600 + offset)) $((1300 + yoffset)) + sleep 0.4 + done +done +sleep 2 +``` diff --git a/.gemini/skills/android-maps-ktx/references/gotchas.md b/.gemini/skills/android-maps-ktx/references/gotchas.md new file mode 100644 index 00000000..3a323bf6 --- /dev/null +++ b/.gemini/skills/android-maps-ktx/references/gotchas.md @@ -0,0 +1,96 @@ +# Critical Gotchas & Stability Workarounds + +This reference encodes the hidden gotchas, crash risks, and architectural constraints you must follow when developing with the Android Maps KTX library. + +--- + +## ⚠️ Gotcha 1: The Single-Subscriber Invariant (Cold Flows) + +### 🚫 The Problem +The underlying Google Maps SDK only supports a **single listener slot** per event type (e.g., only one `setOnMarkerClickListener` or `setOnCameraIdleListener` callback can be active at any time). + +Because the KTX Flows (`clusterItemClickEvents()`, `clickEvents()`, `cameraIdleEvents()`) are **cold flows**, they register a new listener callback on the SDK *every time a collector begins gathering updates*. +- If two active coroutines subscribe to the same cold click flow concurrently, the second subscriber will overwrite the listener slot, **hijacking** the stream. The first subscriber will silently stop receiving click events. +- Worse, if either subscriber is cancelled, the KTX flow's `awaitClose` block will run, completely clearing the listener slot on the Google Maps SDK (`setOnMarkerClickListener(null)`). This **silently kills** all remaining active collectors! + +### The Solution +If you have multiple active collectors subscribing to the same click, camera, or shape event stream, **always** share the cold flow by multicasting it into a hot `SharedFlow` using `.shareIn()`: + +```kotlin +// Wrap the cold KTX flow into a Hot SharedFlow to support multiple concurrent collectors safely +val sharedMarkerClicks: SharedFlow = markerCollection.clickEvents() + .shareIn( + scope = lifecycleScope, + started = SharingStarted.WhileSubscribed(5000), // Keep upstream active 5s after last collector leaves + replay = 0 // Do not replay past clicks to new subscribers + ) + +// Observer 1 (e.g., logs to telemetry panel) +scope.launch { + sharedMarkerClicks.collect { marker -> + logView.append("Observer 1: Clicked '${marker.title}'") + } +} + +// Observer 2 (e.g., plays click sound or displays info bubble) +scope.launch { + sharedMarkerClicks.collect { marker -> + marker.showInfoWindow() + } +} +``` + +--- + +## ⚠️ Gotcha 2: SystemUI Tuner Crashes (Android 15+ / SDK 37) + +### 🚫 The Problem +When generating pixel-perfect catalog snapshots or videos, it is common to use the Android `SystemUI Tuner` via `adb shell am broadcast -a com.android.systemui.demo` (Demo Mode) to hide battery levels, wifi strengths, and network icons to keep status bars clean. + +However, in **Android 15+ (SDK 37)**, attempting to disable or force wifi/mobile cell symbols via demo mode tuner broadcasts causes a fatal `DemoMobileConnectionsRepository` NullPointerException, causing the emulator's system UI server to crash repeatedly! + +### The Solution +When configuring Demo Mode on Android 15+ emulators, **never** broadcast wifi or mobile network override signals. Set a clean clock, full battery, and disable notification icons safely using these exact broadcasts: + +```bash +# 1. Enable Demo Mode +adb shell settings put global sysui_demo_allowed 1 + +# 2. Set a clean clock (e.g., 12:00) +adb shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1200 + +# 3. Set battery to 100% (plugged in) +adb shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged true + +# 4. Hide notification icons (clear status bar clutter) +adb shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false +``` + +--- + +## ⚠️ Gotcha 3: Mock Location Lifecycle (Cold Flow Cancellations) + +### 🚫 The Problem +When testing location flows (`fineLocationEvents`, `coarseLocationEvents`) on-device, you must inject mock locations. + +If you start the `SnippetExecutionActivity` and launch the coroutines that subscribe to KTX location flows *before* creating or enabling the mock location providers in the adb terminal, the OS Location Services daemon will temporarily report `PROVIDER_DISABLED`. +This throws a `CancellationException` inside the KTX cold flow subscription, **immediately cancelling** the coroutine and closing the stream. Subsequent mock coordinate injections will be ignored completely! + +### The Solution +Always pre-configure and enable mock location providers in the shell **before** launching the target map Activity: + +```bash +# 1. Grant mock location appops to both Shell and the Demo App +adb shell appops set com.android.shell android:mock_location allow +adb shell appops set com.google.maps.android.ktx.demo android:mock_location allow + +# 2. Pre-create and enable the target mock provider (gps or network) +adb shell cmd location providers add-test-provider gps +adb shell cmd location providers set-test-provider-enabled gps true + +# 3. NOW launch the Activity safely +adb shell "am start -W -n com.google.maps.android.ktx.demo/com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity --es EXTRA_SNIPPET_TITLE \"Fine Location Flow\"" + +# 4. Inject locations safely +adb shell cmd location providers set-test-provider-location gps --location 51.5074,-0.1278 +``` diff --git a/.gemini/skills/android-maps-ktx/references/location-flows.md b/.gemini/skills/android-maps-ktx/references/location-flows.md new file mode 100644 index 00000000..3f0fbee4 --- /dev/null +++ b/.gemini/skills/android-maps-ktx/references/location-flows.md @@ -0,0 +1,149 @@ +# Reactive Location Streaming Flows + +This reference provides the implementation patterns and testing commands for consuming satellite location streams reactively as cold Flows via `maps-utils-ktx`. + +--- + +## πŸ“‹ Permissions Requirements + +Location streaming relies on standard Android hardware permissions. Declare them in your `AndroidManifest.xml`: + +```xml + + +``` + +--- + +## πŸš€ Kotlin KTX Flow Implementations + +The KTX location extension converts the legacy `LocationListener` callback pattern into clean, lifecycle-aware cold Flows that start and stop location updates automatically as collectors subscribe and unsubscribe. + +### 1. Fine Location GPS Flow +Collects coordinate updates from `LocationManager.GPS_PROVIDER`. Requires `ACCESS_FINE_LOCATION`. + +```kotlin +import com.google.maps.android.ktx.utils.location.fineLocationEvents + +@SuppressLint("MissingPermission") +fun subscribeToFineGPS( + locationManager: LocationManager, + scope: CoroutineScope, + onLocationReceived: (Location) -> Unit, + onGPSDisabled: () -> Unit +) { + scope.launch { + try { + // Collect updates with minTime=2s and minDistance=1m + locationManager.fineLocationEvents(minTimeMs = 2000L, minDistanceM = 1f) + .collect { location -> + onLocationReceived(location) + } + } catch (e: CancellationException) { + // Triggers immediately if GPS gets disabled in settings mid-stream + onGPSDisabled() + } + } +} +``` + +### 2. Coarse Location Network Flow +Collects wifi and cell tower location updates from `LocationManager.NETWORK_PROVIDER`. Requires `ACCESS_COARSE_LOCATION`. + +```kotlin +import com.google.maps.android.ktx.utils.location.coarseLocationEvents + +@SuppressLint("MissingPermission") +fun subscribeToCoarseNetwork( + locationManager: LocationManager, + scope: CoroutineScope, + onLocationReceived: (Location) -> Unit, + onNetworkDisabled: () -> Unit +) { + scope.launch { + try { + // Collect updates with minTime=5s and minDistance=5m + locationManager.coarseLocationEvents(minTimeMs = 5000L, minDistanceM = 5f) + .collect { location -> + onLocationReceived(location) + } + } catch (e: CancellationException) { + onNetworkDisabled() + } + } +} +``` + +--- + +## πŸ§ͺ Automated testing & mock coordinate injection + +To verify location stream flows on an emulator, you can use the Android location subsystem test provider commands inside `adb`. + +### London Fine GPS Trajectory Simulation +Simulates a fine GPS device location sweep in London, plotting successive marker pins on the map: + +```bash +# 1. Pre-configure test provider before launching +adb shell appops set com.google.maps.android.ktx.demo android:mock_location allow +adb shell cmd location providers add-test-provider gps +adb shell cmd location providers set-test-provider-enabled gps true + +# 2. Launch Fine Location snippet activity +adb shell "am start -W -n com.google.maps.android.ktx.demo/com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity --es EXTRA_SNIPPET_TITLE \"Fine Location Flow\"" +sleep 3 + +# 3. Inject successive coordinates with fresh device timestamps +# Point 1: London Center +TIME=$(adb shell date +%s%3N) +adb shell cmd location providers set-test-provider-location gps --location 51.5074,-0.1278 --time $TIME +sleep 2.5 + +# Point 2: London South +TIME=$(adb shell date +%s%3N) +adb shell cmd location providers set-test-provider-location gps --location 51.4800,-0.1200 --time $TIME +sleep 2.5 + +# Point 3: London East +TIME=$(adb shell date +%s%3N) +adb shell cmd location providers set-test-provider-location gps --location 51.4950,-0.0800 --time $TIME +sleep 3.5 + +# 4. Clean up test provider +adb shell cmd location providers set-test-provider-enabled gps false +adb shell cmd location providers remove-test-provider gps +``` + +### Paris Coarse Network Trajectory Simulation +Simulates a coarse network location sweep in Paris: + +```bash +# 1. Pre-configure test provider before launching +adb shell appops set com.google.maps.android.ktx.demo android:mock_location allow +adb shell cmd location providers add-test-provider network +adb shell cmd location providers set-test-provider-enabled network true + +# 2. Launch Coarse Location snippet activity +adb shell "am start -W -n com.google.maps.android.ktx.demo/com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity --es EXTRA_SNIPPET_TITLE \"Coarse Location Flow\"" +sleep 3 + +# 3. Inject successive network coordinates +# Point 1: Paris Center +TIME=$(adb shell date +%s%3N) +adb shell cmd location providers set-test-provider-location network --location 48.8566,2.3522 --time $TIME +sleep 5.5 + +# Point 2: Paris South +TIME=$(adb shell date +%s%3N) +adb shell cmd location providers set-test-provider-location network --location 48.8300,2.3400 --time $TIME +sleep 5.5 + +# Point 3: Paris East +TIME=$(adb shell date +%s%3N) +adb shell cmd location providers set-test-provider-location network --location 48.8400,2.3800 --time $TIME +sleep 6.5 + +# 4. Clean up test provider +adb shell cmd location providers set-test-provider-enabled network false +adb shell cmd location providers remove-test-provider network +``` diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md new file mode 100644 index 00000000..283a791a --- /dev/null +++ b/.gemini/styleguide.md @@ -0,0 +1,32 @@ +# Gemini Code Assist Style Guide: android-maps-ktx + +This guide defines the custom code review and generation rules for the `android-maps-ktx` and `android-maps-utils-ktx` repositories. + +--- + +## πŸš€ Kotlin Extensions (KTX) Idioms +- **Reactive Streams over Callbacks**: Prefer subscribing to reactive KTX Flows (e.g. `fineLocationEvents()`, `clusterClickEvents()`, `cameraIdleEvents()`) rather than overriding standard Maps SDK listeners or executing periodic polling tasks. +- **Lifecycle-Aware Scopes**: Always launch and collect Flow subscriptions inside lifecycle-aware scopes (such as `lifecycleScope` or `repeatOnLifecycle`) to prevent memory leaks and coordinate background subscription cancellations safely. + +--- + +## ⚠️ Multi-Subscriber Multicasting Rules +- **Single-Listener Limitation**: The underlying Google Maps SDK only supports a single callback slot per event listener (e.g. one `setOnMarkerClickListener` or `setOnCameraIdleListener`). +- **The Hijacking Gotcha**: Because KTX click and camera flows are cold, having multiple concurrent collectors subscribe to them directly will override and hijack this single listener slot. Cancelling any single collector will completely clear the slot, silently breaking all remaining active streams! +- **multicasting Invariant**: If you need multiple concurrent observers to collect from the same click or camera stream, **always** multicast the cold flow into a hot `SharedFlow` using the `.shareIn()` operator: + +```kotlin +val sharedClicks = markerCollection.clickEvents() + .shareIn( + scope = lifecycleScope, + started = SharingStarted.WhileSubscribed(5000), + replay = 0 + ) +``` + +--- + +## πŸ§ͺ Visual Regression & Test Coverage Guidelines +- **Automatic Coverage**: Any new KTX location or click extension must be fully covered by both unit tests and mock UI integration tests. +- **Golden Verification**: Support layout testing against visual goldens under `app/src/androidTest/assets/goldens/` utilizing multimodal AI comparison to safeguard map view integrity. +- **Clean Captures**: Run Clean SystemUI scripts and pre-configure test mock location providers in automated screen capture workflows to ensure consistent status bar headers and survive cold flow cancellations. diff --git a/README.md b/README.md index ea874b3c..cbbf198a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ With this KTX library, you should be able to take advantage of several Kotlin la -This repository includes a [demo app](app) that illustrates the use of this KTX library. See [MainActivity.kt](app/src/main/java/com/google/maps/android/ktx/demo/MainActivity.kt) for usage examples. +This repository includes a Material 3 [demo app](app) that illustrates the use of this KTX library. + +For interactive examples, visual flows, and code region breakdowns, check out the immersive [KTX Flow Snippets Catalog](docs/CATALOG.md). To run the demo app, you'll have to: @@ -98,7 +100,7 @@ val marker = googleMap.addMarker { #### Coroutines -See [SupportMapFragment.kt](maps-ktx/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt) for implementation and [MainActivity.kt](app/src/main/java/com/google/maps/android/ktx/demo/MainActivity.kt) for usage. +See [SupportMapFragment.kt](maps-ktx/src/main/java/com/google/maps/android/ktx/SupportMapFragment.kt) for implementation and the [KTX Flow Snippets Catalog](docs/CATALOG.md) for usage examples. Accessing a `GoogleMap` instance can be retrieved using coroutines vs. traditional the callback mechanism. The example here demonstrates how you can use this feature alongside with [Lifecycle-aware coroutine scopes][lifecycle] provided in Android’s Architecture Components. To use this, you'll need to add the following to your `build.gradle` dependencies: `implementation 'androidx.lifecycle:lifecycle-runtime-ktx:'` @@ -133,7 +135,7 @@ override fun onCreate(savedInstanceState: Bundle?) { #### Flow -See [GoogleMap.kt](maps-ktx/src/main/java/com/google/maps/android/ktx/GoogleMap.kt) for implementation and [MainActivity.kt](app/src/main/java/com/google/maps/android/ktx/demo/MainActivity.kt) for usage. +See [GoogleMap.kt](maps-ktx/src/main/java/com/google/maps/android/ktx/GoogleMap.kt) for implementation and the [KTX Flow Snippets Catalog](docs/CATALOG.md) for usage examples. Listing to camera events can be collected via [Kotlin Flow][kotlin-flow]. @@ -179,7 +181,7 @@ val contains = polygon.contains(latLng) #### Named parameters and default arguments -See [GeoJson.kt](maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/geojson/GeoJson.kt) for implementation and [MainActivity.kt](app/src/main/java/com/google/maps/android/ktx/demo/MainActivity.kt) for usage. +See [GeoJson.kt](maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/geojson/GeoJson.kt) for implementation and the [KTX Flow Snippets Catalog](docs/CATALOG.md) for usage examples. Creating a `GeoJsonLayer` object: @@ -224,6 +226,108 @@ val point = Point(1.0, 2.0) val (x, y) = point ``` +#### Flow + +See [LocationManager.kt](maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt) and [ClusterManager.kt](maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt) for implementations and the [KTX Flow Snippets Catalog](docs/CATALOG.md) for usage examples. + +> [!WARNING] +> **Single-Subscriber Invariant (Cold Flows)**: +> The underlying Google Maps SDK only supports a single listener slot (e.g., one `setOnMarkerClickListener` at a time). Because these are cold flows, if you have multiple active collectors subscribing to the same KTX flow concurrently, the latest subscriber will hijack the listener slot. Furthermore, cancelling any one collector will trigger its `awaitClose` block and completely unregister the listener on the SDK, silently breaking all remaining active collectors! +> +> If you need multiple observers to collect from the same stream, **always** share the flow using `.shareIn()` to convert it into a hot `SharedFlow`: +> ```kotlin +> val sharedClicks = storeCollection.clickEvents() +> .shareIn( +> scope = lifecycleScope, +> started = SharingStarted.WhileSubscribed(5000), +> replay = 0 +> ) +> ``` + +##### Device Location Tracking + +You can listen to both **coarse** and **fine** device location changes reactively as cold Flows that start and stop location updates automatically based on active subscribers: + +* **Fine Location Flow** (streams from `GPS_PROVIDER`, requires `ACCESS_FINE_LOCATION` permission): + `locationManager.fineLocationEvents(minTimeMs = 1000L, minDistanceM = 1f)` +* **Coarse Location Flow** (streams from `NETWORK_PROVIDER` with passive fallback, requires `ACCESS_COARSE_LOCATION` permission): + `locationManager.coarseLocationEvents(minTimeMs = 5000L, minDistanceM = 5f)` + +```kotlin +val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + +// Emits GPS location updates dynamically under ACCESS_FINE_LOCATION permission +lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + try { + locationManager.fineLocationEvents(minTimeMs = 2000L, minDistanceM = 1f) + .collect { location -> + // React to new device coordinate updates + } + } catch (e: CancellationException) { + // Triggers if GPS was disabled in Android settings mid-stream + } + } +} +``` + +##### Clustering Events + +Instead of overriding camera idle listeners and setting single click listener callbacks manually: + +```kotlin +val clusterManager = ClusterManager(context, map, markerManager) + +lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + clusterManager.clusterClickEvents().collect { cluster -> + // React to clicked clusters + } + } + launch { + clusterManager.clusterItemClickEvents().collect { myItem -> + // React to clicked cluster items + } + } + } +} +``` + +##### Shape Collection Clicks + +Set separate KTX flow-based click listeners per styled collection: + +```kotlin +val storeCollection = markerManager.newCollection() + +lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + storeCollection.clickEvents().collect { marker -> + // React to clicked store markers + } + } +} +``` + +## Gemini Code Assist Agentic Skill + +This repository includes a local **Gemini Agentic Skill** configured under the standard `.gemini/` directory to assist developers (and autonomous coding agents) inside their IDE and workspaces when modifying, testing, or using this KTX library. + +The skill provides progressive-disclosure reference guides, customized workspace config behaviors, and style enforcement rules protecting KTX reactive architecture. + +### Skill Structure +* **.gemini/config.yaml** / **styleguide.md**: Rules enforcing modern coroutine/Flow idioms, and multi-subscriber `SharedFlow` safety. +* πŸ“‚ **.gemini/skills/android-maps-ktx/**: Core capability registration package. + * [SKILL.md](file:///.gemini/skills/android-maps-ktx/SKILL.md): Capability scopes and trigger scenarios. + * [references/gotchas.md](file:///.gemini/skills/android-maps-ktx/references/gotchas.md): Breakdown of the single-subscriber listener hijacking Gotcha, and clean Android 15+ tuner status bar configuration. + * [references/location-flows.md](file:///.gemini/skills/android-maps-ktx/references/location-flows.md): Coarse and fine location flows setup, permissions, and London/Paris adb trajectory injections. + * [references/clustering-flows.md](file:///.gemini/skills/android-maps-ktx/references/clustering-flows.md): Views-based KTX click flows, viewport zooms, and tap badge sweeps. + * [references/collection-flows.md](file:///.gemini/skills/android-maps-ktx/references/collection-flows.md): Overlay collections click handling (Azure Marker popups, Circle color-swapping UI confirmation). + +### Usage +The skill is automatically discovered and utilized by Gemini Code Assist in supported IDEs (such as Android Studio, VS Code, or internal Google workflows). For manual reference or when pairing with autonomous coding assistants, you can consult the progressive-disclosure markdown guides inside `.gemini/skills/android-maps-ktx/references/` directly. + ## Documentation You can learn more about all the extensions provided by this library by reading the [documentation]. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3750b651..d5309581 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.secrets.gradle.plugin) + alias(libs.plugins.kotlinx.serialization) } android { @@ -27,12 +28,31 @@ android { compileSdk = libs.versions.androidCompileSdk.get().toInt() + testOptions { + unitTests.all { + it.jvmArgs( + "-XX:+EnableDynamicAgentLoading", + "-Xshare:off", + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "-Dnet.bytebuddy.experimental=true" + ) + } + } + defaultConfig { applicationId = "com.google.maps.android.ktx.demo" minSdk = libs.versions.androidMinSdk.get().toInt() targetSdk = libs.versions.androidTargetSdk.get().toInt() versionCode = 1 versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + adbOptions { + installOptions("-g", "-r") } buildFeatures { @@ -72,6 +92,29 @@ dependencies { // the README installation instructions implementation(project(":maps-ktx")) implementation(project(":maps-utils-ktx")) + implementation(libs.play.services.location) + + // Tests + testImplementation(libs.androidx.test) + testImplementation(libs.androidx.junit) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) + + // Android Visual/Screenshot Tests + androidTestImplementation(libs.uiautomator) + androidTestImplementation(libs.ktor.client.core) + androidTestImplementation(libs.ktor.client.cio) + androidTestImplementation(libs.ktor.client.content.negotiation) + androidTestImplementation(libs.ktor.serialization.kotlinx.json) + androidTestImplementation(libs.kotlinx.serialization.json) + androidTestImplementation(libs.androidx.test) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.truth) } secrets { diff --git a/app/src/androidTest/assets/goldens/camera_animation_golden.png b/app/src/androidTest/assets/goldens/camera_animation_golden.png new file mode 100644 index 00000000..72bfb0d1 Binary files /dev/null and b/app/src/androidTest/assets/goldens/camera_animation_golden.png differ diff --git a/app/src/androidTest/assets/goldens/clustering_clicks_after.png b/app/src/androidTest/assets/goldens/clustering_clicks_after.png new file mode 100644 index 00000000..c037d7b9 Binary files /dev/null and b/app/src/androidTest/assets/goldens/clustering_clicks_after.png differ diff --git a/app/src/androidTest/assets/goldens/clustering_clicks_before.png b/app/src/androidTest/assets/goldens/clustering_clicks_before.png new file mode 100644 index 00000000..49de9844 Binary files /dev/null and b/app/src/androidTest/assets/goldens/clustering_clicks_before.png differ diff --git a/app/src/androidTest/assets/goldens/location_flow_london.png b/app/src/androidTest/assets/goldens/location_flow_london.png new file mode 100644 index 00000000..ecf436a5 Binary files /dev/null and b/app/src/androidTest/assets/goldens/location_flow_london.png differ diff --git a/app/src/androidTest/assets/goldens/location_flow_sf.png b/app/src/androidTest/assets/goldens/location_flow_sf.png new file mode 100644 index 00000000..ecf436a5 Binary files /dev/null and b/app/src/androidTest/assets/goldens/location_flow_sf.png differ diff --git a/app/src/androidTest/assets/goldens/map_init_golden.png b/app/src/androidTest/assets/goldens/map_init_golden.png new file mode 100644 index 00000000..1033e53b Binary files /dev/null and b/app/src/androidTest/assets/goldens/map_init_golden.png differ diff --git a/app/src/androidTest/assets/goldens/marker_clicks.png b/app/src/androidTest/assets/goldens/marker_clicks.png new file mode 100644 index 00000000..b82f51f1 Binary files /dev/null and b/app/src/androidTest/assets/goldens/marker_clicks.png differ diff --git a/app/src/androidTest/java/com/google/maps/android/ktx/demo/visual/GeminiVisualTestHelper.kt b/app/src/androidTest/java/com/google/maps/android/ktx/demo/visual/GeminiVisualTestHelper.kt new file mode 100644 index 00000000..0449624b --- /dev/null +++ b/app/src/androidTest/java/com/google/maps/android/ktx/demo/visual/GeminiVisualTestHelper.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.ktx.demo.visual + +import android.graphics.Bitmap +import android.graphics.Point +import android.util.Base64 +import android.util.Log +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.ByteArrayOutputStream + +/** + * Helper class to interact with the Gemini API for visual verification and action. + */ +class GeminiVisualTestHelper { + private val json = + Json { + ignoreUnknownKeys = true + prettyPrint = true + } + + private val client = + HttpClient(CIO) { + install(ContentNegotiation) { + json(json) + } + } + + /** + * Executes a UI action based on a natural language prompt by analyzing UI XML hierarchies. + */ + suspend fun performActionFromPrompt( + prompt: String, + uiDevice: UiDevice, + apiKey: String, + ) { + val hierarchyStream = ByteArrayOutputStream() + uiDevice.dumpWindowHierarchy(hierarchyStream) + val hierarchyXml = hierarchyStream.toString("UTF-8") + + val systemPrompt = + """ + You are an expert Android QA automaton. Your task is to translate a natural language command + into a specific action to be performed on a UI. Given a UI hierarchy (in XML format), + determine the correct action and selector. + + The available actions are: "click", "longClick", "setText". + + Your response MUST be a single, well-formed JSON object with "action" and "selector" keys. + The "selector" object must contain exactly one of "text", "contentDescription", or "resourceId". + If the action is "setText", you must also include a "textValue" field at the top level. + + Example for a click: + { "action": "click", "selector": { "text": "Login" } } + + Example for setting text: + { "action": "setText", "selector": { "resourceId": "com.example.app:id/email_input" }, "textValue": "test@example.com" } + """.trimIndent() + + val fullPrompt = "$systemPrompt\n\nCommand: \"$prompt\"\n\nUI Hierarchy:\n$hierarchyXml" + val modelName = "gemini-2.5-flash" + val request = GeminiRequest(contents = listOf(Content(parts = listOf(Part(text = fullPrompt))))) + + val response: HttpResponse = + client.post("https://generativelanguage.googleapis.com/v1/models/$modelName:generateContent?key=$apiKey") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status != HttpStatusCode.OK) { + val errorBody = response.bodyAsText() + Log.e("GeminiVisualTestHelper", "Action API Error: ${response.status} $errorBody") + throw Exception("Gemini Action API returned an error: ${response.status}\n$errorBody") + } + + val geminiResponse: GeminiResponse = response.body() + val actionJson = + geminiResponse.candidates + .firstOrNull() + ?.content + ?.parts + ?.firstOrNull() + ?.text + ?: throw Exception("Gemini returned no action JSON.") + + val cleanedActionJson = actionJson.removePrefix("```json\n").removeSuffix("\n```") + Log.d("GeminiVisualTestHelper", "Received Action JSON: $cleanedActionJson") + + try { + val aiAction = json.decodeFromString(cleanedActionJson) + val selector = + aiAction.selector.let { + when { + it.text != null -> By.text(it.text) + it.contentDescription != null -> By.desc(it.contentDescription) + it.resourceId != null -> By.res(it.resourceId) + else -> throw IllegalArgumentException("Selector must have text, contentDescription, or resourceId.") + } + } + + val uiObject = + uiDevice.wait(Until.findObject(selector), 10000) + ?: throw Exception("Could not find UI element for selector: $selector") + + when (aiAction.action.lowercase()) { + "click" -> uiObject.click() + "longclick" -> uiObject.longClick() + "settext" -> { + val textToSet = aiAction.textValue ?: throw Exception("Action 'setText' requires a 'textValue' field.") + uiObject.text = textToSet + } + else -> throw UnsupportedOperationException("Action '${aiAction.action}' is not supported.") + } + } catch (e: Exception) { + Log.e("GeminiVisualTestHelper", "Failed to parse or execute AI action", e) + throw e + } + } + + /** + * Analyzes an image (screenshot) with a given prompt using the Gemini multimodal API. + */ + suspend fun analyzeImage( + bitmap: Bitmap, + prompt: String, + apiKey: String, + ): String? { + val base64Image = bitmap.toBase64EncodedJpeg() + val request = + GeminiRequest( + contents = + listOf( + Content( + parts = + listOf( + Part(text = prompt), + Part( + inlineData = + InlineData( + mimeType = "image/jpeg", + data = base64Image, + ), + ), + ), + ), + ), + ) + + // Using gemini-2.5-flash for fast, efficient multimodal image analysis + val response: HttpResponse = + client.post("https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key=$apiKey") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status != HttpStatusCode.OK) { + val errorBody = response.bodyAsText() + Log.e("GeminiVisualTestHelper", "API Error: ${response.status} $errorBody") + throw Exception("Gemini API returned an error: ${response.status}\n$errorBody") + } + + val geminiResponse: GeminiResponse = response.body() + if (geminiResponse.candidates.isEmpty()) { + val rawBody = response.bodyAsText() + Log.w("GeminiVisualTestHelper", "Gemini API returned empty candidates. Full response: $rawBody") + throw Exception("Gemini API returned no candidates.") + } + + return geminiResponse.candidates + .firstOrNull() + ?.content + ?.parts + ?.firstOrNull() + ?.text + } + + /** + * Dynamically locates the coordinates of a visual element described in natural language. + * Returns the center pixel Point on the screen, or null if not found/parse error. + */ + suspend fun findVisualCoordinates( + bitmap: Bitmap, + elementDescription: String, + apiKey: String, + ): Point? { + val spatialPrompt = + """ + You are an expert spatial coordinate regression assistant. Look at this Android map screen. + Locate the visual element matching this description: "$elementDescription". + + Return its exact center pixel coordinate (x and y) on the screen as a single JSON object: + { "x": , "y": } + + Ensure the coordinates match the screen dimensions, and do not return any additional text, code delimiters, or markdown formatting. Reply ONLY with the JSON. + """.trimIndent() + + val responseText = analyzeImage(bitmap, spatialPrompt, apiKey) ?: return null + val cleanedResponse = responseText.removePrefix("```json\n").removeSuffix("\n```").trim() + Log.d("GeminiVisualTestHelper", "Received Spatial Coordinate JSON: $cleanedResponse") + + return try { + val aiCoord = json.decodeFromString(cleanedResponse) + Point(aiCoord.x, aiCoord.y) + } catch (e: Exception) { + Log.e("GeminiVisualTestHelper", "Failed to parse visual coordinates from AI response: $cleanedResponse", e) + null + } + } + + /** + * Performs an AI-driven semantic comparison between a golden reference screenshot and the current run. + * Returns true if they match visually/layout-wise, false otherwise. + */ + suspend fun compareImages( + currentBitmap: Bitmap, + goldenBitmap: Bitmap, + apiKey: String, + ): Boolean { + val base64Current = currentBitmap.toBase64EncodedJpeg() + val base64Golden = goldenBitmap.toBase64EncodedJpeg() + + val regressionPrompt = + """ + You are an expert visual regression QA model. Compare the following two Android Google Map screenshots: + - Image A: Golden Reference Screenshot (First Image attached) + - Image B: Current Test Run Screenshot (Second Image attached) + + Do they display the exact same map layout, coordinates, styled vector polygons, custom marker pins, or clicked Toast feedbacks in the same positions, matching styling and design semantically? + + Ignore minor rendering variances, scale bar differences, and map label text font differences. + + Reply exactly with 'YES' or 'NO' only. + """.trimIndent() + + val request = + GeminiRequest( + contents = + listOf( + Content( + parts = + listOf( + Part(text = regressionPrompt), + Part( + inlineData = + InlineData( + mimeType = "image/jpeg", + data = base64Golden + ) + ), + Part( + inlineData = + InlineData( + mimeType = "image/jpeg", + data = base64Current + ) + ), + ) + ) + ) + ) + + val response: HttpResponse = + client.post("https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key=$apiKey") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status != HttpStatusCode.OK) { + val errorBody = response.bodyAsText() + Log.e("GeminiVisualTestHelper", "API Error during image comparison: ${response.status} $errorBody") + throw Exception("Gemini API returned an error during image comparison: ${response.status}\n$errorBody") + } + + val geminiResponse: GeminiResponse = response.body() + val answer = geminiResponse.candidates + .firstOrNull() + ?.content + ?.parts + ?.firstOrNull() + ?.text + ?.trim() + ?.uppercase() + + Log.i("GeminiVisualTestHelper", "AI Visual Regression Comparison Result: $answer") + return answer == "YES" + } + + private fun Bitmap.toBase64EncodedJpeg(): String { + val outputStream = ByteArrayOutputStream() + compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + val byteArray = outputStream.toByteArray() + return Base64.encodeToString(byteArray, Base64.NO_WRAP) + } +} + +@Serializable +data class AiCoordinate( + val x: Int, + val y: Int, +) + +@Serializable +data class AiAction( + val action: String, + val selector: AiSelector, + val textValue: String? = null, +) + +@Serializable +data class AiSelector( + val text: String? = null, + val contentDescription: String? = null, + val resourceId: String? = null, +) + +@Serializable +data class GeminiRequest( + val contents: List, +) + +@Serializable +data class Content( + val parts: List, +) + +@Serializable +data class Part( + val text: String? = null, + @SerialName("inline_data") + val inlineData: InlineData? = null, +) + +@Serializable +data class InlineData( + @SerialName("mime_type") + val mimeType: String, + val data: String, +) + +@Serializable +data class GeminiResponse( + val candidates: List = emptyList(), +) + +@Serializable +data class Candidate( + val content: Content, +) diff --git a/app/src/androidTest/java/com/google/maps/android/ktx/demo/visual/SnippetVisualVerificationTest.kt b/app/src/androidTest/java/com/google/maps/android/ktx/demo/visual/SnippetVisualVerificationTest.kt new file mode 100644 index 00000000..3634d17d --- /dev/null +++ b/app/src/androidTest/java/com/google/maps/android/ktx/demo/visual/SnippetVisualVerificationTest.kt @@ -0,0 +1,290 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.ktx.demo.visual + +import android.graphics.Bitmap +import java.util.regex.Pattern +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.By +import com.google.common.truth.Truth.assertThat +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity +import kotlinx.coroutines.runBlocking +import androidx.test.rule.GrantPermissionRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented visual verification test leveraging Gemini Multimodal API to assert that + * KTX snippets render map elements and animate camera clustering click events exactly as expected on-device. + */ +@RunWith(AndroidJUnit4::class) +public class SnippetVisualVerificationTest { + + // Scenario launched dynamically per test case to inject custom snippet intent extras! + + @get:Rule + public val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION + ) + + private lateinit var uiDevice: UiDevice + private lateinit var visualHelper: GeminiVisualTestHelper + private lateinit var apiKey: String + + @Before + public fun setUp() { + uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + visualHelper = GeminiVisualTestHelper() + + // Fetch Gemini API Key passed via adb instrumentation arguments or build config environment + val arguments = InstrumentationRegistry.getArguments() + apiKey = arguments.getString("gemini_api_key") + ?: System.getenv("GEMINI_API_KEY") + ?: "" + } + + @Test + public fun testMapInitVisualVerification(): Unit = runBlocking { + val intent = Intent(ApplicationProvider.getApplicationContext(), SnippetExecutionActivity::class.java).apply { + putExtra("EXTRA_SNIPPET_TITLE", "Map Initialization") + } + ActivityScenario.launch(intent).use { + // 1. Wait 5 seconds for basic map tiles and styled GeoJSON layers to fully render + Thread.sleep(5000) + + // 2. Capture screenshot of the initial map centered at London + val screenshot: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot.saveToDeviceStorage("map_init_golden") + + // If API Key is missing, skip semantic validation gracefully + if (apiKey.isEmpty()) { + android.util.Log.w("SnippetVisualTest", "GEMINI_API_KEY is missing. Skipping map initialization visual verification.") + return@runBlocking + } + + // 3. Perform Image-to-Image AI Semantic Regression Comparison against the golden reference! + val golden = loadGoldenFromAssets("map_init_golden") + val match = visualHelper.compareImages(screenshot, golden, apiKey) + assertThat(match).isTrue() + } + } + + @Test + public fun testCameraAnimationAndIdleBehavior(): Unit = runBlocking { + val intent = Intent(ApplicationProvider.getApplicationContext(), SnippetExecutionActivity::class.java).apply { + putExtra("EXTRA_SNIPPET_TITLE", "Animate Camera (Coroutines)") + } + ActivityScenario.launch(intent).use { + Thread.sleep(4000) + + // 1. Click on the 'ANIMATE CAMERA' UI button at the bottom of the screen to trigger awaitAnimateCamera() + val animateButton = uiDevice.findObject(By.text("ANIMATE CAMERA")) + if (animateButton != null) { + animateButton.click() + } + + // 2. Wait 4 seconds for the camera animation coroutine to complete and KTX cameraIdleEvents to fire + Thread.sleep(4000) + + // 3. Capture screenshot showing the close-up view + val screenshot1: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot1.saveToDeviceStorage("camera_animation_golden") + + // If API Key is missing, skip semantic validation gracefully + if (apiKey.isEmpty()) { + android.util.Log.w("SnippetVisualTest", "GEMINI_API_KEY is missing. Skipping camera animation and idle flow verification.") + return@runBlocking + } + + // 4. Perform Image-to-Image AI Semantic Regression Comparison against the golden close-up reference! + val golden = loadGoldenFromAssets("camera_animation_golden") + val match = visualHelper.compareImages(screenshot1, golden, apiKey) + assertThat(match).isTrue() + } + } + + @Test + public fun testClusteringFlowVisualVerification(): Unit = runBlocking { + val intent = Intent(ApplicationProvider.getApplicationContext(), SnippetExecutionActivity::class.java).apply { + putExtra("EXTRA_SNIPPET_TITLE", "Marker Cluster Click Flow") + } + ActivityScenario.launch(intent).use { + // 1. Wait for map layers and cluster badges to settle (5 seconds) + Thread.sleep(5000) + + // 2. Capture initial screenshot of the map displaying London cluster badges + val screenshot1: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot1.saveToDeviceStorage("clustering_clicks_before") + + // 3. Attempt to ask Gemini to dynamically locate the cluster badge coordinates + val badgeDescription = "the circular marker cluster badge containing a number like '10' or '50' near the center of the map" + val targetPoint = if (apiKey.isNotEmpty()) { + visualHelper.findVisualCoordinates(screenshot1, badgeDescription, apiKey) + } else null + + val clickX = targetPoint?.x ?: 540 + val clickY = targetPoint?.y ?: 1200 + android.util.Log.i("SnippetVisualTest", "Cluster Badge Click Coordinate: ($clickX, $clickY)") + + // 4. Trigger click at the cluster badge + uiDevice.click(clickX, clickY) + + // 5. Wait 3 seconds for camera animation to settle + Thread.sleep(3000) + + // 6. Capture final screenshot after cluster expansion + val screenshot2: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot2.saveToDeviceStorage("clustering_clicks_after") + + // If API Key is missing, skip semantic validation gracefully + if (apiKey.isEmpty()) { + android.util.Log.w("SnippetVisualTest", "GEMINI_API_KEY is missing. Skipping semantic visual AI verification.") + return@runBlocking + } + + // 7. Perform Image-to-Image AI Semantic Regression Comparisons for before and after click states! + val goldenBefore = loadGoldenFromAssets("clustering_clicks_before") + val matchBefore = visualHelper.compareImages(screenshot1, goldenBefore, apiKey) + assertThat(matchBefore).isTrue() + + val goldenAfter = loadGoldenFromAssets("clustering_clicks_after") + val matchAfter = visualHelper.compareImages(screenshot2, goldenAfter, apiKey) + assertThat(matchAfter).isTrue() + } + } + + @Test + public fun testLocationFlowStreamingBehavior(): Unit = runBlocking { + val intent = Intent(ApplicationProvider.getApplicationContext(), SnippetExecutionActivity::class.java).apply { + putExtra("EXTRA_SNIPPET_TITLE", "Fine Location Flow") + } + ActivityScenario.launch(intent).use { + Thread.sleep(4000) + + // 1. Inject first mock GPS location (London: 51.5074, -0.1278) via shell commands + val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName + uiDevice.executeShellCommand("appops set $packageName android:mock_location allow") + uiDevice.executeShellCommand("cmd location providers set-test-provider-enabled gps true") + uiDevice.executeShellCommand("cmd location providers set-test-provider-location gps --location 51.5074,-0.1278") + + // Wait 3 seconds for LocationManager.fineLocationEvents() to reactively collect coordinates and draw marker + Thread.sleep(3000) + + // Capture screen for London mock location + val screenshot1: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot1.saveToDeviceStorage("location_flow_london") + + // 2. Inject second mock GPS location (San Francisco: 37.7576, -122.4194) to test active Flow STREAMING! + uiDevice.executeShellCommand("cmd location providers set-test-provider-location gps --location 37.7576,-122.4194") + + // Wait 3 seconds for flow to stream the second coordinate and update the user marker location on screen + Thread.sleep(3000) + + // Capture screen for San Francisco mock location + val screenshot2: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot2.saveToDeviceStorage("location_flow_sf") + + // Clean up and disable mock location provider on the device + uiDevice.executeShellCommand("cmd location providers set-test-provider-enabled gps false") + + // If API Key is missing, skip semantic validation gracefully + if (apiKey.isEmpty()) { + android.util.Log.w("SnippetVisualTest", "GEMINI_API_KEY is missing. Skipping streaming location flow visual AI verification.") + return@runBlocking + } + + // 3. Perform Image-to-Image AI Semantic Regression Comparisons for both mock streaming locations! + val goldenLondon = loadGoldenFromAssets("location_flow_london") + val matchLondon = visualHelper.compareImages(screenshot1, goldenLondon, apiKey) + assertThat(matchLondon).isTrue() + + val goldenSF = loadGoldenFromAssets("location_flow_sf") + val matchSF = visualHelper.compareImages(screenshot2, goldenSF, apiKey) + assertThat(matchSF).isTrue() + } + } + + @Test + public fun testMarkerClicksFlowVisualVerification(): Unit = runBlocking { + val intent = Intent(ApplicationProvider.getApplicationContext(), SnippetExecutionActivity::class.java).apply { + putExtra("EXTRA_SNIPPET_TITLE", "Marker Collection Click Flow") + } + ActivityScenario.launch(intent).use { + Thread.sleep(4000) + val screenshot1: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + + // 1. Attempt to locate the unclustered marker dynamically + val markerDescription = "the unclustered light-blue (azure) marker pin located slightly south of London center" + val targetPoint = if (apiKey.isNotEmpty()) { + visualHelper.findVisualCoordinates(screenshot1, markerDescription, apiKey) + } else null + + val clickX = targetPoint?.x ?: 540 + val clickY = targetPoint?.y ?: 1650 + android.util.Log.i("SnippetVisualTest", "Marker Click Coordinate: ($clickX, $clickY)") + + // 2. Trigger a tap on the marker + uiDevice.click(clickX, clickY) + Thread.sleep(2000) + + // 3. Capture screenshot containing the click toast popup + val screenshot2: Bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + screenshot2.saveToDeviceStorage("marker_clicks") + + // If API Key is missing, skip semantic validation gracefully + if (apiKey.isEmpty()) { + android.util.Log.w("SnippetVisualTest", "GEMINI_API_KEY is missing. Skipping marker click visual AI verification.") + return@runBlocking + } + + // 4. Perform Image-to-Image AI Semantic Regression Comparison against the golden clicked state reference! + val golden = loadGoldenFromAssets("marker_clicks") + val match = visualHelper.compareImages(screenshot2, golden, apiKey) + assertThat(match).isTrue() + } + } + + private fun Bitmap.saveToDeviceStorage(fileName: String) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val dir = context.getExternalFilesDir(null) ?: return + val file = java.io.File(dir, "$fileName.png") + try { + java.io.FileOutputStream(file).use { out -> + this.compress(Bitmap.CompressFormat.PNG, 100, out) + } + android.util.Log.i("SnippetVisualTest", "Screenshot successfully saved to app storage: ${file.absolutePath}") + } catch (e: Exception) { + android.util.Log.e("SnippetVisualTest", "Failed to save screenshot to app storage", e) + } + } + + private fun loadGoldenFromAssets(fileName: String): Bitmap { + val testContext = InstrumentationRegistry.getInstrumentation().context + val assetManager = testContext.assets + assetManager.open("goldens/$fileName.png").use { stream -> + return android.graphics.BitmapFactory.decodeStream(stream) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0c742398..2c9d67a7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ - + + + @@ -17,6 +19,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_snippet_menu.xml b/app/src/main/res/layout/activity_snippet_menu.xml new file mode 100644 index 00000000..7dc1619e --- /dev/null +++ b/app/src/main/res/layout/activity_snippet_menu.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_snippet_card.xml b/app/src/main/res/layout/item_snippet_card.xml new file mode 100644 index 00000000..bdf73e5c --- /dev/null +++ b/app/src/main/res/layout/item_snippet_card.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57fc2639..46270320 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,5 @@ Android Maps KTX Demo Snapshot Animate Camera + ANIMATE CAMERA diff --git a/app/src/test/java/com/google/maps/android/ktx/demo/snippets/SnippetVerificationTest.kt b/app/src/test/java/com/google/maps/android/ktx/demo/snippets/SnippetVerificationTest.kt new file mode 100644 index 00000000..f53b1934 --- /dev/null +++ b/app/src/test/java/com/google/maps/android/ktx/demo/snippets/SnippetVerificationTest.kt @@ -0,0 +1,366 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.demo.snippets + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.content.Context +import com.google.android.gms.maps.CameraUpdate +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.OnMapReadyCallback +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.MarkerManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.any +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class SnippetVerificationTest { + + // Location Mocks + @Mock + private lateinit var locationManager: LocationManager + @Mock + private lateinit var location: Location + @Captor + private lateinit var locationListenerCaptor: ArgumentCaptor + + // Clustering Mocks + @Mock + private lateinit var clusterManager: ClusterManager + @Mock + private lateinit var cluster: Cluster + @Mock + private lateinit var clusterItem: ClusterItem + @Captor + private lateinit var clusterClickListener: ArgumentCaptor> + + // Shape Collections Mocks + @Mock + private lateinit var markerCollection: MarkerManager.Collection + @Mock + private lateinit var circleCollection: CircleManager.Collection + @Mock + private lateinit var marker: Marker + @Mock + private lateinit var circle: Circle + + // Existing KTX Mocks + @Mock + private lateinit var googleMap: GoogleMap + @Mock + private lateinit var mapFragment: SupportMapFragment + @Mock + private lateinit var polygon: Polygon + @Mock + private lateinit var context: Context + @Captor + private lateinit var onMapReadyCallbackCaptor: ArgumentCaptor + @Captor + private lateinit var cameraIdleListenerCaptor: ArgumentCaptor + @Captor + private lateinit var cancelableCallbackCaptor: ArgumentCaptor + + @Mock + private lateinit var cameraUpdate: CameraUpdate + private lateinit var cameraUpdateFactoryMock: MockedStatic + + private var activeClusterItemClickListener: ClusterManager.OnClusterItemClickListener? = null + private var activeMarkerClickListener: GoogleMap.OnMarkerClickListener? = null + private var activeCircleClickListener: GoogleMap.OnCircleClickListener? = null + + @Before + public fun setUp() { + cameraUpdateFactoryMock = mockStatic(CameraUpdateFactory::class.java) + `when`(CameraUpdateFactory.newLatLngZoom(any(), anyFloat())).thenReturn(cameraUpdate) + + activeClusterItemClickListener = null + activeMarkerClickListener = null + activeCircleClickListener = null + + // Stub setOnClusterItemClickListener to capture its active listener + `when`(clusterManager.setOnClusterItemClickListener(any())).thenAnswer { invocation -> + activeClusterItemClickListener = invocation.arguments[0] as? ClusterManager.OnClusterItemClickListener + null + } + + // Stub setOnMarkerClickListener + `when`(markerCollection.setOnMarkerClickListener(any())).thenAnswer { invocation -> + activeMarkerClickListener = invocation.arguments[0] as? GoogleMap.OnMarkerClickListener + null + } + + // Stub setOnCircleClickListener + `when`(circleCollection.setOnCircleClickListener(any())).thenAnswer { invocation -> + activeCircleClickListener = invocation.arguments[0] as? GoogleMap.OnCircleClickListener + null + } + } + + @After + public fun tearDown() { + cameraUpdateFactoryMock.close() + } + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationFlowSnippet(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + val results = mutableListOf() + var isCanceled = false + + LocationSnippets.coarseLocationFlowSnippet( + locationManager, + scope = this, + onLocationReceived = { results.add(it) }, + onGPSDisabled = { isCanceled = true } + ) + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + anyLong(), + anyFloat(), + locationListenerCaptor.capture() + ) + + // Trigger event + locationListenerCaptor.value.onLocationChanged(location) + advanceUntilIdle() + assertThat(results).containsExactly(location) + + // Simulate disablement + locationListenerCaptor.value.onProviderDisabled(LocationManager.NETWORK_PROVIDER) + advanceUntilIdle() + assertThat(isCanceled).isTrue() + } + + @SuppressLint("MissingPermission") + @Test + public fun testFineLocationFlowSnippet(): Unit = runTest { + val results = mutableListOf() + var isCanceled = false + + LocationSnippets.fineLocationFlowSnippet( + locationManager, + scope = this, + onLocationReceived = { results.add(it) }, + onGPSDisabled = { isCanceled = true } + ) + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + anyLong(), + anyFloat(), + locationListenerCaptor.capture() + ) + + locationListenerCaptor.value.onLocationChanged(location) + advanceUntilIdle() + assertThat(results).containsExactly(location) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testClusterClicksSnippet(): Unit = runTest { + val results = mutableListOf>() + + ClusteringSnippets.clusterClicksSnippet( + clusterManager, + scope = this, + onClusterClicked = { results.add(it) } + ) + advanceUntilIdle() + + verify(clusterManager).setOnClusterClickListener(clusterClickListener.capture()) + clusterClickListener.value.onClusterClick(cluster) + advanceUntilIdle() + + assertThat(results).containsExactly(cluster) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testClusterItemClicksSnippet(): Unit = runTest { + val obs1Results = mutableListOf() + val obs2Results = mutableListOf() + + ClusteringSnippets.clusterItemClicksSnippet( + clusterManager, + scope = this, + onObserverOneReceived = { obs1Results.add(it) }, + onObserverTwoReceived = { obs2Results.add(it) } + ) + advanceUntilIdle() + + // Verify shared flow broadcasted the click to BOTH observers! + assertThat(activeClusterItemClickListener).isNotNull() + activeClusterItemClickListener?.onClusterItemClick(clusterItem) + advanceUntilIdle() + + assertThat(obs1Results).containsExactly(clusterItem) + assertThat(obs2Results).containsExactly(clusterItem) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testMarkerCollectionClicksSnippet(): Unit = runTest { + val results = mutableListOf() + + CollectionSnippets.markerCollectionClicksSnippet( + markerCollection, + scope = this, + onMarkerClicked = { results.add(it) } + ) + advanceUntilIdle() + + assertThat(activeMarkerClickListener).isNotNull() + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + assertThat(results).containsExactly(marker) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testCircleCollectionClicksSnippet(): Unit = runTest { + val results = mutableListOf() + + CollectionSnippets.circleCollectionClicksSnippet( + circleCollection, + scope = this, + onCircleClicked = { results.add(it) } + ) + advanceUntilIdle() + + assertThat(activeCircleClickListener).isNotNull() + activeCircleClickListener?.onCircleClick(circle) + advanceUntilIdle() + + assertThat(results).containsExactly(circle) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testAwaitMapInitSnippet(): Unit = runTest { + val mapReadyList = mutableListOf() + + ExistingKtxSnippets.awaitMapInitSnippet( + mapFragment, + scope = this, + onMapReady = { mapReadyList.add(it) } + ) + advanceUntilIdle() + + verify(mapFragment).getMapAsync(onMapReadyCallbackCaptor.capture()) + onMapReadyCallbackCaptor.value.onMapReady(googleMap) + advanceUntilIdle() + + assertThat(mapReadyList).containsExactly(googleMap) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testAwaitCameraAnimationSnippet(): Unit = runTest { + var animationComplete = false + val target = LatLng(51.5, -0.1) + + ExistingKtxSnippets.awaitCameraAnimationSnippet( + googleMap, + scope = this, + targetCoordinate = target, + onAnimationComplete = { animationComplete = true } + ) + advanceUntilIdle() + + verify(googleMap).animateCamera(any(CameraUpdate::class.java), eq(3000), cancelableCallbackCaptor.capture()) + cancelableCallbackCaptor.value.onFinish() + advanceUntilIdle() + + assertThat(animationComplete).isTrue() + coroutineContext.job.cancelChildren() + } + + @Test + public fun testCameraIdleEventsFlowSnippet(): Unit = runTest { + var cameraIdleCount = 0 + + ExistingKtxSnippets.cameraIdleEventsFlowSnippet( + googleMap, + scope = this, + onCameraIdle = { cameraIdleCount++ } + ) + advanceUntilIdle() + + verify(googleMap).setOnCameraIdleListener(cameraIdleListenerCaptor.capture()) + cameraIdleListenerCaptor.value.onCameraIdle() + advanceUntilIdle() + + assertThat(cameraIdleCount).isEqualTo(1) + coroutineContext.job.cancelChildren() + } + + @Test + public fun testPolygonContainsCheckSnippet(): Unit = runTest { + val target = LatLng(51.5074, -0.1278) + // Setup real polygon points list to test the real geometry check engine! + val polygonPoints = listOf( + LatLng(51.52, -0.14), + LatLng(51.52, -0.11), + LatLng(51.49, -0.11), + LatLng(51.49, -0.14) + ) + `when`(polygon.points).thenReturn(polygonPoints) + + val inside = ExistingKtxSnippets.polygonContainsCheckSnippet(polygon, target) + assertThat(inside).isTrue() + } +} diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index e1fd652e..8ca224ab 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -32,9 +32,19 @@ class PublishingConventionPlugin : Plugin { } tasks.withType().configureEach { + // Support Mockito ByteBuddy dynamic agent loading, add-opens reflection, and prevent CDS bytecode sharing issues on JDK 26+ + jvmArgs( + "-XX:+EnableDynamicAgentLoading", + "-Xshare:off", + "--add-opens", "java.base/java.lang=ALL-UNNAMED", + "--add-opens", "java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens", "java.base/java.io=ALL-UNNAMED", + "--add-opens", "java.base/java.util=ALL-UNNAMED", + "-Dnet.bytebuddy.experimental=true" + ) extensions.configure(JacocoTaskExtension::class.java) { isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") + excludes = listOf("jdk.internal.*", "sun.*", "java.*", "jdk.*") } } } diff --git a/docs/CATALOG.md b/docs/CATALOG.md new file mode 100644 index 00000000..d1f26aad --- /dev/null +++ b/docs/CATALOG.md @@ -0,0 +1,32 @@ +# πŸš€ Android Maps KTX Flow Snippets Catalog + +This directory contains standalone code snippets demonstrating how to consume the newly added Kotlin Flow extensions inside the `maps-utils-ktx` library. + +All snippets are enclosed in standard developer documentation region tags to make them easily importable into public guides, and are verified automatically via mock unit tests to prevent regressions. + +## πŸ“Š KTX Flow Snippet Status + +| Feature | Status | Source Code | Visual Preview | Region Tag | Description | +| :--- | :---: | :--- | :--- | :--- | :--- | +| **Fine Location Flow** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/LocationSnippets.kt#L71-L84) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L132)) | Fine Location Flow | `maps_android_ktx_flow_fine_location` | Streams high-precision device coordinate updates reactively using GPS, with an automatic start/stop subscriber lifecycle. | +| **Coarse Location Flow** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/LocationSnippets.kt#L45-L58) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L135)) | Coarse Location Flow | `maps_android_ktx_flow_coarse_location` | Streams coarse WiFi/Cell device coordinate updates reactively using network/passive providers, closing cleanly on setting disablement. | +| **Cluster Clicks** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ClusteringSnippets.kt#L44-L51) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L138)) | Cluster Click Flow | `maps_android_ktx_flow_cluster_clicks` | Reactively streams click events on clustered groups of markers instead of overriding standard camera callbacks. | +| **Cluster Item Clicks** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ClusteringSnippets.kt#L64-L86) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L138)) | Cluster Item Click Flow | `maps_android_ktx_flow_cluster_item_clicks` | Listens to individual marker click events within clusters. Demonstrates how to use `.shareIn()` to safely broadcast to multiple active observers. | +| **Marker Collection Clicks** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/CollectionSnippets.kt#L41-L48) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L141)) | Marker Collection Clicks | `maps_android_ktx_flow_marker_collection_clicks` | Listens to marker click events on separate styled collections managed by `MarkerManager`. | +| **Circle Collection Clicks** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/CollectionSnippets.kt#L59-L66) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L141)) | Circle Collection Clicks | `maps_android_ktx_flow_circle_collection_clicks` | Listens to circle click events on separate styled collections managed by `CircleManager`. | +| **Map Init (Coroutines)** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ExistingKtxSnippets.kt#L50-L56) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L106)) | Map Init | `maps_android_ktx_coroutines_init` | Retrieves the GoogleMap instance safely inside a lifecycle-aware coroutine scope without legacy callback blocks. | +| **Animate Camera (Coroutines)** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ExistingKtxSnippets.kt#L69-L78) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L112)) | Camera Animation | `maps_android_ktx_coroutines_animate` | Smoothly animates the map camera and suspends coroutine execution, resuming only after the animation fully completes. | +| **Camera Idle Events Flow** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ExistingKtxSnippets.kt#L89-L96) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L126)) | Idle states | `maps_android_ktx_flow_camera_events` | Streams map camera stationary idle state changes dynamically via Flow. | +| **Polygon Containment Check** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ExistingKtxSnippets.kt#L106-L109) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L106)) | Boundary checks | `maps_android_ktx_utils_polygon_contains` | Performs robust polygon bounds checking to see if a LatLng coordinate is contained inside. | +| **GeoJSON Overlay Builder** | βœ… Verified | [Source Code](../app/src/main/java/com/google/maps/android/ktx/demo/snippets/ExistingKtxSnippets.kt#L120-L127) ([Activity](../app/src/main/java/com/google/maps/android/ktx/demo/execution/SnippetExecutionActivity.kt#L106)) | GeoJSON lines | `maps_android_ktx_utils_geojson` | Dynamically parses and overlay a styled GeoJSON vector layer on top of the map view. | + +--- + +## πŸ§ͺ Snippet Verification Testing +To ensure these snippets remain 100% correct and compile warning-free after library and dependency upgrades, they are covered by a dedicated unit test suite: +πŸ“‚ [SnippetVerificationTest.kt](../app/src/test/java/com/google/maps/android/ktx/demo/snippets/SnippetVerificationTest.kt) + +You can execute these tests locally at any time: +```bash +./gradlew :app:test --tests "com.google.maps.android.ktx.demo.snippets.*" +``` diff --git a/docs/images/camera_animation_golden.gif b/docs/images/camera_animation_golden.gif new file mode 100644 index 00000000..3a8f22e9 Binary files /dev/null and b/docs/images/camera_animation_golden.gif differ diff --git a/docs/images/camera_animation_golden.png b/docs/images/camera_animation_golden.png new file mode 100644 index 00000000..9de486b4 Binary files /dev/null and b/docs/images/camera_animation_golden.png differ diff --git a/docs/images/clustering_clicks.gif b/docs/images/clustering_clicks.gif new file mode 100644 index 00000000..d96520d6 Binary files /dev/null and b/docs/images/clustering_clicks.gif differ diff --git a/docs/images/collection_clicks.gif b/docs/images/collection_clicks.gif new file mode 100644 index 00000000..9ba2b93a Binary files /dev/null and b/docs/images/collection_clicks.gif differ diff --git a/docs/images/location_flow_coarse_paris.png b/docs/images/location_flow_coarse_paris.png new file mode 100644 index 00000000..6742ee7e Binary files /dev/null and b/docs/images/location_flow_coarse_paris.png differ diff --git a/docs/images/location_flow_fine_london.png b/docs/images/location_flow_fine_london.png new file mode 100644 index 00000000..9aa87e87 Binary files /dev/null and b/docs/images/location_flow_fine_london.png differ diff --git a/docs/images/location_flow_london.png b/docs/images/location_flow_london.png new file mode 100644 index 00000000..fce934d9 Binary files /dev/null and b/docs/images/location_flow_london.png differ diff --git a/docs/images/location_flow_sf.png b/docs/images/location_flow_sf.png new file mode 100644 index 00000000..83aa3589 Binary files /dev/null and b/docs/images/location_flow_sf.png differ diff --git a/docs/images/map_init_golden.png b/docs/images/map_init_golden.png new file mode 100644 index 00000000..053a2f29 Binary files /dev/null and b/docs/images/map_init_golden.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80729769..03952cf8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,19 +15,22 @@ kotlinxCoroutines = "1.11.0" material = "1.14.0" androidxStartup = "1.2.0" -# --- Google Maps --- +# --- Google Maps & Location --- # Versions for Google Play Services libraries, which are essential for this sample. androidMapsSdk = "20.0.0" androidMapsUtils = "4.4.0" +playServicesLocation = "21.3.0" # --- Testing --- # Versions for testing libraries. androidxJunit = "1.3.0" androidxTest = "1.7.0" +androidxTestRunner = "1.7.0" junit = "4.13.2" mockito = "5.23.0" mockitoInline = "5.2.0" mockitoKotlin = "2.2.0" +byteBuddy = "1.17.1" truth = "1.4.5" # --- Gradle Plugins --- @@ -38,6 +41,11 @@ gradleMavenPublishPlugin = "0.36.0" jacocoAndroidGradlePlugin = "0.2.1" secretsGradlePlugin = "2.0.1" +# --- Visual Testing (Gemini API) --- +ktor = "3.5.0" +uiautomator = "2.3.0" +kotlinxSerialization = "1.11.0" + [libraries] # --- Core Android & Kotlin --- @@ -56,29 +64,43 @@ gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-pl jacoco-android-gradle-plugin = { module = "com.mxalbert.gradle:jacoco-android", version.ref = "jacocoAndroidGradlePlugin" } androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidxStartup" } -# --- Google Maps --- -# These are the key libraries for this sample app, providing map and utility functionalities. +# --- Google Maps & Location --- +# These are the key libraries for this sample app, providing map, location, and utility functionalities. android-maps-utils = { group = "com.google.maps.android", name = "android-maps-utils", version.ref = "androidMapsUtils" } play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "androidMapsSdk" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } # --- Testing --- # Libraries for running unit tests (on the JVM) and instrumented tests (on an Android device/emulator). androidx-test = { group = "androidx.test", name = "core", version.ref = "androidxTest" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } junit = { group = "junit", name = "junit", version.ref = "junit" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref = "mockitoInline" } mockito-kotlin = { group = "com.nhaarman.mockitokotlin2", name = "mockito-kotlin", version.ref = "mockitoKotlin" } +bytebuddy = { group = "net.bytebuddy", name = "byte-buddy", version.ref = "byteBuddy" } +bytebuddy-agent = { group = "net.bytebuddy", name = "byte-buddy-agent", version.ref = "byteBuddy" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } # --- Gradle --- android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +# --- Visual Testing (Gemini API) --- +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } + [plugins] # --- Core Gradle Plugins --- android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } # --- Utility Gradle Plugins --- # The secrets plugin is used to securely handle the Maps API key, preventing it from being checked into source control. diff --git a/local.defaults.properties b/local.defaults.properties index 0cf3fd73..f28865d0 100644 --- a/local.defaults.properties +++ b/local.defaults.properties @@ -1 +1,2 @@ MAPS_API_KEY="YOUR_MAPS_API_KEY" +GEMINI_API_KEY="YOUR_GEMINI_API_KEY" diff --git a/maps-ktx/build.gradle.kts b/maps-ktx/build.gradle.kts index 664359db..9bfb310e 100644 --- a/maps-ktx/build.gradle.kts +++ b/maps-ktx/build.gradle.kts @@ -28,6 +28,20 @@ android { compileSdk = libs.versions.androidCompileSdk.get().toInt() + testOptions { + unitTests.all { + it.jvmArgs( + "-XX:+EnableDynamicAgentLoading", + "-Xshare:off", + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "-Dnet.bytebuddy.experimental=true" + ) + } + } + defaultConfig { minSdk = libs.versions.androidMinSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -67,7 +81,6 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) testImplementation(libs.truth) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/maps-utils-ktx/build.gradle.kts b/maps-utils-ktx/build.gradle.kts index 5fbff0f6..5535b0b6 100644 --- a/maps-utils-ktx/build.gradle.kts +++ b/maps-utils-ktx/build.gradle.kts @@ -27,9 +27,23 @@ android { compileSdk = libs.versions.androidCompileSdk.get().toInt() + testOptions { + targetSdk = libs.versions.androidTargetSdk.get().toInt() + unitTests.all { + it.jvmArgs( + "-XX:+EnableDynamicAgentLoading", + "-Xshare:off", + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "-Dnet.bytebuddy.experimental=true" + ) + } + } + defaultConfig { minSdk = libs.versions.androidMinSdk.get().toInt() - testOptions.targetSdk = libs.versions.androidTargetSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -57,6 +71,7 @@ android { dependencies { implementation(libs.kotlin.stdlib) api(libs.android.maps.utils) + api(libs.play.services.location) // Tests testImplementation(libs.androidx.test) @@ -64,6 +79,8 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) - testImplementation(libs.mockito.inline) + testImplementation(libs.bytebuddy) + testImplementation(libs.bytebuddy.agent) testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt new file mode 100644 index 00000000..8726992d --- /dev/null +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/clustering/ClusterManager.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.clustering + +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Returns a flow that emits when a cluster is clicked. Using this to observe cluster clicks + * will override an existing listener (if any) to [ClusterManager.setOnClusterClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun ClusterManager.clusterClickEvents(): Flow> = + callbackFlow { + setOnClusterClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterClickListener(null) + } + } + +/** + * Returns a flow that emits when a cluster item is clicked. Using this to observe cluster item clicks + * will override an existing listener (if any) to [ClusterManager.setOnClusterItemClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun ClusterManager.clusterItemClickEvents(): Flow = + callbackFlow { + setOnClusterItemClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterItemClickListener(null) + } + } + +/** + * Returns a flow that emits when a cluster's info window is clicked. Using this to observe cluster info window clicks + * will override an existing listener (if any) to [ClusterManager.setOnClusterInfoWindowClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun ClusterManager.clusterInfoWindowClickEvents(): Flow> = + callbackFlow { + setOnClusterInfoWindowClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterInfoWindowClickListener(null) + } + } + +/** + * Returns a flow that emits when a cluster's info window is long clicked. Using this to observe cluster info window long clicks + * will override an existing listener (if any) to [ClusterManager.setOnClusterInfoWindowLongClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun ClusterManager.clusterInfoWindowLongClickEvents(): Flow> = + callbackFlow { + setOnClusterInfoWindowLongClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterInfoWindowLongClickListener(null) + } + } + +/** + * Returns a flow that emits when a cluster item's info window is clicked. Using this to observe cluster item info window clicks + * will override an existing listener (if any) to [ClusterManager.setOnClusterItemInfoWindowClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun ClusterManager.clusterItemInfoWindowClickEvents(): Flow = + callbackFlow { + setOnClusterItemInfoWindowClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterItemInfoWindowClickListener(null) + } + } + +/** + * Returns a flow that emits when a cluster item's info window is long clicked. Using this to observe cluster item info window long clicks + * will override an existing listener (if any) to [ClusterManager.setOnClusterItemInfoWindowLongClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun ClusterManager.clusterItemInfoWindowLongClickEvents(): Flow = + callbackFlow { + setOnClusterItemInfoWindowLongClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnClusterItemInfoWindowLongClickListener(null) + } + } diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt index b45947e5..7158a18b 100644 --- a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/CircleManager.kt @@ -20,6 +20,9 @@ package com.google.maps.android.ktx.utils.collection import com.google.android.gms.maps.model.Circle import com.google.android.gms.maps.model.CircleOptions import com.google.maps.android.collections.CircleManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow /** * Adds a new [Circle] to the underlying map and to this [CircleManager.Collection] with the @@ -28,4 +31,23 @@ import com.google.maps.android.collections.CircleManager public inline fun CircleManager.Collection.addCircle(optionsActions: CircleOptions.() -> Unit): Circle = this.addCircle( CircleOptions().apply(optionsActions) - ) \ No newline at end of file + ) + +/** + * Returns a flow that emits when a circle in this collection is clicked. Using this to observe circle clicks + * will override an existing listener (if any) to [CircleManager.Collection.setOnCircleClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun CircleManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnCircleClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnCircleClickListener(null) + } + } \ No newline at end of file diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt index e47f5aac..9037359a 100644 --- a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/GroundOverlayManager.kt @@ -20,6 +20,9 @@ package com.google.maps.android.ktx.utils.collection import com.google.android.gms.maps.model.GroundOverlay import com.google.android.gms.maps.model.GroundOverlayOptions import com.google.maps.android.collections.GroundOverlayManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow /** * Adds a new [GroundOverlay] to the underlying map and to this [GroundOverlayManager.Collection] @@ -30,4 +33,23 @@ public inline fun GroundOverlayManager.Collection.addGroundOverlay( ): GroundOverlay = this.addGroundOverlay( GroundOverlayOptions().apply(optionsActions) - ) \ No newline at end of file + ) + +/** + * Returns a flow that emits when a ground overlay in this collection is clicked. Using this to observe ground overlay clicks + * will override an existing listener (if any) to [GroundOverlayManager.Collection.setOnGroundOverlayClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun GroundOverlayManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnGroundOverlayClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnGroundOverlayClickListener(null) + } + } \ No newline at end of file diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt index 003bf4f8..e631259f 100644 --- a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/MarkerManager.kt @@ -20,6 +20,9 @@ package com.google.maps.android.ktx.utils.collection import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions import com.google.maps.android.collections.MarkerManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow /** * Adds a new [Marker] to the underlying map and to this [MarkerManager.Collection] with the @@ -28,4 +31,63 @@ import com.google.maps.android.collections.MarkerManager public inline fun MarkerManager.Collection.addMarker(optionsActions: MarkerOptions.() -> Unit): Marker = this.addMarker( MarkerOptions().apply(optionsActions) - ) \ No newline at end of file + ) + +/** + * Returns a flow that emits when a marker in this collection is clicked. Using this to observe marker clicks + * will override an existing listener (if any) to [MarkerManager.Collection.setOnMarkerClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun MarkerManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnMarkerClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnMarkerClickListener(null) + } + } + +/** + * Returns a flow that emits when a marker's info window in this collection is clicked. Using this to observe info window clicks + * will override an existing listener (if any) to [MarkerManager.Collection.setOnInfoWindowClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun MarkerManager.Collection.infoWindowClickEvents(): Flow = + callbackFlow { + setOnInfoWindowClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnInfoWindowClickListener(null) + } + } + + + +/** + * Returns a flow that emits when a marker's info window in this collection is long clicked. Using this to observe info window long clicks + * will override an existing listener (if any) to [MarkerManager.Collection.setOnInfoWindowLongClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun MarkerManager.Collection.infoWindowLongClickEvents(): Flow = + callbackFlow { + setOnInfoWindowLongClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnInfoWindowLongClickListener(null) + } + } \ No newline at end of file diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt index db79c427..cd1b8615 100644 --- a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolygonManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 Google Inc. + * Copyright 2026 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,9 @@ package com.google.maps.android.ktx.utils.collection import com.google.android.gms.maps.model.Polygon import com.google.android.gms.maps.model.PolygonOptions import com.google.maps.android.collections.PolygonManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow /** * Adds a new [Polygon] to the underlying map and to this [PolygonManager.Collection] with the @@ -31,3 +34,22 @@ public inline fun PolygonManager.Collection.addPolygon( this.addPolygon( PolygonOptions().apply(optionsActions) ) + +/** + * Returns a flow that emits when a polygon in this collection is clicked. Using this to observe polygon clicks + * will override an existing listener (if any) to [PolygonManager.Collection.setOnPolygonClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun PolygonManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnPolygonClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnPolygonClickListener(null) + } + } diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt index 1f468256..358d2d37 100644 --- a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/collection/PolylineManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 Google Inc. + * Copyright 2026 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,9 @@ package com.google.maps.android.ktx.utils.collection import com.google.android.gms.maps.model.Polyline import com.google.android.gms.maps.model.PolylineOptions import com.google.maps.android.collections.PolylineManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow /** * Adds a new [Polyline] to the underlying map and to this [PolylineManager.Collection] with the @@ -31,3 +34,22 @@ public inline fun PolylineManager.Collection.addPolyline( this.addPolyline( PolylineOptions().apply(optionsActions) ) + +/** + * Returns a flow that emits when a polyline in this collection is clicked. Using this to observe polyline clicks + * will override an existing listener (if any) to [PolylineManager.Collection.setOnPolylineClickListener]. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +public fun PolylineManager.Collection.clickEvents(): Flow = + callbackFlow { + setOnPolylineClickListener { + trySend(it).isSuccess + } + awaitClose { + setOnPolylineClickListener(null) + } + } diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt new file mode 100644 index 00000000..2f0250b5 --- /dev/null +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/FusedLocationProvider.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.location + +import android.Manifest +import android.location.Location +import android.os.Looper +import androidx.annotation.RequiresPermission +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Returns a cold flow that emits device location updates using [FusedLocationProviderClient.requestLocationUpdates]. + * + * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately + * when the collector cancels or closes the subscription. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active callback completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + * + * @param locationRequest The [LocationRequest] specifying the quality of service (e.g. interval, priority). + * @param looper The [Looper] on which the callback runs. Defaults to [Looper.getMainLooper()]. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.locationEvents( + locationRequest: LocationRequest, + looper: Looper = Looper.getMainLooper() +): Flow = + callbackFlow { + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + for (location in result.locations) { + trySend(location) + } + } + } + + requestLocationUpdates(locationRequest, callback, looper) + + awaitClose { + removeLocationUpdates(callback) + } + } + +/** + * Simplified helper returning a cold flow that emits device location updates from [FusedLocationProviderClient]. + * + * @param intervalMs The desired interval for location updates in milliseconds. Defaults to 2000 ms. + * @param minUpdateDistanceM The minimum distance between location updates in meters. Defaults to 1 meter. + * @param priority The location priority accuracy mode. Defaults to [Priority.PRIORITY_HIGH_ACCURACY]. + * @param looper The [Looper] on which the callback runs. Defaults to [Looper.getMainLooper()]. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.fusedLocationEvents( + intervalMs: Long = 2000L, + minUpdateDistanceM: Float = 1f, + priority: Int = Priority.PRIORITY_HIGH_ACCURACY, + looper: Looper = Looper.getMainLooper() +): Flow { + val request = LocationRequest.Builder(priority, intervalMs) + .setMinUpdateDistanceMeters(minUpdateDistanceM) + .build() + return locationEvents(request, looper) +} diff --git a/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt new file mode 100644 index 00000000..52d87f9d --- /dev/null +++ b/maps-utils-ktx/src/main/java/com/google/maps/android/ktx/utils/location/LocationManager.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.location + +import android.Manifest +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Returns a cold flow that emits the device's coarse location updates using [LocationManager.NETWORK_PROVIDER] + * (or [LocationManager.PASSIVE_PROVIDER] if network provider is not available). + * + * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately + * when the collector cancels or closes the subscription. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +@RequiresPermission(Manifest.permission.ACCESS_COARSE_LOCATION) +public fun LocationManager.coarseLocationEvents( + minTimeMs: Long = 1000L, + minDistanceM: Float = 1f +): Flow = + callbackFlow { + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + trySend(location) + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Deprecated in API 29, left empty for backward compatibility with minSdk 23 + } + + override fun onProviderEnabled(provider: String) { + // Left empty for backward compatibility + } + + override fun onProviderDisabled(provider: String) { + close(kotlinx.coroutines.CancellationException("Location provider $provider was disabled")) + } + } + + val provider = if (allProviders.contains(LocationManager.NETWORK_PROVIDER)) { + LocationManager.NETWORK_PROVIDER + } else { + LocationManager.PASSIVE_PROVIDER + } + + requestLocationUpdates(provider, minTimeMs, minDistanceM, listener) + + awaitClose { + removeUpdates(listener) + } + } + +/** + * Returns a cold flow that emits the device's fine location updates using [LocationManager.GPS_PROVIDER]. + * + * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately + * when the collector cancels or closes the subscription. + * + * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing + * multiple collectors will result in listener hijacking, and cancelling any observer will unregister + * the active listener completely. Always share this flow (e.g. using [kotlinx.coroutines.flow.shareIn]) + * for multi-observer configurations. + */ +@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) +public fun LocationManager.fineLocationEvents( + minTimeMs: Long = 1000L, + minDistanceM: Float = 1f +): Flow = + callbackFlow { + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + trySend(location) + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Deprecated in API 29, left empty for backward compatibility with minSdk 23 + } + + override fun onProviderEnabled(provider: String) { + // Left empty for backward compatibility + } + + override fun onProviderDisabled(provider: String) { + close(kotlinx.coroutines.CancellationException("Location provider $provider was disabled")) + } + } + + requestLocationUpdates(LocationManager.GPS_PROVIDER, minTimeMs, minDistanceM, listener) + + awaitClose { + removeUpdates(listener) + } + } diff --git a/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt new file mode 100644 index 00000000..8bafc382 --- /dev/null +++ b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/clustering/ClusterManagerTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.clustering + +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class ClusterManagerTest { + + @Mock + private lateinit var clusterManager: ClusterManager + + @Mock + private lateinit var cluster: Cluster + + @Mock + private lateinit var clusterItem: ClusterItem + + @Captor + private lateinit var clusterClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterInfoWindowClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterInfoWindowLongClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemInfoWindowClickListener: ArgumentCaptor> + + @Captor + private lateinit var clusterItemInfoWindowLongClickListener: ArgumentCaptor> + + @Test + public fun testClusterClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterClickEvents().first() + assertThat(event).isEqualTo(cluster) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterClickListener(clusterClickListener.capture()) + clusterClickListener.value.onClusterClick(cluster) + job.cancel() + } + + @Test + public fun testClusterItemClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterItemClickEvents().first() + assertThat(event).isEqualTo(clusterItem) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemClickListener(clusterItemClickListener.capture()) + clusterItemClickListener.value.onClusterItemClick(clusterItem) + job.cancel() + } + + @Test + public fun testClusterInfoWindowClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterInfoWindowClickEvents().first() + assertThat(event).isEqualTo(cluster) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterInfoWindowClickListener(clusterInfoWindowClickListener.capture()) + clusterInfoWindowClickListener.value.onClusterInfoWindowClick(cluster) + job.cancel() + } + + @Test + public fun testClusterInfoWindowLongClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterInfoWindowLongClickEvents().first() + assertThat(event).isEqualTo(cluster) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterInfoWindowLongClickListener(clusterInfoWindowLongClickListener.capture()) + clusterInfoWindowLongClickListener.value.onClusterInfoWindowLongClick(cluster) + job.cancel() + } + + @Test + public fun testClusterItemInfoWindowClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterItemInfoWindowClickEvents().first() + assertThat(event).isEqualTo(clusterItem) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemInfoWindowClickListener(clusterItemInfoWindowClickListener.capture()) + clusterItemInfoWindowClickListener.value.onClusterItemInfoWindowClick(clusterItem) + job.cancel() + } + + @Test + public fun testClusterItemInfoWindowLongClickEvents(): Unit = runTest { + val job = launch { + val event = clusterManager.clusterItemInfoWindowLongClickEvents().first() + assertThat(event).isEqualTo(clusterItem) + } + advanceUntilIdle() + verify(clusterManager).setOnClusterItemInfoWindowLongClickListener(clusterItemInfoWindowLongClickListener.capture()) + clusterItemInfoWindowLongClickListener.value.onClusterItemInfoWindowLongClick(clusterItem) + job.cancel() + } +} diff --git a/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt new file mode 100644 index 00000000..db7b6e07 --- /dev/null +++ b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/collection/CollectionManagersTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.collection + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.verify +import org.mockito.Mockito.times +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class CollectionManagersTest { + + @Mock + private lateinit var markerCollection: MarkerManager.Collection + + @Mock + private lateinit var polylineCollection: PolylineManager.Collection + + @Mock + private lateinit var polygonCollection: PolygonManager.Collection + + @Mock + private lateinit var circleCollection: CircleManager.Collection + + @Mock + private lateinit var groundOverlayCollection: GroundOverlayManager.Collection + + @Mock + private lateinit var marker: Marker + + @Mock + private lateinit var polyline: Polyline + + @Mock + private lateinit var polygon: Polygon + + @Mock + private lateinit var circle: Circle + + @Mock + private lateinit var groundOverlay: GroundOverlay + + @Captor + private lateinit var markerClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowClickListener: ArgumentCaptor + + @Captor + private lateinit var infoWindowLongClickListener: ArgumentCaptor + + @Captor + private lateinit var polylineClickListener: ArgumentCaptor + + @Captor + private lateinit var polygonClickListener: ArgumentCaptor + + @Captor + private lateinit var circleClickListener: ArgumentCaptor + + @Captor + private lateinit var groundOverlayClickListener: ArgumentCaptor + + private var activeMarkerClickListener: GoogleMap.OnMarkerClickListener? = null + + @Before + public fun setUp() { + activeMarkerClickListener = null + // Stub setOnMarkerClickListener to track the active listener on the collection manager + org.mockito.Mockito.`when`(markerCollection.setOnMarkerClickListener(any())).thenAnswer { invocation -> + activeMarkerClickListener = invocation.arguments[0] as? GoogleMap.OnMarkerClickListener + null + } + } + + @Test + public fun testMarkerCollectionClickEvents(): Unit = runTest { + val job = launch { + val event = markerCollection.clickEvents().first() + assertThat(event).isEqualTo(marker) + } + advanceUntilIdle() + // Trigger the event via our tracked active listener slot! + assertThat(activeMarkerClickListener).isNotNull() + activeMarkerClickListener?.onMarkerClick(marker) + job.cancel() + } + + @Test + public fun testConcurrentCollectionMarkerClick(): Unit = runTest { + val flow = markerCollection.clickEvents() + val collector1Events = mutableListOf() + val collector2Events = mutableListOf() + + // Start Collector 1 + val job1 = launch { + flow.collect { collector1Events.add(it) } + } + advanceUntilIdle() + assertThat(activeMarkerClickListener).isNotNull() + val listener1 = activeMarkerClickListener + + // Start Collector 2 - this will overwrite the listener on the mock + val job2 = launch { + flow.collect { collector2Events.add(it) } + } + advanceUntilIdle() + val listener2 = activeMarkerClickListener + + // Verify they are different listener instances and listener2 hijacked the slot + assertThat(listener1).isNotEqualTo(listener2) + assertThat(activeMarkerClickListener).isEqualTo(listener2) + + // Simulate click via the active listener + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + // Only collector 2 should receive the event because it hijacked the single-listener slot + assertThat(collector1Events).isEmpty() + assertThat(collector2Events).containsExactly(marker) + + // Cancel collector 1. This triggers awaitClose and clears the listener slot (sets to null) + job1.cancel() + advanceUntilIdle() + + // Assert that the active listener slot is now null! + assertThat(activeMarkerClickListener).isNull() + + // Try to trigger a click again via the active listener (which is now null) + activeMarkerClickListener?.onMarkerClick(marker) + advanceUntilIdle() + + // Collector 2 is now BROKEN and receives no further events because Collector 1's cleanup cleared the shared slot! + assertThat(collector2Events).containsExactly(marker) + + job2.cancel() + } + + @Test + public fun testMarkerCollectionInfoWindowClickEvents(): Unit = runTest { + val job = launch { + val event = markerCollection.infoWindowClickEvents().first() + assertThat(event).isEqualTo(marker) + } + advanceUntilIdle() + verify(markerCollection).setOnInfoWindowClickListener(infoWindowClickListener.capture()) + infoWindowClickListener.value.onInfoWindowClick(marker) + job.cancel() + } + + @Test + public fun testMarkerCollectionInfoWindowLongClickEvents(): Unit = runTest { + val job = launch { + val event = markerCollection.infoWindowLongClickEvents().first() + assertThat(event).isEqualTo(marker) + } + advanceUntilIdle() + verify(markerCollection).setOnInfoWindowLongClickListener(infoWindowLongClickListener.capture()) + infoWindowLongClickListener.value.onInfoWindowLongClick(marker) + job.cancel() + } + + @Test + public fun testPolylineCollectionClickEvents(): Unit = runTest { + val job = launch { + val event = polylineCollection.clickEvents().first() + assertThat(event).isEqualTo(polyline) + } + advanceUntilIdle() + verify(polylineCollection).setOnPolylineClickListener(polylineClickListener.capture()) + polylineClickListener.value.onPolylineClick(polyline) + job.cancel() + } + + @Test + public fun testPolygonCollectionClickEvents(): Unit = runTest { + val job = launch { + val event = polygonCollection.clickEvents().first() + assertThat(event).isEqualTo(polygon) + } + advanceUntilIdle() + verify(polygonCollection).setOnPolygonClickListener(polygonClickListener.capture()) + polygonClickListener.value.onPolygonClick(polygon) + job.cancel() + } + + @Test + public fun testCircleCollectionClickEvents(): Unit = runTest { + val job = launch { + val event = circleCollection.clickEvents().first() + assertThat(event).isEqualTo(circle) + } + advanceUntilIdle() + verify(circleCollection).setOnCircleClickListener(circleClickListener.capture()) + circleClickListener.value.onCircleClick(circle) + job.cancel() + } + + @Test + public fun testGroundOverlayCollectionClickEvents(): Unit = runTest { + val job = launch { + val event = groundOverlayCollection.clickEvents().first() + assertThat(event).isEqualTo(groundOverlay) + } + advanceUntilIdle() + verify(groundOverlayCollection).setOnGroundOverlayClickListener(groundOverlayClickListener.capture()) + groundOverlayClickListener.value.onGroundOverlayClick(groundOverlay) + job.cancel() + } +} diff --git a/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt new file mode 100644 index 00000000..2fd0749e --- /dev/null +++ b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/location/FusedLocationProviderTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.location + +import android.annotation.SuppressLint +import android.location.Location +import android.os.Looper +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class FusedLocationProviderTest { + + @Mock + private lateinit var fusedLocationClient: FusedLocationProviderClient + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var locationCallbackCaptor: ArgumentCaptor + + @SuppressLint("MissingPermission") + @Test + public fun testLocationEvents(): Unit = runTest { + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L).build() + + val job = launch { + val event = fusedLocationClient.locationEvents(request, looper).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + verify(fusedLocationClient).requestLocationUpdates( + eq(request), + locationCallbackCaptor.capture(), + eq(looper) + ) + + val result = LocationResult.create(listOf(location)) + locationCallbackCaptor.value.onLocationResult(result) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testFusedLocationEvents(): Unit = runTest { + val job = launch { + val event = fusedLocationClient.fusedLocationEvents( + intervalMs = 2000L, + minUpdateDistanceM = 5f, + priority = Priority.PRIORITY_BALANCED_POWER_ACCURACY, + looper = looper + ).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + verify(fusedLocationClient).requestLocationUpdates( + any(LocationRequest::class.java), + locationCallbackCaptor.capture(), + eq(looper) + ) + + val result = LocationResult.create(listOf(location)) + locationCallbackCaptor.value.onLocationResult(result) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) + } +} diff --git a/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt new file mode 100644 index 00000000..339fd5b1 --- /dev/null +++ b/maps-utils-ktx/src/test/java/com/google/maps/android/ktx/utils/location/LocationManagerTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.google.maps.android.ktx.utils.location + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class LocationManagerTest { + + @Mock + private lateinit var locationManager: LocationManager + + @Mock + private lateinit var location: Location + + @Captor + private lateinit var locationListenerCaptor: ArgumentCaptor + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationEvents(): Unit = runTest { + // Setup providers mock + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + + val job = launch { + val event = locationManager.coarseLocationEvents(1000L, 1f).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + // Verify registered with correct provider and capture listener + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + eq(1000L), + eq(1f), + locationListenerCaptor.capture() + ) + + // Trigger event + locationListenerCaptor.value.onLocationChanged(location) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + // Verify cleanup + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationProviderDisabled(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + + val exceptions = mutableListOf() + val job = launch { + try { + locationManager.coarseLocationEvents(1000L, 1f).collect {} + } catch (e: Throwable) { + exceptions.add(e) + } + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + anyLong(), + anyFloat(), + locationListenerCaptor.capture() + ) + + // Simulate provider disablement! + locationListenerCaptor.value.onProviderDisabled(LocationManager.NETWORK_PROVIDER) + advanceUntilIdle() + + // Verify that the flow threw a CancellationException and terminated cleanly + assertThat(exceptions).hasSize(1) + assertThat(exceptions.first()).isInstanceOf(kotlinx.coroutines.CancellationException::class.java) + assertThat(exceptions.first().message).contains("Location provider ${LocationManager.NETWORK_PROVIDER} was disabled") + + // Verify cleanup runs automatically on closure! + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + job.cancel() + } + + @SuppressLint("MissingPermission") + @Test + public fun testFineLocationEvents(): Unit = runTest { + val job = launch { + val event = locationManager.fineLocationEvents(2000L, 2f).first() + assertThat(event).isEqualTo(location) + } + advanceUntilIdle() + + // Verify registered with GPS provider + verify(locationManager).requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + eq(2000L), + eq(2f), + locationListenerCaptor.capture() + ) + + // Trigger event + locationListenerCaptor.value.onLocationChanged(location) + advanceUntilIdle() + + job.cancel() + advanceUntilIdle() + + // Verify cleanup + verify(locationManager).removeUpdates(eq(locationListenerCaptor.value)) + } +} diff --git a/scripts/configure_screen.sh b/scripts/configure_screen.sh new file mode 100755 index 00000000..ffdb1800 --- /dev/null +++ b/scripts/configure_screen.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# configure_screen.sh β€” Configures Android SystemUI Demo Mode for pixel-perfect, consistent screenshot top status bars. + +if [ "$1" == "off" ]; then + # SystemUI must be allowed to process the exit broadcast + adb shell settings put global sysui_demo_allowed 1 + + # Explicitly target the systemui package + adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command exit + + # Clean up the settings + adb shell settings put global sysui_tuner_demo_on 0 + adb shell settings put global sysui_demo_allowed 0 + + echo "Screenshot mode disabled." + exit 0 +fi + +# Wake up the device screen if asleep, and dismiss the lock screen keyguard +adb shell input keyevent KEYCODE_WAKE +adb shell wm dismiss-keyguard +sleep 0.5 + +# Enable Demo Mode controls +adb shell settings put global sysui_demo_allowed 1 +adb shell settings put global sysui_tuner_demo_on 1 + +# Explicitly enter demo mode +adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command enter + +# Set time to 12:00 +adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command clock -e hhmm 1200 + +# Show full mobile data (Commented out due to SystemUI crash on SDK 37) +# adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command network -e mobile show -e level 4 -e datatype false + +# Hide notifications +adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command notifications -e visible false + +# Show full battery but not charging +adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command battery -e plugged false -e level 100 + +# Hide Wi-Fi symbol (Commented out due to SystemUI crash on SDK 37) +# adb shell am broadcast -a com.android.systemui.demo -p com.android.systemui -e command network -e wifi hide + +echo "Device configured for screenshots!" diff --git a/scripts/refresh_screenshots.sh b/scripts/refresh_screenshots.sh new file mode 100755 index 00000000..5ada9282 --- /dev/null +++ b/scripts/refresh_screenshots.sh @@ -0,0 +1,208 @@ +#!/bin/bash +# refresh_screenshots.sh β€” Automatically capture and update all catalog snapshots persistently. +# Usage: ./scripts/refresh_screenshots.sh [Optional Single Snippet Title] + +set -e + +TARGET_SNIPPET="$1" +OUTPUT_DIR="./docs/images" +mkdir -p "$OUTPUT_DIR" + +# 1. Enable SystemUI Demo Mode for pixel-perfect and consistent top status bar clock/battery/network +./scripts/configure_screen.sh + +get_time_ms() { + adb shell date +%s%3N +} + +# Map KTX Snippet Titles to output filenames +declare -A FILENAMES +FILENAMES["Map Initialization"]="map_init_golden.png" +FILENAMES["Animate Camera (Coroutines)"]="camera_animation_golden.gif" +FILENAMES["Camera Idle Events Flow"]="camera_animation_golden.png" # Shares close-up camera state +FILENAMES["Fine Location Flow"]="location_flow_fine_london.png" +FILENAMES["Coarse Location Flow"]="location_flow_coarse_paris.png" +FILENAMES["Marker Cluster Click Flow"]="clustering_clicks.gif" +FILENAMES["Marker Collection Click Flow"]="collection_clicks.gif" + +capture_snippet() { + local title="$1" + local filename="${FILENAMES[$title]}" + + if [ -z "$filename" ]; then + filename=$(echo "$title" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9 ' | tr ' ' '_').png + fi + + local IS_ANIMATION=false + if [[ "$filename" == *.gif ]]; then + IS_ANIMATION=true + fi + + echo "------------------------------------------------" + echo "Capturing: '$title' -> '$filename'..." + echo "------------------------------------------------" + + # Force-stop the demo application to ensure fresh states + adb shell am force-stop com.google.maps.android.ktx.demo + sleep 1 + + # Pre-configure mock location providers BEFORE launching the app to prevent Flow subscription cancellation + if [ "$title" == "Fine Location Flow" ]; then + echo "Pre-configuring GPS mock provider..." + adb shell appops set com.android.shell android:mock_location allow + adb shell appops set com.google.maps.android.ktx.demo android:mock_location allow + adb shell cmd location providers add-test-provider gps + adb shell cmd location providers set-test-provider-enabled gps true + elif [ "$title" == "Coarse Location Flow" ]; then + echo "Pre-configuring Network mock provider..." + adb shell appops set com.android.shell android:mock_location allow + adb shell appops set com.google.maps.android.ktx.demo android:mock_location allow + adb shell cmd location providers add-test-provider network + adb shell cmd location providers set-test-provider-enabled network true + fi + + # Launch the demo dynamically inside the targeted snippet view + adb shell "am start -W -n com.google.maps.android.ktx.demo/com.google.maps.android.ktx.demo.execution.SnippetExecutionActivity --es EXTRA_SNIPPET_TITLE \"$title\"" + sleep 4 # Let base maps load and vectors render + + if [ "$IS_ANIMATION" == "true" ]; then + echo "Starting background screen recording (6s limit)..." + adb shell screenrecord --time-limit 6 /sdcard/temp_anim.mp4 & + RECORD_PID=$! + sleep 1.5 + fi + + # Route custom automated actions depending on the snippet category + case "$title" in + "Animate Camera (Coroutines)") + echo "Simulating Click on Animate Camera Button..." + # Tap Animate Camera floating button near bottom center + adb shell input tap 540 2220 + sleep 4 + ;; + "Fine Location Flow") + echo "Injecting sequential GPS trajectory coordinates (London)..." + + # Point 1: London Center + TIME_MS=$(get_time_ms) + adb shell cmd location providers set-test-provider-location gps --location 51.5074,-0.1278 --time $TIME_MS + sleep 2.5 + # Point 2: London South + TIME_MS=$(get_time_ms) + adb shell cmd location providers set-test-provider-location gps --location 51.4800,-0.1200 --time $TIME_MS + sleep 2.5 + # Point 3: London East + TIME_MS=$(get_time_ms) + adb shell cmd location providers set-test-provider-location gps --location 51.4950,-0.0800 --time $TIME_MS + sleep 3.5 + ;; + "Coarse Location Flow") + echo "Injecting sequential Network trajectory coordinates (Paris)..." + + # Point 1: Paris Center + TIME_MS=$(get_time_ms) + adb shell cmd location providers set-test-provider-location network --location 48.8566,2.3522 --time $TIME_MS + sleep 5.5 + # Point 2: Paris South + TIME_MS=$(get_time_ms) + adb shell cmd location providers set-test-provider-location network --location 48.8300,2.3400 --time $TIME_MS + sleep 5.5 + # Point 3: Paris East + TIME_MS=$(get_time_ms) + adb shell cmd location providers set-test-provider-location network --location 48.8400,2.3800 --time $TIME_MS + sleep 6.5 + ;; + "Marker Cluster Click Flow") + echo "Simulating Cluster Badge Tapping and individual item clicks..." + # Tap Cluster Badge to zoom in + adb shell input tap 540 1200 + sleep 2.5 + # Sweep tap in center to hit one of the expanded markers + for offset in 0 -60 60; do + for yoffset in 0 -60 60; do + adb shell input tap $((540 + offset)) $((1200 + yoffset)) + sleep 0.4 + done + done + sleep 1.5 + ;; + "Marker Collection Click Flow") + echo "Simulating Custom Marker Tapping and Clickable Circle Tapping..." + # Click custom Azure Marker + adb shell input tap 540 1650 + sleep 2.5 + # Click Clickable Circle (robust sweep around its expected screen coordinate) + for offset in 0 -50 50; do + for yoffset in 0 -50 50; do + adb shell input tap $((600 + offset)) $((1300 + yoffset)) + sleep 0.4 + done + done + sleep 1.5 + ;; + esac + + if [ "$IS_ANIMATION" == "true" ]; then + echo "Waiting for screen recording to complete..." + wait $RECORD_PID || true + + # Pull the MP4 video + adb pull /sdcard/temp_anim.mp4 "$OUTPUT_DIR/temp_anim.mp4" + adb shell rm /sdcard/temp_anim.mp4 + + # Convert MP4 to GIF using ffmpeg + if command -v ffmpeg >/dev/null 2>&1; then + ffmpeg -y -i "$OUTPUT_DIR/temp_anim.mp4" -vf "fps=15,scale=360:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" "$OUTPUT_DIR/$filename" + echo "Successfully generated animation $filename" + else + echo "Error: ffmpeg is required to convert MP4 to GIF!" + mv "$OUTPUT_DIR/temp_anim.mp4" "$OUTPUT_DIR/${filename%.gif}.mp4" + echo "Saved raw MP4 to $OUTPUT_DIR/${filename%.gif}.mp4 instead." + fi + + # Clean up temp MP4 + rm -f "$OUTPUT_DIR/temp_anim.mp4" + else + # Capture screen view + adb shell screencap -p /sdcard/temp_ref.png + + # Pull screenshot to local docs folder + adb pull /sdcard/temp_ref.png "$OUTPUT_DIR/$filename" + adb shell rm /sdcard/temp_ref.png + + # If ImageMagick is installed locally, resize to 360px widths for beautiful markdown rendering efficiency + if command -v convert >/dev/null 2>&1; then + convert "$OUTPUT_DIR/$filename" -resize 360x "$OUTPUT_DIR/$filename" + echo "Resized $filename to elegant 360px width." + else + echo "Notice: install ImageMagick (convert) to automatically scale screenshots to compact sizes." + fi + fi + + # Clean up mock locations if they were enabled + if [ "$title" == "Fine Location Flow" ]; then + adb shell cmd location providers set-test-provider-enabled gps false + adb shell cmd location providers remove-test-provider gps + elif [ "$title" == "Coarse Location Flow" ]; then + adb shell cmd location providers set-test-provider-enabled network false + adb shell cmd location providers remove-test-provider network + fi +} + +if [ -n "$TARGET_SNIPPET" ]; then + # Capture a single targeted snippet catalog item + capture_snippet "$TARGET_SNIPPET" +else + # Capture all snippets in order + capture_snippet "Map Initialization" + capture_snippet "Animate Camera (Coroutines)" + capture_snippet "Fine Location Flow" + capture_snippet "Coarse Location Flow" + capture_snippet "Marker Cluster Click Flow" + capture_snippet "Marker Collection Click Flow" +fi + +# 2. Turn off SystemUI Demo Mode to restore actual hardware bars +./scripts/configure_screen.sh off + +echo "KTX Catalog snapshot refresh complete! References saved to $OUTPUT_DIR/"