1 /*
2  * Copyright (C) 2020 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.controls.ui
18 
19 import android.animation.Animator
20 import android.animation.AnimatorListenerAdapter
21 import android.animation.AnimatorSet
22 import android.animation.ObjectAnimator
23 import android.animation.ValueAnimator
24 import android.annotation.ColorRes
25 import android.app.Dialog
26 import android.content.Context
27 import android.content.res.ColorStateList
28 import android.graphics.drawable.ClipDrawable
29 import android.graphics.drawable.Drawable
30 import android.graphics.drawable.GradientDrawable
31 import android.graphics.drawable.LayerDrawable
32 import android.graphics.drawable.StateListDrawable
33 import android.service.controls.Control
34 import android.service.controls.DeviceTypes
35 import android.service.controls.actions.ControlAction
36 import android.service.controls.templates.ControlTemplate
37 import android.service.controls.templates.RangeTemplate
38 import android.service.controls.templates.StatelessTemplate
39 import android.service.controls.templates.TemperatureControlTemplate
40 import android.service.controls.templates.ThumbnailTemplate
41 import android.service.controls.templates.ToggleRangeTemplate
42 import android.service.controls.templates.ToggleTemplate
43 import android.util.MathUtils
44 import android.util.TypedValue
45 import android.view.View
46 import android.view.ViewGroup
47 import android.widget.ImageView
48 import android.widget.TextView
49 import androidx.annotation.ColorInt
50 import androidx.annotation.VisibleForTesting
51 import com.android.internal.graphics.ColorUtils
52 import com.android.systemui.R
53 import com.android.systemui.animation.Interpolators
54 import com.android.systemui.controls.ControlsMetricsLogger
55 import com.android.systemui.controls.controller.ControlsController
56 import com.android.systemui.util.concurrency.DelayableExecutor
57 import kotlin.reflect.KClass
58 
59 /**
60  * Wraps the widgets that make up the UI representation of a {@link Control}. Updates to the view
61  * are signaled via calls to {@link #bindData}. Similar to the ViewHolder concept used in
62  * RecyclerViews.
63  */
64 class ControlViewHolder(
65     val layout: ViewGroup,
66     val controlsController: ControlsController,
67     val uiExecutor: DelayableExecutor,
68     val bgExecutor: DelayableExecutor,
69     val controlActionCoordinator: ControlActionCoordinator,
70     val controlsMetricsLogger: ControlsMetricsLogger,
71     val uid: Int
72 ) {
73 
74     companion object {
75         const val STATE_ANIMATION_DURATION = 700L
76         private const val ALPHA_ENABLED = 255
77         private const val ALPHA_DISABLED = 0
78         private const val STATUS_ALPHA_ENABLED = 1f
79         private const val STATUS_ALPHA_DIMMED = 0.45f
80         private val FORCE_PANEL_DEVICES = setOf(
81             DeviceTypes.TYPE_THERMOSTAT,
82             DeviceTypes.TYPE_CAMERA
83         )
84         private val ATTR_ENABLED = intArrayOf(android.R.attr.state_enabled)
85         private val ATTR_DISABLED = intArrayOf(-android.R.attr.state_enabled)
86         const val MIN_LEVEL = 0
87         const val MAX_LEVEL = 10000
88 
89         fun findBehaviorClass(
90             status: Int,
91             template: ControlTemplate,
92             deviceType: Int
93         ): KClass<out Behavior> {
94             return when {
95                 status != Control.STATUS_OK -> StatusBehavior::class
96                 template == ControlTemplate.NO_TEMPLATE -> TouchBehavior::class
97                 template is ThumbnailTemplate -> ThumbnailBehavior::class
98 
99                 // Required for legacy support, or where cameras do not use the new template
100                 deviceType == DeviceTypes.TYPE_CAMERA -> TouchBehavior::class
101                 template is ToggleTemplate -> ToggleBehavior::class
102                 template is StatelessTemplate -> TouchBehavior::class
103                 template is ToggleRangeTemplate -> ToggleRangeBehavior::class
104                 template is RangeTemplate -> ToggleRangeBehavior::class
105                 template is TemperatureControlTemplate -> TemperatureControlBehavior::class
106                 else -> DefaultBehavior::class
107             }
108         }
109     }
110 
111     private val toggleBackgroundIntensity: Float = layout.context.resources
112             .getFraction(R.fraction.controls_toggle_bg_intensity, 1, 1)
113     private var stateAnimator: ValueAnimator? = null
114     private var statusAnimator: Animator? = null
115     private val baseLayer: GradientDrawable
116     val icon: ImageView = layout.requireViewById(R.id.icon)
117     val status: TextView = layout.requireViewById(R.id.status)
118     private var nextStatusText: CharSequence = ""
119     val title: TextView = layout.requireViewById(R.id.title)
120     val subtitle: TextView = layout.requireViewById(R.id.subtitle)
121     val context: Context = layout.getContext()
122     val clipLayer: ClipDrawable
123     lateinit var cws: ControlWithState
124     var behavior: Behavior? = null
125     var lastAction: ControlAction? = null
126     var isLoading = false
127     var visibleDialog: Dialog? = null
128     private var lastChallengeDialog: Dialog? = null
129     private val onDialogCancel: () -> Unit = { lastChallengeDialog = null }
130 
131     val deviceType: Int
132         get() = cws.control?.let { it.deviceType } ?: cws.ci.deviceType
133     val controlStatus: Int
134         get() = cws.control?.let { it.status } ?: Control.STATUS_UNKNOWN
135     val controlTemplate: ControlTemplate
136         get() = cws.control?.let { it.controlTemplate } ?: ControlTemplate.NO_TEMPLATE
137 
138     var userInteractionInProgress = false
139 
140     init {
141         val ld = layout.getBackground() as LayerDrawable
142         ld.mutate()
143         clipLayer = ld.findDrawableByLayerId(R.id.clip_layer) as ClipDrawable
144         baseLayer = ld.findDrawableByLayerId(R.id.background) as GradientDrawable
145         // needed for marquee to start
146         status.setSelected(true)
147     }
148 
149     fun bindData(cws: ControlWithState, isLocked: Boolean) {
150         // If an interaction is in progress, the update may visually interfere with the action the
151         // action the user wants to make. Don't apply the update, and instead assume a new update
152         // will coming from when the user interaction is complete.
153         if (userInteractionInProgress) return
154 
155         this.cws = cws
156 
157         // For the following statuses only, assume the title/subtitle could not be set properly
158         // by the app and instead use the last known information from favorites
159         if (controlStatus == Control.STATUS_UNKNOWN || controlStatus == Control.STATUS_NOT_FOUND) {
160             title.setText(cws.ci.controlTitle)
161             subtitle.setText(cws.ci.controlSubtitle)
162         } else {
163             cws.control?.let {
164                 title.setText(it.title)
165                 subtitle.setText(it.subtitle)
166             }
167         }
168 
169         cws.control?.let {
170             layout.setClickable(true)
171             layout.setOnLongClickListener(View.OnLongClickListener() {
172                 controlActionCoordinator.longPress(this@ControlViewHolder)
173                 true
174             })
175 
176             controlActionCoordinator.runPendingAction(cws.ci.controlId)
177         }
178 
179         val wasLoading = isLoading
180         isLoading = false
181         behavior = bindBehavior(behavior,
182             findBehaviorClass(controlStatus, controlTemplate, deviceType))
183         updateContentDescription()
184 
185         // Only log one event per control, at the moment we have determined that the control
186         // switched from the loading to done state
187         val doneLoading = wasLoading && !isLoading
188         if (doneLoading) controlsMetricsLogger.refreshEnd(this, isLocked)
189     }
190 
191     fun actionResponse(@ControlAction.ResponseResult response: Int) {
192         controlActionCoordinator.enableActionOnTouch(cws.ci.controlId)
193 
194         // OK responses signal normal behavior, and the app will provide control updates
195         val failedAttempt = lastChallengeDialog != null
196         when (response) {
197             ControlAction.RESPONSE_OK ->
198                 lastChallengeDialog = null
199             ControlAction.RESPONSE_UNKNOWN -> {
200                 lastChallengeDialog = null
201                 setErrorStatus()
202             }
203             ControlAction.RESPONSE_FAIL -> {
204                 lastChallengeDialog = null
205                 setErrorStatus()
206             }
207             ControlAction.RESPONSE_CHALLENGE_PIN -> {
208                 lastChallengeDialog = ChallengeDialogs.createPinDialog(
209                     this, false /* useAlphanumeric */, failedAttempt, onDialogCancel)
210                 lastChallengeDialog?.show()
211             }
212             ControlAction.RESPONSE_CHALLENGE_PASSPHRASE -> {
213                 lastChallengeDialog = ChallengeDialogs.createPinDialog(
214                     this, true /* useAlphanumeric */, failedAttempt, onDialogCancel)
215                 lastChallengeDialog?.show()
216             }
217             ControlAction.RESPONSE_CHALLENGE_ACK -> {
218                 lastChallengeDialog = ChallengeDialogs.createConfirmationDialog(
219                     this, onDialogCancel)
220                 lastChallengeDialog?.show()
221             }
222         }
223     }
224 
225     fun dismiss() {
226         lastChallengeDialog?.dismiss()
227         lastChallengeDialog = null
228         visibleDialog?.dismiss()
229         visibleDialog = null
230     }
231 
232     fun setErrorStatus() {
233         val text = context.resources.getString(R.string.controls_error_failed)
234         animateStatusChange(/* animated */ true, {
235             setStatusText(text, /* immediately */ true)
236         })
237     }
238 
239     private fun updateContentDescription() =
240         layout.setContentDescription("${title.text} ${subtitle.text} ${status.text}")
241 
242     fun action(action: ControlAction) {
243         lastAction = action
244         controlsController.action(cws.componentName, cws.ci, action)
245     }
246 
247     fun usePanel(): Boolean {
248         return deviceType in ControlViewHolder.FORCE_PANEL_DEVICES ||
249             controlTemplate == ControlTemplate.NO_TEMPLATE
250     }
251 
252     fun bindBehavior(
253         existingBehavior: Behavior?,
254         clazz: KClass<out Behavior>,
255         offset: Int = 0
256     ): Behavior {
257         val behavior = if (existingBehavior == null || existingBehavior!!::class != clazz) {
258             // Behavior changes can signal a change in template from the app or
259             // first time setup
260             val newBehavior = clazz.java.newInstance()
261             newBehavior.initialize(this)
262 
263             // let behaviors define their own, if necessary, and clear any existing ones
264             layout.setAccessibilityDelegate(null)
265             newBehavior
266         } else {
267             existingBehavior
268         }
269 
270         return behavior.also {
271             it.bind(cws, offset)
272         }
273     }
274 
275     internal fun applyRenderInfo(enabled: Boolean, offset: Int, animated: Boolean = true) {
276         val deviceTypeOrError = if (controlStatus == Control.STATUS_OK ||
277                 controlStatus == Control.STATUS_UNKNOWN) {
278             deviceType
279         } else {
280             RenderInfo.ERROR_ICON
281         }
282         val ri = RenderInfo.lookup(context, cws.componentName, deviceTypeOrError, offset)
283         val fg = context.resources.getColorStateList(ri.foreground, context.theme)
284         val newText = nextStatusText
285         val control = cws.control
286 
287         var shouldAnimate = animated
288         if (newText == status.text) {
289             shouldAnimate = false
290         }
291         animateStatusChange(shouldAnimate) {
292             updateStatusRow(enabled, newText, ri.icon, fg, control)
293         }
294 
295         animateBackgroundChange(shouldAnimate, enabled, ri.enabledBackground)
296     }
297 
298     fun getStatusText() = status.text
299 
300     fun setStatusTextSize(textSize: Float) =
301         status.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
302 
303     fun setStatusText(text: CharSequence, immediately: Boolean = false) {
304         if (immediately) {
305             status.alpha = STATUS_ALPHA_ENABLED
306             status.text = text
307             updateContentDescription()
308         }
309         nextStatusText = text
310     }
311 
312     private fun animateBackgroundChange(
313         animated: Boolean,
314         enabled: Boolean,
315         @ColorRes bgColor: Int
316     ) {
317         val bg = context.resources.getColor(R.color.control_default_background, context.theme)
318 
319         val (newClipColor, newAlpha) = if (enabled) {
320             // allow color overrides for the enabled state only
321             val color = cws.control?.getCustomColor()?.let {
322                 val state = intArrayOf(android.R.attr.state_enabled)
323                 it.getColorForState(state, it.getDefaultColor())
324             } ?: context.resources.getColor(bgColor, context.theme)
325             listOf(color, ALPHA_ENABLED)
326         } else {
327             listOf(
328                 context.resources.getColor(R.color.control_default_background, context.theme),
329                 ALPHA_DISABLED
330             )
331         }
332         val newBaseColor = if (behavior is ToggleRangeBehavior) {
333             ColorUtils.blendARGB(bg, newClipColor, toggleBackgroundIntensity)
334         } else {
335             bg
336         }
337 
338         clipLayer.drawable?.apply {
339             clipLayer.alpha = ALPHA_DISABLED
340             stateAnimator?.cancel()
341             if (animated) {
342                 startBackgroundAnimation(this, newAlpha, newClipColor, newBaseColor)
343             } else {
344                 applyBackgroundChange(
345                         this, newAlpha, newClipColor, newBaseColor, newLayoutAlpha = 1f
346                 )
347             }
348         }
349     }
350 
351     private fun startBackgroundAnimation(
352         clipDrawable: Drawable,
353         newAlpha: Int,
354         @ColorInt newClipColor: Int,
355         @ColorInt newBaseColor: Int
356     ) {
357         val oldClipColor = if (clipDrawable is GradientDrawable) {
358             clipDrawable.color?.defaultColor ?: newClipColor
359         } else {
360             newClipColor
361         }
362         val oldBaseColor = baseLayer.color?.defaultColor ?: newBaseColor
363         val oldAlpha = layout.alpha
364 
365         stateAnimator = ValueAnimator.ofInt(clipLayer.alpha, newAlpha).apply {
366             addUpdateListener {
367                 val updatedAlpha = it.animatedValue as Int
368                 val updatedClipColor = ColorUtils.blendARGB(oldClipColor, newClipColor,
369                         it.animatedFraction)
370                 val updatedBaseColor = ColorUtils.blendARGB(oldBaseColor, newBaseColor,
371                         it.animatedFraction)
372                 val updatedLayoutAlpha = MathUtils.lerp(oldAlpha, 1f, it.animatedFraction)
373                 applyBackgroundChange(
374                         clipDrawable,
375                         updatedAlpha,
376                         updatedClipColor,
377                         updatedBaseColor,
378                         updatedLayoutAlpha
379                 )
380             }
381             addListener(object : AnimatorListenerAdapter() {
382                 override fun onAnimationEnd(animation: Animator?) {
383                     stateAnimator = null
384                 }
385             })
386             duration = STATE_ANIMATION_DURATION
387             interpolator = Interpolators.CONTROL_STATE
388             start()
389         }
390     }
391 
392     /**
393      * Applies a change in background.
394      *
395      * Updates both alpha and background colors. Only updates colors for GradientDrawables and not
396      * static images as used for the ThumbnailTemplate.
397      */
398     private fun applyBackgroundChange(
399         clipDrawable: Drawable,
400         newAlpha: Int,
401         @ColorInt newClipColor: Int,
402         @ColorInt newBaseColor: Int,
403         newLayoutAlpha: Float
404     ) {
405         clipDrawable.alpha = newAlpha
406         if (clipDrawable is GradientDrawable) {
407             clipDrawable.setColor(newClipColor)
408         }
409         baseLayer.setColor(newBaseColor)
410         layout.alpha = newLayoutAlpha
411     }
412 
413     private fun animateStatusChange(animated: Boolean, statusRowUpdater: () -> Unit) {
414         statusAnimator?.cancel()
415 
416         if (!animated) {
417             statusRowUpdater.invoke()
418             return
419         }
420 
421         if (isLoading) {
422             statusRowUpdater.invoke()
423             statusAnimator = ObjectAnimator.ofFloat(status, "alpha", STATUS_ALPHA_DIMMED).apply {
424                 repeatMode = ValueAnimator.REVERSE
425                 repeatCount = ValueAnimator.INFINITE
426                 duration = 500L
427                 interpolator = Interpolators.LINEAR
428                 startDelay = 900L
429                 start()
430             }
431         } else {
432             val fadeOut = ObjectAnimator.ofFloat(status, "alpha", 0f).apply {
433                 duration = 200L
434                 interpolator = Interpolators.LINEAR
435                 addListener(object : AnimatorListenerAdapter() {
436                     override fun onAnimationEnd(animation: Animator?) {
437                         statusRowUpdater.invoke()
438                     }
439                 })
440             }
441             val fadeIn = ObjectAnimator.ofFloat(status, "alpha", STATUS_ALPHA_ENABLED).apply {
442                 duration = 200L
443                 interpolator = Interpolators.LINEAR
444             }
445             statusAnimator = AnimatorSet().apply {
446                 playSequentially(fadeOut, fadeIn)
447                 addListener(object : AnimatorListenerAdapter() {
448                     override fun onAnimationEnd(animation: Animator?) {
449                         status.alpha = STATUS_ALPHA_ENABLED
450                         statusAnimator = null
451                     }
452                 })
453                 start()
454             }
455         }
456     }
457 
458     @VisibleForTesting
459     internal fun updateStatusRow(
460         enabled: Boolean,
461         text: CharSequence,
462         drawable: Drawable,
463         color: ColorStateList,
464         control: Control?
465     ) {
466         setEnabled(enabled)
467 
468         status.text = text
469         updateContentDescription()
470 
471         status.setTextColor(color)
472 
473         control?.getCustomIcon()?.let {
474             icon.setImageIcon(it)
475             icon.imageTintList = it.tintList
476         } ?: run {
477             if (drawable is StateListDrawable) {
478                 // Only reset the drawable if it is a different resource, as it will interfere
479                 // with the image state and animation.
480                 if (icon.drawable == null || !(icon.drawable is StateListDrawable)) {
481                     icon.setImageDrawable(drawable)
482                 }
483                 val state = if (enabled) ATTR_ENABLED else ATTR_DISABLED
484                 icon.setImageState(state, true)
485             } else {
486                 icon.setImageDrawable(drawable)
487             }
488 
489             // do not color app icons
490             if (deviceType != DeviceTypes.TYPE_ROUTINE) {
491                 icon.imageTintList = color
492             }
493         }
494     }
495 
496     private fun setEnabled(enabled: Boolean) {
497         status.setEnabled(enabled)
498         icon.setEnabled(enabled)
499     }
500 }
501