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