Skip to content

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, source distribution. The car modules are not in any published SDK artefact yet: there is no ai.mapmap:car on Maven Central or GitHub Packages, and no MapMapCarPlay product in the public Mapmapai/mapmap-ios package. Today you build them from a source checkout (see the install notes for each platform below); public artefacts land with sdk-v0.7.0, after head-unit hardware validation. Source access is on the same early-access-while-private terms as the rest of the SDKs (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.

Getting the modules today

ai.mapmap:core resolves from Maven Central, but ai.mapmap:car and ai.mapmap:car-maplibre are not published to any remote repository yet. Until sdk-v0.7.0 they come from the source checkout, published into your local Maven repository:

bash
# in the Android workspace of the SDK source checkout
./gradlew :car:publishToMavenLocal :car-maplibre:publishToMavenLocal

Both modules take their version from mapmap.sdkVersion (the same tag-driven property :core uses), which currently defaults to 0.3.0 in the source tree, so that is what a local publish installs unless you pass -Pmapmap.sdkVersion= yourself. Check android/car/build.gradle.kts if in doubt, then add mavenLocal() to the consuming app alongside the repositories it already declares:

kotlin
// settings.gradle.kts, inside dependencyResolutionManagement
repositories {
    google()
    mavenCentral() // ai.mapmap:core
    mavenLocal()   // ai.mapmap:car and :car-maplibre, until sdk-v0.7.0
}
kotlin
// app/build.gradle.kts
dependencies {
    implementation("ai.mapmap:core:0.3.0")        // Maven Central
    implementation("ai.mapmap:car:0.3.0")         // mavenLocal, built from source
    implementation("ai.mapmap:car-maplibre:0.3.0") // optional, needs MapLibre on the classpath
}

A Gradle composite build (includeBuild("path/to/android") in your settings.gradle.kts) works just as well and skips the publish step; use whichever suits your CI. Either way, pin the source checkout to a known commit: nothing here is a versioned release yet, and both approaches stop being necessary once sdk-v0.7.0 publishes the artefacts.

: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

  • Distances default to metric. Imperial (feet and miles) and UK (yards and miles) output is available but opt-in: pass CarDistanceUnits.LOCALE_DEFAULT (or an explicit case) to NavigationManagerBridge, NavigationTemplateBuilder.setDistanceUnits or the toCar* converters. The no-units overloads stay metric, so existing integrations are unaffected.
  • 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 product of the MapMapKit Swift package, alongside MapMapKit and MapMapValhalla.

MapMapCarPlay is not in the public Mapmapai/mapmap-ios distribution package. Adding .package(url: …/mapmap-ios, from: "0.6.0") and asking for a MapMapCarPlay product will not resolve. Until sdk-v0.7.0 the product exists only in the SDK source checkout, referenced as a local SwiftPM path dependency:

swift
// Package.swift of your app or feature module
.package(path: "path/to/MapMap/ios/MapMapKit"),
// …
.product(name: "MapMapKit", package: "MapMapKit"),
.product(name: "MapMapCarPlay", package: "MapMapKit"),

The path dependency needs the FFI binary built first: run ios/build-xcframework.sh in the checkout, which produces the MapMapFFI.xcframework the package's binary target points at. The CarPlay product and its tests exist only in the iOS branch of the manifest, so build and test them against an iOS Simulator destination, never plain swift build on macOS.

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