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