diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts
index 76ed262..4807c22 100644
--- a/Maps3DSamples/advanced/app/build.gradle.kts
+++ b/Maps3DSamples/advanced/app/build.gradle.kts
@@ -80,6 +80,7 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt.android)
alias(libs.plugins.secrets.gradle.plugin)
+ alias(libs.plugins.kotlinx.serialization)
}
android {
@@ -154,6 +155,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..a7406b2
--- /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,routes.legs.steps.startLocation")
+ 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..537a190
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/RoutesModels.kt
@@ -0,0 +1,80 @@
+// 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,
+ val legs: List = emptyList()
+)
+
+@Serializable
+data class RouteLeg(
+ val steps: List = emptyList()
+)
+
+@Serializable
+data class RouteStep(
+ val startLocation: Location? = 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..862069c
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteSampleActivity.kt
@@ -0,0 +1,335 @@
+// 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.fillMaxSize
+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.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.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.ui.theme.AdvancedMaps3DSamplesTheme
+import com.example.advancedmaps3dsamples.utils.awaitCameraAnimation
+import com.example.advancedmaps3dsamples.utils.calculateHeading
+import com.example.advancedmaps3dsamples.utils.haversineDistance
+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.Polyline
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.flyToOptions
+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.MAPS3D_API_KEY, origin, dest)
+ },
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(32.dp)
+ ) {
+ Text("Fetch & Draw Route")
+ }
+
+ Button(
+ onClick = {
+ val state = uiState as? RouteUiState.Success ?: return@Button
+ val safeMap = map3D ?: return@Button
+
+ coroutineScope.launch {
+ // 1. Merge waypoints that are too close to avoid "stuttering" at start/end
+ val thresholdMeters = 200.0
+ val rawPoints = state.navigationPoints
+ val turnPoints = mutableListOf()
+
+ if (rawPoints.isNotEmpty()) {
+ turnPoints.add(rawPoints.first())
+ for (i in 1 until rawPoints.size - 1) {
+ if (haversineDistance(turnPoints.last(), rawPoints[i]) >= thresholdMeters) {
+ turnPoints.add(rawPoints[i])
+ }
+ }
+ // Always include the actual destination
+ if (haversineDistance(turnPoints.last(), rawPoints.last()) > 10.0) {
+ turnPoints.add(rawPoints.last())
+ }
+ }
+
+ // 2. Inject intermediate points from the raw polyline for long stretches
+ // This ensures the camera follows highway curves rather than flying in a straight line.
+ val finalFlightPath = mutableListOf()
+ if (turnPoints.isNotEmpty()) {
+ finalFlightPath.add(turnPoints.first())
+ for (i in 0 until turnPoints.size - 1) {
+ val start = turnPoints[i]
+ val end = turnPoints[i + 1]
+
+ if (haversineDistance(start, end) > 500.0) {
+ // Find indices in raw polyline to pull curve details
+ val startIndex = state.decodedPolyline.indexOfFirst { haversineDistance(it, start) < 20.0 }
+ val endIndex = state.decodedPolyline.indexOfFirst { haversineDistance(it, end) < 20.0 }
+
+ if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) {
+ for (j in (startIndex + 1) until endIndex) {
+ val p = state.decodedPolyline[j]
+ // Subsample for smooth camera motion
+ if (haversineDistance(finalFlightPath.last(), p) > 400.0) {
+ finalFlightPath.add(p)
+ }
+ }
+ }
+ }
+ if (haversineDistance(finalFlightPath.last(), end) > 10.0) {
+ finalFlightPath.add(end)
+ }
+ }
+ }
+
+ // 3. Fly along the curve-aware path
+ val targetSpeedMetersPerSecond = 750.0
+
+ for (i in 0 until finalFlightPath.size - 1) {
+ val current = finalFlightPath[i]
+ val next = finalFlightPath[i + 1]
+
+ // Calculate heading towards the next point
+ val flightHeading = calculateHeading(current, next)
+
+ // Calculate distance to determine duration
+ val distanceMeters = haversineDistance(current, next)
+
+ // Duration = distance / speed
+ // Clamp duration to [100ms, 10000ms]
+ val calculatedDuration = (distanceMeters / targetSpeedMetersPerSecond * 1000).toLong()
+ val segmentDuration = calculatedDuration.coerceIn(250, 2000)
+
+ val flightCamera = camera {
+ center = latLngAltitude {
+ latitude = current.latitude
+ longitude = current.longitude
+ altitude = 0.0
+ }
+ heading = flightHeading
+ tilt = 50.0
+ range = 3000.0
+ }
+
+ // Animate to this segment's view
+ safeMap.flyCameraTo(
+ flyToOptions {
+ endCamera = flightCamera
+ durationInMillis = segmentDuration
+ }
+ )
+
+ // Wait for this segment to finish
+ safeMap.awaitCameraAnimation()
+ }
+ }
+ },
+ enabled = uiState is RouteUiState.Success,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(32.dp)
+ ) {
+ Text("Fly Along")
+ }
+
+ // 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()
+
+ // DRAW POLYLINE (Raw version, as requested)
+ 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 = 10.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..c7e9cf0
--- /dev/null
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/RouteViewModel.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.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,
+ val navigationPoints: 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)
+
+ // Extract important navigation points from legs/steps
+ val navPoints = route.legs.flatMap { leg ->
+ leg.steps.mapNotNull { step ->
+ step.startLocation?.latLng?.let { LatLng(it.latitude, it.longitude) }
+ }
+ }.toMutableList()
+
+ // Ensure the destination is the last point
+ navPoints.add(dest)
+
+ _uiState.value = RouteUiState.Success(decoded, navPoints)
+ } 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/java/com/example/advancedmaps3dsamples/utils/CameraUpdate.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/CameraUpdate.kt
index 73be80e..8dc9478 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/CameraUpdate.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/CameraUpdate.kt
@@ -119,3 +119,21 @@ suspend fun awaitCameraUpdate(
cameraUpdate.invoke(controller)
}
+
+/**
+ * Suspends the coroutine until the current camera animation is finished.
+ *
+ * In a 3D environment, this is essential for sequencing cinematic movements.
+ */
+suspend fun GoogleMap3D.awaitCameraAnimation() = suspendCancellableCoroutine { continuation ->
+ setCameraAnimationEndListener {
+ setCameraAnimationEndListener(null) // Cleanup
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ setCameraAnimationEndListener(null)
+ }
+}
diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/Utilities.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/Utilities.kt
index d30977a..dc29109 100644
--- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/Utilities.kt
+++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/utils/Utilities.kt
@@ -14,6 +14,7 @@
package com.example.advancedmaps3dsamples.utils
+import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps3d.model.Camera
import com.google.android.gms.maps3d.model.FlyAroundOptions
import com.google.android.gms.maps3d.model.FlyToOptions
@@ -23,7 +24,13 @@ import com.google.android.gms.maps3d.model.flyAroundOptions
import com.google.android.gms.maps3d.model.flyToOptions
import com.google.android.gms.maps3d.model.latLngAltitude
import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
import kotlin.math.floor
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
val headingRange = 0.0..360.0
val tiltRange = 0.0..90.0
@@ -335,3 +342,144 @@ internal fun Double?.format(decimalPlaces: Int): String {
String.format(Locale.US, "%.${decimalPlaces}f", this)
}
}
+
+/**
+ * Smooths a path of LatLng points using Chaikin's algorithm.
+ *
+ * Chaikin's algorithm works by cutting corners. Each iteration replaces each
+ * internal point with two points, each 1/4 and 3/4 along the edge between the
+ * previous and next points.
+ *
+ * @param iterations The number of smoothing iterations to perform. Higher
+ * values result in smoother curves but more points.
+ * @return A new list of smoothed [LatLng] points.
+ */
+fun List.smoothPath(iterations: Int = 1): List {
+ if (size < 3 || iterations <= 0) return this
+
+ var currentPath = this
+ repeat(iterations) {
+ val nextPath = mutableListOf()
+ // Keep the first point
+ nextPath.add(currentPath.first())
+
+ for (i in 0 until currentPath.size - 1) {
+ val p0 = currentPath[i]
+ val p1 = currentPath[i + 1]
+
+ // Point at 1/4 of the way
+ val q = LatLng(
+ p0.latitude * 0.75 + p1.latitude * 0.25,
+ p0.longitude * 0.75 + p1.longitude * 0.25
+ )
+
+ // Point at 3/4 of the way
+ val r = LatLng(
+ p0.latitude * 0.25 + p1.latitude * 0.75,
+ p0.longitude * 0.25 + p1.longitude * 0.75
+ )
+
+ nextPath.add(q)
+ nextPath.add(r)
+ }
+
+ // Keep the last point
+ nextPath.add(currentPath.last())
+ currentPath = nextPath
+ }
+
+ return currentPath
+}
+
+/**
+ * Calculates the heading (bearing) from one LatLng to another.
+ *
+ * @return The heading in degrees clockwise from North.
+ */
+fun calculateHeading(from: LatLng, to: LatLng): Double {
+ val lat1 = Math.toRadians(from.latitude)
+ val lon1 = Math.toRadians(from.longitude)
+ val lat2 = Math.toRadians(to.latitude)
+ val lon2 = Math.toRadians(to.longitude)
+
+ val dLon = lon2 - lon1
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) -
+ sin(lat1) * cos(lat2) * cos(dLon)
+
+ val bearing = Math.toDegrees(atan2(y, x))
+ return (bearing + 360.0) % 360.0
+}
+
+/**
+ * Simplifies a path of LatLng points using the Ramer-Douglas-Peucker algorithm.
+ *
+ * This algorithm reduces the number of points in a curve that is approximated
+ * by a series of points, while preserving the overall shape.
+ *
+ * @param epsilon The maximum distance between the original path and the
+ * simplified path. Higher values result in more simplification.
+ * Value is in degrees (very rough approximation).
+ * @return A new list of simplified [LatLng] points.
+ */
+fun List.simplifyPath(epsilon: Double = 0.001): List {
+ if (size < 3) return this
+
+ var maxDistance = 0.0
+ var index = 0
+ val first = first()
+ val last = last()
+
+ for (i in 1 until size - 1) {
+ val distance = perpendicularDistance(this[i], first, last)
+ if (distance > maxDistance) {
+ index = i
+ maxDistance = distance
+ }
+ }
+
+ return if (maxDistance > epsilon) {
+ val left = subList(0, index + 1).simplifyPath(epsilon)
+ val right = subList(index, size).simplifyPath(epsilon)
+ left.dropLast(1) + right
+ } else {
+ listOf(first, last)
+ }
+}
+
+/**
+ * Calculates the perpendicular distance from a point to a line segment.
+ */
+private fun perpendicularDistance(point: LatLng, start: LatLng, end: LatLng): Double {
+ val x = point.longitude
+ val y = point.latitude
+ val x1 = start.longitude
+ val y1 = start.latitude
+ val x2 = end.longitude
+ val y2 = end.latitude
+
+ val area = abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1)
+ val bottom = sqrt((y2 - y1).pow(2.0) + (x2 - x1).pow(2.0))
+ return area / bottom
+}
+
+/**
+ * Calculates the distance in meters between two [LatLng] points using the Haversine formula.
+ */
+fun haversineDistance(p1: LatLng, p2: LatLng): Double {
+ val r = 6371000.0 // Earth radius in meters
+ val lat1 = Math.toRadians(p1.latitude)
+ val lon1 = Math.toRadians(p1.longitude)
+ val lat2 = Math.toRadians(p2.latitude)
+ val lon2 = Math.toRadians(p2.longitude)
+
+ val dLat = lat2 - lat1
+ val dLon = lon2 - lon1
+
+ val a = sin(dLat / 2).pow(2.0) +
+ cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2.0)
+ val c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+ return r * c
+}
+
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