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