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