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 theapplication
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 flagshared-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.
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
maxVersion
s 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.
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)
// }
}