Car UI Plugins

Stay organized with collections Save and categorize content based on your preferences.

Use car-ui-lib plugins to create complete implementations of component customizations in car-ui-lib instead of using runtime resource overlays (RROs). RROs enable you to change only the XML resources of car-ui-lib components, which limits the extent to what you can customize.

Creating a plugin

A car-ui-lib plugin is an APK that contains classes that implement a set of Plugin APIs. The Plugin APIs are located in packages/apps/Car/libs/car-ui-lib/oem-apis and can be compiled into a plugin as a static library.

See the Soong and in Gradle examples below:

Soong

Consider this Soong example:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    sdk_version: "current",

    manifest: "src/main/AndroidManifest.xml",
    srcs: ["src/main/java/**/*.java"],
    resource_dirs: ["src/main/res"],
    static_libs: [
        "car-ui-lib-oem-apis",
    ],

    optimize: {
        enabled: false,
    },

    certificate: ":my-plugin-certificate",

Gradle

See this build.gradle file:

apply plugin: 'com.android.application'

android {
  compileSdkVersion 30

  defaultConfig {
    minSdkVersion 28
    targetSdkVersion 30
  }

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  signingConfigs {
    debug {
      storeFile file('chassis_upload_key.jks')
      storePassword 'chassis'
      keyAlias 'chassis'
      keyPassword 'chassis'
    }
  }
}

dependencies {
  implementation project(':oem-apis')
  // Or use the following if you'd like to use the maven artifact
  // implementation 'com.android.car.ui:car-ui-lib-plugin-apis:1.0.0`
}

Settings.gradle:

// You can skip the remove the ':oem-apis' if you're using the maven artifact.
include ':oem-apis'
project(':oem-apis').projectDir = new File('./path/to/oem-apis')
include ':my-plugin'
project(':my-plugin').projectDir = new File('./my-plugin')

The plugin must have a content provider declared in its manifest that has the following attributes:

  android:authorities="com.android.car.ui.plugin"
  android:enabled="true"
  android:exported="true"

android:authorities="com.android.car.ui.plugin" makes the plugin discoverable to car-ui-lib. The provider has to be exported so it can be queried at runtime. Also, if the enabled attribute is set to false the default implementation will be used instead of the plugin implementation. The content provider class doesn't have to exist. In which case, be sure to add tools:ignore="MissingClass" to the provider definition. See the sample manifest entry below:

    <application>
        <provider
            android:name="com.android.car.ui.plugin.PluginNameProvider"
            android:authorities="com.android.car.ui.plugin"
            android:enabled="false"
            android:exported="true"
            tools:ignore="MissingClass"/>
    </application>

Finally, as a security measure, Sign your app.

Installing a plugin

Once you've built the plugin, it can be installed like any other app, such as adding it to PRODUCT_PACKAGES or using adb install. However, if this is a new, fresh install of a plugin, the apps must be restarted for the changes to take effect. This can be done by performing a full adb reboot, or adb shell am force-stop package.name for a specific app.

If you're updating an existing car-ui-lib plugin on the system, any apps using that plugin close automatically and, once reopened by the user, have the updated changes. This looks like a crash if the apps are in the foreground at the time. If the app was not running, the next time it's started it has the updated plugin.

When installing a plugin with Android Studio, there are some additional considerations to take into account. At the time of writing, there is a bug in the Android Studio app installation process that causes updates to a plugin to not take effect. This can be fixed by selecting the option Always install with package manager (disables deploy optimizations on Android 11 and later) in the plugin's build configuration.

In addition, when installing the plugin, Android Studio reports an error that it can't find a main activity to launch. This is expected, as the plugin doesn't have any activities (except the empty intent used to resolve an intent). To eliminate the error, change the Launch option to Nothing in the build configuration.

Plugin Android Studio configuration Figure 1. Plugin Android Studio configuration

Implementing the plugin APIs

The main entrypoint to the plugin is the com.android.car.ui.plugin.PluginVersionProviderImpl class. All plugins must include a class with this exact name and package name. This class must have a default constructor and implement the PluginVersionProviderOEMV1 interface.

CarUi plugins must work with apps that are older or newer than the plugin. To facilitate this, all plugin APIs are versioned with a V# at the end of their classname. If a new version of car-ui-lib is released with new features, they are part of the V2 version of the component. car-ui-lib does its best to make new features work within the scope of an older plugin component. For example, by converting a new type of button in the toolbar into MenuItems.

However, an old app with an old version of car-ui-lib can't adapt to a new plugin written against newer APIs. To solve this problem, we allow plugins to return different implementations of themselves based on the version of OEM API supported by the apps.

PluginVersionProviderOEMV1 has one method in it:

Object getPluginFactory(int maxVersion, Context context, String packageName);

This method returns an object that implements the highest version of PluginFactoryOEMV# supported by the plugin, while still being less than or equal to maxVersion. If a plugin doesn't have an implementation of a PluginFactory that old, it may return null, in which case the statically- nked implementation of CarUi components are used.

The PluginFactory is the interface that creates all the other CarUi components. It also defines which version of their interfaces should be used. If the plugin does not seek to implement any of these components, it may return null in their creation function (with the exception of the toolbar, which has a separate customizesBaseLayout() function).

The pluginFactory limits which versions of CarUi components can be used together. For example, there will never be a pluginFactory that can create version 100 of a Toolbar and also version 1 of a RecyclerView, as there would be little guarantee that a wide variety of versions of components would work together. To use toolbar version 100, developers are expected to provide an implementation of a version of pluginFactory that creates a toolbar version 100, which then limits the options on the versions of other components that can be created. The versions of other components may not be equal, for example a pluginFactoryOEMV100 could create a ToolbarControllerOEMV100 and a RecyclerViewOEMV70.

Toolbar

Base layout

The toolbar and the "base layout" are very closely related, hense the function that creates the toolbar is called installBaseLayoutAround. The base layout is a concept that allows the toolbar to be positioned anywhere around the app's content, to allow for a toolbar across the top/bottom of the app, vertically along the sides, or even a circular toolbar enclosing the whole app. This is accomplished by passing a View to installBaseLayoutAround for the toolbar/base layout to wrap around.

The plugin should take the provided View, detach it from it's parent, inflate the plugin's own layout in the same index of the parent and with the same LayoutParams as the view that was just detatched, and then reattach the View somewhere inside the layout that was just inflated. The inflated layout will contain the toolbar, if requested by the app.

The app can request a base layout without a toolbar. If it does, installBaseLayoutAround should return null. For most plugins, that's all that needs to happen, but if the plugin author would like to apply e.g. a decoration around the edge of the app, that could still be done with a base layout. These decorations are particuarly useful for devices with non-rectangular screens, as they can push the app into a rectangular space and add clean transitions into the non-rectangular space.

installBaseLayoutAround is also passed a Consumer<InsetsOEMV1>. This consumer can be used to communicate to the app that the plugin is partially covering the app's content (with the toolbar or otherwise). The app will then know to keep drawing in this space, but keep any critical user-interactable components out of it. This effect is used in our reference design, to make the toolbar semi-transparent, and have lists scroll under it. If this feature was not implemented, the first item in a list would be stuck underneath the toolbar and not clickable. If this effect is not needed, the plugin can ignore the Consumer.

Content scrolling beneath the toolbar Figure 2. Content scrolling beneath the toolbar

From the app's perspective, when the plugin sends new insets, it will receive them via any activities/fragments that implement InsetsChangedListener. Here is an example snippit of an implementation that applies the insets as padding on a recyclerview in the app:

public class MainActivity extends Activity implements InsetsChangedListener {
  @Override
  public void onCarUiInsetsChanged(Insets insets) {
    CarUiRecyclerView rv = requireViewById(R.id.recyclerview);
    rv.setPadding(insets.getLeft(), insets.getTop(),
                  insets.getRight(), insets.getBottom());
  }
}

Finally, the plugin is given a fullscreen hint, which is used to indicate if the View that should be wrapped takes up the entire app or just a small section. This can be used to avoid applying some decorations along the edge that only make sense if they appear along the edge of the entire screen. An sample app that uses non-fullscreen base layouts is Settings, in which each pane of the dual-pane layout has its own toolbar.

Since it is expected for installBaseLayoutAround to return null when toolbarEnabled is false, for the plugin to indicate that it does not wish to customize the base layout, it must return false from customizesBaseLayout.

The base layout must contain a FocusParkingView and a FocusArea to fully support rotary controls. These views can be omitted on devices that don't support rotary. The FocusParkingView/FocusAreas are implemented in the static CarUi library, so a setRotaryFactories is used to provide factories to create the views from contexts.

The contexts used to create Focus views must be the source context, not the plugin's context. The FocusParkingView should be the closest to the first view in the tree as reasonably possible, as it is what is focused when there should be no focus visible to the user. The FocusArea must wrap the toolbar in the base layout to indicate that it is a rotary nudge zone. If the FocusArea isn't provided, the user is unable to navigate to any buttons in the toolbar with the rotary controller.

Toolbar controller

The actual ToolbarController returned should be much more straightforward to implement than the base layout. Its job is to take information passed to its setters and display it in the base layout. See the Javadoc for information on most methods. Some of the more complex methods are discussed below.

getImeSearchInterface is used for showing search results in the IME (keyboard) window. This can be useful for displaying/animating search results alongside the keyboard, for example if the keyboard only took up half of the screen. Most of the functionality is implemented in the static CarUi library, the search interface in the plugin just provides methods for the static library to get the TextView and onPrivateIMECommand callbacks. To support this, the plugin should use a TextView subclass that overrides onPrivateIMECommand and passes the call to the provided listener as its search bar's TextView.

setMenuItems simply displays MenuItems on the screen, but it will be called suprisingly often. Since the plugin API for MenuItems are immutable, whenever a MenuItem is changed, a whole new setMenuItems call will happen. This could happen for something as trivial as a user clicked a switch MenuItem, and that click caused the switch to toggle. For both performance and animation reasons, it is therefore encouraged to calculate the difference between the old and new MenuItems list, and only update the Views that actually changed. The MenuItems provide a key field that can help with this, as the key should be the same across different calls to setMenuItems for the same MenuItem.

AppStyledView

The AppStyledView is a container for a View that is not customized at all. It can be used to provide a border around that View that makes it stand out from the rest of the app, and indicate to the user that this is a different kind of interface. The View that is wrapped by the AppStyledView is given in setContent. The AppStyledView can also have a back or close button as requested by the app.

The AppStyledView does not immediately insert it's Views into the View hierarchy like installBaseLayoutAround does, it instead just returns it's view to the static library through getView, which then does the insertion. The position and size of the AppStyledView can also be controlled by implementing getDialogWindowLayoutParam.

Contexts

The plugin must be careful when using Contexts, as there are both plugin and "source" contexts. The plugin context is given as an argument to getPluginFactory, and is the only context that is guaranteed to have the plugin's resources in it. This means it's the only context that can be used to inflate layouts in the plugin.

However, the plugin context may not have the correct Configuration set on it. To get the correct configuration, we provide source contexts in methods that create components. The source context is usually an Activity, but in some cases may also be a Service or other Android component. To use the configuration from the source context with the resources from the plugin context, a new context must be created useing createConfigurationContext. If the correct Configuration is not used, there will be an Android strict mode violation, and the inflated views may not have the correct dimensions.

Context layoutInflationContext = pluginContext.createConfigurationContext(
        sourceContext.getResources().getConfiguration());

Mode changes

Some plugins cansupport multiple modes for their components, such as a sport mode or and eco mode that look visually distinct. There is no built-in support for such functionality in CarUi, but there is nothing stopping the plugin from implementing it entirely internally. The plugin can monitor whatever conditions it wants to figure out when to switch modes, such as listening for broadcasts. The plugin cannot trigger a configuration change to change modes, but it isn't recommended to rely on configuration changes anyways, as manually updating the appearance of each component is smoother to the user and also allows for transitions that are not possible with configuration changes.

Jetpack Compose

Plugins can be implemented using Jetpack Compose, but this is an alpha-level feature and should not be considered stable.

Plugins can use ComposeView to create a Compose-enabled surface to render into. This ComposeView would be what's returned from to app from the getView method in components.

One major issue with using ComposeView is that it sets tags on the root view in the layout in order to store global variables that are shared across different ComposeViews in the hierarchy. Since the plugin's resource ids aren't namespaced separately from the app's, this could cause conflicts when both the app and the plugin set tags on the same View. A custom ComposeViewWithLifecycle that moves these global variables down to the ComposeView is provided below. Again, this should not be considered stable.

ComposeViewWithLifecycle:

class ComposeViewWithLifecycle @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
    LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {

  private val lifeCycle = LifecycleRegistry(this)
  private val modelStore = ViewModelStore()
  private val savedStateRegistryController = SavedStateRegistryController.create(this)
  private var composeView: ComposeView? = null
  private var content = @Composable {}

  init {
    ViewTreeLifecycleOwner.set(this, this)
    ViewTreeViewModelStoreOwner.set(this, this)
    ViewTreeSavedStateRegistryOwner.set(this, this)
    compositionContext = createCompositionContext()
  }

  fun setContent(content: @Composable () -> Unit) {
    this.content = content
    composeView?.setContent(content)
  }

  override fun getLifecycle(): Lifecycle {
    return lifeCycle
  }

  override fun getViewModelStore(): ViewModelStore {
    return modelStore
  }

  override fun getSavedStateRegistry(): SavedStateRegistry {
    return savedStateRegistryController.savedStateRegistry
  }

  override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    savedStateRegistryController.performRestore(Bundle())
    lifeCycle.currentState = Lifecycle.State.RESUMED
    composeView = ComposeView(context)
    composeView?.setContent(content)
    addView(composeView, LayoutParams(
      LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
  }

  override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    lifeCycle.currentState = Lifecycle.State.DESTROYED
    modelStore.clear()
    removeAllViews()
    composeView = null
  }

  // Exact copy of View.createCompositionContext() in androidx's WindowRecomposer.android.kt
  private fun createCompositionContext(): CompositionContext {
    val currentThreadContext = AndroidUiDispatcher.CurrentThread
    val pausableClock = currentThreadContext[MonotonicFrameClock]?.let {
      PausableMonotonicFrameClock(it).apply { pause() }
    }
    val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
    val recomposer = Recomposer(contextWithClock)
    val runRecomposeScope = CoroutineScope(contextWithClock)
    val viewTreeLifecycleOwner = checkNotNull(ViewTreeLifecycleOwner.get(this)) {
      "ViewTreeLifecycleOwner not found from $this"
    }
    viewTreeLifecycleOwner.lifecycle.addObserver(
      LifecycleEventObserver { _, event ->
        @Suppress("NON_EXHAUSTIVE_WHEN")
        when (event) {
          Lifecycle.Event.ON_CREATE ->
            // Undispatched launch since we've configured this scope
            // to be on the UI thread
            runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
              recomposer.runRecomposeAndApplyChanges()
            }
          Lifecycle.Event.ON_START -> pausableClock?.resume()
          Lifecycle.Event.ON_STOP -> pausableClock?.pause()
          Lifecycle.Event.ON_DESTROY -> {
            recomposer.cancel()
          }
        }
      }
    )
    return recomposer
  }

//  TODO: ComposeViewWithLifecycle should handle saving state and other lifecycle things
//  override fun onSaveInstanceState(): Parcelable? {
//    val superState = super.onSaveInstanceState()
//    val bundle = Bundle()
//    savedStateRegistryController.performSave(bundle)
//  }
}