blob: 972b24343d10ff681ae563f2a5e34ebd40e1e706 [file] [log] [blame]
/*
* Copyright (C) 2021 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.qs.tileimpl
import android.animation.ArgbEvaluator
import android.animation.PropertyValuesHolder
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.content.res.Resources.ID_NULL
import android.graphics.drawable.Drawable
import android.graphics.drawable.RippleDrawable
import android.os.Trace
import android.service.quicksettings.Tile
import android.text.TextUtils
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Switch
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import com.android.settingslib.Utils
import com.android.systemui.FontSizeUtils
import com.android.systemui.R
import com.android.systemui.animation.LaunchableView
import com.android.systemui.animation.LaunchableViewDelegate
import com.android.systemui.plugins.qs.QSIconView
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.plugins.qs.QSTile.BooleanState
import com.android.systemui.plugins.qs.QSTileView
import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
import java.util.Objects
private const val TAG = "QSTileViewImpl"
open class QSTileViewImpl @JvmOverloads constructor(
context: Context,
private val _icon: QSIconView,
private val collapsed: Boolean = false
) : QSTileView(context), HeightOverrideable, LaunchableView {
companion object {
private const val INVALID = -1
private const val BACKGROUND_NAME = "background"
private const val LABEL_NAME = "label"
private const val SECONDARY_LABEL_NAME = "secondaryLabel"
private const val CHEVRON_NAME = "chevron"
const val UNAVAILABLE_ALPHA = 0.3f
@VisibleForTesting
internal const val TILE_STATE_RES_PREFIX = "tile_states_"
}
private var _position: Int = INVALID
override fun setPosition(position: Int) {
_position = position
}
override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
set(value) {
if (field == value) return
field = value
updateHeight()
}
override var squishinessFraction: Float = 1f
set(value) {
if (field == value) return
field = value
updateHeight()
}
private val colorActive = Utils.getColorAttrDefaultColor(context,
com.android.internal.R.attr.colorAccentPrimary)
private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.offStateColor)
private val colorUnavailable = Utils.applyAlpha(UNAVAILABLE_ALPHA, colorInactive)
private val colorLabelActive =
Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.textColorOnAccent)
private val colorLabelInactive =
Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimary)
private val colorLabelUnavailable =
Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.textColorTertiary)
private val colorSecondaryLabelActive =
Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondaryInverse)
private val colorSecondaryLabelInactive =
Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondary)
private val colorSecondaryLabelUnavailable =
Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.textColorTertiary)
private lateinit var label: TextView
protected lateinit var secondaryLabel: TextView
private lateinit var labelContainer: IgnorableChildLinearLayout
protected lateinit var sideView: ViewGroup
private lateinit var customDrawableView: ImageView
private lateinit var chevronView: ImageView
protected var showRippleEffect = true
private lateinit var ripple: RippleDrawable
private lateinit var colorBackgroundDrawable: Drawable
private var paintColor: Int = 0
private val singleAnimator: ValueAnimator = ValueAnimator().apply {
setDuration(QS_ANIM_LENGTH)
addUpdateListener { animation ->
setAllColors(
// These casts will throw an exception if some property is missing. We should
// always have all properties.
animation.getAnimatedValue(BACKGROUND_NAME) as Int,
animation.getAnimatedValue(LABEL_NAME) as Int,
animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
animation.getAnimatedValue(CHEVRON_NAME) as Int
)
}
}
private var accessibilityClass: String? = null
private var stateDescriptionDeltas: CharSequence? = null
private var lastStateDescription: CharSequence? = null
private var tileState = false
private var lastState = INVALID
private val launchableViewDelegate = LaunchableViewDelegate(
this,
superSetVisibility = { super.setVisibility(it) },
superSetTransitionVisibility = { super.setTransitionVisibility(it) },
)
private var lastDisabledByPolicy = false
private val locInScreen = IntArray(2)
init {
setId(generateViewId())
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL or Gravity.START
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
clipChildren = false
clipToPadding = false
isFocusable = true
background = createTileBackground()
setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
setPaddingRelative(startPadding, padding, padding, padding)
val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
addView(_icon, LayoutParams(iconSize, iconSize))
createAndAddLabels()
createAndAddSideView()
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
updateResources()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
Trace.traceBegin(Trace.TRACE_TAG_APP, "QSTileViewImpl#onMeasure")
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
Trace.endSection()
}
override fun resetOverride() {
heightOverride = HeightOverrideable.NO_OVERRIDE
updateHeight()
}
fun updateResources() {
FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
_icon.layoutParams.apply {
height = iconSize
width = iconSize
}
val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
setPaddingRelative(startPadding, padding, padding, padding)
val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
(labelContainer.layoutParams as MarginLayoutParams).apply {
marginStart = labelMargin
}
(sideView.layoutParams as MarginLayoutParams).apply {
marginStart = labelMargin
}
(chevronView.layoutParams as MarginLayoutParams).apply {
height = iconSize
width = iconSize
}
val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
(customDrawableView.layoutParams as MarginLayoutParams).apply {
height = iconSize
marginEnd = endMargin
}
}
private fun createAndAddLabels() {
labelContainer = LayoutInflater.from(context)
.inflate(R.layout.qs_tile_label, this, false) as IgnorableChildLinearLayout
label = labelContainer.requireViewById(R.id.tile_label)
secondaryLabel = labelContainer.requireViewById(R.id.app_label)
if (collapsed) {
labelContainer.ignoreLastView = true
// Ideally, it'd be great if the parent could set this up when measuring just this child
// instead of the View class having to support this. However, due to the mysteries of
// LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its
// sibling methods to have special behavior for labelContainer.
labelContainer.forceUnspecifiedMeasure = true
secondaryLabel.alpha = 0f
}
setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
addView(labelContainer)
}
private fun createAndAddSideView() {
sideView = LayoutInflater.from(context)
.inflate(R.layout.qs_tile_side_icon, this, false) as ViewGroup
customDrawableView = sideView.requireViewById(R.id.customDrawable)
chevronView = sideView.requireViewById(R.id.chevron)
setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
addView(sideView)
}
fun createTileBackground(): Drawable {
ripple = mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
colorBackgroundDrawable = ripple.findDrawableByLayerId(R.id.background)
return ripple
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
updateHeight()
}
private fun updateHeight() {
val actualHeight = if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
heightOverride
} else {
measuredHeight
}
// Limit how much we affect the height, so we don't have rounding artifacts when the tile
// is too short.
val constrainedSquishiness = constrainSquishiness(squishinessFraction)
bottom = top + (actualHeight * constrainedSquishiness).toInt()
scrollY = (actualHeight - height) / 2
}
override fun updateAccessibilityOrder(previousView: View?): View {
accessibilityTraversalAfter = previousView?.id ?: ID_NULL
return this
}
override fun getIcon(): QSIconView {
return _icon
}
override fun getIconWithBackground(): View {
return icon
}
override fun init(tile: QSTile) {
init(
{ v: View? -> tile.click(this) },
{ view: View? ->
tile.longClick(this)
true
}
)
}
private fun init(
click: OnClickListener?,
longClick: OnLongClickListener?
) {
setOnClickListener(click)
onLongClickListener = longClick
}
override fun onStateChanged(state: QSTile.State) {
post {
handleStateChanged(state)
}
}
override fun getDetailY(): Int {
return top + height / 2
}
override fun hasOverlappingRendering(): Boolean {
// Avoid layers for this layout - we don't need them.
return false
}
override fun setClickable(clickable: Boolean) {
super.setClickable(clickable)
background = if (clickable && showRippleEffect) {
ripple.also {
// In case that the colorBackgroundDrawable was used as the background, make sure
// it has the correct callback instead of null
colorBackgroundDrawable.callback = it
}
} else {
colorBackgroundDrawable
}
}
override fun getLabelContainer(): View {
return labelContainer
}
override fun getLabel(): View {
return label
}
override fun getSecondaryLabel(): View {
return secondaryLabel
}
override fun getSecondaryIcon(): View {
return sideView
}
override fun setShouldBlockVisibilityChanges(block: Boolean) {
launchableViewDelegate.setShouldBlockVisibilityChanges(block)
}
override fun setVisibility(visibility: Int) {
launchableViewDelegate.setVisibility(visibility)
}
override fun setTransitionVisibility(visibility: Int) {
launchableViewDelegate.setTransitionVisibility(visibility)
}
// Accessibility
override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
super.onInitializeAccessibilityEvent(event)
if (!TextUtils.isEmpty(accessibilityClass)) {
event.className = accessibilityClass
}
if (event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
stateDescriptionDeltas != null) {
event.text.add(stateDescriptionDeltas)
stateDescriptionDeltas = null
}
}
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
// Clear selected state so it is not announce by talkback.
info.isSelected = false
if (lastDisabledByPolicy) {
info.addAction(
AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id,
resources.getString(
R.string.accessibility_tile_disabled_by_policy_action_description
)
)
)
}
if (!TextUtils.isEmpty(accessibilityClass)) {
info.className = if (lastDisabledByPolicy) {
Button::class.java.name
} else {
accessibilityClass
}
if (Switch::class.java.name == accessibilityClass) {
val label = resources.getString(
if (tileState) R.string.switch_bar_on else R.string.switch_bar_off)
// Set the text here for tests in
// android.platform.test.scenario.sysui.quicksettings. Can be removed when
// UiObject2 has a new getStateDescription() API and tests are updated.
info.text = label
info.isChecked = tileState
info.isCheckable = true
if (isLongClickable) {
info.addAction(
AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
resources.getString(
R.string.accessibility_long_click_tile)))
}
}
}
if (_position != INVALID) {
info.collectionItemInfo =
AccessibilityNodeInfo.CollectionItemInfo(_position, 1, 0, 1, false)
}
}
override fun toString(): String {
val sb = StringBuilder(javaClass.simpleName).append('[')
sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
sb.append(", iconView=$_icon")
sb.append(", tileState=$tileState")
sb.append("]")
return sb.toString()
}
// HANDLE STATE CHANGES RELATED METHODS
protected open fun handleStateChanged(state: QSTile.State) {
val allowAnimations = animationsEnabled()
showRippleEffect = state.showRippleEffect
isClickable = state.state != Tile.STATE_UNAVAILABLE
isLongClickable = state.handlesLongClick
icon.setIcon(state, allowAnimations)
contentDescription = state.contentDescription
// State handling and description
val stateDescription = StringBuilder()
val stateText = getStateText(state)
if (!TextUtils.isEmpty(stateText)) {
stateDescription.append(stateText)
if (TextUtils.isEmpty(state.secondaryLabel)) {
state.secondaryLabel = stateText
}
}
if (state.disabledByPolicy && state.state != Tile.STATE_UNAVAILABLE) {
stateDescription.append(", ")
stateDescription.append(getUnavailableText(state.spec))
}
if (!TextUtils.isEmpty(state.stateDescription)) {
stateDescription.append(", ")
stateDescription.append(state.stateDescription)
if (lastState != INVALID && state.state == lastState &&
state.stateDescription != lastStateDescription) {
stateDescriptionDeltas = state.stateDescription
}
}
setStateDescription(stateDescription.toString())
lastStateDescription = state.stateDescription
accessibilityClass = if (state.state == Tile.STATE_UNAVAILABLE) {
null
} else {
state.expandedAccessibilityClassName
}
if (state is BooleanState) {
val newState = state.value
if (tileState != newState) {
tileState = newState
}
}
//
// Labels
if (!Objects.equals(label.text, state.label)) {
label.text = state.label
}
if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
secondaryLabel.text = state.secondaryLabel
secondaryLabel.visibility = if (TextUtils.isEmpty(state.secondaryLabel)) {
GONE
} else {
VISIBLE
}
}
// Colors
if (state.state != lastState || state.disabledByPolicy || lastDisabledByPolicy) {
singleAnimator.cancel()
if (allowAnimations) {
singleAnimator.setValues(
colorValuesHolder(
BACKGROUND_NAME,
paintColor,
getBackgroundColorForState(state.state, state.disabledByPolicy)
),
colorValuesHolder(
LABEL_NAME,
label.currentTextColor,
getLabelColorForState(state.state, state.disabledByPolicy)
),
colorValuesHolder(
SECONDARY_LABEL_NAME,
secondaryLabel.currentTextColor,
getSecondaryLabelColorForState(state.state, state.disabledByPolicy)
),
colorValuesHolder(
CHEVRON_NAME,
chevronView.imageTintList?.defaultColor ?: 0,
getChevronColorForState(state.state, state.disabledByPolicy)
)
)
singleAnimator.start()
} else {
setAllColors(
getBackgroundColorForState(state.state, state.disabledByPolicy),
getLabelColorForState(state.state, state.disabledByPolicy),
getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
getChevronColorForState(state.state, state.disabledByPolicy)
)
}
}
// Right side icon
loadSideViewDrawableIfNecessary(state)
label.isEnabled = !state.disabledByPolicy
lastState = state.state
lastDisabledByPolicy = state.disabledByPolicy
}
private fun setAllColors(
backgroundColor: Int,
labelColor: Int,
secondaryLabelColor: Int,
chevronColor: Int
) {
setColor(backgroundColor)
setLabelColor(labelColor)
setSecondaryLabelColor(secondaryLabelColor)
setChevronColor(chevronColor)
}
private fun setColor(color: Int) {
colorBackgroundDrawable.mutate().setTint(color)
paintColor = color
}
private fun setLabelColor(color: Int) {
label.setTextColor(color)
}
private fun setSecondaryLabelColor(color: Int) {
secondaryLabel.setTextColor(color)
}
private fun setChevronColor(color: Int) {
chevronView.imageTintList = ColorStateList.valueOf(color)
}
private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
if (state.sideViewCustomDrawable != null) {
customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
customDrawableView.visibility = VISIBLE
chevronView.visibility = GONE
} else if (state !is BooleanState || state.forceExpandIcon) {
customDrawableView.setImageDrawable(null)
customDrawableView.visibility = GONE
chevronView.visibility = VISIBLE
} else {
customDrawableView.setImageDrawable(null)
customDrawableView.visibility = GONE
chevronView.visibility = GONE
}
}
private fun getUnavailableText(spec: String?): String {
val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE]
}
private fun getStateText(state: QSTile.State): String {
return if (state.state == Tile.STATE_UNAVAILABLE || state is BooleanState) {
val arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec)
val array = resources.getStringArray(arrayResId)
array[state.state]
} else {
""
}
}
/*
* The view should not be animated if it's not on screen and no part of it is visible.
*/
protected open fun animationsEnabled(): Boolean {
if (!isShown) {
return false
}
if (alpha != 1f) {
return false
}
getLocationOnScreen(locInScreen)
return locInScreen.get(1) >= -height
}
private fun getBackgroundColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
return when {
state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorUnavailable
state == Tile.STATE_ACTIVE -> colorActive
state == Tile.STATE_INACTIVE -> colorInactive
else -> {
Log.e(TAG, "Invalid state $state")
0
}
}
}
private fun getLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
return when {
state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorLabelUnavailable
state == Tile.STATE_ACTIVE -> colorLabelActive
state == Tile.STATE_INACTIVE -> colorLabelInactive
else -> {
Log.e(TAG, "Invalid state $state")
0
}
}
}
private fun getSecondaryLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
return when {
state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorSecondaryLabelUnavailable
state == Tile.STATE_ACTIVE -> colorSecondaryLabelActive
state == Tile.STATE_INACTIVE -> colorSecondaryLabelInactive
else -> {
Log.e(TAG, "Invalid state $state")
0
}
}
}
private fun getChevronColorForState(state: Int, disabledByPolicy: Boolean = false): Int =
getSecondaryLabelColorForState(state, disabledByPolicy)
@VisibleForTesting
internal fun getCurrentColors(): List<Int> = listOf(
paintColor,
label.currentTextColor,
secondaryLabel.currentTextColor,
chevronView.imageTintList?.defaultColor ?: 0
)
}
@VisibleForTesting
internal object SubtitleArrayMapping {
private val subtitleIdsMap = mapOf<String?, Int>(
"internet" to R.array.tile_states_internet,
"wifi" to R.array.tile_states_wifi,
"cell" to R.array.tile_states_cell,
"battery" to R.array.tile_states_battery,
"dnd" to R.array.tile_states_dnd,
"flashlight" to R.array.tile_states_flashlight,
"rotation" to R.array.tile_states_rotation,
"bt" to R.array.tile_states_bt,
"airplane" to R.array.tile_states_airplane,
"location" to R.array.tile_states_location,
"hotspot" to R.array.tile_states_hotspot,
"inversion" to R.array.tile_states_inversion,
"saver" to R.array.tile_states_saver,
"dark" to R.array.tile_states_dark,
"work" to R.array.tile_states_work,
"cast" to R.array.tile_states_cast,
"night" to R.array.tile_states_night,
"screenrecord" to R.array.tile_states_screenrecord,
"reverse" to R.array.tile_states_reverse,
"reduce_brightness" to R.array.tile_states_reduce_brightness,
"cameratoggle" to R.array.tile_states_cameratoggle,
"mictoggle" to R.array.tile_states_mictoggle,
"controls" to R.array.tile_states_controls,
"wallet" to R.array.tile_states_wallet,
"qr_code_scanner" to R.array.tile_states_qr_code_scanner,
"alarm" to R.array.tile_states_alarm,
"onehanded" to R.array.tile_states_onehanded,
"color_correction" to R.array.tile_states_color_correction,
"dream" to R.array.tile_states_dream
)
fun getSubtitleId(spec: String?): Int {
return subtitleIdsMap.getOrDefault(spec, R.array.tile_states_default)
}
}
fun constrainSquishiness(squish: Float): Float {
return 0.1f + squish * 0.9f
}
private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
return PropertyValuesHolder.ofInt(name, *values).apply {
setEvaluator(ArgbEvaluator.getInstance())
}
}