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