AndroidAuto

This module provides the infrastructure for building Android Auto navigation applications using the BeNomad MSDK. It handles the complex rendering pipeline (VirtualDisplay → Presentation → MapView), session lifecycle, screen navigation with state machine validation, phone ↔ AA bidirectional communication, gesture handling, dark mode detection, overlay support, and vehicle positioning.

The client provides app-specific screens, providers (search, guidance, settings), and a MapView delegate — the SDK handles everything else.

Dependencies

This module depends on other BeNomad's modules :

  • Core

  • Maps

  • Navigation

  • Error Manager

  • Planner

Other module's dependencies :

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
implementation "androidx.car.app:app:1.4.0"

Getting Started

A minimal Android Auto integration requires 3 classes: a Session, a SurfaceCallback, and at least one Screen. The SDK provides base classes that handle all the boilerplate — you only implement the app-specific parts.

AndroidAutoConfig

Before creating any AA component, configure the SDK with your app's MainActivity class name. This is used to launch the phone app when AA connects before the phone app is running.

// In your Hilt module or Application.onCreate()
AndroidAutoConfig.mainActivityClassName = "com.example.myapp.MainActivity"

Session (MSDKSession)

Subclass MSDKSession and implement 3 abstract methods. The base class handles lifecycle, phone state observation, dark mode, deferred surface allocation, and screen navigation via the coordinator.

class MyNavSession : MSDKSession() {

override fun createMainScreen(): Screen {
return MyNavMainScreen(carContext)
}

override fun createSurfaceCallback(
carContext: CarContext,
holder: AndroidAutoSurfaceHolder?,
onMapReady: (() -> Unit)?
): SurfaceCallback {
return MyNavSurfaceCallback(carContext, holder, onMapReady)
}

override fun createScreenFactory(): AAScreenFactory = object : AAScreenFactory {
override fun createSearchScreen(carContext: CarContext) = MySearchScreen(carContext)
override fun createRoutePreviewScreen(carContext: CarContext) = MyRoutePreviewScreen(carContext)
override fun createNavigationScreen(carContext, title, demo, sync) =
MyNavigationScreen(carContext, title, demo, sync)
}

// Optional: override launchPhoneApp() for custom phone app launch logic
}

Register your Session via a CarAppService:

class MyNavCarAppService : CarAppService() {
override fun createHostValidator() = HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
override fun onCreateSession() = MyNavSession()
}

Surface Callback (MSDKSurfaceCallback)

Subclass MSDKSurfaceCallback and pass a SurfaceCallbackConfig to customize rendering behavior. The base class handles the full VirtualDisplay → Presentation → MapView pipeline, gesture handling, dark mode observation, overlay management, and phone state observation.

class MyNavSurfaceCallback(
carContext: CarContext,
holder: AndroidAutoSurfaceHolder? = null,
onMapReady: (() -> Unit)? = null
) : MSDKSurfaceCallback(
carContext = carContext,
config = SurfaceCallbackConfig(
overlayProvider = MySpeedLimitOverlay(),
defaultCenterOnZoomLevel = 14.0
),
androidAutoSurfaceHolder = holder,
onMapReady = onMapReady
)

Screen Factory (AAScreenFactory)

The AAScreenFactory interface decouples screen creation from the SDK's navigation coordinator. Implement it to provide your app-specific screens.

interface AAScreenFactory {
fun createSearchScreen(carContext: CarContext): Screen
fun createRoutePreviewScreen(carContext: CarContext): Screen
fun createNavigationScreen(
carContext: CarContext,
destinationTitle: String,
useDemoMode: Boolean,
isSyncPush: Boolean = false
): Screen
// Optional: for custom navigation states
fun createCustomScreen(carContext: CarContext, state: AANavigationState.Custom): Screen? = null
}

Providers

The SDK defines provider interfaces for all features that require app-specific logic. Implement these and share them via AndroidAutoSurfaceHolder.

MapController

Controls the AA MapView camera. Typically wraps your app's MapViewModel.

class MyMapController(private val mapViewModel: MapViewModel) : MapController {
override fun centerOn(longitude: Double, latitude: Double, zoomLevel: Double, tilt: Double) {
mapViewModel.centerOn(longitude, latitude, zoomLevel, tilt)
}
override fun zoomIn() { mapViewModel.zoomIn() }
override fun zoomOut() { mapViewModel.zoomOut() }
}

MapViewDelegate

Handles MapView initialization and state observation. The SDK's SurfaceCallback uses this to set up the AA MapView without depending on your app's ViewModel.

class MyMapViewDelegate(private val mapViewModel: MapViewModel) : MapViewDelegate {
override fun initializeMapView(mapView: MapView) {
mapViewModel.initializeMapView(mapView)
}
override fun startMapStateObservation(mapView: MapView, scope: CoroutineScope): Job {
return scope.launch {
mapViewModel.uiStateSharedFlow.collect { state ->
mapView.updateMapState(state.mapState)
mapView.redraw(drawTexts = true, tryLock = false)
}
}
}
override fun applyDarkModeStyle(isDark: Boolean) {
mapViewModel.setExternalMapStyleOverride(if (isDark) "Night" else "Day")
}
override fun getDefaultZoomLevel(): Double = 14.0
}

applyDarkModeStyle(isDark) receives the mode the car map must be drawn in: the car's day/night mode (CarContext.isDarkMode, read when the session is created, started and re-opened, when a car surface becomes available, then on every configuration change), unless the user chose to keep the map in day or night colours (see #DayNightMode). It is called when that mode changes, and for each car MapView the SDK brings up, just before initializeMapView — so the implementation must not depend on initializeMapView having run, and the state initializeMapView applies already carries the mode. An exception it throws is logged and does not stop the MapView's bring-up.

A host whose Session does not extend MSDKSession gets the car's mode read when each surface becomes available, but must forward later changes itself: call AndroidAutoStateHolder.setCarDarkMode(carContext.isDarkMode) from its Session.onCarConfigurationChanged.

SearchProvider

Provides search functionality for the AA search screen.

class MySearchProvider(
private val geocoder: OnlineGeoCoder,
private val favoritesDao: FavoriteAddressesDao
) : SearchProvider {
override val searchState: StateFlow<SearchProviderState> = _searchState.asStateFlow()

override fun search(query: String) {
// Perform geocoding search, emit results via _searchState
}
override fun clearSearch() { _searchState.value = SearchProviderState() }
}

GuidanceProvider

Provides navigation guidance data for the AA navigation screen. Extend BaseGuidanceProviderAdapter for built-in icon processing, lifecycle detection, and state flow management.

class MyGuidanceAdapter(
private val guidanceViewModel: GuidanceViewModel,
scope: CoroutineScope
) : BaseGuidanceProviderAdapter(scope = scope, iconsRenderedForDarkMode = false) {

init { startObservation() }

override fun observeGuidanceState(): Flow<AAGuidanceState> {
return snapshotFlow { guidanceViewModel.uiState }.map { state ->
AAGuidanceState(
maneuverIcon = processIconForAA(state.icon, isChained = false),
instruction = state.nextInstruction,
distanceToNextInstruction = state.distanceToNext,
eta = state.eta,
isGuidanceReady = state.isReady,
// ... map all fields
)
}
}

override fun stopNavigation() { guidanceViewModel.stopGuidance() }
override fun toggleMute() { guidanceViewModel.toggleMute() }
override fun recenter() { guidanceViewModel.recenter() }
}

QuickAccessProvider

Provides Home, Work, and Recent destinations for quick navigation from the AA search screen.

class MyQuickAccessProvider(
private val getHome: GetHomeAddress,
private val getWork: GetWorkAddress,
private val recentPrefs: AndroidAutoPreferences
) : QuickAccessProvider {
override val quickAccessState: StateFlow<QuickAccessState> = _state.asStateFlow()
// Observe Room database for Home/Work, SharedPreferences for recents
}

SettingsProvider

Provides read/write access to navigation and routing settings for the AA settings screen.

class MySettingsProvider(
private val guidancePrefs: GuidancePreferences,
private val routingPrefs: RoutingPreferences
) : SettingsProvider {
override val settingsState: StateFlow<AASettingsState> = _state.asStateFlow()

override fun updateSetting(key: AASettingKey, enabled: Boolean) {
when (key) {
AASettingKey.TTS -> guidancePrefs.saveTTSEnabled(enabled)
AASettingKey.HIGHWAY -> routingPrefs.saveHighwayEnabled(enabled)
// ... handle all 8 keys
}
}
override fun refresh() { /* reload all prefs */ }
override fun dispose() { /* cancel scope */ }
override fun reinitialize() { /* recreate scope, reload */ }
}

Day/night mode setting

By default the car map follows the car: dark when the car is in night mode, light otherwise (AADayNightMode.AUTO). A host can also let the user keep the map in day or night colours at all times (AADayNightMode.DAY / NIGHT), as the Android Auto quality guidelines allow (MR-1):

  • show AASettingsState.mapDayNightMode on its settings screen and call SettingsProvider.updateMapDayNightMode(mode) when the user picks one;

  • in the provider, persist the choice, emit the new state, and hand it to the SDK — which draws the car map with its own copy, AAMapState.dayNightMode:

class MySettingsProvider(
private val prefs: MyAndroidAutoPreferences, // where the app persists the choice
private val stateHolder: AndroidAutoStateHolder // the SDK's Android Auto state
) : SettingsProvider {
// ...
override fun updateMapDayNightMode(mode: AADayNightMode) {
prefs.saveDayNightMode(mode)
_state.update { it.copy(mapDayNightMode = mode) }
stateHolder.setDayNightMode(mode) // restyles the car map, or the next one the SDK builds
}

private fun loadAllSettings() {
val mode = prefs.loadDayNightMode()
_state.value = AASettingsState(/* ... */ mapDayNightMode = mode)
stateHolder.setDayNightMode(mode) // the SDK keeps no copy across process restarts
}
}

updateMapDayNightMode defaults to a no-op and mapDayNightMode to AUTO, so a host that does not offer the setting needs no change. Adding mapDayNightMode is a source-compatible change for Kotlin callers, not a binary one: recompile against this SDK, and Java callers of the full AASettingsState constructor pass the new argument.

State Management

AndroidAutoStateHolder

The source of truth for all Android Auto state. Provides three focused sub-holders:

  • connection (AAConnectionState): Connection lifecycle, phone state, callbacks

  • screen (AAScreenStateHolder): Screen state, loading lifecycle with guards, guidance session

  • map (AAMapState): Camera following, zoom handlers, the car's dark mode and the user's day/night choice, split screen

// In Hilt-injectable contexts (Activity, ViewModel):
@Inject
lateinit var stateHolder: AndroidAutoStateHolder

// Set phone state
stateHolder.connection.setPhoneState(PhoneAppState.READY)
// Read AA screen state
val screenState = stateHolder.screen.aaScreenState.value
// Track phone equivalent state for AA sync on reconnect
stateHolder.screen.setPhoneEquivalentAAState(AndroidAutoScreenState.NAVIGATION)

AndroidAutoSurfaceManager

Static facade for framework-created components (Session, SurfaceCallback, Screens) that cannot use Hilt injection. Delegates to AndroidAutoStateHolder.

// In AA screens or non-injectable contexts:
val connectionState = AndroidAutoSurfaceManager.connectionState
val screenState = AndroidAutoSurfaceManager.screenState
val mapState = AndroidAutoSurfaceManager.mapState

// Or use the Hilt provider to get the state holder:
@Provides @Singleton
fun provideStateHolder(): AndroidAutoStateHolder {
return AndroidAutoSurfaceManager.getOrCreateStateHolder()
}

Phone ↔ AA Communication

PhoneAppCallback

Register a PhoneAppCallback to receive events from Android Auto on the phone side. The interface is split into 4 focused sub-interfaces:

private val phoneAppCallback = object : PhoneAppCallback {
// AAConnectionCallback
override fun onAAConnected() { /* AA session created */ }
override fun onAADisconnected() { /* AA session destroyed */ }
override fun onAAScreenStateChanged(state: AndroidAutoScreenState) { /* screen changed */ }

// AASurfaceLifecycleCallback
override fun onAARequestingInit() { /* AA ready to create MapView */ }
override fun onAAReleasing() { /* AA MapView being released */ }

// AANavigationCallback
override fun onAANavigationStarted(useDemoMode: Boolean) { /* guidance started on AA */ }
override fun onAANavigationEnded() { /* guidance ended on AA */ }
override fun onAARequestsMapReset() { /* reset MapView to tracking mode */ }
override fun isDemoModeEnabled(): Boolean = false

// AARouteCallback
override fun onDestinationSelected(latitude: Double, longitude: Double, title: String) { }
override fun onAARouteSelected(index: Int) { }
override fun onAARouteCancelled() { }
override fun onAARecenterOnRoute() { }
}

// Register in Activity.onCreate():
stateHolder.connection.setPhoneAppCallback(phoneAppCallback)

PhoneToAACallback

The phone-to-AA callback is automatically wired by MSDKSession via the AANavigationCoordinator. The phone sends commands via:

stateHolder.connection.phoneToAACallback?.onPhoneRequestsSearch()
stateHolder.connection.phoneToAACallback?.onPhoneSelectsDestination(lat, lon, title)
stateHolder.connection.phoneToAACallback?.onPhoneStartsNavigation(title, demoMode)
stateHolder.connection.phoneToAACallback?.onPhoneStopsNavigation()

Configuration

SurfaceCallbackConfig

Central configuration for all SurfaceCallback behaviors:

val config = SurfaceCallbackConfig(
overlayProvider = MySpeedLimitOverlay(), // Custom overlay (null = no overlay)
vehiclePositionStrategy = DefaultVehiclePositionStrategy(), // Vehicle icon positioning
gestureConfig = GestureConfig(
scrollEnabled = true, // Pan gestures
scaleEnabled = true, // Pinch-to-zoom
flingEnabled = false // Momentum scrolling (disabled by default)
),
defaultCenterOnZoomLevel = 13.0, // Initial zoom when centering on GPS
zoomButtonStep = 1.0, // Zoom level change per button tap
maxZoomLevel = 22.5, // Maximum allowed zoom
zoomToRectInsets = ZoomToRectInsets( // Margins for route camera fitting
left = 0.10f, right = 0.10f,
top = 0.05f, bottom = 0.05f
),
mapViewDestroyDelayMs = 100L, // Delay before MapView.onDestroy
mapCreationMaxRetries = 10, // Retries for MapView creation
mapCreationRetryDelayMs = 500L // Delay between retries
)

OverlayViewProvider

Add custom views on top of the MapView (speed limit signs, compass, ADAS info). The SDK delegates visibility entirely to the provider:

class MyOverlay : OverlayViewProvider {
override fun createOverlayView(context: Context): View? = MyOverlayView(context)

override fun getLayoutParams(context: Context): FrameLayout.LayoutParams {
val size = (60 * context.resources.displayMetrics.density).toInt()
return FrameLayout.LayoutParams(size, size).apply {
gravity = Gravity.BOTTOM or Gravity.START
}
}

override fun shouldShowOverlay(
guidanceState: AAGuidanceState?,
settingsState: AASettingsState?,
screenState: AndroidAutoScreenState
): Boolean {
// Client decides when the overlay is visible
return screenState == AndroidAutoScreenState.NAVIGATION
}

override fun onGuidanceStateChanged(guidanceState: AAGuidanceState) {
// Update overlay content
}
}

Custom Navigation States

Extend the navigation flow with app-specific screens (Payment, POI Details, etc.):

class MyNavSession : MSDKSession() {

override fun provideAdditionalTransitions() = mapOf(
// RoutePreview → Payment screen
Pair(AANavigationState.RoutePreview, AANavigationAction.Custom("OpenPayment"))
to AANavigationState.Custom("Payment"),
// Payment → back to RoutePreview
Pair(AANavigationState.Custom("Payment"), AANavigationAction.Back)
to AANavigationState.RoutePreview
)

override fun createScreenFactory() = object : AAScreenFactory {
// ... standard screens ...
override fun createCustomScreen(carContext: CarContext, state: AANavigationState.Custom): Screen? {
return when (state.customScreenName) {
"Payment" -> PaymentScreen(carContext)
else -> null
}
}
}
}

Utilities

TripBuilder

Builds Trip objects from AAGuidanceState for NavigationManager.updateTrip() (instrument cluster display). Subclass for custom formatting:

val tripBuilder = TripBuilder()
val trip = tripBuilder.buildTrip(guidanceState, destinationTitle)
if (trip != null) {
navigationManager.updateTrip(trip)
}

ManeuverMapper

Maps MSDK Instruction types to Android Auto Maneuver.TYPE_* constants using a 4-level priority cascade. Subclass for region-specific mapping:

val mapper = ManeuverMapper()
val maneuverType = mapper.toAAManeuverType(instruction, ManeuverMapperConfig())
val exitNumber = mapper.getRoundaboutExitNumber(instruction)

TemplateRateLimiter

Prevents overwhelming the Android Auto host with rapid template invalidations during guidance updates:

val rateLimiter = TemplateRateLimiter(
scope = coroutineScope,
minIntervalMs = 100 // Max 10 updates/second
)

// Normal updates (throttled):
rateLimiter.requestInvalidate { invalidate() }

// Critical updates (immediate):
rateLimiter.forceInvalidate { invalidate() }

MapViewLifecycleHelper

Handles resetting the MSDK MapView to tracking mode after guidance ends. Must be called from Dispatchers.IO:

withContext(Dispatchers.IO) {
MapViewLifecycleHelper.resetToTrackingMode(
applicationContext = context,
restoreLocationSource = { /* restore GPS source */ }
)
}

Packages

Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard