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