Build and test a parked app for Android Automotive OS

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

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.

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

  1. The code for this codelab can be found in the build-a-parked-app directory within the car-codelabs GitHub repository. To clone it, run the following command:
git clone https://github.com/android/car-codelabs.git
  1. 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. The build-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

  1. 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.
  2. 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

  1. 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.

The Virtual Device Configuration wizard showing the 'Automotive (1024p landscape)' hardware profile selected.

  1. 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.

301e6c0d3675e937.png

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.

8685bcde6b21901f.png

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.

19e4aa8135553f62.png

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.

  1. In the Project window, right click on the app module. From the dropdown that appears, select New > Other > Android Manifest File
  2. 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.

3fe290685a1026f5.png

  1. Within the AndroidManifest.xml file that was just created (under the path app/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.

  1. 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

47ac6bf76ef8ad45.png

  1. 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>
  1. In the automotive source set's AndroidManifest.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 the automotive_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:

  1. Update the TopControls composable to take a callback parameter called onClose and add an IconButton 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) { ... }
    }
}
  1. Update the PlayerControls composable to take also take a onClose callback parameter and pass it on to the TopControls

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
            )
            ...
        }
    }
}
  1. Next, update the PlayerScreen composable to take the same parameter, and pass it down to its PlayerControls.

PlayerScreen.kt

@Compsable
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) }
    )
}
  1. Finally, in the RoadReelsNavHost, provide the implementation that gets passed to the PlayerScreen:

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.

43122e716eeeeb20.gif

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.

  1. 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)
    }
}
  1. 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 orientation configuration changes)

The player screen is letterboxed on the Polestar 2 emulator before adding the check (when the activity handles orientation configuration changes)

The player screen is not letterboxed on the Polestar 2 emulator after adding the check.

The player screen enters a flickering loop on the Polestar 2 emulator before adding the check (when the activity does not handle orientation configuration changes)

The player screen is letterboxed on the Polestar 2 emulator before adding the check (when the activity handles orientation configuration changes)

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 Large screen 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.

9c51956e2093820a.gif

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.

  1. 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 the com.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 }
  1. 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)
    }
}
  1. Add a top level CompositionLocalProvider that contains the theme and app composables and which binds values to LocalControllableInsets.

MainActivity.kt

import androidx.compose.runtime.CompositionLocalProvider

...

CompositionLocalProvider(LocalControllableInsets provides controllableInsetsTypeMask) {
    RoadReelsTheme {
        RoadReelsApp(calculateWindowSizeClass(this))
    }
}
  1. 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

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.

839af1382c1f10ca.png

Pause playback when driving begins

  1. Add a dependency on the androidx.lifecycle:lifecycle-runtime-compose artifact, which contains the LifecycleEventEffect 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)
  1. After syncing the project to download the dependency, add a LifecycleEventEffect that runs on the ON_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

  1. 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.
  2. 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

  1. 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.
  2. 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.

b277bd18a94e9c1b.png

9. Improve the app experience on the distant display

As you used the app on the distant display, you may have noticed two things:

  1. Playback restarts when the app is moved to and from the distant display
  2. 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.

  1. 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)
  1. 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()
    }
    ...
}
  1. Then, add an additional line in the onDispose block of the DisposableEffect in the Player composable to release the MediaSession when the Player leaves the composition tree.

PlayerScreen.kt

DisposableEffect(Unit) {
    onDispose {
        mediaSession.release()
        player.release()
        ...
    }
}
  1. 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

  1. 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.
  2. 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

  1. 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.
  2. 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

b642703a7278b219.png

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.

9898f7298a7dfb4.gif

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.

212628db84981025.gif

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.

  1. 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, because LAYOUT_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.

22b7f17657cac3fd.png

  1. 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.

f0eefa42dee6f7c7.gif

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.

7d59ebb63ada5f71.gif

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.

6b3824ca3214cbfa.gif

For a more immersive user interface, you can handle the insets on each component within the screen.

  1. Update the contentWindowInsets of the Scaffold to constantly be 0dp (instead of just for the PlayerScreen). 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)
) { ... }
  1. Set the windowInsetsPadding of the row header Text composables to use the horizontal components of the safeDrawing 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))
        )
    ...
}
  1. Remove the contentPadding parameter of the LazyRow. Then, at the start and end of each LazyRow, add a Spacer the width of the corresponding safeDrawing component to make sure all of the thumbnails can be fully viewed. Use the widthIn 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))
        )
    }
}
  1. Finally, add a Spacer to the end of the LazyColumn 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 the LazyColumn because the top app bar handles those. If the app used a bottom app bar instead of a top app bar, you would add a Spacer at the start of the list using the windowInsetsTopHeight 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!

543706473398114a.gif

Fix the detail screen

f622958a8d0c16c8.png

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)
) { ... }

bdd6de6010fc139d.png

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.

427227df5e44f554.png

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)

b523d8c1e1423757.gif

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.

dc7918499a33df31.png

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

Further reading