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