Controls UI - Stateless controls

Add support for touchable controls without any state. Refactor to
support using app icons as well as device type specific icons. Cache
icons since they will most likely be used many times.

Bug: 150849751
Test: visual

Change-Id: I2d729ced2361e623d3f575149b5c0b4492072ff3
This commit is contained in:
Matt Pietal
2020-03-05 16:10:34 -05:00
parent 4a4e1e45d0
commit 53a8bbdd72
17 changed files with 212 additions and 89 deletions

View File

@@ -16,10 +16,12 @@
package com.android.systemui.controls
import android.content.ComponentName
import android.service.controls.Control
data class ControlStatus(
val control: Control,
val component: ComponentName,
var favorite: Boolean,
val removed: Boolean = false
)
)

View File

@@ -238,7 +238,11 @@ class ControlsControllerImpl @Inject constructor (
}
val removed = findRemoved(favoritesForComponentKeys.toSet(), controls)
val controlsWithFavorite = controls.map {
ControlStatus(it, it.controlId in favoritesForComponentKeys)
ControlStatus(
it,
componentName,
it.controlId in favoritesForComponentKeys
)
}
val loadData = createLoadDataObject(
Favorites.getControlsForComponent(componentName)
@@ -290,7 +294,7 @@ class ControlsControllerImpl @Inject constructor (
.setTitle(controlInfo.controlTitle)
.setDeviceType(controlInfo.deviceType)
.build()
return ControlStatus(control, true, setRemoved)
return ControlStatus(control, componentName, true, setRemoved)
}
private fun findRemoved(favoriteKeys: Set<String>, list: List<Control>): Set<String> {

View File

@@ -16,8 +16,8 @@
package com.android.systemui.controls.management
import android.content.ComponentName
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.service.controls.DeviceTypes
import android.view.LayoutInflater
import android.view.View
@@ -147,7 +147,7 @@ private class ControlHolder(view: View, val favoriteCallback: ModelFavoriteChang
override fun bindData(wrapper: ElementWrapper) {
wrapper as ControlWrapper
val data = wrapper.controlStatus
val renderInfo = getRenderInfo(data.control.deviceType)
val renderInfo = getRenderInfo(data.component, data.control.deviceType)
title.text = data.control.title
subtitle.text = data.control.subtitle
favorite.isChecked = data.favorite
@@ -160,16 +160,17 @@ private class ControlHolder(view: View, val favoriteCallback: ModelFavoriteChang
}
private fun getRenderInfo(
component: ComponentName,
@DeviceTypes.DeviceType deviceType: Int
): RenderInfo {
return RenderInfo.lookup(deviceType, true)
return RenderInfo.lookup(itemView.context, component, deviceType, true)
}
private fun applyRenderInfo(ri: RenderInfo) {
val context = itemView.context
val fg = context.getResources().getColorStateList(ri.foreground, context.getTheme())
icon.setImageIcon(Icon.createWithResource(context, ri.iconResourceId))
icon.setImageDrawable(ri.icon)
icon.setImageTintList(fg)
}
}
@@ -191,4 +192,4 @@ class MarginItemDecorator(
right = sideMargins
}
}
}
}

View File

@@ -21,7 +21,6 @@ import android.app.Dialog
import android.content.ComponentName
import android.content.DialogInterface
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Bundle
import android.os.UserHandle
import android.service.controls.Control
@@ -137,11 +136,10 @@ class ControlsRequestDialog @Inject constructor(
}
fun createDialog(label: CharSequence): Dialog {
val renderInfo = RenderInfo.lookup(control.deviceType, true)
val renderInfo = RenderInfo.lookup(this, component, control.deviceType, true)
val frame = LayoutInflater.from(this).inflate(R.layout.controls_dialog, null).apply {
requireViewById<ImageView>(R.id.icon).apply {
setImageIcon(Icon.createWithResource(context, renderInfo.iconResourceId))
setImageDrawable(renderInfo.icon)
setImageTintList(
context.resources.getColorStateList(renderInfo.foreground, context.theme))
}
@@ -176,4 +174,4 @@ class ControlsRequestDialog @Inject constructor(
}
finish()
}
}
}

View File

@@ -20,6 +20,7 @@ import android.app.PendingIntent
import android.content.Intent
import android.provider.Settings
import android.service.controls.actions.BooleanAction
import android.service.controls.actions.CommandAction
import android.util.Log
import android.view.HapticFeedbackConstants
@@ -36,6 +37,10 @@ object ControlActionCoordinator {
cvh.clipLayer.setLevel(nextLevel)
}
fun touch(cvh: ControlViewHolder, templateId: String) {
cvh.action(CommandAction(templateId))
}
fun longPress(cvh: ControlViewHolder) {
// Long press snould only be called when there is valid control state, otherwise ignore
cvh.cws.control?.let {

View File

@@ -19,11 +19,11 @@ package com.android.systemui.controls.ui
import android.content.Context
import android.graphics.BlendMode
import android.graphics.drawable.ClipDrawable
import android.graphics.drawable.Icon
import android.graphics.drawable.LayerDrawable
import android.service.controls.Control
import android.service.controls.actions.ControlAction
import android.service.controls.templates.ControlTemplate
import android.service.controls.templates.StatelessTemplate
import android.service.controls.templates.TemperatureControlTemplate
import android.service.controls.templates.ToggleRangeTemplate
import android.service.controls.templates.ToggleTemplate
@@ -122,19 +122,25 @@ class ControlViewHolder(
return when {
status == Control.STATUS_UNKNOWN -> UnknownBehavior::class
template is ToggleTemplate -> ToggleBehavior::class
template is StatelessTemplate -> TouchBehavior::class
template is ToggleRangeTemplate -> ToggleRangeBehavior::class
template is TemperatureControlTemplate -> TemperatureControlBehavior::class
else -> DefaultBehavior::class
}
}
internal fun applyRenderInfo(ri: RenderInfo) {
internal fun applyRenderInfo(enabled: Boolean, offset: Int = 0) {
setEnabled(enabled)
val deviceType = cws.control?.let { it.getDeviceType() } ?: cws.ci.deviceType
val ri = RenderInfo.lookup(context, cws.componentName, deviceType, enabled, offset)
val fg = context.getResources().getColorStateList(ri.foreground, context.getTheme())
val bg = context.getResources().getColorStateList(ri.background, context.getTheme())
status.setTextColor(fg)
statusExtra.setTextColor(fg)
icon.setImageIcon(Icon.createWithResource(context, ri.iconResourceId))
icon.setImageDrawable(ri.icon)
icon.setImageTintList(fg)
clipLayer.getDrawable().apply {
@@ -143,7 +149,7 @@ class ControlViewHolder(
}
}
fun setEnabled(enabled: Boolean) {
private fun setEnabled(enabled: Boolean) {
status.setEnabled(enabled)
icon.setEnabled(enabled)
}

View File

@@ -188,36 +188,23 @@ class ControlsUiControllerImpl @Inject constructor (
parent.removeAllViews()
controlViewsById.clear()
val inflater = LayoutInflater.from(context)
inflater.inflate(R.layout.controls_with_favorites, parent, true)
createListView()
createDropDown(items)
}
val listView = parent.requireViewById(R.id.global_actions_controls_list) as ViewGroup
var lastRow: ViewGroup = createRow(inflater, listView)
selectedStructure.controls.forEach {
if (lastRow.getChildCount() == 2) {
lastRow = createRow(inflater, listView)
}
val item = inflater.inflate(
R.layout.controls_base_item, lastRow, false) as ViewGroup
lastRow.addView(item)
val cvh = ControlViewHolder(item, controlsController.get(), uiExecutor, bgExecutor)
val key = ControlKey(selectedStructure.componentName, it.controlId)
cvh.bindData(controlsById.getValue(key))
controlViewsById.put(key, cvh)
}
// add spacer if necessary to keep control size consistent
if ((selectedStructure.controls.size % 2) == 1) {
lastRow.addView(Space(context), LinearLayout.LayoutParams(0, 0, 1f))
private fun createDropDown(items: List<SelectionItem>) {
items.forEach {
RenderInfo.registerComponentIcon(it.componentName, it.icon)
}
val itemsByComponent = items.associateBy { it.componentName }
var adapter = ItemAdapter(context, R.layout.controls_spinner_item).apply {
val listItems = allStructures.mapNotNull {
itemsByComponent.get(it.componentName)?.copy(structure = it.structure)
}
val itemsWithStructure = allStructures.mapNotNull {
itemsByComponent.get(it.componentName)?.copy(structure = it.structure)
}
val selectionItem = findSelectionItem(selectedStructure, itemsWithStructure) ?: items[0]
addAll(listItems + addControlsItem)
var adapter = ItemAdapter(context, R.layout.controls_spinner_item).apply {
addAll(itemsWithStructure + addControlsItem)
}
/*
@@ -225,16 +212,15 @@ class ControlsUiControllerImpl @Inject constructor (
* for this dialog. Use a textView with the ListPopupWindow to achieve
* a similar effect
*/
val item = adapter.findSelectionItem(selectedStructure) ?: adapter.getItem(0)
parent.requireViewById<TextView>(R.id.app_or_structure_spinner).apply {
setText(item.getTitle())
setText(selectionItem.getTitle())
// override the default color on the dropdown drawable
(getBackground() as LayerDrawable).getDrawable(1)
.setTint(context.resources.getColor(R.color.control_spinner_dropdown, null))
}
parent.requireViewById<ImageView>(R.id.app_icon).apply {
setContentDescription(item.getTitle())
setImageDrawable(item.icon)
setContentDescription(selectionItem.getTitle())
setImageDrawable(selectionItem.icon)
}
val anchor = parent.requireViewById<ViewGroup>(R.id.controls_header)
anchor.setOnClickListener(object : View.OnClickListener {
@@ -272,6 +258,36 @@ class ControlsUiControllerImpl @Inject constructor (
})
}
private fun createListView() {
val inflater = LayoutInflater.from(context)
inflater.inflate(R.layout.controls_with_favorites, parent, true)
val listView = parent.requireViewById(R.id.global_actions_controls_list) as ViewGroup
var lastRow: ViewGroup = createRow(inflater, listView)
selectedStructure.controls.forEach {
if (lastRow.getChildCount() == 2) {
lastRow = createRow(inflater, listView)
}
val baseLayout = inflater.inflate(
R.layout.controls_base_item, lastRow, false) as ViewGroup
lastRow.addView(baseLayout)
val cvh = ControlViewHolder(
baseLayout,
controlsController.get(),
uiExecutor,
bgExecutor
)
val key = ControlKey(selectedStructure.componentName, it.controlId)
cvh.bindData(controlsById.getValue(key))
controlViewsById.put(key, cvh)
}
// add spacer if necessary to keep control size consistent
if ((selectedStructure.controls.size % 2) == 1) {
lastRow.addView(Space(context), LinearLayout.LayoutParams(0, 0, 1f))
}
}
private fun loadPreference(structures: List<StructureInfo>): StructureInfo {
if (structures.isEmpty()) return EMPTY_STRUCTURE
@@ -320,6 +336,8 @@ class ControlsUiControllerImpl @Inject constructor (
controlsById.clear()
controlViewsById.clear()
controlsListingController.get().removeCallback(listingCallback)
RenderInfo.clearCache()
}
override fun onRefreshState(componentName: ComponentName, controls: List<Control>) {
@@ -358,6 +376,11 @@ class ControlsUiControllerImpl @Inject constructor (
listView.addView(row)
return row
}
private fun findSelectionItem(si: StructureInfo, items: List<SelectionItem>): SelectionItem? =
items.firstOrNull {
it.componentName == si.componentName && it.structure == si.structure
}
}
private data class SelectionItem(
@@ -388,17 +411,4 @@ private class ItemAdapter(
}
return view
}
fun findSelectionItem(si: StructureInfo): SelectionItem? {
var i = 0
while (i < getCount()) {
val item = getItem(i)
if (item.componentName == si.componentName &&
item.structure == si.structure) {
return item
}
i++
}
return null
}
}

View File

@@ -25,7 +25,6 @@ class DefaultBehavior : Behavior {
override fun bind(cws: ControlWithState) {
cvh.status.setText(cws.control?.getStatusText() ?: "")
cvh.setEnabled(false)
cvh.applyRenderInfo(RenderInfo.lookup(cws.ci.deviceType, false))
cvh.applyRenderInfo(false)
}
}

View File

@@ -16,8 +16,14 @@
package com.android.systemui.controls.ui
import android.annotation.MainThread
import android.content.ComponentName
import android.content.Context
import android.graphics.drawable.Drawable
import android.service.controls.DeviceTypes
import android.service.controls.templates.TemperatureControlTemplate
import android.util.ArrayMap
import android.util.SparseArray
import com.android.systemui.R
@@ -31,18 +37,54 @@ data class IconState(val disabledResourceId: Int, val enabledResourceId: Int) {
}
}
data class RenderInfo(val iconResourceId: Int, val foreground: Int, val background: Int) {
data class RenderInfo(val icon: Drawable, val foreground: Int, val background: Int) {
companion object {
fun lookup(deviceType: Int, enabled: Boolean): RenderInfo {
val iconState = deviceIconMap.getValue(deviceType)
const val APP_ICON_ID = -1
private val iconMap = SparseArray<Drawable>()
private val appIconMap = ArrayMap<ComponentName, Drawable>()
@MainThread
fun lookup(
context: Context,
componentName: ComponentName,
deviceType: Int,
enabled: Boolean,
offset: Int = 0
): RenderInfo {
val (fg, bg) = deviceColorMap.getValue(deviceType)
return RenderInfo(iconState[enabled], fg, bg)
val iconKey = if (offset > 0) {
deviceType * BUCKET_SIZE + offset
} else deviceType
val iconState = deviceIconMap.getValue(iconKey)
val resourceId = iconState[enabled]
var icon: Drawable? = null
if (resourceId == APP_ICON_ID) {
icon = appIconMap.get(componentName)
if (icon == null) {
icon = context.resources
.getDrawable(R.drawable.ic_device_unknown_gm2_24px, null)
appIconMap.put(componentName, icon)
}
} else {
icon = iconMap.get(resourceId)
if (icon == null) {
icon = context.resources.getDrawable(resourceId, null)
iconMap.put(resourceId, icon)
}
}
return RenderInfo(icon!!, fg, bg)
}
fun lookup(deviceType: Int, offset: Int, enabled: Boolean): RenderInfo {
val key = deviceType * BUCKET_SIZE + offset
return lookup(key, enabled)
fun registerComponentIcon(componentName: ComponentName, icon: Drawable) {
appIconMap.put(componentName, icon)
}
fun clearCache() {
iconMap.clear()
appIconMap.clear()
}
}
}
@@ -116,6 +158,10 @@ private val deviceIconMap = mapOf<Int, IconState>(
DeviceTypes.TYPE_MOP to IconState(
R.drawable.ic_vacuum_gm2_24px,
R.drawable.ic_vacuum_gm2_24px
),
DeviceTypes.TYPE_ROUTINE to IconState(
RenderInfo.APP_ICON_ID,
RenderInfo.APP_ICON_ID
)
).withDefault {
IconState(

View File

@@ -47,10 +47,7 @@ class TemperatureControlBehavior : Behavior {
val activeMode = template.getCurrentActiveMode()
val enabled = activeMode != 0 && activeMode != TemperatureControlTemplate.MODE_OFF
val deviceType = control.getDeviceType()
clipLayer.setLevel(if (enabled) MAX_LEVEL else MIN_LEVEL)
cvh.setEnabled(enabled)
cvh.applyRenderInfo(RenderInfo.lookup(deviceType, activeMode, enabled))
cvh.applyRenderInfo(enabled, activeMode)
}
}

View File

@@ -34,7 +34,7 @@ class ToggleBehavior : Behavior {
override fun initialize(cvh: ControlViewHolder) {
this.cvh = cvh
cvh.setEnabled(false)
cvh.applyRenderInfo(false)
cvh.layout.setOnClickListener(View.OnClickListener() {
ControlActionCoordinator.toggle(cvh, template.getTemplateId(), template.isChecked())
@@ -51,10 +51,7 @@ class ToggleBehavior : Behavior {
clipLayer = ld.findDrawableByLayerId(R.id.clip_layer)
val checked = template.isChecked()
val deviceType = control.getDeviceType()
clipLayer.setLevel(if (checked) MAX_LEVEL else MIN_LEVEL)
cvh.setEnabled(checked)
cvh.applyRenderInfo(RenderInfo.lookup(deviceType, checked))
cvh.applyRenderInfo(checked)
}
}

View File

@@ -56,7 +56,7 @@ class ToggleRangeBehavior : Behavior {
status = cvh.status
context = status.getContext()
cvh.setEnabled(false)
cvh.applyRenderInfo(false)
val gestureListener = ToggleRangeGestureListener(cvh.layout)
val gestureDetector = GestureDetector(context, gestureListener)
@@ -89,14 +89,11 @@ class ToggleRangeBehavior : Behavior {
rangeTemplate = template.getRange()
val checked = template.isChecked()
val deviceType = control.getDeviceType()
val currentRatio = rangeTemplate.getCurrentValue() /
(rangeTemplate.getMaxValue() - rangeTemplate.getMinValue())
updateRange(currentRatio, checked)
cvh.setEnabled(checked)
cvh.applyRenderInfo(RenderInfo.lookup(deviceType, checked))
cvh.applyRenderInfo(checked)
}
fun beginUpdateRange() {

View File

@@ -0,0 +1,58 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.controls.ui
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.service.controls.Control
import android.service.controls.templates.StatelessTemplate
import com.android.systemui.R
import com.android.systemui.controls.ui.ControlActionCoordinator.MIN_LEVEL
/**
* Supports touch events, but has no notion of state as the {@link ToggleBehavior} does. Must be
* used with {@link StatelessTemplate}.
*/
class TouchBehavior : Behavior {
lateinit var clipLayer: Drawable
lateinit var template: StatelessTemplate
lateinit var control: Control
lateinit var cvh: ControlViewHolder
override fun initialize(cvh: ControlViewHolder) {
this.cvh = cvh
cvh.applyRenderInfo(false)
cvh.layout.setOnClickListener(View.OnClickListener() {
ControlActionCoordinator.touch(cvh, template.getTemplateId())
})
}
override fun bind(cws: ControlWithState) {
this.control = cws.control!!
cvh.status.setText(control.getStatusText())
template = control.getControlTemplate() as StatelessTemplate
val ld = cvh.layout.getBackground() as LayerDrawable
clipLayer = ld.findDrawableByLayerId(R.id.clip_layer)
clipLayer.setLevel(MIN_LEVEL)
cvh.applyRenderInfo(false)
}
}

View File

@@ -25,7 +25,6 @@ class UnknownBehavior : Behavior {
override fun bind(cws: ControlWithState) {
cvh.status.setText(cvh.context.getString(com.android.internal.R.string.loading))
cvh.setEnabled(false)
cvh.applyRenderInfo(RenderInfo.lookup(cws.ci.deviceType, false))
cvh.applyRenderInfo(false)
}
}

View File

@@ -234,7 +234,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
loaded = true
assertEquals(1, controls.size)
val controlStatus = controls[0]
assertEquals(ControlStatus(control, false), controlStatus)
assertEquals(ControlStatus(control, TEST_COMPONENT, false), controlStatus)
assertTrue(favorites.isEmpty())
assertFalse(data.errorOnLoad)
@@ -265,10 +265,10 @@ class ControlsControllerImplTest : SysuiTestCase() {
loaded = true
assertEquals(2, controls.size)
val controlStatus = controls.first { it.control.controlId == TEST_CONTROL_ID }
assertEquals(ControlStatus(control, true), controlStatus)
assertEquals(ControlStatus(control, TEST_COMPONENT, true), controlStatus)
val controlStatus2 = controls.first { it.control.controlId == TEST_CONTROL_ID_2 }
assertEquals(ControlStatus(control2, false), controlStatus2)
assertEquals(ControlStatus(control2, TEST_COMPONENT, false), controlStatus2)
assertEquals(1, favorites.size)
assertEquals(TEST_CONTROL_ID, favorites[0])

View File

@@ -17,6 +17,7 @@
package com.android.systemui.controls.management
import android.app.PendingIntent
import android.content.ComponentName
import android.service.controls.Control
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
@@ -78,6 +79,7 @@ class AllModelTest : SysuiTestCase() {
Control.StatelessBuilder("$idPrefix$it", pendingIntent)
.setZone(zoneMap(it))
.build(),
ComponentName("", ""),
it in favoritesIndices
)
}
@@ -189,4 +191,4 @@ class AllModelTest : SysuiTestCase() {
assertTrue(sameControl(it.first, it.second))
}
}
}
}

View File

@@ -17,6 +17,7 @@
package com.android.systemui.controls.management
import android.app.PendingIntent
import android.content.ComponentName
import android.service.controls.Control
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
@@ -70,6 +71,7 @@ open class FavoriteModelTest : SysuiTestCase() {
Control.StatelessBuilder("$idPrefix$it", pendingIntent)
.setZone((it % 3).toString())
.build(),
ComponentName("", ""),
it in favoritesIndices
)
}
@@ -195,4 +197,4 @@ class FavoriteModelParameterizedTest(val from: Int, val to: Int) : FavoriteModel
verifyNoMoreInteractions(allAdapter, favoritesAdapter)
}
}
}