xref: /aosp_15_r20/frameworks/base/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)
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.annotation.SuppressLint
23 import android.content.Context
24 import android.content.res.ColorStateList
25 import android.content.res.Configuration
26 import android.content.res.Resources.ID_NULL
27 import android.graphics.Color
28 import android.graphics.PorterDuff
29 import android.graphics.Rect
30 import android.graphics.Typeface
31 import android.graphics.drawable.Drawable
32 import android.graphics.drawable.GradientDrawable
33 import android.graphics.drawable.LayerDrawable
34 import android.graphics.drawable.RippleDrawable
35 import android.os.Trace
36 import android.service.quicksettings.Tile
37 import android.text.TextUtils
38 import android.util.Log
39 import android.util.TypedValue
40 import android.view.Gravity
41 import android.view.LayoutInflater
42 import android.view.MotionEvent
43 import android.view.View
44 import android.view.ViewConfiguration
45 import android.view.ViewGroup
46 import android.view.accessibility.AccessibilityEvent
47 import android.view.accessibility.AccessibilityNodeInfo
48 import android.view.animation.AccelerateDecelerateInterpolator
49 import android.widget.Button
50 import android.widget.ImageView
51 import android.widget.LinearLayout
52 import android.widget.Switch
53 import android.widget.TextView
54 import androidx.annotation.VisibleForTesting
55 import androidx.core.animation.doOnCancel
56 import androidx.core.animation.doOnEnd
57 import androidx.core.animation.doOnStart
58 import androidx.core.graphics.drawable.updateBounds
59 import com.android.app.tracing.traceSection
60 import com.android.settingslib.Utils
61 import com.android.systemui.Flags
62 import com.android.systemui.FontSizeUtils
63 import com.android.systemui.animation.Expandable
64 import com.android.systemui.animation.LaunchableView
65 import com.android.systemui.animation.LaunchableViewDelegate
66 import com.android.systemui.haptics.qs.QSLongPressEffect
67 import com.android.systemui.plugins.qs.QSIconView
68 import com.android.systemui.plugins.qs.QSTile
69 import com.android.systemui.plugins.qs.QSTile.AdapterState
70 import com.android.systemui.plugins.qs.QSTileView
71 import com.android.systemui.qs.logging.QSLogger
72 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
73 import com.android.systemui.res.R
74 import java.util.Objects
75 
76 private const val TAG = "QSTileViewImpl"
77 
78 open class QSTileViewImpl
79 @JvmOverloads
80 constructor(
81     context: Context,
82     private val collapsed: Boolean = false,
83     private val longPressEffect: QSLongPressEffect? = null,
84 ) : QSTileView(context), HeightOverrideable, LaunchableView {
85 
86     companion object {
87         private const val INVALID = -1
88         private const val BACKGROUND_NAME = "background"
89         private const val LABEL_NAME = "label"
90         private const val SECONDARY_LABEL_NAME = "secondaryLabel"
91         private const val CHEVRON_NAME = "chevron"
92         private const val OVERLAY_NAME = "overlay"
93         const val UNAVAILABLE_ALPHA = 0.3f
94         @VisibleForTesting internal const val TILE_STATE_RES_PREFIX = "tile_states_"
95         @VisibleForTesting internal const val LONG_PRESS_EFFECT_WIDTH_SCALE = 1.1f
96         @VisibleForTesting internal const val LONG_PRESS_EFFECT_HEIGHT_SCALE = 1.2f
97         internal val EMPTY_RECT = Rect()
98     }
99 
100     private val icon: QSIconViewImpl = QSIconViewImpl(context)
101     private var position: Int = INVALID
102 
103     override fun setPosition(position: Int) {
104         this.position = position
105     }
106 
107     override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
108         set(value) {
109             if (field == value) return
110             field = value
111             if (longPressEffect?.state != QSLongPressEffect.State.RUNNING_BACKWARDS_FROM_CANCEL) {
112                 updateHeight()
113             }
114         }
115 
116     override var squishinessFraction: Float = 1f
117         set(value) {
118             if (field == value) return
119             field = value
120             updateHeight()
121         }
122 
123     private val colorActive = Utils.getColorAttrDefaultColor(context, R.attr.shadeActive)
124     private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.shadeInactive)
125     private val colorUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.shadeDisabled)
126 
127     private val overlayColorActive =
128         Utils.applyAlpha(
129             /* alpha= */ 0.11f,
130             Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive),
131         )
132     private val overlayColorInactive =
133         Utils.applyAlpha(
134             /* alpha= */ 0.08f,
135             Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive),
136         )
137 
138     private val colorLabelActive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive)
139     private val colorLabelInactive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive)
140     private val colorLabelUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.outline)
141 
142     private val colorSecondaryLabelActive =
143         Utils.getColorAttrDefaultColor(context, R.attr.onShadeActiveVariant)
144     private val colorSecondaryLabelInactive =
145         Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactiveVariant)
146     private val colorSecondaryLabelUnavailable =
147         Utils.getColorAttrDefaultColor(context, R.attr.outline)
148 
149     private lateinit var label: TextView
150     protected lateinit var secondaryLabel: TextView
151     private lateinit var labelContainer: IgnorableChildLinearLayout
152     protected lateinit var sideView: ViewGroup
153     private lateinit var customDrawableView: ImageView
154     private lateinit var chevronView: ImageView
155     private var mQsLogger: QSLogger? = null
156 
157     /** Controls if tile background is set to a [RippleDrawable] see [setClickable] */
158     protected var showRippleEffect = true
159 
160     private lateinit var qsTileBackground: RippleDrawable
161     private lateinit var qsTileFocusBackground: Drawable
162     private lateinit var backgroundDrawable: LayerDrawable
163     private lateinit var backgroundBaseDrawable: Drawable
164     private lateinit var backgroundOverlayDrawable: Drawable
165 
166     private var backgroundColor: Int = 0
167     private var backgroundOverlayColor: Int = 0
168 
169     private val singleAnimator: ValueAnimator =
170         ValueAnimator().apply {
171             setDuration(QS_ANIM_LENGTH)
172             addUpdateListener { animation ->
173                 setAllColors(
174                     // These casts will throw an exception if some property is missing. We should
175                     // always have all properties.
176                     animation.getAnimatedValue(BACKGROUND_NAME) as Int,
177                     animation.getAnimatedValue(LABEL_NAME) as Int,
178                     animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
179                     animation.getAnimatedValue(CHEVRON_NAME) as Int,
180                     animation.getAnimatedValue(OVERLAY_NAME) as Int,
181                 )
182             }
183         }
184 
185     private var accessibilityClass: String? = null
186     private var stateDescriptionDeltas: CharSequence? = null
187     private var lastStateDescription: CharSequence? = null
188     private var tileState = false
189     private var lastState = INVALID
190     private var lastIconTint = 0
191     private val launchableViewDelegate =
192         LaunchableViewDelegate(this, superSetVisibility = { super.setVisibility(it) })
193     private var lastDisabledByPolicy = false
194 
195     private val locInScreen = IntArray(2)
196 
197     /** Visuo-haptic long-press effects */
198     private var longPressEffectAnimator: ValueAnimator? = null
199     var haveLongPressPropertiesBeenReset = true
200         private set
201 
202     private var paddingForLaunch = Rect()
203     private var initialLongPressProperties: QSLongPressProperties? = null
204     private var finalLongPressProperties: QSLongPressProperties? = null
205     private val colorEvaluator = ArgbEvaluator.getInstance()
206     val isLongPressEffectInitialized: Boolean
207         get() = longPressEffect?.hasInitialized == true
208 
209     val areLongPressEffectPropertiesSet: Boolean
210         get() = initialLongPressProperties != null && finalLongPressProperties != null
211 
212     init {
213         val typedValue = TypedValue()
214         if (!getContext().theme.resolveAttribute(R.attr.isQsTheme, typedValue, true)) {
215             throw IllegalStateException(
216                 "QSViewImpl must be inflated with a theme that contains " +
217                     "Theme.SystemUI.QuickSettings"
218             )
219         }
220         setId(generateViewId())
221         orientation = LinearLayout.HORIZONTAL
222         gravity = Gravity.CENTER_VERTICAL or Gravity.START
223         importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
224         clipChildren = false
225         clipToPadding = false
226         isFocusable = true
227         background = createTileBackground()
228         setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
229 
230         val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
231         val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
232         setPaddingRelative(startPadding, padding, padding, padding)
233 
234         val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
235         addView(icon, LayoutParams(iconSize, iconSize))
236 
237         createAndAddLabels()
238         createAndAddSideView()
239     }
240 
241     override fun onConfigurationChanged(newConfig: Configuration?) {
242         super.onConfigurationChanged(newConfig)
243         updateResources()
244     }
245 
246     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
247         Trace.traceBegin(Trace.TRACE_TAG_APP, "QSTileViewImpl#onMeasure")
248         super.onMeasure(widthMeasureSpec, heightMeasureSpec)
249         Trace.endSection()
250     }
251 
252     override fun resetOverride() {
253         heightOverride = HeightOverrideable.NO_OVERRIDE
254         updateHeight()
255     }
256 
257     fun setQsLogger(qsLogger: QSLogger) {
258         mQsLogger = qsLogger
259     }
260 
261     fun updateResources() {
262         FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
263         FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
264 
265         val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
266         icon.layoutParams.apply {
267             height = iconSize
268             width = iconSize
269         }
270 
271         val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
272         val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
273         setPaddingRelative(startPadding, padding, padding, padding)
274 
275         val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
276         (labelContainer.layoutParams as MarginLayoutParams).apply { marginStart = labelMargin }
277 
278         (sideView.layoutParams as MarginLayoutParams).apply { marginStart = labelMargin }
279         (chevronView.layoutParams as MarginLayoutParams).apply {
280             height = iconSize
281             width = iconSize
282         }
283 
284         val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
285         (customDrawableView.layoutParams as MarginLayoutParams).apply {
286             height = iconSize
287             marginEnd = endMargin
288         }
289 
290         background = createTileBackground()
291         setColor(backgroundColor)
292         setOverlayColor(backgroundOverlayColor)
293     }
294 
295     private fun createAndAddLabels() {
296         labelContainer =
297             LayoutInflater.from(context).inflate(R.layout.qs_tile_label, this, false)
298                 as IgnorableChildLinearLayout
299         label = labelContainer.requireViewById(R.id.tile_label)
300         secondaryLabel = labelContainer.requireViewById(R.id.app_label)
301         if (collapsed) {
302             labelContainer.ignoreLastView = true
303             // Ideally, it'd be great if the parent could set this up when measuring just this child
304             // instead of the View class having to support this. However, due to the mysteries of
305             // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its
306             // sibling methods to have special behavior for labelContainer.
307             labelContainer.forceUnspecifiedMeasure = true
308             secondaryLabel.alpha = 0f
309         }
310         setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
311         setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
312 
313         if (Flags.gsfQuickSettings()) {
314             label.apply {
315                 typeface = Typeface.create("gsf-title-small-emphasized", Typeface.NORMAL)
316             }
317             secondaryLabel.apply { typeface = Typeface.create("gsf-label-medium", Typeface.NORMAL) }
318         }
319 
320         addView(labelContainer)
321     }
322 
323     private fun createAndAddSideView() {
324         sideView =
325             LayoutInflater.from(context).inflate(R.layout.qs_tile_side_icon, this, false)
326                 as ViewGroup
327         customDrawableView = sideView.requireViewById(R.id.customDrawable)
328         chevronView = sideView.requireViewById(R.id.chevron)
329         setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
330         addView(sideView)
331     }
332 
333     private fun createTileBackground(): Drawable {
334         qsTileBackground =
335             if (Flags.qsTileFocusState()) {
336                 mContext.getDrawable(R.drawable.qs_tile_background_flagged) as RippleDrawable
337             } else {
338                 mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
339             }
340         qsTileFocusBackground = mContext.getDrawable(R.drawable.qs_tile_focused_background)!!
341         backgroundDrawable =
342             qsTileBackground.findDrawableByLayerId(R.id.background) as LayerDrawable
343         backgroundBaseDrawable =
344             backgroundDrawable.findDrawableByLayerId(R.id.qs_tile_background_base)
345         backgroundOverlayDrawable =
346             backgroundDrawable.findDrawableByLayerId(R.id.qs_tile_background_overlay)
347         backgroundOverlayDrawable.mutate().setTintMode(PorterDuff.Mode.SRC)
348         return qsTileBackground
349     }
350 
351     override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
352         super.onLayout(changed, l, t, r, b)
353         updateHeight()
354         maybeUpdateLongPressEffectWidth(measuredWidth.toFloat())
355     }
356 
357     private fun maybeUpdateLongPressEffectWidth(width: Float) {
358         if (!isLongClickable || longPressEffect == null) return
359 
360         initialLongPressProperties?.width = width
361         finalLongPressProperties?.width = LONG_PRESS_EFFECT_WIDTH_SCALE * width
362 
363         val deltaW = (LONG_PRESS_EFFECT_WIDTH_SCALE - 1f) * width
364         paddingForLaunch.left = -deltaW.toInt() / 2
365         paddingForLaunch.right = deltaW.toInt() / 2
366     }
367 
368     private fun maybeUpdateLongPressEffectHeight(height: Float) {
369         if (!isLongClickable || longPressEffect == null) return
370 
371         initialLongPressProperties?.height = height
372         finalLongPressProperties?.height = LONG_PRESS_EFFECT_HEIGHT_SCALE * height
373 
374         val deltaH = (LONG_PRESS_EFFECT_HEIGHT_SCALE - 1f) * height
375         paddingForLaunch.top = -deltaH.toInt() / 2
376         paddingForLaunch.bottom = deltaH.toInt() / 2
377     }
378 
379     override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
380         super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
381         if (Flags.qsTileFocusState()) {
382             if (gainFocus) {
383                 qsTileFocusBackground.setBounds(0, 0, width, height)
384                 overlay.add(qsTileFocusBackground)
385             } else {
386                 overlay.clear()
387             }
388         }
389     }
390 
391     private fun updateHeight() {
392         val actualHeight =
393             if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
394                 heightOverride
395             } else {
396                 measuredHeight
397             }
398         // Limit how much we affect the height, so we don't have rounding artifacts when the tile
399         // is too short.
400         val constrainedSquishiness = constrainSquishiness(squishinessFraction)
401         bottom = top + (actualHeight * constrainedSquishiness).toInt()
402         scrollY = (actualHeight - height) / 2
403         maybeUpdateLongPressEffectHeight(actualHeight.toFloat())
404     }
405 
406     override fun updateAccessibilityOrder(previousView: View?): View {
407         accessibilityTraversalAfter = previousView?.id ?: ID_NULL
408         return this
409     }
410 
411     override fun getIcon(): QSIconView {
412         return icon
413     }
414 
415     override fun getIconWithBackground(): View {
416         return icon
417     }
418 
419     override fun init(tile: QSTile) {
420         if (longPressEffect != null) {
421             isHapticFeedbackEnabled = false
422             longPressEffect.qsTile = tile
423             longPressEffect.createExpandableFromView(this)
424             initLongPressEffectCallback()
425             init(
426                 { _: View -> longPressEffect.onTileClick() },
427                 { _: View ->
428                     longPressEffect.onTileLongClick()
429                     true
430                 }, // Haptics and long-clicks are handled by [QSLongPressEffect]
431             )
432         } else {
433             val expandable = Expandable.fromView(this)
434             init(
435                 { _: View? -> tile.click(expandable) },
436                 { _: View? ->
437                     tile.longClick(expandable)
438                     true
439                 },
440             )
441         }
442     }
443 
444     private fun initLongPressEffectCallback() {
445         longPressEffect?.callback =
446             object : QSLongPressEffect.Callback {
447 
448                 override fun onResetProperties() {
449                     resetLongPressEffectProperties()
450                 }
451 
452                 override fun onEffectFinishedReversing() {
453                     // The long-press effect properties finished at the same starting point.
454                     // This is the same as if the properties were reset
455                     haveLongPressPropertiesBeenReset = true
456                 }
457 
458                 override fun onStartAnimator() {
459                     if (longPressEffectAnimator?.isRunning != true) {
460                         longPressEffectAnimator =
461                             ValueAnimator.ofFloat(0f, 1f).apply {
462                                 this.duration = longPressEffect?.effectDuration?.toLong() ?: 0L
463                                 interpolator = AccelerateDecelerateInterpolator()
464 
465                                 doOnStart { longPressEffect?.handleAnimationStart() }
466                                 addUpdateListener {
467                                     val value = animatedValue as Float
468                                     if (value == 0f) {
469                                         bringToFront()
470                                     } else {
471                                         updateLongPressEffectProperties(value)
472                                     }
473                                 }
474                                 doOnEnd { longPressEffect?.handleAnimationComplete() }
475                                 doOnCancel { longPressEffect?.handleAnimationCancel() }
476                                 start()
477                             }
478                     }
479                 }
480 
481                 override fun onReverseAnimator(playHaptics: Boolean) {
482                     longPressEffectAnimator?.let {
483                         val pausedProgress = it.animatedFraction
484                         if (playHaptics) longPressEffect?.playReverseHaptics(pausedProgress)
485                         it.reverse()
486                     }
487                 }
488 
489                 override fun onCancelAnimator() {
490                     resetLongPressEffectProperties()
491                     longPressEffectAnimator?.cancel()
492                 }
493             }
494     }
495 
496     private fun init(click: OnClickListener?, longClick: OnLongClickListener?) {
497         setOnClickListener(click)
498         onLongClickListener = longClick
499     }
500 
501     override fun onStateChanged(state: QSTile.State) {
502         // We cannot use the handler here because sometimes, the views are not attached (if they
503         // are in a page that the ViewPager hasn't attached). Instead, we use a runnable where
504         // all its instances are `equal` to each other, so they can be used to remove them from the
505         // queue.
506         // This means that at any given time there's at most one enqueued runnable to change state.
507         // However, as we only ever care about the last state posted, this is fine.
508         val runnable = StateChangeRunnable(state.copy())
509         removeCallbacks(runnable)
510         post(runnable)
511     }
512 
513     override fun getDetailY(): Int {
514         return top + height / 2
515     }
516 
517     override fun hasOverlappingRendering(): Boolean {
518         // Avoid layers for this layout - we don't need them.
519         return false
520     }
521 
522     override fun setClickable(clickable: Boolean) {
523         super.setClickable(clickable)
524         if (!Flags.qsTileFocusState()) {
525             background =
526                 if (clickable && showRippleEffect) {
527                     qsTileBackground.also {
528                         // In case that the colorBackgroundDrawable was used as the background, make
529                         // sure
530                         // it has the correct callback instead of null
531                         backgroundDrawable.callback = it
532                     }
533                 } else {
534                     backgroundDrawable
535                 }
536         }
537     }
538 
539     override fun getLabelContainer(): View {
540         return labelContainer
541     }
542 
543     override fun getLabel(): View {
544         return label
545     }
546 
547     override fun getSecondaryLabel(): View {
548         return secondaryLabel
549     }
550 
551     override fun getSecondaryIcon(): View {
552         return sideView
553     }
554 
555     override fun setShouldBlockVisibilityChanges(block: Boolean) {
556         launchableViewDelegate.setShouldBlockVisibilityChanges(block)
557     }
558 
559     override fun setVisibility(visibility: Int) {
560         launchableViewDelegate.setVisibility(visibility)
561     }
562 
563     // Accessibility
564 
565     override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
566         super.onInitializeAccessibilityEvent(event)
567         if (!TextUtils.isEmpty(accessibilityClass)) {
568             event.className = accessibilityClass
569         }
570         if (
571             event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
572                 stateDescriptionDeltas != null
573         ) {
574             event.text.add(stateDescriptionDeltas)
575             stateDescriptionDeltas = null
576         }
577     }
578 
579     override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
580         super.onInitializeAccessibilityNodeInfo(info)
581         // Clear selected state so it is not announce by talkback.
582         info.isSelected = false
583         info.text =
584             if (TextUtils.isEmpty(secondaryLabel.text)) {
585                 "${label.text}"
586             } else {
587                 "${label.text}, ${secondaryLabel.text}"
588             }
589         if (lastDisabledByPolicy) {
590             info.addAction(
591                 AccessibilityNodeInfo.AccessibilityAction(
592                     AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id,
593                     resources.getString(
594                         R.string.accessibility_tile_disabled_by_policy_action_description
595                     ),
596                 )
597             )
598         } else {
599             if (isLongClickable) {
600                 info.addAction(
601                     AccessibilityNodeInfo.AccessibilityAction(
602                         AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
603                         resources.getString(R.string.accessibility_long_click_tile),
604                     )
605                 )
606             }
607         }
608         if (!TextUtils.isEmpty(accessibilityClass)) {
609             info.className =
610                 if (lastDisabledByPolicy) {
611                     Button::class.java.name
612                 } else {
613                     accessibilityClass
614                 }
615             if (Switch::class.java.name == accessibilityClass) {
616                 info.isChecked = tileState
617                 info.isCheckable = true
618             }
619         }
620         if (position != INVALID) {
621             info.collectionItemInfo =
622                 AccessibilityNodeInfo.CollectionItemInfo(position, 1, 0, 1, false)
623         }
624     }
625 
626     override fun toString(): String {
627         val sb = StringBuilder(javaClass.simpleName).append('[')
628         sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
629         sb.append(", iconView=$icon")
630         sb.append(", tileState=$tileState")
631         sb.append("]")
632         return sb.toString()
633     }
634 
635     @SuppressLint("ClickableViewAccessibility")
636     override fun onTouchEvent(event: MotionEvent?): Boolean {
637         // let the View run the onTouch logic for click and long-click detection
638         val result = super.onTouchEvent(event)
639         if (longPressEffect != null) {
640             when (event?.actionMasked) {
641                 MotionEvent.ACTION_DOWN -> {
642                     longPressEffect.handleActionDown()
643                     if (isLongClickable) {
644                         postDelayed(
645                             { longPressEffect.handleTimeoutComplete() },
646                             ViewConfiguration.getTapTimeout().toLong(),
647                         )
648                     }
649                 }
650                 MotionEvent.ACTION_UP -> longPressEffect.handleActionUp()
651                 MotionEvent.ACTION_CANCEL -> longPressEffect.handleActionCancel()
652             }
653         }
654         return result
655     }
656 
657     // HANDLE STATE CHANGES RELATED METHODS
658     protected open fun handleStateChanged(state: QSTile.State) {
659         val allowAnimations = animationsEnabled()
660         isClickable = state.state != Tile.STATE_UNAVAILABLE
661         isLongClickable = state.handlesLongClick
662         icon.setIcon(state, allowAnimations)
663         contentDescription = state.contentDescription
664 
665         // State handling and description
666         val stateDescription = StringBuilder()
667         val arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec)
668         val stateText = state.getStateText(arrayResId, resources)
669         state.secondaryLabel = state.getSecondaryLabel(stateText)
670         if (!TextUtils.isEmpty(stateText)) {
671             stateDescription.append(stateText)
672         }
673         if (state.disabledByPolicy && state.state != Tile.STATE_UNAVAILABLE) {
674             stateDescription.append(", ")
675             stateDescription.append(getUnavailableText(state.spec))
676         }
677         if (!TextUtils.isEmpty(state.stateDescription)) {
678             stateDescription.append(", ")
679             stateDescription.append(state.stateDescription)
680             if (
681                 lastState != INVALID &&
682                     state.state == lastState &&
683                     state.stateDescription != lastStateDescription
684             ) {
685                 stateDescriptionDeltas = state.stateDescription
686             }
687         }
688 
689         setStateDescription(stateDescription.toString())
690         lastStateDescription = state.stateDescription
691 
692         accessibilityClass =
693             if (state.state == Tile.STATE_UNAVAILABLE) {
694                 null
695             } else {
696                 state.expandedAccessibilityClassName
697             }
698 
699         if (state is AdapterState) {
700             val newState = state.value
701             if (tileState != newState) {
702                 tileState = newState
703             }
704         }
705 
706         // Labels
707         if (!Objects.equals(label.text, state.label)) {
708             label.text = state.label
709         }
710         if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
711             secondaryLabel.text = state.secondaryLabel
712             secondaryLabel.visibility =
713                 if (TextUtils.isEmpty(state.secondaryLabel)) {
714                     GONE
715                 } else {
716                     VISIBLE
717                 }
718         }
719 
720         // Colors
721         if (state.state != lastState || state.disabledByPolicy != lastDisabledByPolicy) {
722             singleAnimator.cancel()
723             mQsLogger?.logTileBackgroundColorUpdateIfInternetTile(
724                 state.spec,
725                 state.state,
726                 state.disabledByPolicy,
727                 getBackgroundColorForState(state.state, state.disabledByPolicy),
728             )
729             if (allowAnimations) {
730                 singleAnimator.setValues(
731                     colorValuesHolder(
732                         BACKGROUND_NAME,
733                         backgroundColor,
734                         getBackgroundColorForState(state.state, state.disabledByPolicy),
735                     ),
736                     colorValuesHolder(
737                         LABEL_NAME,
738                         label.currentTextColor,
739                         getLabelColorForState(state.state, state.disabledByPolicy),
740                     ),
741                     colorValuesHolder(
742                         SECONDARY_LABEL_NAME,
743                         secondaryLabel.currentTextColor,
744                         getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
745                     ),
746                     colorValuesHolder(
747                         CHEVRON_NAME,
748                         chevronView.imageTintList?.defaultColor ?: 0,
749                         getChevronColorForState(state.state, state.disabledByPolicy),
750                     ),
751                     colorValuesHolder(
752                         OVERLAY_NAME,
753                         backgroundOverlayColor,
754                         getOverlayColorForState(state.state),
755                     ),
756                 )
757                 singleAnimator.start()
758             } else {
759                 setAllColors(
760                     getBackgroundColorForState(state.state, state.disabledByPolicy),
761                     getLabelColorForState(state.state, state.disabledByPolicy),
762                     getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
763                     getChevronColorForState(state.state, state.disabledByPolicy),
764                     getOverlayColorForState(state.state),
765                 )
766             }
767         }
768 
769         // Right side icon
770         loadSideViewDrawableIfNecessary(state)
771 
772         label.isEnabled = !state.disabledByPolicy
773 
774         lastState = state.state
775         lastDisabledByPolicy = state.disabledByPolicy
776         lastIconTint = icon.getColor(state)
777 
778         // Long-press effects
779         longPressEffect?.qsTile?.state?.handlesLongClick = state.handlesLongClick
780         if (
781             state.handlesLongClick &&
782                 longPressEffect?.initializeEffect(longPressEffectDuration) == true
783         ) {
784             showRippleEffect = false
785             longPressEffect.qsTile?.state?.state = lastState // Store the tile's state
786             longPressEffect.resetState()
787             initializeLongPressProperties(measuredHeight, measuredWidth)
788         } else {
789             // Long-press effects might have been enabled before but the new state does not
790             // handle a long-press. In this case, we go back to the behaviour of a regular tile
791             // and clean-up the resources
792             showRippleEffect = isClickable
793             initialLongPressProperties = null
794             finalLongPressProperties = null
795         }
796     }
797 
798     private fun setAllColors(
799         backgroundColor: Int,
800         labelColor: Int,
801         secondaryLabelColor: Int,
802         chevronColor: Int,
803         overlayColor: Int,
804     ) {
805         setColor(backgroundColor)
806         setLabelColor(labelColor)
807         setSecondaryLabelColor(secondaryLabelColor)
808         setChevronColor(chevronColor)
809         setOverlayColor(overlayColor)
810     }
811 
812     private fun setColor(color: Int) {
813         backgroundBaseDrawable.mutate().setTint(color)
814         backgroundColor = color
815     }
816 
817     private fun setLabelColor(color: Int) {
818         label.setTextColor(color)
819     }
820 
821     private fun setSecondaryLabelColor(color: Int) {
822         secondaryLabel.setTextColor(color)
823     }
824 
825     private fun setChevronColor(color: Int) {
826         chevronView.imageTintList = ColorStateList.valueOf(color)
827     }
828 
829     private fun setOverlayColor(overlayColor: Int) {
830         backgroundOverlayDrawable.setTint(overlayColor)
831         backgroundOverlayColor = overlayColor
832     }
833 
834     private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
835         if (state.sideViewCustomDrawable != null) {
836             customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
837             customDrawableView.visibility = VISIBLE
838             chevronView.visibility = GONE
839         } else if (state !is AdapterState || state.forceExpandIcon) {
840             customDrawableView.setImageDrawable(null)
841             customDrawableView.visibility = GONE
842             chevronView.visibility = VISIBLE
843         } else {
844             customDrawableView.setImageDrawable(null)
845             customDrawableView.visibility = GONE
846             chevronView.visibility = GONE
847         }
848     }
849 
850     private fun getUnavailableText(spec: String?): String {
851         val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
852         return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE]
853     }
854 
855     /*
856      * The view should not be animated if it's not on screen and no part of it is visible.
857      */
858     protected open fun animationsEnabled(): Boolean {
859         if (!isShown) {
860             return false
861         }
862         if (alpha != 1f) {
863             return false
864         }
865         getLocationOnScreen(locInScreen)
866         return locInScreen.get(1) >= -height
867     }
868 
869     private fun getBackgroundColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
870         return when {
871             state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorUnavailable
872             state == Tile.STATE_ACTIVE -> colorActive
873             state == Tile.STATE_INACTIVE -> colorInactive
874             else -> {
875                 Log.e(TAG, "Invalid state $state")
876                 0
877             }
878         }
879     }
880 
881     private fun getLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
882         return when {
883             state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorLabelUnavailable
884             state == Tile.STATE_ACTIVE -> colorLabelActive
885             state == Tile.STATE_INACTIVE -> colorLabelInactive
886             else -> {
887                 Log.e(TAG, "Invalid state $state")
888                 0
889             }
890         }
891     }
892 
893     private fun getSecondaryLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
894         return when {
895             state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorSecondaryLabelUnavailable
896             state == Tile.STATE_ACTIVE -> colorSecondaryLabelActive
897             state == Tile.STATE_INACTIVE -> colorSecondaryLabelInactive
898             else -> {
899                 Log.e(TAG, "Invalid state $state")
900                 0
901             }
902         }
903     }
904 
905     private fun getChevronColorForState(state: Int, disabledByPolicy: Boolean = false): Int =
906         getSecondaryLabelColorForState(state, disabledByPolicy)
907 
908     private fun getOverlayColorForState(state: Int): Int {
909         return when (state) {
910             Tile.STATE_ACTIVE -> overlayColorActive
911             Tile.STATE_INACTIVE -> overlayColorInactive
912             else -> Color.TRANSPARENT
913         }
914     }
915 
916     override fun onActivityLaunchAnimationEnd() {
917         longPressEffect?.resetState()
918         if (longPressEffect != null && !haveLongPressPropertiesBeenReset) {
919             resetLongPressEffectProperties()
920         }
921     }
922 
923     private fun prepareForLaunch() {
924         val startingHeight = initialLongPressProperties?.height?.toInt() ?: 0
925         val startingWidth = initialLongPressProperties?.width?.toInt() ?: 0
926         val deltaH = finalLongPressProperties?.height?.minus(startingHeight)?.toInt() ?: 0
927         val deltaW = finalLongPressProperties?.width?.minus(startingWidth)?.toInt() ?: 0
928         paddingForLaunch.left = -deltaW / 2
929         paddingForLaunch.top = -deltaH / 2
930         paddingForLaunch.right = deltaW / 2
931         paddingForLaunch.bottom = deltaH / 2
932     }
933 
934     override fun getPaddingForLaunchAnimation(): Rect =
935         if (longPressEffect?.state == QSLongPressEffect.State.LONG_CLICKED) {
936             paddingForLaunch
937         } else {
938             EMPTY_RECT
939         }
940 
941     fun updateLongPressEffectProperties(effectProgress: Float) {
942         if (!isLongClickable || longPressEffect == null) return
943 
944         if (haveLongPressPropertiesBeenReset) haveLongPressPropertiesBeenReset = false
945 
946         // Dimensions change
947         val newHeight =
948             interpolateFloat(
949                     effectProgress,
950                     initialLongPressProperties?.height ?: 0f,
951                     finalLongPressProperties?.height ?: 0f,
952                 )
953                 .toInt()
954         val newWidth =
955             interpolateFloat(
956                     effectProgress,
957                     initialLongPressProperties?.width ?: 0f,
958                     finalLongPressProperties?.width ?: 0f,
959                 )
960                 .toInt()
961 
962         val startingHeight = initialLongPressProperties?.height?.toInt() ?: 0
963         val startingWidth = initialLongPressProperties?.width?.toInt() ?: 0
964         val deltaH = (newHeight - startingHeight) / 2
965         val deltaW = (newWidth - startingWidth) / 2
966 
967         background.updateBounds(
968             left = -deltaW,
969             top = -deltaH,
970             right = newWidth - deltaW,
971             bottom = newHeight - deltaH,
972         )
973 
974         // Radius change
975         val newRadius =
976             interpolateFloat(
977                 effectProgress,
978                 initialLongPressProperties?.cornerRadius ?: 0f,
979                 finalLongPressProperties?.cornerRadius ?: 0f,
980             )
981         changeCornerRadius(newRadius)
982 
983         // Color change
984         setAllColors(
985             colorEvaluator.evaluate(
986                 effectProgress,
987                 initialLongPressProperties?.backgroundColor ?: 0,
988                 finalLongPressProperties?.backgroundColor ?: 0,
989             ) as Int,
990             colorEvaluator.evaluate(
991                 effectProgress,
992                 initialLongPressProperties?.labelColor ?: 0,
993                 finalLongPressProperties?.labelColor ?: 0,
994             ) as Int,
995             colorEvaluator.evaluate(
996                 effectProgress,
997                 initialLongPressProperties?.secondaryLabelColor ?: 0,
998                 finalLongPressProperties?.secondaryLabelColor ?: 0,
999             ) as Int,
1000             colorEvaluator.evaluate(
1001                 effectProgress,
1002                 initialLongPressProperties?.chevronColor ?: 0,
1003                 finalLongPressProperties?.chevronColor ?: 0,
1004             ) as Int,
1005             colorEvaluator.evaluate(
1006                 effectProgress,
1007                 initialLongPressProperties?.overlayColor ?: 0,
1008                 finalLongPressProperties?.overlayColor ?: 0,
1009             ) as Int,
1010         )
1011         icon.setTint(
1012             icon.mIcon as ImageView,
1013             colorEvaluator.evaluate(
1014                 effectProgress,
1015                 initialLongPressProperties?.iconColor ?: 0,
1016                 finalLongPressProperties?.iconColor ?: 0,
1017             ) as Int,
1018         )
1019     }
1020 
1021     private fun interpolateFloat(fraction: Float, start: Float, end: Float): Float =
1022         start + fraction * (end - start)
1023 
1024     fun resetLongPressEffectProperties() {
1025         background.updateBounds(
1026             left = 0,
1027             top = 0,
1028             right = initialLongPressProperties?.width?.toInt() ?: measuredWidth,
1029             bottom = initialLongPressProperties?.height?.toInt() ?: measuredHeight,
1030         )
1031         changeCornerRadius(resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat())
1032         setAllColors(
1033             getBackgroundColorForState(lastState, lastDisabledByPolicy),
1034             getLabelColorForState(lastState, lastDisabledByPolicy),
1035             getSecondaryLabelColorForState(lastState, lastDisabledByPolicy),
1036             getChevronColorForState(lastState, lastDisabledByPolicy),
1037             getOverlayColorForState(lastState),
1038         )
1039         icon.setTint(icon.mIcon as ImageView, lastIconTint)
1040         haveLongPressPropertiesBeenReset = true
1041     }
1042 
1043     @VisibleForTesting
1044     fun initializeLongPressProperties(startingHeight: Int, startingWidth: Int) {
1045         initialLongPressProperties =
1046             QSLongPressProperties(
1047                 height = startingHeight.toFloat(),
1048                 width = startingWidth.toFloat(),
1049                 resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat(),
1050                 getBackgroundColorForState(lastState),
1051                 getLabelColorForState(lastState),
1052                 getSecondaryLabelColorForState(lastState),
1053                 getChevronColorForState(lastState),
1054                 getOverlayColorForState(lastState),
1055                 lastIconTint,
1056             )
1057 
1058         finalLongPressProperties =
1059             QSLongPressProperties(
1060                 height = LONG_PRESS_EFFECT_HEIGHT_SCALE * startingHeight,
1061                 width = LONG_PRESS_EFFECT_WIDTH_SCALE * startingWidth,
1062                 resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat() - 20,
1063                 getBackgroundColorForState(Tile.STATE_ACTIVE),
1064                 getLabelColorForState(Tile.STATE_ACTIVE),
1065                 getSecondaryLabelColorForState(Tile.STATE_ACTIVE),
1066                 getChevronColorForState(Tile.STATE_ACTIVE),
1067                 getOverlayColorForState(Tile.STATE_ACTIVE),
1068                 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive),
1069             )
1070         prepareForLaunch()
1071     }
1072 
1073     private fun changeCornerRadius(radius: Float) {
1074         for (i in 0 until backgroundDrawable.numberOfLayers) {
1075             val layer = backgroundDrawable.getDrawable(i)
1076             if (layer is GradientDrawable) {
1077                 layer.cornerRadius = radius
1078             }
1079         }
1080     }
1081 
1082     @VisibleForTesting
1083     internal fun getCurrentColors(): List<Int> =
1084         listOf(
1085             backgroundColor,
1086             label.currentTextColor,
1087             secondaryLabel.currentTextColor,
1088             chevronView.imageTintList?.defaultColor ?: 0,
1089         )
1090 
1091     inner class StateChangeRunnable(private val state: QSTile.State) : Runnable {
1092         override fun run() {
1093             var traceTag = "QSTileViewImpl#handleStateChanged"
1094             if (!state.spec.isNullOrEmpty()) {
1095                 traceTag += ":"
1096                 traceTag += state.spec
1097             }
1098             traceSection(traceTag.take(Trace.MAX_SECTION_NAME_LEN)) { handleStateChanged(state) }
1099         }
1100 
1101         // We want all instances of this runnable to be equal to each other, so they can be used to
1102         // remove previous instances from the Handler/RunQueue of this view
1103         override fun equals(other: Any?): Boolean {
1104             return other is StateChangeRunnable
1105         }
1106 
1107         // This makes sure that all instances have the same hashcode (because they are `equal`)
1108         override fun hashCode(): Int {
1109             return StateChangeRunnable::class.hashCode()
1110         }
1111     }
1112 }
1113 
constrainSquishinessnull1114 fun constrainSquishiness(squish: Float): Float {
1115     return 0.1f + squish * 0.9f
1116 }
1117 
colorValuesHoldernull1118 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
1119     return PropertyValuesHolder.ofInt(name, *values).apply {
1120         setEvaluator(ArgbEvaluator.getInstance())
1121     }
1122 }
1123