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