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