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