From 7a85c45959f2c68a92dcd6434f0784948b413147 Mon Sep 17 00:00:00 2001
From: dkhawk <107309+dkhawk@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:14:43 -0700
Subject: [PATCH 1/3] Add Routes API sample with Map3D integration
---
Maps3DSamples/advanced/app/build.gradle.kts | 7 +
.../advanced/app/src/main/AndroidManifest.xml | 4 +
.../advancedmaps3dsamples/MainActivity.kt | 2 +
.../common/RoutesApiService.kt | 104 ++++++++
.../common/RoutesModels.kt | 69 +++++
.../scenarios/RouteSampleActivity.kt | 237 ++++++++++++++++++
.../scenarios/RouteViewModel.kt | 90 +++++++
.../app/src/main/res/values/strings.xml | 1 +
.../advanced/gradle/libs.versions.toml | 8 +
9 files changed, 522 insertions(+)
create mode 100644 Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesApiService.kt
create mode 100644 Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesModels.kt
create mode 100644 Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteSampleActivity.kt
create mode 100644 Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteViewModel.kt
diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts
index 2205bd7..d41ae04 100644
--- a/Maps3DSamples/advanced/app/build.gradle.kts
+++ b/Maps3DSamples/advanced/app/build.gradle.kts
@@ -65,6 +65,7 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt.android)
alias(libs.plugins.secrets.gradle.plugin)
+ alias(libs.plugins.kotlinx.serialization)
}
android {
@@ -139,6 +140,12 @@ dependencies {
// Google Maps Utils for the polyline decoder
implementation(libs.maps.utils.ktx)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ implementation(libs.kotlinx.serialization.json)
+
implementation(libs.androidx.material.icons.extended)
}
diff --git a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml
index 54402a3..a94a453 100644
--- a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml
+++ b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml
@@ -50,6 +50,10 @@
android:name=".scenarios.ScenariosActivity"
android:exported="true"
/>
+
\ No newline at end of file
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt
index 29a5ba2..db0494e 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt
@@ -39,6 +39,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import com.example.advancedmaps3dsamples.scenarios.RouteSampleActivity
import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity
import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme
import dagger.hilt.android.AndroidEntryPoint
@@ -48,6 +49,7 @@ data class MapSample(@StringRes val label: Int, val clazz: Class<*>)
private val samples =
listOf(
MapSample(R.string.map_sample_scenarios, ScenariosActivity::class.java),
+ MapSample(R.string.map_sample_route, RouteSampleActivity::class.java),
)
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesApiService.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesApiService.kt
new file mode 100644
index 0000000..30c5bed
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesApiService.kt
@@ -0,0 +1,104 @@
+// Copyright 2025 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.example.advancedmaps3dsamples.common
+
+import android.util.Log
+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.header
+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.contentType
+import io.ktor.http.isSuccess
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+
+/**
+ * Exception thrown when the Routes API returns an error, such as a 403 Forbidden
+ * if the API is not enabled for the provided key.
+ */
+class DirectionsErrorException(message: String) : Exception(message)
+
+/**
+ * A simple network service to fetch routes from the Google Maps Routes API.
+ *
+ * Note: In a production application, making direct API calls to Google Maps Platform
+ * services from a client device requires embedding the API key in the app, which
+ * poses a security risk. Best practice is to proxy these requests through a secure
+ * backend server. This client implementation is provided for demonstration purposes.
+ */
+object RoutesApiService {
+
+ private val client = HttpClient(CIO) {
+ install(ContentNegotiation) {
+ json(Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ })
+ }
+ }
+
+ /**
+ * Fetches a route between the origin and destination coordinates.
+ *
+ * @param apiKey The Google Maps API key (requires Routes API enabled).
+ * @param originLat The latitude of the starting point.
+ * @param originLng The longitude of the starting point.
+ * @param destLat The latitude of the destination point.
+ * @param destLng The longitude of the destination point.
+ * @return [RoutesResponse] containing the computed route.
+ * @throws [DirectionsErrorException] if the API returns a non-success HTTP status.
+ */
+ suspend fun fetchRoute(
+ apiKey: String,
+ originLat: Double,
+ originLng: Double,
+ destLat: Double,
+ destLng: Double
+ ): RoutesResponse {
+ val requestBody = RoutesRequest(
+ origin = Waypoint(Location(RequestLatLng(originLat, originLng))),
+ destination = Waypoint(Location(RequestLatLng(destLat, destLng)))
+ )
+
+ val response: HttpResponse = client.post("https://routes.googleapis.com/directions/v2:computeRoutes") {
+ contentType(ContentType.Application.Json)
+ header("X-Goog-Api-Key", apiKey)
+ // Requesting only the most relevant fields to optimize payload size
+ header("X-Goog-FieldMask", "routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline")
+ setBody(requestBody)
+ }
+
+ if (response.status.isSuccess()) {
+ return response.body()
+ } else {
+ val errorBody = response.bodyAsText()
+ Log.e("RoutesApiService", "Failed to fetch route: ${response.status.value}\n$errorBody")
+
+ // Provide a localized, user-friendly message based on typical API errors
+ val userMsg = if (response.status.value == 403) {
+ "API Error (HTTP 403). Ensure the Routes API is enabled in the Google Cloud Console for the provided API key."
+ } else {
+ "Failed to fetch route (HTTP ${response.status.value})."
+ }
+ throw DirectionsErrorException(userMsg)
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesModels.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesModels.kt
new file mode 100644
index 0000000..396906a
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesModels.kt
@@ -0,0 +1,69 @@
+// Copyright 2025 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.example.advancedmaps3dsamples.common
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RoutesRequest(
+ val origin: Waypoint,
+ val destination: Waypoint,
+ val travelMode: String = "DRIVE",
+ val routingPreference: String = "TRAFFIC_AWARE",
+ val computeAlternativeRoutes: Boolean = false,
+ val routeModifiers: RouteModifiers = RouteModifiers(),
+ val languageCode: String = "en-US",
+ val units: String = "METRIC"
+)
+
+@Serializable
+data class Waypoint(
+ val location: Location
+)
+
+@Serializable
+data class Location(
+ val latLng: RequestLatLng
+)
+
+@Serializable
+data class RequestLatLng(
+ val latitude: Double,
+ val longitude: Double
+)
+
+@Serializable
+data class RouteModifiers(
+ val avoidTolls: Boolean = false,
+ val avoidHighways: Boolean = false,
+ val avoidFerries: Boolean = false
+)
+
+@Serializable
+data class RoutesResponse(
+ val routes: List = emptyList()
+)
+
+@Serializable
+data class Route(
+ val distanceMeters: Int? = null,
+ val duration: String? = null,
+ val polyline: Polyline? = null
+)
+
+@Serializable
+data class Polyline(
+ val encodedPolyline: String
+)
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteSampleActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteSampleActivity.kt
new file mode 100644
index 0000000..4164b14
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteSampleActivity.kt
@@ -0,0 +1,237 @@
+// Copyright 2025 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.example.advancedmaps3dsamples.scenarios
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.advancedmaps3dsamples.BuildConfig
+import com.example.advancedmaps3dsamples.R
+import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.Map3DOptions
+import com.google.android.gms.maps3d.Map3DView
+import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.Polyline
+import com.google.android.gms.maps3d.model.PolylineOptions
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.gms.maps3d.model.polylineOptions
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+@OptIn(ExperimentalMaterial3Api::class)
+class RouteSampleActivity : ComponentActivity() {
+ private val viewModel: RouteViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ var map3D by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+ var currentPolyline by remember { mutableStateOf(null) }
+
+ // Show the critical API Key leakage warning immediately on load
+ var displayWarning by remember { mutableStateOf(true) }
+
+ AdvancedMaps3DSamplesTheme {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ ),
+ title = {
+ Text("Routes API Integration")
+ }
+ )
+ }
+ ) { innerPadding ->
+ Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
+
+ // The Map3D View wrapper
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { context ->
+ val options = Map3DOptions(
+ centerLat = 21.350,
+ centerLng = -157.800,
+ centerAlt = 0.0,
+ tilt = 60.0,
+ range = 25000.0
+ )
+ val view = Map3DView(context, options)
+ view.onCreate(savedInstanceState)
+ view
+ },
+ update = { view ->
+ view.getMap3DViewAsync(object : OnMap3DViewReadyCallback {
+ override fun onMap3DViewReady(googleMap3D: GoogleMap3D) {
+ map3D = googleMap3D
+ }
+ override fun onError(e: Exception) {
+ // Simple error log
+ }
+ })
+ },
+ onRelease = { view ->
+ // Optional cleanup
+ }
+ )
+
+ // Floating Action Button to trigger the route fetch
+ Button(
+ onClick = {
+ // Hardcoded parameters as requested (Honolulu to Kailua)
+ val origin = LatLng(21.307043, -157.858984)
+ val dest = LatLng(21.390177, -157.719454)
+ viewModel.fetchRoute(BuildConfig.MAPS_API_KEY, origin, dest)
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(32.dp)
+ ) {
+ Text("Fetch & Draw Route")
+ }
+
+ // State interpretation UI
+ when (val state = uiState) {
+ is RouteUiState.Loading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ is RouteUiState.Success -> {
+ // Draw the polyline once the map is ready and we have successful data
+ LaunchedEffect(state.decodedPolyline, map3D) {
+ val safeMap = map3D ?: return@LaunchedEffect
+
+ // Clean up previous line if it exists
+ currentPolyline?.remove()
+
+ val polylinePath = state.decodedPolyline.map { latLng ->
+ latLngAltitude {
+ latitude = latLng.latitude
+ longitude = latLng.longitude
+ altitude = 0.0
+ }
+ }
+
+ val lineOptions = polylineOptions {
+ this.path = polylinePath
+ strokeColor = android.graphics.Color.BLUE
+ strokeWidth = 20.0
+ }
+
+ currentPolyline = safeMap.addPolyline(lineOptions)
+
+ // Move the camera to see the full route
+ val routeCamera = camera {
+ center = latLngAltitude {
+ latitude = 21.350
+ longitude = -157.800
+ altitude = 0.0
+ }
+ tilt = 45.0
+ range = 35000.0
+ }
+ safeMap.setCamera(routeCamera)
+ }
+ }
+ is RouteUiState.Error -> {
+ // Display error overlay
+ Box(modifier = Modifier
+ .align(Alignment.Center)
+ .padding(32.dp)) {
+ Text(
+ text = state.message,
+ color = MaterialTheme.colorScheme.error,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ RouteUiState.Idle -> { /* Do nothing */ }
+ }
+
+ if (displayWarning) {
+ AlertDialog(
+ onDismissRequest = { displayWarning = false },
+ icon = {
+ Icon(Icons.Filled.Warning, contentDescription = null)
+ },
+ title = {
+ Text("Security Warning")
+ },
+ text = {
+ Text("This sample makes a direct REST API call from a mobile client to the Google Maps Routes API. In a production application, doing this exposes your API key to malicious extraction.\n\nAlways proxy your Routes API requests through a secure backend server!")
+ },
+ confirmButton = {
+ TextButton(onClick = { displayWarning = false }) {
+ Text("I Understand")
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteViewModel.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteViewModel.kt
new file mode 100644
index 0000000..da052e7
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteViewModel.kt
@@ -0,0 +1,90 @@
+// Copyright 2025 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.example.advancedmaps3dsamples.scenarios
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.advancedmaps3dsamples.common.DirectionsErrorException
+import com.example.advancedmaps3dsamples.common.RoutesApiService
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.PolyUtil
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * UI State for the Routes Sample API call.
+ */
+sealed interface RouteUiState {
+ object Idle : RouteUiState
+ object Loading : RouteUiState
+ data class Success(val decodedPolyline: List) : RouteUiState
+ data class Error(val message: String) : RouteUiState
+}
+
+/**
+ * ViewModel responsible for orchestrating the route fetch and
+ * converting the API's encoded polyline into a List of LatLngs
+ * to be consumed by the UI.
+ */
+@HiltViewModel
+class RouteViewModel @Inject constructor() : ViewModel() {
+
+ private val _uiState = MutableStateFlow(RouteUiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun fetchRoute(apiKey: String, origin: LatLng, dest: LatLng) {
+ if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) {
+ _uiState.value = RouteUiState.Error("Invalid API Key. Please provide a real key.")
+ return
+ }
+
+ _uiState.value = RouteUiState.Loading
+
+ viewModelScope.launch {
+ try {
+ // Execute network call via Ktor
+ val response = RoutesApiService.fetchRoute(
+ apiKey = apiKey,
+ originLat = origin.latitude,
+ originLng = origin.longitude,
+ destLat = dest.latitude,
+ destLng = dest.longitude
+ )
+
+ // The Routes API returns an array of routes. Grab the first one.
+ val route = response.routes.firstOrNull()
+ val encodedPolyline = route?.polyline?.encodedPolyline
+
+ if (encodedPolyline != null) {
+ // Decode the polyline so Map3D can consume it (or further convert it to Polyline3DOptions)
+ val decoded = PolyUtil.decode(encodedPolyline)
+ _uiState.value = RouteUiState.Success(decoded)
+ } else {
+ _uiState.value = RouteUiState.Error("No route returned from the Maps API.")
+ }
+ } catch (e: DirectionsErrorException) {
+ // Re-emit known, user-friendly API errors
+ _uiState.value = RouteUiState.Error(e.message ?: "Unknown API Error")
+ } catch (e: Exception) {
+ // Catch all other network/parsing issues
+ _uiState.value = RouteUiState.Error("Network Error: ${e.message}")
+ }
+ }
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml
index 509c21c..2b72c48 100644
--- a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml
+++ b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml
@@ -19,6 +19,7 @@
3D Map Samples
Scenarios (video visuals)
+ Routes API Integration