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.os.Trace
29 import android.service.quicksettings.Tile
30 import android.text.TextUtils
31 import android.util.Log
32 import android.view.Gravity
33 import android.view.LayoutInflater
34 import android.view.View
35 import android.view.ViewGroup
36 import android.view.accessibility.AccessibilityEvent
37 import android.view.accessibility.AccessibilityNodeInfo
38 import android.widget.Button
39 import android.widget.ImageView
40 import android.widget.LinearLayout
41 import android.widget.Switch
42 import android.widget.TextView
43 import androidx.annotation.VisibleForTesting
44 import com.android.settingslib.Utils
45 import com.android.systemui.FontSizeUtils
46 import com.android.systemui.R
47 import com.android.systemui.animation.LaunchableView
48 import com.android.systemui.animation.LaunchableViewDelegate
49 import com.android.systemui.plugins.qs.QSIconView
50 import com.android.systemui.plugins.qs.QSTile
51 import com.android.systemui.plugins.qs.QSTile.BooleanState
52 import com.android.systemui.plugins.qs.QSTileView
53 import com.android.systemui.qs.logging.QSLogger
54 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
55 import java.util.Objects
56
57 private const val TAG = "QSTileViewImpl"
58 open class QSTileViewImpl @JvmOverloads constructor(
59 context: Context,
60 private val _icon: QSIconView,
61 private val collapsed: Boolean = false
62 ) : QSTileView(context), HeightOverrideable, LaunchableView {
63
64 companion object {
65 private const val INVALID = -1
66 private const val BACKGROUND_NAME = "background"
67 private const val LABEL_NAME = "label"
68 private const val SECONDARY_LABEL_NAME = "secondaryLabel"
69 private const val CHEVRON_NAME = "chevron"
70 const val UNAVAILABLE_ALPHA = 0.3f
71 @VisibleForTesting
72 internal const val TILE_STATE_RES_PREFIX = "tile_states_"
73 }
74
75 private var _position: Int = INVALID
76
77 override fun setPosition(position: Int) {
78 _position = position
79 }
80
81 override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
82 set(value) {
83 if (field == value) return
84 field = value
85 updateHeight()
86 }
87
88 override var squishinessFraction: Float = 1f
89 set(value) {
90 if (field == value) return
91 field = value
92 updateHeight()
93 }
94
95 private val colorActive = Utils.getColorAttrDefaultColor(context,
96 com.android.internal.R.attr.colorAccentPrimary)
97 private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.offStateColor)
98 private val colorUnavailable = Utils.applyAlpha(UNAVAILABLE_ALPHA, colorInactive)
99
100 private val colorLabelActive =
101 Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.textColorOnAccent)
102 private val colorLabelInactive =
103 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimary)
104 private val colorLabelUnavailable =
105 Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.textColorTertiary)
106
107 private val colorSecondaryLabelActive =
108 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondaryInverse)
109 private val colorSecondaryLabelInactive =
110 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondary)
111 private val colorSecondaryLabelUnavailable =
112 Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.textColorTertiary)
113
114 private lateinit var label: TextView
115 protected lateinit var secondaryLabel: TextView
116 private lateinit var labelContainer: IgnorableChildLinearLayout
117 protected lateinit var sideView: ViewGroup
118 private lateinit var customDrawableView: ImageView
119 private lateinit var chevronView: ImageView
120 private var mQsLogger: QSLogger? = null
121 protected var showRippleEffect = true
122
123 private lateinit var ripple: RippleDrawable
124 private lateinit var colorBackgroundDrawable: Drawable
125 private var paintColor: Int = 0
126 private val singleAnimator: ValueAnimator = ValueAnimator().apply {
127 setDuration(QS_ANIM_LENGTH)
128 addUpdateListener { animation ->
129 setAllColors(
130 // These casts will throw an exception if some property is missing. We should
131 // always have all properties.
132 animation.getAnimatedValue(BACKGROUND_NAME) as Int,
133 animation.getAnimatedValue(LABEL_NAME) as Int,
134 animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
135 animation.getAnimatedValue(CHEVRON_NAME) as Int
136 )
137 }
138 }
139
140 private var accessibilityClass: String? = null
141 private var stateDescriptionDeltas: CharSequence? = null
142 private var lastStateDescription: CharSequence? = null
143 private var tileState = false
144 private var lastState = INVALID
145 private val launchableViewDelegate = LaunchableViewDelegate(
146 this,
147 superSetVisibility = { super.setVisibility(it) },
148 )
149 private var lastDisabledByPolicy = false
150
151 private val locInScreen = IntArray(2)
152
153 init {
154 setId(generateViewId())
155 orientation = LinearLayout.HORIZONTAL
156 gravity = Gravity.CENTER_VERTICAL or Gravity.START
157 importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
158 clipChildren = false
159 clipToPadding = false
160 isFocusable = true
161 background = createTileBackground()
162 setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
163
164 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
165 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
166 setPaddingRelative(startPadding, padding, padding, padding)
167
168 val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
169 addView(_icon, LayoutParams(iconSize, iconSize))
170
171 createAndAddLabels()
172 createAndAddSideView()
173 }
174
175 override fun onConfigurationChanged(newConfig: Configuration?) {
176 super.onConfigurationChanged(newConfig)
177 updateResources()
178 }
179
180 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
181 Trace.traceBegin(Trace.TRACE_TAG_APP, "QSTileViewImpl#onMeasure")
182 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
183 Trace.endSection()
184 }
185
186 override fun resetOverride() {
187 heightOverride = HeightOverrideable.NO_OVERRIDE
188 updateHeight()
189 }
190
191 fun setQsLogger(qsLogger: QSLogger) {
192 mQsLogger = qsLogger
193 }
194
195 fun updateResources() {
196 FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
197 FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
198
199 val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
200 _icon.layoutParams.apply {
201 height = iconSize
202 width = iconSize
203 }
204
205 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
206 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
207 setPaddingRelative(startPadding, padding, padding, padding)
208
209 val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
210 (labelContainer.layoutParams as MarginLayoutParams).apply {
211 marginStart = labelMargin
212 }
213
214 (sideView.layoutParams as MarginLayoutParams).apply {
215 marginStart = labelMargin
216 }
217 (chevronView.layoutParams as MarginLayoutParams).apply {
218 height = iconSize
219 width = iconSize
220 }
221
222 val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
223 (customDrawableView.layoutParams as MarginLayoutParams).apply {
224 height = iconSize
225 marginEnd = endMargin
226 }
227 }
228
229 private fun createAndAddLabels() {
230 labelContainer = LayoutInflater.from(context)
231 .inflate(R.layout.qs_tile_label, this, false) as IgnorableChildLinearLayout
232 label = labelContainer.requireViewById(R.id.tile_label)
233 secondaryLabel = labelContainer.requireViewById(R.id.app_label)
234 if (collapsed) {
235 labelContainer.ignoreLastView = true
236 // Ideally, it'd be great if the parent could set this up when measuring just this child
237 // instead of the View class having to support this. However, due to the mysteries of
238 // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its
239 // sibling methods to have special behavior for labelContainer.
240 labelContainer.forceUnspecifiedMeasure = true
241 secondaryLabel.alpha = 0f
242 }
243 setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
244 setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
245 addView(labelContainer)
246 }
247
248 private fun createAndAddSideView() {
249 sideView = LayoutInflater.from(context)
250 .inflate(R.layout.qs_tile_side_icon, this, false) as ViewGroup
251 customDrawableView = sideView.requireViewById(R.id.customDrawable)
252 chevronView = sideView.requireViewById(R.id.chevron)
253 setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
254 addView(sideView)
255 }
256
257 fun createTileBackground(): Drawable {
258 ripple = mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
259 colorBackgroundDrawable = ripple.findDrawableByLayerId(R.id.background)
260 return ripple
261 }
262
263 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
264 super.onLayout(changed, l, t, r, b)
265 updateHeight()
266 }
267
268 private fun updateHeight() {
269 val actualHeight = if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
270 heightOverride
271 } else {
272 measuredHeight
273 }
274 // Limit how much we affect the height, so we don't have rounding artifacts when the tile
275 // is too short.
276 val constrainedSquishiness = constrainSquishiness(squishinessFraction)
277 bottom = top + (actualHeight * constrainedSquishiness).toInt()
278 scrollY = (actualHeight - height) / 2
279 }
280
281 override fun updateAccessibilityOrder(previousView: View?): View {
282 accessibilityTraversalAfter = previousView?.id ?: ID_NULL
283 return this
284 }
285
286 override fun getIcon(): QSIconView {
287 return _icon
288 }
289
290 override fun getIconWithBackground(): View {
291 return icon
292 }
293
294 override fun init(tile: QSTile) {
295 init(
296 { v: View? -> tile.click(this) },
297 { view: View? ->
298 tile.longClick(this)
299 true
300 }
301 )
302 }
303
304 private fun init(
305 click: OnClickListener?,
306 longClick: OnLongClickListener?
307 ) {
308 setOnClickListener(click)
309 onLongClickListener = longClick
310 }
311
312 override fun onStateChanged(state: QSTile.State) {
313 post {
314 handleStateChanged(state)
315 }
316 }
317
318 override fun getDetailY(): Int {
319 return top + height / 2
320 }
321
322 override fun hasOverlappingRendering(): Boolean {
323 // Avoid layers for this layout - we don't need them.
324 return false
325 }
326
327 override fun setClickable(clickable: Boolean) {
328 super.setClickable(clickable)
329 background = if (clickable && showRippleEffect) {
330 ripple.also {
331 // In case that the colorBackgroundDrawable was used as the background, make sure
332 // it has the correct callback instead of null
333 colorBackgroundDrawable.callback = it
334 }
335 } else {
336 colorBackgroundDrawable
337 }
338 }
339
340 override fun getLabelContainer(): View {
341 return labelContainer
342 }
343
344 override fun getLabel(): View {
345 return label
346 }
347
348 override fun getSecondaryLabel(): View {
349 return secondaryLabel
350 }
351
352 override fun getSecondaryIcon(): View {
353 return sideView
354 }
355
356 override fun setShouldBlockVisibilityChanges(block: Boolean) {
357 launchableViewDelegate.setShouldBlockVisibilityChanges(block)
358 }
359
360 override fun setVisibility(visibility: Int) {
361 launchableViewDelegate.setVisibility(visibility)
362 }
363
364 // Accessibility
365
366 override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
367 super.onInitializeAccessibilityEvent(event)
368 if (!TextUtils.isEmpty(accessibilityClass)) {
369 event.className = accessibilityClass
370 }
371 if (event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
372 stateDescriptionDeltas != null) {
373 event.text.add(stateDescriptionDeltas)
374 stateDescriptionDeltas = null
375 }
376 }
377
378 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
379 super.onInitializeAccessibilityNodeInfo(info)
380 // Clear selected state so it is not announce by talkback.
381 info.isSelected = false
382 if (lastDisabledByPolicy) {
383 info.addAction(
384 AccessibilityNodeInfo.AccessibilityAction(
385 AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id,
386 resources.getString(
387 R.string.accessibility_tile_disabled_by_policy_action_description
388 )
389 )
390 )
391 }
392 if (!TextUtils.isEmpty(accessibilityClass)) {
393 info.className = if (lastDisabledByPolicy) {
394 Button::class.java.name
395 } else {
396 accessibilityClass
397 }
398 if (Switch::class.java.name == accessibilityClass) {
399 val label = resources.getString(
400 if (tileState) R.string.switch_bar_on else R.string.switch_bar_off)
401 // Set the text here for tests in
402 // android.platform.test.scenario.sysui.quicksettings. Can be removed when
403 // UiObject2 has a new getStateDescription() API and tests are updated.
404 info.text = label
405 info.isChecked = tileState
406 info.isCheckable = true
407 if (isLongClickable) {
408 info.addAction(
409 AccessibilityNodeInfo.AccessibilityAction(
410 AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
411 resources.getString(
412 R.string.accessibility_long_click_tile)))
413 }
414 }
415 }
416 if (_position != INVALID) {
417 info.collectionItemInfo =
418 AccessibilityNodeInfo.CollectionItemInfo(_position, 1, 0, 1, false)
419 }
420 }
421
422 override fun toString(): String {
423 val sb = StringBuilder(javaClass.simpleName).append('[')
424 sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
425 sb.append(", iconView=$_icon")
426 sb.append(", tileState=$tileState")
427 sb.append("]")
428 return sb.toString()
429 }
430
431 // HANDLE STATE CHANGES RELATED METHODS
432
433 protected open fun handleStateChanged(state: QSTile.State) {
434 val allowAnimations = animationsEnabled()
435 showRippleEffect = state.showRippleEffect
436 isClickable = state.state != Tile.STATE_UNAVAILABLE
437 isLongClickable = state.handlesLongClick
438 icon.setIcon(state, allowAnimations)
439 contentDescription = state.contentDescription
440
441 // State handling and description
442 val stateDescription = StringBuilder()
443 val arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec)
444 val stateText = state.getStateText(arrayResId, resources)
445 state.secondaryLabel = state.getSecondaryLabel(stateText)
446 if (!TextUtils.isEmpty(stateText)) {
447 stateDescription.append(stateText)
448 }
449 if (state.disabledByPolicy && state.state != Tile.STATE_UNAVAILABLE) {
450 stateDescription.append(", ")
451 stateDescription.append(getUnavailableText(state.spec))
452 }
453 if (!TextUtils.isEmpty(state.stateDescription)) {
454 stateDescription.append(", ")
455 stateDescription.append(state.stateDescription)
456 if (lastState != INVALID && state.state == lastState &&
457 state.stateDescription != lastStateDescription) {
458 stateDescriptionDeltas = state.stateDescription
459 }
460 }
461
462 setStateDescription(stateDescription.toString())
463 lastStateDescription = state.stateDescription
464
465 accessibilityClass = if (state.state == Tile.STATE_UNAVAILABLE) {
466 null
467 } else {
468 state.expandedAccessibilityClassName
469 }
470
471 if (state is BooleanState) {
472 val newState = state.value
473 if (tileState != newState) {
474 tileState = newState
475 }
476 }
477 //
478
479 // Labels
480 if (!Objects.equals(label.text, state.label)) {
481 label.text = state.label
482 }
483 if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
484 secondaryLabel.text = state.secondaryLabel
485 secondaryLabel.visibility = if (TextUtils.isEmpty(state.secondaryLabel)) {
486 GONE
487 } else {
488 VISIBLE
489 }
490 }
491
492 // Colors
493 if (state.state != lastState || state.disabledByPolicy || lastDisabledByPolicy) {
494 singleAnimator.cancel()
495 mQsLogger?.logTileBackgroundColorUpdateIfInternetTile(
496 state.spec,
497 state.state,
498 state.disabledByPolicy,
499 getBackgroundColorForState(state.state, state.disabledByPolicy))
500 if (allowAnimations) {
501 singleAnimator.setValues(
502 colorValuesHolder(
503 BACKGROUND_NAME,
504 paintColor,
505 getBackgroundColorForState(state.state, state.disabledByPolicy)
506 ),
507 colorValuesHolder(
508 LABEL_NAME,
509 label.currentTextColor,
510 getLabelColorForState(state.state, state.disabledByPolicy)
511 ),
512 colorValuesHolder(
513 SECONDARY_LABEL_NAME,
514 secondaryLabel.currentTextColor,
515 getSecondaryLabelColorForState(state.state, state.disabledByPolicy)
516 ),
517 colorValuesHolder(
518 CHEVRON_NAME,
519 chevronView.imageTintList?.defaultColor ?: 0,
520 getChevronColorForState(state.state, state.disabledByPolicy)
521 )
522 )
523 singleAnimator.start()
524 } else {
525 setAllColors(
526 getBackgroundColorForState(state.state, state.disabledByPolicy),
527 getLabelColorForState(state.state, state.disabledByPolicy),
528 getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
529 getChevronColorForState(state.state, state.disabledByPolicy)
530 )
531 }
532 }
533
534 // Right side icon
535 loadSideViewDrawableIfNecessary(state)
536
537 label.isEnabled = !state.disabledByPolicy
538
539 lastState = state.state
540 lastDisabledByPolicy = state.disabledByPolicy
541 }
542
543 private fun setAllColors(
544 backgroundColor: Int,
545 labelColor: Int,
546 secondaryLabelColor: Int,
547 chevronColor: Int
548 ) {
549 setColor(backgroundColor)
550 setLabelColor(labelColor)
551 setSecondaryLabelColor(secondaryLabelColor)
552 setChevronColor(chevronColor)
553 }
554
555 private fun setColor(color: Int) {
556 colorBackgroundDrawable.mutate().setTint(color)
557 paintColor = color
558 }
559
560 private fun setLabelColor(color: Int) {
561 label.setTextColor(color)
562 }
563
564 private fun setSecondaryLabelColor(color: Int) {
565 secondaryLabel.setTextColor(color)
566 }
567
568 private fun setChevronColor(color: Int) {
569 chevronView.imageTintList = ColorStateList.valueOf(color)
570 }
571
572 private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
573 if (state.sideViewCustomDrawable != null) {
574 customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
575 customDrawableView.visibility = VISIBLE
576 chevronView.visibility = GONE
577 } else if (state !is BooleanState || state.forceExpandIcon) {
578 customDrawableView.setImageDrawable(null)
579 customDrawableView.visibility = GONE
580 chevronView.visibility = VISIBLE
581 } else {
582 customDrawableView.setImageDrawable(null)
583 customDrawableView.visibility = GONE
584 chevronView.visibility = GONE
585 }
586 }
587
588 private fun getUnavailableText(spec: String?): String {
589 val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
590 return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE]
591 }
592
593 /*
594 * The view should not be animated if it's not on screen and no part of it is visible.
595 */
596 protected open fun animationsEnabled(): Boolean {
597 if (!isShown) {
598 return false
599 }
600 if (alpha != 1f) {
601 return false
602 }
603 getLocationOnScreen(locInScreen)
604 return locInScreen.get(1) >= -height
605 }
606
607 private fun getBackgroundColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
608 return when {
609 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorUnavailable
610 state == Tile.STATE_ACTIVE -> colorActive
611 state == Tile.STATE_INACTIVE -> colorInactive
612 else -> {
613 Log.e(TAG, "Invalid state $state")
614 0
615 }
616 }
617 }
618
619 private fun getLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
620 return when {
621 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorLabelUnavailable
622 state == Tile.STATE_ACTIVE -> colorLabelActive
623 state == Tile.STATE_INACTIVE -> colorLabelInactive
624 else -> {
625 Log.e(TAG, "Invalid state $state")
626 0
627 }
628 }
629 }
630
631 private fun getSecondaryLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
632 return when {
633 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorSecondaryLabelUnavailable
634 state == Tile.STATE_ACTIVE -> colorSecondaryLabelActive
635 state == Tile.STATE_INACTIVE -> colorSecondaryLabelInactive
636 else -> {
637 Log.e(TAG, "Invalid state $state")
638 0
639 }
640 }
641 }
642
643 private fun getChevronColorForState(state: Int, disabledByPolicy: Boolean = false): Int =
644 getSecondaryLabelColorForState(state, disabledByPolicy)
645
646 @VisibleForTesting
647 internal fun getCurrentColors(): List<Int> = listOf(
648 paintColor,
649 label.currentTextColor,
650 secondaryLabel.currentTextColor,
651 chevronView.imageTintList?.defaultColor ?: 0
652 )
653 }
654
constrainSquishinessnull655 fun constrainSquishiness(squish: Float): Float {
656 return 0.1f + squish * 0.9f
657 }
658
colorValuesHoldernull659 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
660 return PropertyValuesHolder.ofInt(name, *values).apply {
661 setEvaluator(ArgbEvaluator.getInstance())
662 }
663 }
664