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, source distribution. The car modules are not in any published SDK artefact yet: there is no
ai.mapmap:caron Maven Central or GitHub Packages, and noMapMapCarPlayproduct in the publicMapmapai/mapmap-iospackage. Today you build them from a source checkout (see the install notes for each platform below); public artefacts land withsdk-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.); 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. |
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:
# 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:
// settings.gradle.kts, inside dependencyResolutionManagement
repositories {
google()
mavenCentral() // ai.mapmap:core
mavenLocal() // ai.mapmap:car and :car-maplibre, until sdk-v0.7.0
}
// 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
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
- 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) toNavigationManagerBridge,NavigationTemplateBuilder.setDistanceUnitsor thetoCar*converters. The no-units overloads stay metric, so existing integrations are unaffected. - 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 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:
// 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:
<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