# 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](/docs/sdks)). 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](https://github.com/stadiamaps/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](https://developer.apple.com/documentation/carplay/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](https://developer.android.com/training/cars/apps/navigation)
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:

| Module | What it is |
| --- | --- |
| `:car` | Headless: `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-maplibre` | `CarMapViewHost`: 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](https://nvd.nist.gov/vuln/detail/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](https://developer.android.com/training/cars/testing/dhu)
(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 `RouteResult`s:

```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](https://developer.apple.com/documentation/carplay)
(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](https://github.com/maplibre/maplibre-native/issues/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](/docs/sdks): the phone-side Android/iOS SDKs
  these modules build on, and early-access terms
- [Quickstart](/docs/quickstart): issue an `snk_` key and route a truck
  in two calls
- [Conventions](/docs/conventions): base URL, auth, error envelope, ADR
  tunnel codes
