1 /*
<lambda>null2 * Copyright (C) 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.systemui.qs.tileimpl
18
19 import android.animation.ArgbEvaluator
20 import android.animation.PropertyValuesHolder
21 import android.animation.ValueAnimator
22 import android.content.Context
23 import android.content.res.ColorStateList
24 import android.content.res.Configuration
25 import android.content.res.Resources.ID_NULL
26 import android.graphics.drawable.Drawable
27 import android.graphics.drawable.RippleDrawable
28 import android.service.quicksettings.Tile
29 import android.text.TextUtils
30 import android.util.Log
31 import android.view.Gravity
32 import android.view.LayoutInflater
33 import android.view.View
34 import android.view.ViewGroup
35 import android.view.accessibility.AccessibilityEvent
36 import android.view.accessibility.AccessibilityNodeInfo
37 import android.widget.ImageView
38 import android.widget.LinearLayout
39 import android.widget.Switch
40 import android.widget.TextView
41 import androidx.annotation.VisibleForTesting
42 import com.android.settingslib.Utils
43 import com.android.systemui.FontSizeUtils
44 import com.android.systemui.R
45 import com.android.systemui.plugins.qs.QSIconView
46 import com.android.systemui.plugins.qs.QSTile
47 import com.android.systemui.plugins.qs.QSTile.BooleanState
48 import com.android.systemui.plugins.qs.QSTileView
49 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
50 import java.util.Objects
51
52 private const val TAG = "QSTileViewImpl"
53 open class QSTileViewImpl @JvmOverloads constructor(
54 context: Context,
55 private val _icon: QSIconView,
56 private val collapsed: Boolean = false
57 ) : QSTileView(context), HeightOverrideable {
58
59 companion object {
60 private const val INVALID = -1
61 private const val BACKGROUND_NAME = "background"
62 private const val LABEL_NAME = "label"
63 private const val SECONDARY_LABEL_NAME = "secondaryLabel"
64 private const val CHEVRON_NAME = "chevron"
65 const val UNAVAILABLE_ALPHA = 0.3f
66 @VisibleForTesting
67 internal const val TILE_STATE_RES_PREFIX = "tile_states_"
68 }
69
70 override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
71
72 private val colorActive = Utils.getColorAttrDefaultColor(context,
73 com.android.internal.R.attr.colorAccentPrimary)
74 private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.offStateColor)
75 private val colorUnavailable = Utils.applyAlpha(UNAVAILABLE_ALPHA, colorInactive)
76
77 private val colorLabelActive =
78 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimaryInverse)
79 private val colorLabelInactive =
80 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimary)
81 private val colorLabelUnavailable = Utils.applyAlpha(UNAVAILABLE_ALPHA, colorLabelInactive)
82
83 private val colorSecondaryLabelActive =
84 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondaryInverse)
85 private val colorSecondaryLabelInactive =
86 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondary)
87 private val colorSecondaryLabelUnavailable =
88 Utils.applyAlpha(UNAVAILABLE_ALPHA, colorSecondaryLabelInactive)
89
90 private lateinit var label: TextView
91 protected lateinit var secondaryLabel: TextView
92 private lateinit var labelContainer: IgnorableChildLinearLayout
93 protected lateinit var sideView: ViewGroup
94 private lateinit var customDrawableView: ImageView
95 private lateinit var chevronView: ImageView
96
97 protected var showRippleEffect = true
98
99 private lateinit var ripple: RippleDrawable
100 private lateinit var colorBackgroundDrawable: Drawable
101 private var paintColor: Int = 0
102 private val singleAnimator: ValueAnimator = ValueAnimator().apply {
103 setDuration(QS_ANIM_LENGTH)
104 addUpdateListener { animation ->
105 setAllColors(
106 // These casts will throw an exception if some property is missing. We should
107 // always have all properties.
108 animation.getAnimatedValue(BACKGROUND_NAME) as Int,
109 animation.getAnimatedValue(LABEL_NAME) as Int,
110 animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
111 animation.getAnimatedValue(CHEVRON_NAME) as Int
112 )
113 }
114 }
115
116 private var accessibilityClass: String? = null
117 private var stateDescriptionDeltas: CharSequence? = null
118 private var lastStateDescription: CharSequence? = null
119 private var tileState = false
120 private var lastState = INVALID
121
122 private val locInScreen = IntArray(2)
123
124 init {
125 setId(generateViewId())
126 orientation = LinearLayout.HORIZONTAL
127 gravity = Gravity.CENTER_VERTICAL or Gravity.START
128 importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
129 clipChildren = false
130 clipToPadding = false
131 isFocusable = true
132 background = createTileBackground()
133 setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
134
135 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
136 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
137 setPaddingRelative(startPadding, padding, padding, padding)
138
139 val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
140 addView(_icon, LayoutParams(iconSize, iconSize))
141
142 createAndAddLabels()
143 createAndAddSideView()
144 }
145
146 override fun onConfigurationChanged(newConfig: Configuration?) {
147 super.onConfigurationChanged(newConfig)
148 updateResources()
149 }
150
151 fun updateResources() {
152 FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
153 FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
154
155 val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
156 _icon.layoutParams.apply {
157 height = iconSize
158 width = iconSize
159 }
160
161 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
162 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
163 setPaddingRelative(startPadding, padding, padding, padding)
164
165 val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
166 (labelContainer.layoutParams as MarginLayoutParams).apply {
167 marginStart = labelMargin
168 }
169
170 (sideView.layoutParams as MarginLayoutParams).apply {
171 marginStart = labelMargin
172 }
173 (chevronView.layoutParams as MarginLayoutParams).apply {
174 height = iconSize
175 width = iconSize
176 }
177
178 val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
179 (customDrawableView.layoutParams as MarginLayoutParams).apply {
180 height = iconSize
181 marginEnd = endMargin
182 }
183 }
184
185 private fun createAndAddLabels() {
186 labelContainer = LayoutInflater.from(context)
187 .inflate(R.layout.qs_tile_label, this, false) as IgnorableChildLinearLayout
188 label = labelContainer.requireViewById(R.id.tile_label)
189 secondaryLabel = labelContainer.requireViewById(R.id.app_label)
190 if (collapsed) {
191 labelContainer.ignoreLastView = true
192 // Ideally, it'd be great if the parent could set this up when measuring just this child
193 // instead of the View class having to support this. However, due to the mysteries of
194 // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its
195 // sibling methods to have special behavior for labelContainer.
196 labelContainer.forceUnspecifiedMeasure = true
197 secondaryLabel.alpha = 0f
198 }
199 setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
200 setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
201 addView(labelContainer)
202 }
203
204 private fun createAndAddSideView() {
205 sideView = LayoutInflater.from(context)
206 .inflate(R.layout.qs_tile_side_icon, this, false) as ViewGroup
207 customDrawableView = sideView.requireViewById(R.id.customDrawable)
208 chevronView = sideView.requireViewById(R.id.chevron)
209 setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
210 addView(sideView)
211 }
212
213 fun createTileBackground(): Drawable {
214 ripple = mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
215 colorBackgroundDrawable = ripple.findDrawableByLayerId(R.id.background)
216 return ripple
217 }
218
219 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
220 super.onLayout(changed, l, t, r, b)
221 if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
222 bottom = top + heightOverride
223 }
224 }
225
226 override fun updateAccessibilityOrder(previousView: View?): View {
227 accessibilityTraversalAfter = previousView?.id ?: ID_NULL
228 return this
229 }
230
231 override fun getIcon(): QSIconView {
232 return _icon
233 }
234
235 override fun getIconWithBackground(): View {
236 return icon
237 }
238
239 override fun init(tile: QSTile) {
240 init(
241 { v: View? -> tile.click(this) },
242 { view: View? ->
243 tile.longClick(this)
244 true
245 }
246 )
247 }
248
249 private fun init(
250 click: OnClickListener?,
251 longClick: OnLongClickListener?
252 ) {
253 setOnClickListener(click)
254 onLongClickListener = longClick
255 }
256
257 override fun onStateChanged(state: QSTile.State) {
258 post {
259 handleStateChanged(state)
260 }
261 }
262
263 override fun getDetailY(): Int {
264 return top + height / 2
265 }
266
267 override fun hasOverlappingRendering(): Boolean {
268 // Avoid layers for this layout - we don't need them.
269 return false
270 }
271
272 override fun setClickable(clickable: Boolean) {
273 super.setClickable(clickable)
274 background = if (clickable && showRippleEffect) {
275 ripple.also {
276 // In case that the colorBackgroundDrawable was used as the background, make sure
277 // it has the correct callback instead of null
278 colorBackgroundDrawable.callback = it
279 }
280 } else {
281 colorBackgroundDrawable
282 }
283 }
284
285 override fun getLabelContainer(): View {
286 return labelContainer
287 }
288
289 override fun getSecondaryLabel(): View {
290 return secondaryLabel
291 }
292
293 override fun getSecondaryIcon(): View {
294 return sideView
295 }
296
297 // Accessibility
298
299 override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
300 super.onInitializeAccessibilityEvent(event)
301 if (!TextUtils.isEmpty(accessibilityClass)) {
302 event.className = accessibilityClass
303 }
304 if (event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
305 stateDescriptionDeltas != null) {
306 event.text.add(stateDescriptionDeltas)
307 stateDescriptionDeltas = null
308 }
309 }
310
311 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
312 super.onInitializeAccessibilityNodeInfo(info)
313 // Clear selected state so it is not announce by talkback.
314 info.isSelected = false
315 if (!TextUtils.isEmpty(accessibilityClass)) {
316 info.className = accessibilityClass
317 if (Switch::class.java.name == accessibilityClass) {
318 val label = resources.getString(
319 if (tileState) R.string.switch_bar_on else R.string.switch_bar_off)
320 // Set the text here for tests in
321 // android.platform.test.scenario.sysui.quicksettings. Can be removed when
322 // UiObject2 has a new getStateDescription() API and tests are updated.
323 info.text = label
324 info.isChecked = tileState
325 info.isCheckable = true
326 if (isLongClickable) {
327 info.addAction(
328 AccessibilityNodeInfo.AccessibilityAction(
329 AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
330 resources.getString(
331 R.string.accessibility_long_click_tile)))
332 }
333 }
334 }
335 }
336
337 override fun toString(): String {
338 val sb = StringBuilder(javaClass.simpleName).append('[')
339 sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
340 sb.append(", iconView=$_icon")
341 sb.append(", tileState=$tileState")
342 sb.append("]")
343 return sb.toString()
344 }
345
346 // HANDLE STATE CHANGES RELATED METHODS
347
348 protected open fun handleStateChanged(state: QSTile.State) {
349 val allowAnimations = animationsEnabled()
350 showRippleEffect = state.showRippleEffect
351 isClickable = state.state != Tile.STATE_UNAVAILABLE
352 isLongClickable = state.handlesLongClick
353 icon.setIcon(state, allowAnimations)
354 contentDescription = state.contentDescription
355
356 // State handling and description
357 val stateDescription = StringBuilder()
358 val stateText = getStateText(state)
359 if (!TextUtils.isEmpty(stateText)) {
360 stateDescription.append(stateText)
361 if (TextUtils.isEmpty(state.secondaryLabel)) {
362 state.secondaryLabel = stateText
363 }
364 }
365 if (!TextUtils.isEmpty(state.stateDescription)) {
366 stateDescription.append(", ")
367 stateDescription.append(state.stateDescription)
368 if (lastState != INVALID && state.state == lastState &&
369 state.stateDescription != lastStateDescription) {
370 stateDescriptionDeltas = state.stateDescription
371 }
372 }
373
374 setStateDescription(stateDescription.toString())
375 lastStateDescription = state.stateDescription
376
377 accessibilityClass = if (state.state == Tile.STATE_UNAVAILABLE) {
378 null
379 } else {
380 state.expandedAccessibilityClassName
381 }
382
383 if (state is BooleanState) {
384 val newState = state.value
385 if (tileState != newState) {
386 tileState = newState
387 }
388 }
389 //
390
391 // Labels
392 if (!Objects.equals(label.text, state.label)) {
393 label.text = state.label
394 }
395 if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
396 secondaryLabel.text = state.secondaryLabel
397 secondaryLabel.visibility = if (TextUtils.isEmpty(state.secondaryLabel)) {
398 GONE
399 } else {
400 VISIBLE
401 }
402 }
403
404 // Colors
405 if (state.state != lastState) {
406 singleAnimator.cancel()
407 if (allowAnimations) {
408 singleAnimator.setValues(
409 colorValuesHolder(
410 BACKGROUND_NAME,
411 paintColor,
412 getBackgroundColorForState(state.state)
413 ),
414 colorValuesHolder(
415 LABEL_NAME,
416 label.currentTextColor,
417 getLabelColorForState(state.state)
418 ),
419 colorValuesHolder(
420 SECONDARY_LABEL_NAME,
421 secondaryLabel.currentTextColor,
422 getSecondaryLabelColorForState(state.state)
423 ),
424 colorValuesHolder(
425 CHEVRON_NAME,
426 chevronView.imageTintList?.defaultColor ?: 0,
427 getChevronColorForState(state.state)
428 )
429 )
430 singleAnimator.start()
431 } else {
432 setAllColors(
433 getBackgroundColorForState(state.state),
434 getLabelColorForState(state.state),
435 getSecondaryLabelColorForState(state.state),
436 getChevronColorForState(state.state)
437 )
438 }
439 }
440
441 // Right side icon
442 loadSideViewDrawableIfNecessary(state)
443
444 label.isEnabled = !state.disabledByPolicy
445
446 lastState = state.state
447 }
448
449 private fun setAllColors(
450 backgroundColor: Int,
451 labelColor: Int,
452 secondaryLabelColor: Int,
453 chevronColor: Int
454 ) {
455 setColor(backgroundColor)
456 setLabelColor(labelColor)
457 setSecondaryLabelColor(secondaryLabelColor)
458 setChevronColor(chevronColor)
459 }
460
461 private fun setColor(color: Int) {
462 colorBackgroundDrawable.setTint(color)
463 paintColor = color
464 }
465
466 private fun setLabelColor(color: Int) {
467 label.setTextColor(color)
468 }
469
470 private fun setSecondaryLabelColor(color: Int) {
471 secondaryLabel.setTextColor(color)
472 }
473
474 private fun setChevronColor(color: Int) {
475 chevronView.imageTintList = ColorStateList.valueOf(color)
476 }
477
478 private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
479 if (state.sideViewCustomDrawable != null) {
480 customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
481 customDrawableView.visibility = VISIBLE
482 chevronView.visibility = GONE
483 } else if (state !is BooleanState || state.forceExpandIcon) {
484 customDrawableView.setImageDrawable(null)
485 customDrawableView.visibility = GONE
486 chevronView.visibility = VISIBLE
487 } else {
488 customDrawableView.setImageDrawable(null)
489 customDrawableView.visibility = GONE
490 chevronView.visibility = GONE
491 }
492 }
493
494 private fun getStateText(state: QSTile.State): String {
495 if (state.disabledByPolicy) {
496 return context.getString(R.string.tile_disabled)
497 }
498
499 return if (state.state == Tile.STATE_UNAVAILABLE || state is BooleanState) {
500 var arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec)
501 val array = resources.getStringArray(arrayResId)
502 array[state.state]
503 } else {
504 ""
505 }
506 }
507
508 /*
509 * The view should not be animated if it's not on screen and no part of it is visible.
510 */
511 protected open fun animationsEnabled(): Boolean {
512 if (!isShown) {
513 return false
514 }
515 if (alpha != 1f) {
516 return false
517 }
518 getLocationOnScreen(locInScreen)
519 return locInScreen.get(1) >= -height
520 }
521
522 private fun getBackgroundColorForState(state: Int): Int {
523 return when (state) {
524 Tile.STATE_ACTIVE -> colorActive
525 Tile.STATE_INACTIVE -> colorInactive
526 Tile.STATE_UNAVAILABLE -> colorUnavailable
527 else -> {
528 Log.e(TAG, "Invalid state $state")
529 0
530 }
531 }
532 }
533
534 private fun getLabelColorForState(state: Int): Int {
535 return when (state) {
536 Tile.STATE_ACTIVE -> colorLabelActive
537 Tile.STATE_INACTIVE -> colorLabelInactive
538 Tile.STATE_UNAVAILABLE -> colorLabelUnavailable
539 else -> {
540 Log.e(TAG, "Invalid state $state")
541 0
542 }
543 }
544 }
545
546 private fun getSecondaryLabelColorForState(state: Int): Int {
547 return when (state) {
548 Tile.STATE_ACTIVE -> colorSecondaryLabelActive
549 Tile.STATE_INACTIVE -> colorSecondaryLabelInactive
550 Tile.STATE_UNAVAILABLE -> colorSecondaryLabelUnavailable
551 else -> {
552 Log.e(TAG, "Invalid state $state")
553 0
554 }
555 }
556 }
557
558 private fun getChevronColorForState(state: Int): Int = getSecondaryLabelColorForState(state)
559 }
560
561 @VisibleForTesting
562 internal object SubtitleArrayMapping {
563 private val subtitleIdsMap = mapOf<String?, Int>(
564 "internet" to R.array.tile_states_internet,
565 "wifi" to R.array.tile_states_wifi,
566 "cell" to R.array.tile_states_cell,
567 "battery" to R.array.tile_states_battery,
568 "dnd" to R.array.tile_states_dnd,
569 "flashlight" to R.array.tile_states_flashlight,
570 "rotation" to R.array.tile_states_rotation,
571 "bt" to R.array.tile_states_bt,
572 "airplane" to R.array.tile_states_airplane,
573 "location" to R.array.tile_states_location,
574 "hotspot" to R.array.tile_states_hotspot,
575 "inversion" to R.array.tile_states_inversion,
576 "saver" to R.array.tile_states_saver,
577 "dark" to R.array.tile_states_dark,
578 "work" to R.array.tile_states_work,
579 "cast" to R.array.tile_states_cast,
580 "night" to R.array.tile_states_night,
581 "screenrecord" to R.array.tile_states_screenrecord,
582 "reverse" to R.array.tile_states_reverse,
583 "reduce_brightness" to R.array.tile_states_reduce_brightness,
584 "cameratoggle" to R.array.tile_states_cameratoggle,
585 "mictoggle" to R.array.tile_states_mictoggle,
586 "controls" to R.array.tile_states_controls,
587 "wallet" to R.array.tile_states_wallet,
588 "alarm" to R.array.tile_states_alarm
589 )
590
getSubtitleIdnull591 fun getSubtitleId(spec: String?): Int {
592 return subtitleIdsMap.getOrDefault(spec, R.array.tile_states_default)
593 }
594 }
595
colorValuesHoldernull596 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
597 return PropertyValuesHolder.ofInt(name, *values).apply {
598 setEvaluator(ArgbEvaluator.getInstance())
599 }
600 }