diff --git a/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt b/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt index 3c499be4..a286ae45 100644 --- a/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt +++ b/example/android/app/src/main/kotlin/com/google/maps/flutter/navigation_example/SampleAndroidAutoScreen.kt @@ -17,6 +17,7 @@ package com.google.maps.flutter.navigation_example import android.annotation.SuppressLint +import android.app.Application import android.util.Log import androidx.car.app.CarContext import androidx.car.app.CarToast @@ -29,54 +30,215 @@ import androidx.car.app.navigation.model.Maneuver import androidx.car.app.navigation.model.NavigationTemplate import androidx.car.app.navigation.model.RoutingInfo import androidx.car.app.navigation.model.Step +import androidx.car.app.SurfaceContainer import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.Observer import com.google.android.gms.maps.GoogleMap +import com.google.android.libraries.navigation.NavigationUpdatesOptions.GeneratedStepImagesType import com.google.android.libraries.mapsplatform.turnbyturn.model.NavInfo import com.google.android.libraries.mapsplatform.turnbyturn.model.StepInfo import com.google.maps.flutter.navigation.AndroidAutoBaseScreen import com.google.maps.flutter.navigation.AutoMapViewOptions -import com.google.maps.flutter.navigation.GoogleMapsNavigationNavUpdatesService +import com.google.maps.flutter.navigation.GoogleMapsNavigatorHolder +import kotlin.math.max +/** + * Example Android Auto [androidx.car.app.Screen] that shows how to extend the SDK-provided + * [AndroidAutoBaseScreen] to build a full turn-by-turn experience on the car head unit. + * + * This class is intended to be read as documentation. It demonstrates the two responsibilities an + * app has when integrating the Google Navigation SDK with Android Auto: + * + * 1. Rendering the map. [AndroidAutoBaseScreen] already draws the navigation map onto the Android + * Auto surface, so we only need to build the [NavigationTemplate] (action strips and buttons) in + * [onGetTemplate]. + * 2. Feeding turn-by-turn guidance into the Android Auto navigation "turn card". Android Auto does + * not read guidance from the map automatically: the app must translate the SDK's turn-by-turn + * [NavInfo] updates into Android Auto's [RoutingInfo]/[Step]/[Maneuver] objects and hand them to + * the template. + * + * The overall data flow is: + * ``` + * Navigator --> TurnByTurn service --> NavInfo LiveData --> SampleAndroidAutoScreen + * --> RoutingInfo (current/next step, distance, lanes) + * --> NavigationTemplate turn card + * ``` + * + * The turn-by-turn feed is only observed while the car surface is available AND navigation is ready + * (see [startListeningNavInfoIfPossible]/[stopListeningNavInfo]); this avoids doing work when there + * is no turn card to populate. + * + * See https://developers.google.com/maps/documentation/navigation/android-sdk/android-auto for the + * official guide that this example follows. + */ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(carContext) { + /** The latest turn-by-turn guidance converted into an Android Auto [RoutingInfo], or null. */ private var mNavInfo: RoutingInfo? = null + + /** Whether the turn-by-turn updates service is currently registered. */ + private var hasRegisteredTurnByTurnService: Boolean = false + + /** Whether we are currently observing the [NavInfo] LiveData. */ + private var hasRegisteredNavInfoObserver: Boolean = false + + /** Whether the Android Auto drawing surface is currently available. */ + private var isAutoSurfaceAvailable: Boolean = false + + /** Observer that converts each [NavInfo] update into Android Auto data structures. */ + private val navInfoObserver = Observer { navInfo: NavInfo? -> + buildNavInfo(navInfo) + } + init { - // Connect to the Turn-by-Turn Navigation service to receive navigation data. - GoogleMapsNavigationNavUpdatesService.navInfoLiveData.observe(this) { navInfo: NavInfo? -> - this.buildNavInfo( - navInfo + // Listening is driven by lifecycle hooks rather than started here: + // onSurfaceAvailable/onSurfaceDestroyed track surface availability and + // onNavigationReady tracks navigator readiness. Only when both are true + // do we observe the turn-by-turn feed (see startListeningNavInfoIfPossible). + } + + // region Turn-by-turn feed lifecycle + + /** + * Starts observing the turn-by-turn feed, but only when both preconditions hold: + * the car surface is available and the navigator is ready. Called from both the + * surface and navigation lifecycle hooks so whichever happens last triggers listening. + */ + private fun startListeningNavInfoIfPossible() { + if (!isAutoSurfaceAvailable || !mIsNavigationReady) { + return + } + + if (!hasRegisteredNavInfoObserver) { + GoogleMapsNavigatorHolder.addNavInfoObserver(navInfoObserver) + hasRegisteredNavInfoObserver = true + } + + tryRegisterTurnByTurnServiceIfNeeded() + } + + /** + * Stops observing the turn-by-turn feed and unregisters the updates service. Called when the + * surface is destroyed or navigation is no longer ready, so we don't process updates that + * cannot be displayed. + */ + private fun stopListeningNavInfo() { + if (hasRegisteredNavInfoObserver) { + GoogleMapsNavigatorHolder.removeNavInfoObserver(navInfoObserver) + hasRegisteredNavInfoObserver = false + } + + if (hasRegisteredTurnByTurnService) { + GoogleMapsNavigatorHolder.unregisterTurnByTurnService() + hasRegisteredTurnByTurnService = false + } + } + + /** + * Registers the turn-by-turn updates service that feeds the [NavInfo] LiveData. The service + * must be registered for guidance updates to flow; it is registered once and torn down in + * [stopListeningNavInfo]. + */ + private fun tryRegisterTurnByTurnServiceIfNeeded() { + if (hasRegisteredTurnByTurnService || !mIsNavigationReady) { + return + } + + val app = carContext.applicationContext as? Application ?: return + + // Register nav updates with no generated step images; we use the bitmaps already provided on + // the NavInfo steps (maneuverBitmap/lanesBitmap) when available. + hasRegisteredTurnByTurnService = + GoogleMapsNavigatorHolder.registerTurnByTurnService( + app, + 1, + GeneratedStepImagesType.NONE, ) + if (!hasRegisteredTurnByTurnService) { + Log.w("SampleAndroidAutoScreen", "Failed to register turn-by-turn nav updates service") } } + // endregion + + // region Converting NavInfo into Android Auto data structures + + /** + * Converts a single turn-by-turn [NavInfo] update into an Android Auto [RoutingInfo] and + * triggers a template refresh. This is the heart of the integration and runs on every + * guidance update. + */ private fun buildNavInfo(navInfo: NavInfo?) { if (navInfo == null || navInfo.currentStep == null) { + // No active step means guidance is not (or no longer) running. Clear any stale turn + // card and refresh the template. + if (mNavInfo != null) { + mNavInfo = null + invalidate() + } return } - /** - * Converts data received from the Navigation data feed into Android-Auto compatible data - * structures. - */ + // Convert the current step and its distance into Android Auto types. val currentStep: Step = buildStepFromStepInfo(navInfo.currentStep!!) val distanceToStep = Distance.create( - java.lang.Double.max( - navInfo.distanceToCurrentStepMeters?.toDouble() ?: 0.0, - 0.0 - ), Distance.UNIT_METERS + max(navInfo.distanceToCurrentStepMeters?.toDouble() ?: 0.0, 0.0), + Distance.UNIT_METERS, ) - mNavInfo = RoutingInfo.Builder().setCurrentStep(currentStep, distanceToStep).build() + val routingInfoBuilder = RoutingInfo.Builder().setCurrentStep(currentStep, distanceToStep) + + // Include the next maneuver when available so the turn card can preview it. + val remainingSteps = navInfo.remainingSteps + if (!remainingSteps.isNullOrEmpty()) { + routingInfoBuilder.setNextStep(buildStepFromStepInfo(remainingSteps[0])) + } + + // Use the lanes bitmap as the junction image when present to show lane guidance. + navInfo.currentStep!!.lanesBitmap?.let { lanesBitmap -> + val lanesIcon = CarIcon.Builder(IconCompat.createWithBitmap(lanesBitmap)).build() + routingInfoBuilder.setJunctionImage(lanesIcon) + } + + mNavInfo = routingInfoBuilder.build() - // Invalidate the current template which leads to another onGetTemplate call. + // Invalidate the current template, which leads to another onGetTemplate call that renders + // the updated turn card. invalidate() } + /** + * Converts a turn-by-turn [StepInfo] into an Android Auto [Step], including the maneuver type, + * its icon, the road name and the instruction cue. + */ private fun buildStepFromStepInfo(stepInfo: StepInfo): Step { - val maneuver: Int = ManeuverConverter.getAndroidAutoManeuverType(stepInfo.maneuver) - val maneuverBuilder = Maneuver.Builder(maneuver) + val maneuverType: Int = ManeuverConverter.getAndroidAutoManeuverType(stepInfo.maneuver) + val maneuverBuilder = Maneuver.Builder(maneuverType) + + // Roundabout maneuver types carry required metadata. Without it, + // Maneuver.Builder.build() throws IllegalArgumentException: + // - *_WITH_ANGLE types require a roundabout exit angle in the range [1, 360]. + // - ENTER_AND_EXIT (non-angle) types require a roundabout exit number >= 1. + when (maneuverType) { + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE, + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> { + ManeuverConverter.getAndroidAutoRoundaboutAngle(stepInfo.maneuver)?.let { exitAngle + -> + maneuverBuilder.setRoundaboutExitAngle(exitAngle) + } + } + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW -> { + // roundaboutTurnNumber may be null or 0; fall back to 1 since the API + // requires a valid exit number. + val exitNumber = stepInfo.roundaboutTurnNumber ?: 0 + maneuverBuilder.setRoundaboutExitNumber(if (exitNumber >= 1) exitNumber else 1) + } + } + + // The maneuver icon is provided as a bitmap by the turn-by-turn feed. if (stepInfo.maneuverBitmap != null) { val maneuverIcon = IconCompat.createWithBitmap(stepInfo.maneuverBitmap!!) val maneuverCarIcon = CarIcon.Builder(maneuverIcon).build() @@ -90,29 +252,68 @@ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(car return stepBuilder.build() } + // endregion + + // region Lifecycle hooks from AndroidAutoBaseScreen + + /** + * Called when navigator readiness changes. We start or stop listening to the turn-by-turn feed + * accordingly and refresh the template, since [onGetTemplate] renders differently depending on + * readiness. + */ override fun onNavigationReady(ready: Boolean) { super.onNavigationReady(ready) + if (ready) { + startListeningNavInfoIfPossible() + } else { + stopListeningNavInfo() + } // Invalidate template layout because of conditional rendering in the // onGetTemplate method. invalidate() } - // Example of handling prompt visibility changes - // This is called when traffic prompts appear/disappear on the Android Auto screen + /** Called when the Android Auto drawing surface becomes available. */ + override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { + super.onSurfaceAvailable(surfaceContainer) + isAutoSurfaceAvailable = true + startListeningNavInfoIfPossible() + } + + /** Called when the Android Auto drawing surface is torn down. */ + override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) { + super.onSurfaceDestroyed(surfaceContainer) + isAutoSurfaceAvailable = false + stopListeningNavInfo() + } + + // endregion + + // region Custom events and prompts + + /** + * Called when a traffic/incident prompt appears or disappears on the Android Auto screen. + * Always call super so the event is forwarded to Flutter; add custom UI adjustments afterwards + * if needed. + */ override fun onPromptVisibilityChanged(promptVisible: Boolean) { super.onPromptVisibilityChanged(promptVisible) // This sends the event to Flutter Log.d("SampleAndroidAutoScreen", "Prompt visibility changed to: $promptVisible") - + // You can add custom logic here, such as: // - Hiding/showing custom action buttons when prompts appear // - Adjusting your template layout // - Updating custom UI elements - + // For example, you might want to refresh the template: // invalidate() } - // Example of handling custom events from Flutter + /** + * Called when Flutter sends a custom event to the native side via + * `GoogleMapsAutoViewController.sendCustomNavigationAutoEvent`. This example surfaces the + * message as a [CarToast]. + */ override fun onCustomNavigationAutoEventFromFlutter(event: String, data: Any) { Log.d("SampleAndroidAutoScreen", "Received custom event from Flutter: event=$event, data=$data") @@ -125,11 +326,15 @@ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(car ).show() } - // Example of providing custom map options from native code + /** + * Provides the map options used when [AndroidAutoBaseScreen] creates the Android Auto map. + * Returning super's value uses the options supplied from Flutter; override to hard-code native + * options instead. + */ override fun getAutoMapOptions(): AutoMapViewOptions? { - // Call super to use Flutter-provided options + // Call super to use Flutter-provided options. return super.getAutoMapOptions() - + // Or provide your own custom options: // return AutoMapViewOptions( // mapId = "your-custom-map-id", @@ -139,6 +344,18 @@ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(car // ) } + // endregion + + // region Template + + /** + * Builds the [NavigationTemplate] shown on the Android Auto screen. Android Auto calls this + * whenever [invalidate] is called. The template carries: + * - an action strip with a "Re-center" button (only when navigation is ready) and a custom + * event button; + * - a map action strip with the required [Action.PAN] button so the map is pannable; + * - the turn card navigation info, when available. + */ override fun onGetTemplate(): Template { // Suppresses the missing permission check for the followMyLocation method, which requires // "android.permission.ACCESS_COARSE_LOCATION" or "android.permission.ACCESS_FINE_LOCATION", as @@ -173,11 +390,13 @@ class SampleAndroidAutoScreen(carContext: CarContext): AndroidAutoBaseScreen(car .setMapActionStrip(ActionStrip.Builder().addAction(Action.PAN).build()) - // Show turn-by-turn navigation information if available. + // Show the turn card when guidance info is available (populated by buildNavInfo). if (mNavInfo != null) { navigationTemplateBuilder.setNavigationInfo(mNavInfo!!) } return navigationTemplateBuilder.build() } + + // endregion } \ No newline at end of file diff --git a/example/ios/Runner/CarSceneDelegate.swift b/example/ios/Runner/CarSceneDelegate.swift index a3442649..e2b87919 100644 --- a/example/ios/Runner/CarSceneDelegate.swift +++ b/example/ios/Runner/CarSceneDelegate.swift @@ -14,18 +14,99 @@ import CarPlay import GoogleNavigation +import MapKit import UIKit import google_navigation_flutter -class CarSceneDelegate: BaseCarSceneDelegate { +/// Example CarPlay scene delegate that shows how to extend the SDK-provided +/// `BaseCarSceneDelegate` to build a full turn-by-turn CarPlay experience. +/// +/// This class is intended to be read as documentation. It demonstrates the two +/// responsibilities an app has when integrating the Google Navigation SDK with +/// Apple CarPlay: +/// +/// 1. Rendering the map and CarPlay UI. `BaseCarSceneDelegate` already creates +/// the `GMSMapView`-backed navigation view and attaches it to the CarPlay +/// window. We only customize the `CPMapTemplate` (buttons, panning) by +/// overriding `getTemplate()`. +/// 2. Feeding turn-by-turn guidance into CarPlay's navigation card. CarPlay does +/// not read guidance from the map automatically: the app must translate the +/// SDK's `GMSNavigationNavInfo` updates into CarPlay's `CPTrip`, +/// `CPManeuver` and `CPTravelEstimates` objects. We do this by conforming to +/// `GMSNavigatorListener` and driving a `CPNavigationSession`. +/// +/// The overall data flow is: +/// +/// GMSNavigator --(didUpdate navInfo)--> CarSceneDelegate +/// --> CPNavigationSession (maneuvers, road name, estimates) +/// --> CarPlay guidance card +/// +/// See https://developers.google.com/maps/documentation/navigation/ios-sdk/carplay +/// for the official guide that this example follows. +class CarSceneDelegate: BaseCarSceneDelegate, GMSNavigatorListener { + /// Tracks whether `self` is currently registered as a `GMSNavigatorListener`. + /// Used to avoid registering twice or removing a listener that was never added. + private var isNavigatorListenerRegistered = false + + /// The map template currently shown on the CarPlay screen. Held weakly because + /// the template's lifetime is owned by `BaseCarSceneDelegate`/CarPlay; we only + /// need it to start and update the navigation session. + private weak var activeMapTemplate: CPMapTemplate? + + /// The CarPlay trip describing the origin/destination of the active route. + /// A trip must exist before a `CPNavigationSession` can be started, and the + /// trip is what trip-level estimates (time/distance to destination) are + /// reported against. + private var activeTrip: CPTrip? + + /// The live CarPlay navigation session. This is the object that actually + /// renders the turn-by-turn guidance card and that we push maneuvers and + /// travel estimates into as `navInfo` updates arrive. + private var activeNavigationSession: CPNavigationSession? + + /// Options used to render the maneuver instructions and images that the + /// Navigation SDK provides for the turn-by-turn guidance card. Reusing a + /// single instance keeps the instruction styling consistent across updates. + private lazy var instructionOptions: GMSNavigationInstructionOptions = { + let options = GMSNavigationInstructionOptions() + options.imageOptions = GMSNavigationStepInfoImageOptions() + return options + }() + + /// An object stored in the `userInfo` field of a `CPManeuver` so the template + /// delegate (`mapTemplate(_:displayStyleFor:)`) can determine the correct + /// `CPManeuverDisplayStyle`. CarPlay does not distinguish a regular turn + /// maneuver from a lane-guidance maneuver on its own, so we tag each maneuver + /// here and inspect the tag when CarPlay asks how to display it. + private struct ManeuverUserInfo { + var stepInfo: GMSNavigationStepInfo + var isLaneGuidance: Bool + } + + // MARK: - CarPlay template + + /// Builds the root `CPMapTemplate` shown on the CarPlay screen. + /// + /// `BaseCarSceneDelegate` calls this whenever it needs to (re)build the + /// template, for example after the navigation session attaches/detaches. We + /// use it to add our custom toolbar buttons and to enable map panning. override func getTemplate() -> CPMapTemplate { let template = CPMapTemplate() template.showPanningInterface(animated: true) + // Keep a reference so navigator updates can start/update the navigation + // session on the currently visible template. + activeMapTemplate = template + // A button that demonstrates sending a custom event back to Flutter. This + // lets the Flutter side react to app-specific actions taken on the car + // screen (e.g. accepting an order). let customEventButton = CPBarButton(title: "Custom Event") { [weak self] _ in let data = ["sampleDataKey": "sampleDataContent"] self?.sendCustomNavigationAutoEvent(event: "CustomCarPlayEvent", data: data) } + // A button that re-centers the camera on the user's location. Re-centering + // only makes sense once navigation guidance is active, so it is added + // conditionally below. let recenterButton = CPBarButton(title: "Re-center") { [weak self] _ in self?.getNavView()?.followMyLocation( perspective: GMSNavigationCameraPerspective.tilted, @@ -35,6 +116,9 @@ class CarSceneDelegate: BaseCarSceneDelegate { let navView = getNavView() var leadingButtons = [customEventButton] + // Only offer "Re-center" when the view is attached to a navigation session + // and the navigation UI is enabled, otherwise the action would have nothing + // to follow. if (navView?.isAttachedToSession ?? false) && (navView?.isNavigationUIEnabled() ?? false) { leadingButtons.append(recenterButton) } @@ -42,7 +126,11 @@ class CarSceneDelegate: BaseCarSceneDelegate { return template } - // Example of handling custom events from Flutter + // MARK: - Custom Flutter events + + /// Called when Flutter sends a custom event to the native side via + /// `GoogleMapsAutoViewController.sendCustomNavigationAutoEvent`. This example + /// surfaces the message as a CarPlay alert. override func onCustomNavigationAutoEventFromFlutter(event: String, data: Any) { NSLog("CarSceneDelegate: Received custom event from Flutter: event=\(event), data=\(data)") @@ -50,10 +138,13 @@ class CarSceneDelegate: BaseCarSceneDelegate { showCarPlayMessage(String(message.prefix(120))) } - // Example of providing custom map options from native code - // Override this method to provide custom map configuration + // MARK: - Map options + + /// Provides the map options used when `BaseCarSceneDelegate` creates the + /// CarPlay map view. Returning `super`'s value uses the options supplied from + /// Flutter; override to hard-code native options instead. override func getAutoMapOptions() -> AutoMapViewOptions? { - // Call super to use Flutter-provided options + // Call super to use Flutter-provided options. return super.getAutoMapOptions() // Or provide your own custom options: @@ -67,9 +158,13 @@ class CarSceneDelegate: BaseCarSceneDelegate { // ) } - // Example of handling prompt visibility changes + // MARK: - Prompt visibility + + /// Called when a traffic/incident prompt appears or disappears on the CarPlay + /// screen. Always call `super` so the event is forwarded to Flutter; add + /// custom UI adjustments afterwards if needed. override func onPromptVisibilityChanged(promptVisible: Bool) { - // Call super to ensure Flutter receives the event + // Call super to ensure Flutter receives the event. super.onPromptVisibilityChanged(promptVisible: promptVisible) NSLog("CarSceneDelegate: onPromptVisibilityChanged called with promptVisible=\(promptVisible)") @@ -94,15 +189,328 @@ class CarSceneDelegate: BaseCarSceneDelegate { // } } + // MARK: - Navigation lifecycle + + /// Called when the navigation UI is enabled or disabled. We keep our + /// navigator-listener registration in sync (we only want guidance updates + /// while the navigation UI is active) and rebuild the template so the + /// conditional "Re-center" button reflects the new state. override func onNavigationUIEnabledChanged(isEnabled: Bool) { super.onNavigationUIEnabledChanged(isEnabled: isEnabled) + syncNavigatorListenerRegistration() refreshTemplate() } + /// Tells CarPlay that this delegate supplies navigation metadata (maneuvers, + /// estimates, lane guidance). Required on iOS 17.4+ for the guidance card to + /// display the data we push into the `CPNavigationSession`. + @available(iOS 17.4, *) + func mapTemplateShouldProvideNavigationMetadata(_ mapTemplate: CPMapTemplate) -> Bool { + true + } + + /// Determines how each maneuver is rendered in the guidance card. Lane + /// guidance maneuvers only show their symbol, while regular maneuvers show a + /// leading symbol alongside the instruction text. We rely on the + /// `ManeuverUserInfo` tag attached when the maneuver was created. + func mapTemplate( + _ mapTemplate: CPMapTemplate, + displayStyleFor maneuver: CPManeuver + ) -> CPManeuverDisplayStyle { + if let maneuverUserInfo = maneuver.userInfo as? ManeuverUserInfo { + return maneuverUserInfo.isLaneGuidance ? .symbolOnly : .leadingSymbol + } + return .leadingSymbol + } + + /// Called when the view attaches to or detaches from a navigation session. + /// On detach we tear down the CarPlay navigation session so no stale guidance + /// card remains, and we keep the navigator-listener registration in sync. override func onSessionAttachmentChanged(isAttachedToSession: Bool) { + super.onSessionAttachmentChanged(isAttachedToSession: isAttachedToSession) + syncNavigatorListenerRegistration() + if !isAttachedToSession { + clearCarPlayNavigationSession() + } refreshTemplate() } + deinit { + // Always release the navigation session and unregister the listener so we + // don't leak or receive callbacks after this delegate is gone. + clearCarPlayNavigationSession() + unregisterNavigatorListener() + } + + // MARK: - GMSNavigatorListener + + /// The SDK calls this on every guidance update. This is the entry point for + /// translating SDK guidance into CarPlay's navigation card. + func navigator(_ navigator: GMSNavigator, didUpdate navInfo: GMSNavigationNavInfo) { + updateCarPlayNavigationMetadata(navigator: navigator, navInfo: navInfo) + } + + // MARK: - Navigator listener registration + + /// Registers `self` as a `GMSNavigatorListener` exactly once. The navigator is + /// a singleton owned by the SDK; we obtain it through `ExposedGoogleMapsNavigator`. + private func registerNavigatorListener() { + do { + let navigator = try ExposedGoogleMapsNavigator.getNavigator() + if !isNavigatorListenerRegistered { + navigator.add(self) + isNavigatorListenerRegistered = true + } + } catch { + isNavigatorListenerRegistered = false + NSLog("CarSceneDelegate: Unable to register navigator listener: \(error)") + } + } + + /// Removes `self` as a `GMSNavigatorListener` if it was previously registered. + private func unregisterNavigatorListener() { + do { + let navigator = try ExposedGoogleMapsNavigator.getNavigator() + if isNavigatorListenerRegistered { + _ = navigator.remove(self) + isNavigatorListenerRegistered = false + } + } catch { + isNavigatorListenerRegistered = false + NSLog("CarSceneDelegate: Unable to unregister navigator listener: \(error)") + } + } + + /// Registers the navigator listener only while the CarPlay view is attached to + /// a session and its navigation UI is enabled, and unregisters it otherwise. + /// This guarantees we only consume guidance updates when there is a CarPlay + /// guidance card to populate. + private func syncNavigatorListenerRegistration() { + let navView = getNavView() + let shouldRegister = + (navView?.isAttachedToSession ?? false) && + (navView?.isNavigationUIEnabled() ?? false) + if shouldRegister { + registerNavigatorListener() + } else { + unregisterNavigatorListener() + } + } + + // MARK: - Populating the CarPlay guidance card + + /// Translates a single `GMSNavigationNavInfo` update into the CarPlay + /// navigation session. This is the heart of the integration and runs on every + /// guidance update: + /// + /// 1. If there is no current step, tear down the session (navigation ended). + /// 2. Start a `CPNavigationSession` (once) for the current trip. + /// 3. Build the current maneuver (+ optional lane guidance) and hand it to the + /// session. + /// 4. Update both the trip-level and step-level travel estimates. + private func updateCarPlayNavigationMetadata( + navigator: GMSNavigator, + navInfo: GMSNavigationNavInfo + ) { + guard let currentStep = navInfo.currentStep else { + // No active step means guidance is not (or no longer) running. + clearCarPlayNavigationSession() + return + } + + guard let mapTemplate = activeMapTemplate else { + // Nothing to render into yet; the template will be rebuilt and a later + // update will populate it. + return + } + + // When the route changes (e.g. a reroute) the previous trip is no longer + // valid, so drop the old session and start a fresh one below. + if navInfo.routeChanged { + clearCarPlayNavigationSession() + } + + // Start the navigation session lazily the first time we have guidance. A + // `CPNavigationSession` is always associated with a `CPTrip`. + if activeNavigationSession == nil || activeTrip == nil { + let trip = makeTrip(navigator: navigator, navInfo: navInfo) + activeTrip = trip + activeNavigationSession = mapTemplate.startNavigationSession(for: trip) + } + + let maneuver = makeManeuver(for: currentStep, navInfo: navInfo) + maneuver.initialTravelEstimates = makeStepTravelEstimates(navInfo: navInfo) + + // Build the list of maneuvers shown in the guidance card. The current + // maneuver is shown first, optionally followed by a lane guidance maneuver + // that only displays the lanes image. + var upcomingManeuvers = [maneuver] + if let laneGuidance = makeLaneGuidanceManeuver(for: currentStep) { + upcomingManeuvers.append(laneGuidance) + } + + if #available(iOS 17.4, *) { + // On iOS 17.4+ maneuvers must first be registered with the session via + // `add(_:)` before they can be shown through `upcomingManeuvers`. + activeNavigationSession?.add(upcomingManeuvers) + let roadName = currentStep.fullRoadName + if !roadName.isEmpty { + activeNavigationSession?.currentRoadNameVariants = [roadName] + } + } + // The display list of maneuvers. Available since iOS 12; on 17.4+ these must + // have been added above first. + activeNavigationSession?.upcomingManeuvers = upcomingManeuvers + + // Trip-level estimates drive the "time/distance to destination" UI. + if let trip = activeTrip { + mapTemplate.updateEstimates(makeTripTravelEstimates(navInfo: navInfo), for: trip) + } + // Step-level estimates drive the "distance to next maneuver" UI. + activeNavigationSession?.updateEstimates( + makeStepTravelEstimates(navInfo: navInfo), + for: maneuver + ) + } + + /// Builds the `CPTrip` describing the current route's origin and destination. + /// CarPlay needs a trip before a navigation session can be created, and uses + /// the trip for the destination name and route summary. + private func makeTrip(navigator: GMSNavigator, navInfo: GMSNavigationNavInfo) -> CPTrip { + let originCoordinate = + getNavView()?.getMyLocation() + ?? CLLocationCoordinate2D(latitude: 0, longitude: 0) + + let destinationCoordinate = navigator.currentRouteLeg?.destinationCoordinate + ?? originCoordinate + let destinationTitle = + navigator.currentRouteLeg?.destinationWaypoint?.title + ?? "Destination" + + let originPlacemark = MKPlacemark(coordinate: originCoordinate) + let destinationPlacemark = MKPlacemark(coordinate: destinationCoordinate) + + let originItem = MKMapItem(placemark: originPlacemark) + originItem.name = "Current Location" + + let destinationItem = MKMapItem(placemark: destinationPlacemark) + destinationItem.name = destinationTitle + + let routeSummary = buildRouteSummary(navInfo: navInfo) + let routeDetails = buildRouteDetails(navInfo: navInfo) + let routeChoice = CPRouteChoice( + summaryVariants: [routeSummary], + additionalInformationVariants: [routeDetails], + selectionSummaryVariants: [routeSummary] + ) + + let trip = CPTrip(origin: originItem, destination: destinationItem, routeChoices: [routeChoice]) + if #available(iOS 17.4, *) { + trip.destinationNameVariants = [destinationTitle] + } + return trip + } + + // Builds a CPManeuver for the given step. Uses the attributed instruction + // variants generated by the Navigation SDK for most maneuvers, and provides + // friendly text for the arrival maneuvers. The maneuver symbol image is also + // generated by the SDK. + // + // The maneuver is tagged via `ManeuverUserInfo` so the template delegate can + // render it with the `.leadingSymbol` style. + private func makeManeuver( + for step: GMSNavigationStepInfo, + navInfo: GMSNavigationNavInfo + ) -> CPManeuver { + let maneuver = CPManeuver() + maneuver.userInfo = ManeuverUserInfo(stepInfo: step, isLaneGuidance: false) + + switch step.maneuver { + case .destination: + maneuver.instructionVariants = ["Your destination is ahead."] + case .destinationLeft: + maneuver.instructionVariants = ["Your destination is ahead on your left."] + case .destinationRight: + maneuver.instructionVariants = ["Your destination is ahead on your right."] + default: + let attributedInstructions = navInfo.instructions( + forStep: step, + options: instructionOptions + ) + if !attributedInstructions.isEmpty { + maneuver.attributedInstructionVariants = attributedInstructions + } else { + maneuver.instructionVariants = [step.fullInstructionText] + } + } + + if let maneuverImage = step.maneuverImage(with: instructionOptions.imageOptions) { + maneuver.symbolImage = maneuverImage + } + + return maneuver + } + + // Builds a separate CPManeuver that only renders the lane guidance image for + // the given step, or nil if no lanes image is available. It is tagged as lane + // guidance so the template delegate renders it with the `.symbolOnly` style. + private func makeLaneGuidanceManeuver(for step: GMSNavigationStepInfo) -> CPManeuver? { + guard let lanesImage = step.lanesImage(with: instructionOptions.imageOptions) else { + return nil + } + let maneuver = CPManeuver() + maneuver.userInfo = ManeuverUserInfo(stepInfo: step, isLaneGuidance: true) + maneuver.symbolImage = lanesImage + return maneuver + } + + // Travel estimates to the final destination, shown on the trip overview. + // `roundedDistance`/`roundedTime` apply the SDK's locale-aware rounding so the + // values match what the on-phone navigation UI displays. + private func makeTripTravelEstimates(navInfo: GMSNavigationNavInfo) -> CPTravelEstimates { + CPTravelEstimates( + distanceRemaining: navInfo.roundedDistance(navInfo.distanceToFinalDestinationMeters) + as Measurement, + timeRemaining: navInfo.roundedTime(navInfo.timeToFinalDestinationSeconds) + ) + } + + // Travel estimates to the next maneuver (current step), shown on the guidance + // card. Uses the same SDK rounding as the trip estimates above. + private func makeStepTravelEstimates(navInfo: GMSNavigationNavInfo) -> CPTravelEstimates { + CPTravelEstimates( + distanceRemaining: navInfo.roundedDistance(navInfo.distanceToCurrentStepMeters) + as Measurement, + timeRemaining: navInfo.roundedTime(navInfo.timeToCurrentStepSeconds) + ) + } + + // A short, human-readable summary ("~12 min") used for the trip's route choice. + private func buildRouteSummary(navInfo: GMSNavigationNavInfo) -> String { + let seconds = max(Int(navInfo.timeToFinalDestinationSeconds), 0) + let minutes = Int((Double(seconds) / 60.0).rounded()) + return minutes > 0 ? "~\(minutes) min" : "Arriving" + } + + // A short, human-readable distance ("3.4 km" / "850 m") used for the trip's + // route choice additional information. + private func buildRouteDetails(navInfo: GMSNavigationNavInfo) -> String { + let meters = max(Int(navInfo.distanceToFinalDestinationMeters), 0) + return meters >= 1000 + ? String(format: "%.1f km", Double(meters) / 1000.0) + : "\(meters) m" + } + + // Cancels and releases the active CarPlay navigation session and trip, so the + // guidance card disappears and a fresh session can be started later. + private func clearCarPlayNavigationSession() { + activeNavigationSession?.cancelTrip() + activeNavigationSession = nil + activeTrip = nil + } + + // Presents a simple dismissible alert on the CarPlay screen. Used here to + // surface messages received from Flutter. private func showCarPlayMessage(_ message: String) { DispatchQueue.main.async { guard