1. Before you begin
What this isn't
- A guide on how to create media (audio - e.g. music, radio, podcasts) apps for Android Auto and Android Automotive OS. See Build media apps for cars for details on how to build such apps.
What you'll need
- Android Studio Preview. Some of the Android Automotive OS emulators used in this codelab are only available via Android Studio Preview. If you don't have Android Studio Preview installed yet, you can start the codelab with the stable release while the preview version downloads.
- Experience with basic Kotlin.
- Experience creating Android Virtual Devices and running them in the Android Emulator.
- Basic knowledge of Jetpack Compose.
- An understanding of Side-effects
- Basic familiarity with window insets.
What you'll build
In this codelab, you'll learn how to migrate an existing video streaming mobile app, Road Reels, to Android Automotive OS.
The starting point version of the app running on a phone | The completed version of the app running on an Android Automotive OS emulator with a display cutout. |
What you'll learn
- How to use the Android Automotive OS emulator.
- How to make the changes required to create an Android Automotive OS build
- Common assumptions made when developing apps for mobile that may be broken when an app runs on Android Automotive OS
- The different quality tiers for apps in cars
- How to use media session to enable other apps to control your app's playback
- How system UI and window insets may differ on Android Automotive OS devices as compared to mobile devices
2. Get set up
Get the code
- The code for this codelab can be found in the
build-a-parked-app
directory within thecar-codelabs
GitHub repository. To clone it, run the following command:
git clone https://github.com/android/car-codelabs.git
- Alternatively, you can download the repository as a ZIP file:
Open the project
- After starting Android Studio, import the project, selecting just the
build-a-parked-app/start
directory. Thebuild-a-parked-app/end
directory contains the solution code, which you can reference at any point if you get stuck or just want to see the full project.
Familiarize yourself with the code
- After opening the project in Android studio, take some time to look through the starting code.
3. Learn about parked apps for Android Automotive OS
Parked apps make up a subset of the app categories supported by Android Automotive OS. At the time of writing, they consist of video streaming apps, web browsers, and games. These apps are a great fit in cars given the hardware present in vehicles with Google built-in and the increasing prevalence of electric vehicles, in which charging time represents a great opportunity for drivers and passengers to engage with these types of apps.
In many ways, cars are similar to other large screen devices like tablets and foldables. They have touchscreens with similar sizes, resolutions, and aspect ratios and which may be in either portrait or landscape orientation (though, unlike tablets, their orientation is fixed). They are also connected devices which may come in and out of network connection. With all that in mind, it's not surprising that apps which are already optimized for large screens often require a minimal amount of work to bring a great user experience to cars.
Similar to large screens, there are also app quality tiers for apps in cars:
- Tier 3 - Car ready: Your app is large screen compatible and can be used while the car is parked. While it may not have any car-optimized features, users can experience the app just as they would on any other large screen Android device. Mobile apps that meet these requirements are eligible to be distributed to cars as-is through the Car ready mobile apps program.
- Tier 2 - Car optimized: Your app provides a great experience on the car's center stack display. To accomplish this, your app will have some car-specific engineering to include capabilities that can be used across driving or parked modes, depending on your app's category.
- Tier 1- Car differentiated: Your app is built to work across the variety of hardware in cars and can adapt its experience across driving and parked modes. It provides the best user experience designed for the different screens in cars such as the center console, instrument cluster, and additional screens - like panoramic displays seen in many premium cars.
4. Run the app in the Android Automotive OS emulator
Install the Automotive with Play Store System images
- First, open the SDK Manager in Android Studio and select the SDK Platforms tab if it is not already selected. In the bottom-right corner of the SDK Manager window, make sure that the Show package details box is checked.
- Install one of the Automotive with Play Store emulator images listed in Add generic system images. Images can only run on machines with the same architecture (x86/ARM) as themselves.
Create an Android Automotive OS Android Virtual Device
- After opening the Device Manager, select Automotive under the Category column on the left side of the window. Then, select the Automotive (1024p landscape) bundled hardware profile from the list and click Next.
- On the next page, select the system image from the previous step. Click Next and select any advanced options you want before finally creating the AVD by clicking Finish. Note: if you chose the API 30 image, it may be under a tab other than the Recommended tab.
Run the app
Run the app on the emulator you just created using the existing app
run configuration. Play around with the app to go through the different screens and compare how it behaves compared to running the app on a phone or tablet emulator.
5. Create an Android Automotive OS build
Though the app "just works", there are a few small changes that need to be made for it to work well on Android Automotive OS and meet the requirements to be publishable on the Play Store. Not all of these changes make sense to include in the mobile version of the app, so you'll first create an Android Automotive OS build variant.
Add a form factor flavor dimension
To begin, add a flavor dimension for the form factor targeted by the build by modifying the flavorDimensions
in the build.gradle.kts
file. Then, add a productFlavors
block and flavors for each form factor (mobile
and automotive
).
For more information, see Configure product flavors.
build.gradle.kts (Module :app)
android {
...
flavorDimensions += "formFactor"
productFlavors {
create("mobile") {
// Inform Android Studio to use this flavor as the default (e.g. in the Build Variants tool window)
isDefault = true
// Since there is only one flavor dimension, this is optional
dimension = "formFactor"
}
create("automotive") {
// Since there is only one flavor dimension, this is optional
dimension = "formFactor"
// Adding a suffix makes it easier to differentiate builds (e.g. in the Play Console)
versionNameSuffix = "-automotive"
}
}
...
}
After updating the build.gradle.kts
file, you should see a banner at the top of the file informing you that "Gradle files have changed since the last project sync. A project sync may be necessary for the IDE to work properly". Click the Sync Now button in that banner so that Android Studio can import these build configuration changes.
Next, open the Build Variants tool window from the Build > Select Build Variant... menu item and select the automotiveDebug
variant. This will ensure that you see the files for automotive
source set in the Project window and that this build variant is used when running the app through Android Studio.
Create an Android Automotive OS manifest
Next, you'll create an AndroidManifest.xml
file for the automotive
source set. This file contains the necessary elements required of Android Automotive OS apps.
- In the Project window, right click on the
app
module. From the dropdown that appears, select New > Other > Android Manifest File - In the New Android Component window that opens, select
automotive
as the Target Source Set for the new file. Click Finish to create the file.
- Within the
AndroidManifest.xml
file that was just created (under the pathapp/src/automotive/AndroidManifest.xml
), add the following:
AndroidManifest.xml (automotive)
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- https://developer.android.com/training/cars/parked#required-features -->
<uses-feature
android:name="android.hardware.type.automotive"
android:required="true" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />
<uses-feature
android:name="android.hardware.screen.landscape"
android:required="false" />
</manifest>
The first declaration is required to upload the build artifact to the Android Automotive OS track on the Play Console. This feature's presence is used by Google Play to only distribute the app to devices that have the android.hardware.type.automotive
feature (i.e. cars).
The other declarations are required in order to ensure that the app is installable on the various hardware configurations present in cars. For more details, see Required Android Automotive OS features.
Mark the app as a video app
The last piece of metadata that needs to be added is the automotive_app_desc.xml
file. This is used to declare the category of your app within the context of Android for Cars, and is independent of the category you select for your app in the Play Console.
- Right click the
app
module and select the New > Android Resource File option, and enter the following values before clicking OK:
- File name:
automotive_app_desc.xml
- Resource type:
XML
- Root element:
automotiveApp
- Source set:
automotive
- Directory name:
xml
- Within that file, add the following
<uses>
element in order to declare that your app is a video app.
automotive_app_desc.xml
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses
name="video"
tools:ignore="InvalidUsesTagAttribute" />
</automotiveApp>
- In the
automotive
source set'sAndroidManifest.xml
file (the one where you just added the<uses-feature>
elements), add an empty<application>
element. Within it, add the following<meta-data>
element that references theautomotive_app_desc.xml
file you just created.
AndroidManifest.xml (automotive)
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
...
<application>
<meta-data
android:name="com.android.automotive"
android:resource="@xml/automotive_app_desc" />
</application>
</manifest>
With that, you've made all of the changes necessary to create an Android Automotive OS build of the app!
6. Meet the Android Automotive OS quality requirements: Navigability
Though making an Android Automotive OS build variant is one part of bringing your app to cars, making sure the app is usable and safe to use is still necessary.
Add navigation affordances
While running the app in the Android Automotive OS emulator, you may have noticed that it wasn't possible to return from the detail screen to the main screen or from the player screen to the detail screen. Unlike other form factors, which may require a back button or a touch gesture to enable back navigation, there is no such requirement for Android Automotive OS devices. As such, apps must provide navigation affordances in their UI to ensure that users are able to navigate without getting stuck on a screen within the app. This requirement is codified as the AN-1 quality guideline.
To support back navigation from the detail screen to the main screen, add an additional navigationIcon
parameter for the detail screen's CenterAlignedTopAppBar
as follows:
RoadReelsApp.kt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
...
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
)
}
}
To support back navigation from the player screen to the main screen:
- Update the
TopControls
composable to take a callback parameter calledonClose
and add anIconButton
that calls it when clicked.
PlayerControls.kt
@Composable
fun TopControls(
title: String?,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier) {
IconButton(
modifier = Modifier
.align(Alignment.TopStart),
onClick = onClose
) {
Icon(
Icons.TwoTone.Close,
contentDescription = "Close player",
tint = Color.White
)
}
if (title != null) { ... }
}
}
- Update the
PlayerControls
composable to take also take aonClose
callback parameter and pass it on to theTopControls
PlayerControls.kt
fun PlayerControls(
visible: Boolean,
playerState: PlayerState,
onClose: () -> Unit,
onPlayPause: () -> Unit,
onSeek: (seekToMillis: Long) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = modifier.background(Color.Black.copy(alpha = .5f))) {
TopControls(
modifier = Modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.screen_edge_padding))
.align(Alignment.TopCenter),
title = playerState.mediaMetadata.title?.toString(),
onClose = onClose
)
...
}
}
}
- Next, update the
PlayerScreen
composable to take the same parameter, and pass it down to itsPlayerControls
.
PlayerScreen.kt
@Composable
fun PlayerScreen(
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
...
PlayerControls(
modifier = Modifier
.fillMaxSize(),
visible = isShowingControls,
playerState = playerState,
onClose = onClose,
onPlayPause = { if (playerState.isPlaying) player.pause() else player.play() },
onSeek = { player.seekTo(it) }
)
}
- Finally, in the
RoadReelsNavHost
, provide the implementation that gets passed to thePlayerScreen
:
RoadReelsNavHost.kt
composable(route = Screen.Player.name) {
PlayerScreen(onClose = { navController.popBackStack() })
}
Awesome, now the user can move between screens without running into any dead-ends! And, the user experience may even be better on other form factors as well – for example, on a tall phone when the user's hand is already near the top of the screen, they can more easily navigate through the app without needing to move their device in their hand.
Adapt to screen orientation support
Unlike the vast majority of mobile devices, most cars are fixed orientation. That is, they support either landscape or portrait, but not both, since their screens cannot be rotated. Because of this, apps should avoid assuming that both orientations are supported.
In Create an Android Automotive OS manifest, you added two <uses-feature>
elements for the android.hardware.screen.portrait
and android.hardware.screen.landscape
features with the required
attribute set to false
. Doing that ensures that no implicit feature dependency on either screen orientation can prevent the app from being distributed to cars. However, those manifest elements don't change the behavior of the app, just how it's distributed.
Currently, the app has a helpful feature where it automatically sets the orientation of the activity to landscape when the video player opens, making it so that phone users don't have to fiddle with their device to change its orientation if it's not already landscape.
Unfortunately, that same behavior can result in a flickering loop or letterboxing on devices that are fixed portrait orientation, which includes many cars on the road today.
To fix this, you can add a check based on the screen orientations that the current device supports.
- To simplify the implementation, first add the following in
Extensions.kt
:
Extensions.kt
import android.content.Context
import android.content.pm.PackageManager
...
enum class SupportedOrientation {
Landscape,
Portrait,
}
fun Context.supportedOrientations(): List<SupportedOrientation> {
return when (Pair(
packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_LANDSCAPE),
packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_PORTRAIT)
)) {
Pair(true, false) -> listOf(SupportedOrientation.Landscape)
Pair(false, true) -> listOf(SupportedOrientation.Portrait)
// For backwards compat, if neither feature is declared, both can be assumed to be supported
else -> listOf(SupportedOrientation.Landscape, SupportedOrientation.Portrait)
}
}
- Then, guard the call to set the requested orientation. Since apps can run into a similar issue in multi-window mode on mobile devices, you can also include a check to not dynamically set the orientation in that case either.
PlayerScreen.kt
import com.example.android.cars.roadreels.SupportedOrientation
import com.example.android.cars.roadreels.supportedOrientations
...
LaunchedEffect(Unit) {
...
// Only automatically set the orientation to landscape if the device supports landscape.
// On devices that are portrait only, the activity may enter a compat mode and won't get to
// use the full window available if so. The same applies if the app's window is portrait
// in multi-window mode.
if (context.supportedOrientations().contains(SupportedOrientation.Landscape)
&& !context.isInMultiWindowMode
) {
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
...
}
The player screen enters a flickering loop on the Polestar 2 emulator before adding the check (when the activity does not handle | The player screen is letterboxed on the Polestar 2 emulator before adding the check (when the activity handles | The player screen is not letterboxed on the Polestar 2 emulator after adding the check |
Since this is the only location in the app that sets the screen orientation, the app now avoids letterboxing! In your own app, check for any screenOrientation
attributes or setRequestedOrientation
calls that are for landscape or portrait orientations only (including the sensor, reverse, and user variants of each) and remove or guard them as necessary to limit letterboxing. For more details, see Device compatibility mode.
Adapt to system bar controllability
Unfortunately, although the previous change ensures the app doesn't enter a flickering loop or get letterboxed, it also exposes another assumption that's been broken – namely, that system bars can always be hidden! Because users have different needs when using their car (as compared to using their phone or tablet), OEMs have the option of preventing apps from hiding the system bars to ensure that vehicle controls, such as climate controls, are always accessible on screen.
As a result, there's the potential for apps to render behind the system bars when they are rendering in immersive mode and assume that the bars can be hidden. You can see this in the previous step, as the top and bottom player controls are no longer visible when the app is not letterboxed! In this specific instance, the app is no longer navigable as the button to close the player is obscured and its functionality is impeded since the seek bar cannot be used.
The easiest fix would be to apply the systemBars
window insets padding to the player as follows:
PlayerScreen.kt
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
...
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
) {
PlayerView(...)
PlayerControls(...)
}
However, this solution isn't ideal as it causes the UI elements to jump around as the system bars animate away.
To improve the user experience, you can update the app to keep track of which insets can be controlled and apply padding only for the insets that can't be controlled.
- Since other screens within the app could be interested in controlling window insets, it makes sense to pass the controllable insets as a
CompositionLocal
. Create a new file,LocalControllableInsets.kt
, in thecom.example.android.cars.roadreels
package and add the following:
LocalControllableInsets.kt
import androidx.compose.runtime.compositionLocalOf
// Assume that no insets can be controlled by default
const val DEFAULT_CONTROLLABLE_INSETS = 0
val LocalControllableInsets = compositionLocalOf { DEFAULT_CONTROLLABLE_INSETS }
- Set up an
OnControllableInsetsChangedListener
to listen for changes.
MainActivity.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat.OnControllableInsetsChangedListener
...
class MainActivity : ComponentActivity() {
private lateinit var onControllableInsetsChangedListener: OnControllableInsetsChangedListener
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
var controllableInsetsTypeMask by remember { mutableIntStateOf(DEFAULT_CONTROLLABLE_INSETS) }
onControllableInsetsChangedListener =
OnControllableInsetsChangedListener { _, typeMask ->
if (controllableInsetsTypeMask != typeMask) {
controllableInsetsTypeMask = typeMask
}
}
WindowCompat.getInsetsController(window, window.decorView)
.addOnControllableInsetsChangedListener(onControllableInsetsChangedListener)
RoadReelsTheme {
RoadReelsApp(calculateWindowSizeClass(this))
}
}
}
override fun onDestroy() {
super.onDestroy()
WindowCompat.getInsetsController(window, window.decorView)
.removeOnControllableInsetsChangedListener(onControllableInsetsChangedListener)
}
}
- Add a top level
CompositionLocalProvider
that contains the theme and app composables and which binds values toLocalControllableInsets
.
MainActivity.kt
import androidx.compose.runtime.CompositionLocalProvider
...
CompositionLocalProvider(LocalControllableInsets provides controllableInsetsTypeMask) {
RoadReelsTheme {
RoadReelsApp(calculateWindowSizeClass(this))
}
}
- In the player, read the current value and use it to determine the insets to be used for padding.
PlayerScreen.kt
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.ui.unit.dp
import com.example.android.cars.roadreels.LocalControllableInsets
...
val controllableInsetsTypeMask = LocalControllableInsets.current
// When the system bars can be hidden, ignore them when applying padding to the player and
// controls so they don't jump around as the system bars disappear. If they can't be hidden
// include them so nothing renders behind the system bars
var windowInsetsForPadding = WindowInsets(0.dp)
if (controllableInsetsTypeMask.and(WindowInsetsCompat.Type.statusBars()) == 0) {
windowInsetsForPadding = windowInsetsForPadding.union(WindowInsets.statusBars)
}
if (controllableInsetsTypeMask.and(WindowInsetsCompat.Type.navigationBars()) == 0) {
windowInsetsForPadding = windowInsetsForPadding.union(WindowInsets.navigationBars)
}
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(windowInsetsForPadding)
) {
PlayerView(...)
PlayerControls(...)
}
The content doesn't jump around when the system bars can be hidden | The content remains visible when the system bars can't be hidden |
Much better! The content doesn't jump around, at the same time, the controls are fully visible even on cars where the system bars can't be controlled.
7. Meet the Android Automotive OS quality requirements: Driver distraction
Finally, there's one major difference between cars and other form factors – they're used for driving! As such, limiting distractions while driving is very important. All parked apps for Android Automotive OS must pause playback when driving begins. A system overlay appears when driving begins and in turn, the onPause
lifecycle event is called for the app being overlaid. It is during this call that apps should pause playback.
Simulate driving
Navigate to the player view in the emulator and begin playing content. Then, follow the steps to simulate driving and notice that, while the app's UI is obscured by the system, playback does not pause. This is in violation of the DD-2 car app quality guideline.
Pause playback when driving begins
- Add a dependency on the
androidx.lifecycle:lifecycle-runtime-compose
artifact, which contains theLifecycleEventEffect
that helps run code on lifecycle events.
libs.version.toml
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
Build.gradle.kts (Module :app)
implementation(libs.androidx.lifecycle.runtime.compose)
- After syncing the project to download the dependency, add a
LifecycleEventEffect
that runs on theON_PAUSE
event to pause playback.
PlayerScreen.kt
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
...
@Composable
fun PlayerScreen(...) {
...
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) {
player.pause()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
player.play()
}
...
}
With the fix implemented, follow the same steps you did earlier to simulate driving during active playback and notice that the playback stops, meeting the DD-2 requirement!
8. Test the app in the distant display emulator
A new configuration that's beginning to appear in cars is a two screen set up with a primary screen in the center console and a secondary screen high up on the dashboard near the windshield. Apps can be moved from the center screen to the secondary screen and back to give drivers and passengers more options.
Install the Automotive Distant Display image
- First, open the SDK Manager in Android Studio and select the SDK Platforms tab if it is not already selected. In the bottom-right corner of the SDK Manager window, make sure that the Show package details box is checked.
- Install the Automotive Distant Display with Google APIs emulator image for your computer's architecture (x86/ARM).
Create an Android Automotive OS Android Virtual Device
- After opening the Device Manager, select Automotive under the Category column on the left side of the window. Then, select the Automotive Distant Display bundled hardware profile from the list and click Next.
- On the next page, select the system image from the previous step. Click Next and select any advanced options you want before finally creating the AVD by clicking Finish.
Run the app
Run the app on the emulator you just created using the existing app
run configuration. Follow the instructions in Use the distant display emulator to move the app to and from the distant display. Test moving the app both when it's on the main/detail screen and when it's on the player screen and trying to interact with the app on both screens.
9. Improve the app experience on the distant display
As you used the app on the distant display, you may have noticed two things:
- Playback restarts when the app is moved to and from the distant display
- You can't interact with the app while it's on the distant display, including changing the playback state.
Improve app continuity
The issue where playback restarts is caused by the activity being recreated due to a configuration change. Because the app is written using Compose and the configuration that's changing is size-related, it's straightforward to let Compose handle configuration changes for you by restricting activity recreation for size-based configuration changes. This makes the transition between displays seamless, with no stop in playback or reloading due to activity recreation.
AndroidManifest.xml
<activity
android:name="com.example.android.cars.roadreels.MainActivity"
...
android:configChanges="screenSize|smallestScreenSize|orientation|screenLayout|density">
...
</activity>
Implement playback controls
To fix the issue where the app can't be controlled while it's on the distant display, you can implement MediaSession
. Media sessions provide a universal way of interacting with an audio or video player. For more information, see Control and advertise playback using a MediaSession.
- Add a dependency on the
androidx.media3:media3-session
artifact
libs.version.toml
androidx-media3-mediasession = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
build.gradle.kts (Module :app)
implementation(libs.androidx.media3.mediasession)
- Create a
MediaSession
using its builder.
PlayerScreen.kt
import androidx.media3.session.MediaSession
@Composable
fun PlayerScreen(...) {
...
val mediaSession = remember(context, player) {
MediaSession.Builder(context, player).build()
}
...
}
- Then, add an additional line in the
onDispose
block of theDisposableEffect
in thePlayer
composable to release theMediaSession
when thePlayer
leaves the composition tree.
PlayerScreen.kt
DisposableEffect(Unit) {
onDispose {
mediaSession.release()
player.release()
...
}
}
- Finally, when on the player screen, you can test the media controls using the
adb shell cmd media_session dispatch
command
# To play content
adb shell cmd media_session dispatch play
# To pause content
adb shell cmd media_session dispatch pause
# To toggle the playing state
adb shell cmd media_session dispatch play-pause
With that, the app works much better in cars with distant displays! But more than that, it also works better on other form factors! On devices that can rotate their screen or allow users to resize an app's window, the app now seamlessly adapts in those situations as well.
Plus, thanks to the media session integration, the app's playback can be controlled not only by hardware and software controls in cars but also by other sources, such as a Google Assistant query or a pause button on a pair of headphones, giving users more options to control the app across form factors!
10. Test the app under different system configurations
With the app working well on the main display and distant display, the last thing to check is how the app handles different system bar configurations and display cutouts. As described in Work with window insets and display cutouts, Android Automotive OS devices may come in configurations that break assumptions that generally hold true on mobile form factors.
In this section, you'll download an emulator that can be configured at runtime, configure the emulator to have a left system bar, and test the app in that configuration.
Install the Android Automotive with Google APIs image
- First, open the SDK Manager in Android Studio and select the SDK Platforms tab if it is not already selected. In the bottom-right corner of the SDK Manager window, make sure that the Show package details box is checked.
- Install the API 33 Android Automotive with Google APIs emulator image for your computer's architecture (x86/ARM).
Create an Android Automotive OS Android Virtual Device
- After opening the Device Manager, select Automotive under the Category column on the left side of the window. Then, select the Automotive (1080p landscape) bundled hardware profile from the list and click Next.
- On the next page, select the system image from the previous step. Click Next and select any advanced options you want before finally creating the AVD by clicking Finish.
Configure a side system bar
As detailed in Test using the configurable emulator, there are a variety of options to emulate different system configurations present in cars.
For the purposes of this codelab, the com.android.systemui.rro.left
can be used to test a different system bar configuration. To enable it, use the following command:
adb shell cmd overlay enable --user 0 com.android.systemui.rro.left
Because the app is using the systemBars
modifier as the contentWindowInsets
in the Scaffold
, the content is already being drawn in an area safe of the system bars. To see what would happen if the app assumed that system bars only appeared on the top and bottom of the screen, change that parameter to be the following:
RoadReelsApp.kt
contentWindowInsets = if (route?.equals(Screen.Player.name) == true) WindowInsets(0.dp) else WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
Uh-oh! The list and detail screen renders behind the system bar. Thanks to the earlier work, the player screen would be OK, even if the system bars weren't controllable since.
Before moving on to the next section, be sure to revert the change you just made to the windowContentPadding
parameter!
11. Work with display cutouts
Finally, some cars have screens with display cutouts that are very different when compared to those seen on mobile devices. Instead of the notches or pinhole camera cutouts, some Android Automotive OS vehicles have curved screens that make the screen non-rectangular.
To see how the app behaves when such a display cutout is present, first enable the display cutout using the following command:
adb shell cmd overlay enable --user 0 com.android.internal.display.cutout.emulation.free_form
To really test how well the app behaves, also enable the left system bar used in the last section, if it isn't already:
adb shell cmd overlay enable --user 0 com.android.systemui.rro.left
As is, the app does not render into the display cutout (the exact shape of the cutout is difficult to tell currently, but will become clear in the next step). This is perfectly OK and a better experience than an app that does render into cutouts, but does not carefully adapt to them.
Render into the display cutout
To give your users the most immersive experience possible, you can make use of much more screen real estate by rendering into the display cutout.
- To render into the display cutout, create an
integers.xml
file to hold the override specific to cars. To do this, use the UI mode qualifier with the value Car Dock (the name is a holdover from when only Android Auto existed, but it also is used by Android Automotive OS). Additionally, becauseLAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
was introduced in Android R, also add the Android Version qualifier with the value 30. See Use alternate resources for more details.
- Within the file you just created (
res/values-car-v30/integers.xml
), add the following:
integers.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="windowLayoutInDisplayCutoutMode">3</integer>
</resources>
The integer value 3
corresponds to LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
and overrides the default value of 0
from res/values/integers.xml
, which corresponds to LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
. This integer value is already referenced in the MainActivity.kt
to override the mode set by enableEdgeToEdge()
. For more information on this attribute, see the reference documentation.
Now, when you run the app, notice that the content extends into the cutout and looks very immersive! However, the top app bar and some of the content is partially obscured by the display cutout, causing an issue similar to what happened when the app assumed system bars would only appear on the top and bottom.
Fix the top app bars
To fix the top app bars, you can add the following windowInsets
parameter to the CenterAlignedTopAppBar
Composables:
RoadReelsApp.kt
import androidx.compose.foundation.layout.safeDrawing
...
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
Since safeDrawing
consists of both the displayCutout
and systemBars
insets, this improves upon the default windowInsets
parameter, which only uses the systemBars
when positioning the top app bar.
Additionally, because the top app bar is positioned at the top of the window, you should not include the bottom component of the safeDrawing
insets – doing so could potentially add unnecessary padding.
Fix the main screen
One option to fix the content on the main and detail screens would be to use safeDrawing
instead of systemBars
for the contentWindowInsets
of the Scaffold
. However, the app looks decidedly less immersive using that option, with content abruptly being cut off where the display cutout begins – not much better than if the app didn't render into the display cutout at all.
For a more immersive user interface, you can handle the insets on each component within the screen.
- Update the
contentWindowInsets
of theScaffold
to constantly be 0dp (instead of just for thePlayerScreen
). This allows each screen and/or component within a screen to determine how it behaves with regards to insets.
RoadReelsApp.kt
Scaffold(
...,
contentWindowInsets = WindowInsets(0.dp)
) { ... }
- Set the
windowInsetsPadding
of the row headerText
composables to use the horizontal components of thesafeDrawing
insets. The top component of these insets is handled by the top app bar and the bottom component will be handled later.
MainScreen.kt
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
...
LazyColumn(
contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.screen_edge_padding))
) {
items(NUM_ROWS) { rowIndex: Int ->
Text(
"Row $rowIndex",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.padding(
horizontal = dimensionResource(R.dimen.screen_edge_padding),
vertical = dimensionResource(R.dimen.row_header_vertical_padding)
)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
)
...
}
- Remove the
contentPadding
parameter of theLazyRow
. Then, at the start and end of eachLazyRow
, add aSpacer
the width of the correspondingsafeDrawing
component to make sure all of the thumbnails can be fully viewed. Use thewidthIn
modifier to ensure these spacers are at least as wide as the content padding had been. Without these elements, items at the beginning and ends of the row might be occluded behind the system bars and/or display cutout, even when swiped fully to the beginning/end of the row.
MainScreen.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth
...
LazyRow(
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_item_spacing)),
) {
item {
Spacer(
Modifier
.windowInsetsStartWidth(WindowInsets.safeDrawing)
.widthIn(min = dimensionResource(R.dimen.screen_edge_padding))
)
}
items(NUM_ITEMS_PER_ROW) { ... }
item {
Spacer(
Modifier
.windowInsetsEndWidth(WindowInsets.safeDrawing)
.widthIn(min = dimensionResource(R.dimen.screen_edge_padding))
)
}
}
- Finally, add a
Spacer
to the end of theLazyColumn
to account for any potential system bars or display cutout insets at the bottom of the screen. There's no need for an equivalent spacer at the top of theLazyColumn
because the top app bar handles those. If the app used a bottom app bar instead of a top app bar, you would add aSpacer
at the start of the list using thewindowInsetsTopHeight
modifier. And if the app used both a top and bottom app bar, neither spacer would be needed.
MainScreen.kt
import androidx.compose.foundation.layout.windowInsetsBottomHeight
...
LazyColumn(...){
items(NUM_ROWS) { ... }
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
Perfect, the top app bars are entirely visible and, when you scroll to the end of a row, you can now see all of the thumbnails in their entirety!
Fix the detail screen
The detail screen isn't quite as bad, but content is still being cut off.
Since the detail screen doesn't have any scrollable content, all it takes to fix it is adding a windowInsetsPadding
modifier on the top level Box
.
DetailScreen.kt
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
...
Box(
modifier = modifier
.padding(dimensionResource(R.dimen.screen_edge_padding))
.windowInsetsPadding(WindowInsets.safeDrawing)
) { ... }
Fix the player screen
Although the PlayerScreen
already applies padding for some or all of the system bar window insets from back in Meet the Android Automotive OS quality requirements: Navigability, that's not enough to make sure it's not obscured now that the app is rendering into display cutouts. On mobile devices, display cutouts are almost always entirely contained within the system bars. In cars, however, display cutouts may extend far beyond the system bars, breaking assumptions.
To fix this, just change the initial value of the windowInsetsForPadding
variable from a zero value to displayCutout
:
PlayerScreen.kt
import androidx.compose.foundation.layout.displayCutout
...
var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)
Sweet, the app really makes the most of the screen while also remaining usable!
And, if you run the app on a mobile device, it's more immersive there as well! List items render all the way to the edges of the screen, including behind the navigation bar.
12. Congratulations
You successfully migrated and optimized your first parked app. Now it's time to take what you learned and apply it to your own app!
Things to try out
- Override some of the dimension resource values to increase the size of elements when running on a car
- Try even more configurations of the configurable emulator
- Test the app using some of the available OEM emulator images
Further reading
- Build parked apps for Android Automotive OS
- Build video apps for Android Automotive OS
- Build games for Android Automotive OS
- Build browsers for Android Automotive OS
- The Android app quality for cars page describes the criteria that your app must meet in order to create a great user experience and pass Play Store review. Make sure to filter for your app's category.