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