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