50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / car · raw .md

CarPlay & Android Auto

In-car turn-by-turn on top of the same NavigationEngine the phone SDKs use: Android's :car + :car-maplibre modules, and iOS's MapMapCarPlay product. Both wrap the standard platform navigation surfaces (the androidx.car.app Car App Library, and CarPlay/CPMapTemplate) around MapMap's guidance state, so the app supplies a map view and destination picker and the modules handle the car-specific chrome.

Status: beta. These modules ship from the same sdk-v* tags as the rest of the mobile SDKs, under the same early-access-while-private terms (see SDKs & installation). The API surface is not yet frozen and may change before 1.0. Everything below builds and is unit-tested; none of it has been run on real head-unit hardware; validate on the Desktop Head Unit / CarPlay Simulator (or your own head unit) before shipping. Portions of both platforms' template/converter code are adapted from Ferrostar (BSD-3-Clause, © Stadia Maps, Inc.); see THIRD-PARTY-NOTICES.md.

Before you integrate: platform gates

Both platforms gate in-car navigation behind an app-level approval step that happens outside your build; start it early, in parallel with integration.

Apple: the CarPlay entitlement

Apps need the com.apple.developer.carplay-maps entitlement before a CarPlay scene can connect, including in the CarPlay Simulator; there is no entitlement-free way to test. Request it from your app's own Apple Developer Account Holder at Requesting CarPlay Entitlements. It is granted per app, per developer account; a team with several apps must request it separately for each one. Lead time runs from days to around eight weeks, and a working, substantive iPhone app is effectively a prerequisite for approval, so apply as soon as the rest of the app is in reasonable shape rather than waiting for CarPlay work to be "done".

What to tell Apple: describe the app as an offline/on-device turn-by- turn navigation app (categorise it as Navigation, not Maps/EV charging/parking; those get separate CarPlay entitlements with different review bars), and be concrete about the driving use case the entitlement is for. Once granted, add it in Signing & Capabilities or directly in your .entitlements file:

xml
<key>com.apple.developer.carplay-maps</key>
<true/>

MapMapCarPlay.requiredEntitlement holds this exact string as a Swift constant, handy for onboarding copy or a pre-flight check before offering a "Navigate on CarPlay" affordance.

Google: Play Console review

Android Auto apps that declare the androidx.car.app.category.NAVIGATION category go through Google's manual review against the car app quality guidelines before the release reaches users. A failed review on a production track blocks release of the whole app, not just the car surface; push car-app changes through an internal or closed testing track first, and only promote to production once a reviewed release has cleared.

Reviewers exercise auto-drive simulation: the host calls NavigationManagerCallback.onAutoDriveEnabled() to ask the app to drive the route itself rather than wait for a real GNSS fix. NavigationManagerBridge already surfaces this as the onAutoDriveEnabled callback; wire it to a simulated ReplayLocationProvider (or any accelerated LocationProvider) so a reviewer's "Start Auto Drive" always produces a moving trip.

Android: :car and :car-maplibre

Two modules, split so a headless integration never pulls in a map renderer:

ModuleWhat it is
:carHeadless: NavigationManagerBridge (drives NavigationManager's start/update/stop lifecycle), TurnByTurnNotificationManager (heads-up notification), NavigationIntentParser (geo:/google.navigation: URIs) and the template layer (the NavigationTemplateBuilder class plus the toCar* converter extensions in TripBuilder.kt, ManeuverBuilder.kt, LaneBuilder.kt, RoutingInfoBuilder.kt, TravelEstimateBuilder.kt) that turns a GuidanceUpdate into Car App Library Trip/Maneuver/RoutingInfo objects.
:car-maplibreCarMapViewHost: projects an app-supplied View (typically a MapLibre MapView) onto the car display's surface via a VirtualDisplay + Presentation, plus CarMapGestureListener/CarMapInsetsListener for touch and chrome-occlusion callbacks. MapLibre is compileOnly; only apps that already depend on org.maplibre.gl:android-sdk pull it in.
kotlin
dependencies {
    implementation("ai.mapmap:core:0.3.0")
    implementation("ai.mapmap:car:0.2.0")
    implementation("ai.mapmap:car-maplibre:0.2.0") // optional, needs MapLibre on the classpath
}

:car pulls in androidx.car.app:app:1.7.0 (as api, since its public signatures expose Car App Library types directly); 1.7.0 is a floor, not a suggestion: versions below it are affected by CVE-2024-10382 (arbitrary app launch from an unvalidated host).

Quickstart: CarAppService, Session, Screen

kotlin
class MyCarAppService : CarAppService() {
    override fun createHostValidator(): HostValidator =
        HostValidator.Builder(this)
            .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
            .build() // use ALLOW_ALL_HOSTS_VALIDATOR only in debug builds

    override fun onCreateSession(): Session = MyCarSession()
}

class MyCarSession : Session() {
    private val intentParser = NavigationIntentParser()

    override fun onCreateScreen(intent: Intent): Screen =
        MyNavigationScreen(carContext, intentParser.parse(intent))

    override fun onNewIntent(intent: Intent) {
        val destination = intentParser.parse(intent) ?: return
        carContext.getCarService(ScreenManager::class.java)
            .push(MyNavigationScreen(carContext, destination))
    }
}

class MyNavigationScreen(
    carContext: CarContext,
    private val destination: NavigationDestination?,
) : Screen(carContext) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    private val mapHost = CarMapViewHost(carContext)
    private var latestUpdate: GuidanceUpdate? = null

    private val bridge = NavigationManagerBridge(
        navigationManager = carContext.getCarService(NavigationManager::class.java),
        notificationManager = TurnByTurnNotificationManager(
            context = carContext,
            smallIconRes = R.drawable.ic_navigation,
        ),
        drivingSide = DrivingSide.LEFT, // from your territory lookup; see DrivingSide's KDoc
        destinationName = destination?.displayName,
        isCarForeground = { lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) },
        onStopNavigation = { screenManager.pop() },
        onAutoDriveEnabled = { startGuidance(timeScale = 8.0) }, // DHU / Play review
    )

    init {
        lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onCreate(owner: LifecycleOwner) {
                carContext.getCarService(AppManager::class.java).setSurfaceCallback(mapHost)
                mapHost.contentFactory = mapLibreCarMapContentFactory()
                startGuidance(timeScale = 1.0)
            }

            override fun onDestroy(owner: LifecycleOwner) {
                bridge.stop()
                mapHost.onDestroy()
                scope.cancel()
            }
        })
    }

    private fun startGuidance(timeScale: Double) {
        scope.launch {
            val engine = NavigationEngine(routeJson) // from your own routing call
            // Real GNSS in normal use; replayed fixes for auto-drive: this is
            // what the DHU's "Start Auto Drive" (and Play review) exercises.
            val provider = if (timeScale == 1.0) {
                AndroidLocationProvider(carContext)
            } else {
                ReplayLocationProvider(replayFixes, timeScale = timeScale) // recorded fixes, e.g. from a drive log
            }
            val states = engine.navigate(provider).onEach { state ->
                latestUpdate = state.update
                invalidate() // triggers onGetTemplate()
            }
            bridge.start(scope, states)
        }
    }

    override fun onGetTemplate(): Template = NavigationTemplateBuilder()
        .setGuidanceUpdate(latestUpdate)
        .setDrivingSide(DrivingSide.LEFT)
        .setOnStopNavigation { screenManager.pop() }
        .build()
}

Manifest entries (see the demo's AndroidManifest.xml for the full, working version):

xml
<uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES" />
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<service
    android:name=".MyCarAppService"
    android:exported="true">
    <intent-filter>
        <action android:name="androidx.car.app.CarAppService" />
        <category android:name="androidx.car.app.category.NAVIGATION" />
    </intent-filter>
    <meta-data android:name="androidx.car.app.minCarApiLevel" android:value="5" />
</service>
<meta-data
    android:name="com.google.android.gms.car.application"
    android:resource="@xml/automotive_app_desc" />

An app driving real guidance (rather than a replay) also needs ACCESS_FINE_LOCATION and a foreground service with foregroundServiceType="location"; see "Location permissions and device caveats" in android/README.md in the source checkout.

Testing: the Desktop Head Unit

Google's Desktop Head Unit (DHU) is the primary way to exercise the car surface without hardware: connect a device or emulator over adb, run desktop-head-unit, and your CarAppService shows up as an installed app on the simulated dash. Use the DHU's Start Auto Drive control to exercise the onAutoDriveEnabled path end-to-end (the same path Play Console's reviewers trigger) before you ever submit for review.

Known Android constraints

  • Metric-only distances. TravelEstimateBuilder's conversion from metres to Car App Library Distance is metric-only for now (no locale-aware imperial formatting yet); revisit once a locale formatter lands in :core.
  • One map instance per display. CarMapViewHost tears down and rebuilds the VirtualDisplay/Presentation from scratch on every onSurfaceAvailable call rather than resizing in place (deliberate; it sidesteps a class of "wrong DPI after reconnect" bugs); it hosts one view at a time.
  • Car App Library floor: 1.7.0, for the CVE fix noted above; do not pin lower.

iOS: MapMapCarPlay

An optional Swift package product, alongside MapMapKit and MapMapValhalla:

swift
.package(path: "path/to/MapMap/ios/MapMapKit"),
// …
.product(name: "MapMapKit", package: "MapMapKit"),
.product(name: "MapMapCarPlay", package: "MapMapKit"),

MapMapCarPlay is headless in the same way the rest of the SDK is: it owns the CarPlay chrome (CPMapTemplate root, activity states, CPNavigationSession lifecycle, screen-scale correction) but never renders a map; your app supplies its own MapLibre-backed view controller.

Quickstart: scene delegate + CarPlayManagerDelegate

Declare the CarPlay scene manifest in your app target's Info.plist:

xml
<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>CPTemplateApplicationSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>CarPlay Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).YourCarPlaySceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>

Then forward the two scene-lifecycle callbacks to a CarPlayManager. This is the entire contract; everything else lives in the manager:

swift
final class YourCarPlaySceneDelegate: NSObject, CPTemplateApplicationSceneDelegate {
    private let carPlayManager = CarPlayManager()

    func templateApplicationScene(
        _ scene: CPTemplateApplicationScene,
        didConnect interfaceController: CPInterfaceController,
        to window: CPWindow
    ) {
        carPlayManager.delegate = YourCarPlayManagerDelegate.shared
        carPlayManager.sceneConnected(interfaceController: interfaceController, window: window)
    }

    func templateApplicationScene(
        _ scene: CPTemplateApplicationScene,
        didDisconnect interfaceController: CPInterfaceController,
        from window: CPWindow
    ) {
        carPlayManager.sceneDisconnected()
    }
}

@MainActor
final class YourCarPlayManagerDelegate: CarPlayManagerDelegate {
    static let shared = YourCarPlayManagerDelegate()

    func carPlayManager(
        _ manager: CarPlayManager,
        mapViewControllerFor window: CPWindow,
        scaleAdjuster: CarPlayWindowScaleAdjuster
    ) -> UIViewController {
        let vc = YourMapLibreCarPlayViewController(styleURL: styleURL)
        scaleAdjuster.applyToContentScale(of: vc.view) // corrects maplibre-native#3214
        return vc
    }

    func carPlayManager(_ manager: CarPlayManager, didStartTrip trip: CPTrip, using routeChoice: CPRouteChoice) {
        guard let route = routeChoice.mapMapRouteResult else { return }
        try? CarPlayAudioSession.configureForVoicePrompts()
        Task {
            let engine = NavigationEngine(route: route)
            for try await state in await engine.navigate(provider: CoreLocationProvider()) {
                manager.updateNavigationState(state)
                if state.isArrived {
                    manager.stopTrip(cancelled: false)
                    break
                }
                if state.needsReroute {
                    // Compute a new route for the remaining waypoints and
                    // start a fresh NavigationEngine; an engine is bound
                    // to a single route.
                }
            }
        }
    }

    func carPlayManager(_ manager: CarPlayManager, didEndTrip trip: CPTrip, cancelled: Bool) {
        try? CarPlayAudioSession.deactivate()
    }
}

A full, compiled, heavily-commented version of this (including the Info.plist keys and entitlement above) ships in the package itself as ExampleCarPlaySceneDelegate.swift; copy it into your app target rather than starting from a blank scene delegate.

Building a route into a CPTrip

CPTrip.mapMapTrip builds a CPTrip (with one CPRouteChoice per candidate route) straight from RouteResults:

swift
let trip = CPTrip.mapMapTrip(
    routes: routeResults,          // from OfflineRouting.route / your gateway call
    origin: MKMapItem(placemark: originPlacemark),
    destination: MKMapItem(placemark: destinationPlacemark),
)
carPlayManager.previewRoutes(for: trip)
// … driver picks one → CPMapTemplateDelegate calls back into
// CarPlayManager.startTrip(_:using:) automatically.

routeChoice.mapMapRouteResult recovers the original RouteResult when the driver selects or starts a choice, since CarPlay only round-trips CPRouteChoice, not your own route objects.

Voice prompts

CarPlayAudioSession.configureForVoicePrompts() sets the AVAudioSession category/mode/options combination (.playback / .voicePrompt / .duckOthers + .interruptSpokenAudioAndMixWithOthers) that ducks the car's current audio instead of fighting with it. Call it once navigation starts and deactivate() when it ends; MapMapKit does not synthesise speech itself; feed SpokenPrompt.text/.ssml to your own AVSpeechSynthesizer, as on the phone.

Testing: the CarPlay Simulator

The CarPlay Simulator (Xcode → Open Developer Tool → Additional Tools for Xcode) is the serious testing tool; the external-display CarPlay support built into the iOS Simulator app is fine for a quick smoke test but is not a substitute. The com.apple.developer.carplay-maps entitlement gates the CarPlay Simulator too, so request it before you plan to start CarPlay QA, not after.

Known iOS constraints

  • Lane guidance is an approximation, not a port. OSRM lane directions ("slight right", "straight", …) are discrete buckets, not measured angles; CPLaneGuidanceConverter maps each bucket to a representative angle. CPLaneGuidance/the non-deprecated CPLane initialiser need iOS 18.0+; nothing is shown below that.
  • No CPSearchTemplate yet. If you add your own, check sessionConfiguration.limitedUserInterfaces first (via CarPlayManager.sessionConfiguration); CarPlay units without a keyboard will otherwise dead-end the driver on a search screen.
  • One map view per connection. mapViewControllerFor: is called exactly once per scene connection; recreate it on the next sceneConnected rather than trying to reuse one across connections.
  • Light and dark map styles are required. Apple's CarPlay review guideline MR-1 requires a navigation app to support both a day and a night map style. CarPlayManager already tracks sessionConfiguration's contentStyle via CPSessionConfigurationDelegate; switch your MapLibre style in response rather than assuming light mode.
  • Screen-scale correction is your responsibility. MapLibre Native iOS computes its display scale from UIScreen.main, not the CarPlay screen (maplibre-native#3214). Apply CarPlayWindowScaleAdjuster when you build the map view, and again on reconnect; a swapped head unit can have a different native scale.

Next steps

  • SDKs & installation: the phone-side Android/iOS SDKs these modules build on, and early-access terms
  • Quickstart: issue an snk_ key and route a truck in two calls
  • Conventions: base URL, auth, error envelope, ADR tunnel codes