Documentation menu
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.); seeTHIRD-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:
<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:
| 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. |
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
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):
<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 LibraryDistanceis metric-only for now (no locale-aware imperial formatting yet); revisit once a locale formatter lands in:core. - One map instance per display.
CarMapViewHosttears down and rebuilds theVirtualDisplay/Presentationfrom scratch on everyonSurfaceAvailablecall 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:
.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:
<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:
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:
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;CPLaneGuidanceConvertermaps each bucket to a representative angle.CPLaneGuidance/the non-deprecatedCPLaneinitialiser need iOS 18.0+; nothing is shown below that. - No
CPSearchTemplateyet. If you add your own, checksessionConfiguration.limitedUserInterfacesfirst (viaCarPlayManager.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 nextsceneConnectedrather 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.
CarPlayManageralready trackssessionConfiguration'scontentStyleviaCPSessionConfigurationDelegate; switch your MapLibre style in response rather than assuming light mode. - Screen-scale correction is your responsibility.
MapLibre NativeiOS computes its display scale fromUIScreen.main, not the CarPlay screen (maplibre-native#3214). ApplyCarPlayWindowScaleAdjusterwhen 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