Car UI plugins

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

Create a plugin

A Car UI library plugin is an APK that contains classes that implement a set of Plugin APIs. The Plugin APIs can be compiled into a plugin as a static library.

See examples in Soong and Gradle:

Soong

Consider this Soong example:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    aaptflags: ["--shared-lib"],
    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",
    ],
    // Disable optimization is mandatory to prevent R.java class from being
    // stripped out
    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 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 the Car UI library. 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.

Plugins as a shared library

Unlike Android static libraries which are compiled directly into apps, Android shared libraries are compiled into a standalone APK that is referenced by other apps at runtime.

Plugins that are implemented as an Android shared library have their classes automatically added to the shared classloader between apps. When an app which uses the Car UI library specifies a runtime dependency on the plugin shared library, its classloader can access the plugin shared library's classes. Plugins implemented as normal Android apps (not a shared library) can negatively impact app cold start times.

Implement and build shared libraries

Developing with Android shared libraries is much like that of normal Android apps, with a few key differences.

  • Use the library tag under the application tag with the plugin package name in your plugin's app manifest:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Configure your Soong android_app build rule (Android.bp) with the AAPT flag shared-lib, which is used to build a shared library:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Dependencies on shared libraries

For each app on the system which uses the Car UI library, include the uses-library tag in the app manifest under the application tag with the plugin package name:

<manifest>
  <application
      android:name=".MyApp"
      ...>
    <uses-library android:name="com.chassis.car.ui.plugin" android:required="false"/>
    ...
  </application>
</manifest>

Install a plugin

Plugins MUST be preinstalled on the system partition by including the module in PRODUCT_PACKAGES. The pre-installed package can be updated similarly to any other installed app.

If you're updating an existing plugin on the system, any apps using that plugin close automatically. Once reopened by the user, they have the updated changes. 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

Proxy plugin

Customization of apps using the Car UI library requires a RRO that targets each specific app that is to be modified, including when customizations are identical across apps. This means a RRO per app is required. See which apps use the Car UI library.

The Car UI library proxy plugin is an example plugin shared library that delegates its component implementations to the static version of Car UI library. This plugin can be targeted with a RRO, which can be used as a single point of customization for apps which use Car UI library without the need to implement a functional plugin. For more information about RROs, see Change the value of an app's resources at runtime.

The proxy plugin is only an example and starting point to do customization using a plugin. For customization beyond RROs, one can implement a subset of plugin components and use the proxy plugin for the rest, or implement all plugin components entirely from scratch.

Although the proxy plugin provides a single point of RRO customization for apps, apps that opt-out of using the plugin will still require a RRO that directly targets the app itself.

Implement 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 the Car UI library is released with new features, they are part of the V2 version of the component. The Car UI library 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 app with an older version of Car UI library 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- linked implementation of CarUi components are used.

To maintain backwards compatibility with apps which are compiled against older versions of the static Car Ui library, it is recommended to support maxVersions of 2, 5, and higher from within your plugin's implementation of the PluginVersionProvider class. Versions 1, 3, and 4 are not supported. For more information, see PluginVersionProviderImpl.

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, hence 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 its 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 particularly 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 from any activities or fragments that implement InsetsChangedListener. If an activity or fragment doesn't implement InsetsChangedListener, the Car Ui library will handle insets by default by applying the insets as padding to the Activity or FragmentActivity containing the fragment. The library does not apply the insets by default to fragments. Here is a sample snippet 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 surprisingly 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 has 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 using 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)
//  }
}