1 /* 2 * Copyright (C) 2021 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.qs.tileimpl 18 19 import android.animation.ArgbEvaluator 20 import android.animation.PropertyValuesHolder 21 import android.animation.ValueAnimator 22 import android.content.Context 23 import android.content.res.ColorStateList 24 import android.content.res.Configuration 25 import android.content.res.Resources.ID_NULL 26 import android.graphics.drawable.Drawable 27 import android.graphics.drawable.RippleDrawable 28 import android.service.quicksettings.Tile 29 import android.text.TextUtils 30 import android.util.Log 31 import android.view.Gravity 32 import android.view.LayoutInflater 33 import android.view.View 34 import android.view.ViewGroup 35 import android.view.accessibility.AccessibilityEvent 36 import android.view.accessibility.AccessibilityNodeInfo 37 import android.widget.ImageView 38 import android.widget.LinearLayout 39 import android.widget.Switch 40 import android.widget.TextView 41 import androidx.annotation.VisibleForTesting 42 import com.android.settingslib.Utils 43 import com.android.systemui.FontSizeUtils 44 import com.android.systemui.R 45 import com.android.systemui.animation.LaunchableView 46 import com.android.systemui.plugins.qs.QSIconView 47 import com.android.systemui.plugins.qs.QSTile 48 import com.android.systemui.plugins.qs.QSTile.BooleanState 49 import com.android.systemui.plugins.qs.QSTileView 50 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH 51 import java.util.Objects 52 53 private const val TAG = "QSTileViewImpl" 54 open class QSTileViewImpl @JvmOverloads constructor( 55 context: Context, 56 private val _icon: QSIconView, 57 private val collapsed: Boolean = false 58 ) : QSTileView(context), HeightOverrideable, LaunchableView { 59 60 companion object { 61 private const val INVALID = -1 62 private const val BACKGROUND_NAME = "background" 63 private const val LABEL_NAME = "label" 64 private const val SECONDARY_LABEL_NAME = "secondaryLabel" 65 private const val CHEVRON_NAME = "chevron" 66 const val UNAVAILABLE_ALPHA = 0.3f 67 @VisibleForTesting 68 internal const val TILE_STATE_RES_PREFIX = "tile_states_" 69 } 70 71 override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE 72 set(value) { 73 if (field == value) return 74 field = value 75 updateHeight() 76 } 77 78 override var squishinessFraction: Float = 1f 79 set(value) { 80 if (field == value) return 81 field = value 82 updateHeight() 83 } 84 85 private val colorActive = Utils.getColorAttrDefaultColor(context, 86 com.android.internal.R.attr.colorAccentPrimary) 87 private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.offStateColor) 88 private val colorUnavailable = Utils.applyAlpha(UNAVAILABLE_ALPHA, colorInactive) 89 90 private val colorLabelActive = 91 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimaryInverse) 92 private val colorLabelInactive = 93 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimary) 94 private val colorLabelUnavailable = Utils.applyAlpha(UNAVAILABLE_ALPHA, colorLabelInactive) 95 96 private val colorSecondaryLabelActive = 97 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondaryInverse) 98 private val colorSecondaryLabelInactive = 99 Utils.getColorAttrDefaultColor(context, android.R.attr.textColorSecondary) 100 private val colorSecondaryLabelUnavailable = 101 Utils.applyAlpha(UNAVAILABLE_ALPHA, colorSecondaryLabelInactive) 102 103 private lateinit var label: TextView 104 protected lateinit var secondaryLabel: TextView 105 private lateinit var labelContainer: IgnorableChildLinearLayout 106 protected lateinit var sideView: ViewGroup 107 private lateinit var customDrawableView: ImageView 108 private lateinit var chevronView: ImageView 109 110 protected var showRippleEffect = true 111 112 private lateinit var ripple: RippleDrawable 113 private lateinit var colorBackgroundDrawable: Drawable 114 private var paintColor: Int = 0 115 private val singleAnimator: ValueAnimator = ValueAnimator().apply { 116 setDuration(QS_ANIM_LENGTH) 117 addUpdateListener { animation -> 118 setAllColors( 119 // These casts will throw an exception if some property is missing. We should 120 // always have all properties. 121 animation.getAnimatedValue(BACKGROUND_NAME) as Int, 122 animation.getAnimatedValue(LABEL_NAME) as Int, 123 animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int, 124 animation.getAnimatedValue(CHEVRON_NAME) as Int 125 ) 126 } 127 } 128 129 private var accessibilityClass: String? = null 130 private var stateDescriptionDeltas: CharSequence? = null 131 private var lastStateDescription: CharSequence? = null 132 private var tileState = false 133 private var lastState = INVALID 134 private var blockVisibilityChanges = false 135 private var lastVisibility = View.VISIBLE 136 137 private val locInScreen = IntArray(2) 138 139 init { 140 setId(generateViewId()) 141 orientation = LinearLayout.HORIZONTAL 142 gravity = Gravity.CENTER_VERTICAL or Gravity.START 143 importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES 144 clipChildren = false 145 clipToPadding = false 146 isFocusable = true 147 background = createTileBackground() 148 setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE)) 149 150 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding) 151 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding) 152 setPaddingRelative(startPadding, padding, padding, padding) 153 154 val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size) 155 addView(_icon, LayoutParams(iconSize, iconSize)) 156 157 createAndAddLabels() 158 createAndAddSideView() 159 } 160 161 override fun onConfigurationChanged(newConfig: Configuration?) { 162 super.onConfigurationChanged(newConfig) 163 updateResources() 164 } 165 166 override fun resetOverride() { 167 heightOverride = HeightOverrideable.NO_OVERRIDE 168 updateHeight() 169 } 170 171 fun updateResources() { 172 FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size) 173 FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size) 174 175 val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size) 176 _icon.layoutParams.apply { 177 height = iconSize 178 width = iconSize 179 } 180 181 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding) 182 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding) 183 setPaddingRelative(startPadding, padding, padding, padding) 184 185 val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin) 186 (labelContainer.layoutParams as MarginLayoutParams).apply { 187 marginStart = labelMargin 188 } 189 190 (sideView.layoutParams as MarginLayoutParams).apply { 191 marginStart = labelMargin 192 } 193 (chevronView.layoutParams as MarginLayoutParams).apply { 194 height = iconSize 195 width = iconSize 196 } 197 198 val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin) 199 (customDrawableView.layoutParams as MarginLayoutParams).apply { 200 height = iconSize 201 marginEnd = endMargin 202 } 203 } 204 205 private fun createAndAddLabels() { 206 labelContainer = LayoutInflater.from(context) 207 .inflate(R.layout.qs_tile_label, this, false) as IgnorableChildLinearLayout 208 label = labelContainer.requireViewById(R.id.tile_label) 209 secondaryLabel = labelContainer.requireViewById(R.id.app_label) 210 if (collapsed) { 211 labelContainer.ignoreLastView = true 212 // Ideally, it'd be great if the parent could set this up when measuring just this child 213 // instead of the View class having to support this. However, due to the mysteries of 214 // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its 215 // sibling methods to have special behavior for labelContainer. 216 labelContainer.forceUnspecifiedMeasure = true 217 secondaryLabel.alpha = 0f 218 } 219 setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE)) 220 setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE)) 221 addView(labelContainer) 222 } 223 224 private fun createAndAddSideView() { 225 sideView = LayoutInflater.from(context) 226 .inflate(R.layout.qs_tile_side_icon, this, false) as ViewGroup 227 customDrawableView = sideView.requireViewById(R.id.customDrawable) 228 chevronView = sideView.requireViewById(R.id.chevron) 229 setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE)) 230 addView(sideView) 231 } 232 233 fun createTileBackground(): Drawable { 234 ripple = mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable 235 colorBackgroundDrawable = ripple.findDrawableByLayerId(R.id.background) 236 return ripple 237 } 238 239 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { 240 super.onLayout(changed, l, t, r, b) 241 updateHeight() 242 } 243 244 private fun updateHeight() { 245 val actualHeight = if (heightOverride != HeightOverrideable.NO_OVERRIDE) { 246 heightOverride 247 } else { 248 measuredHeight 249 } 250 // Limit how much we affect the height, so we don't have rounding artifacts when the tile 251 // is too short. 252 val constrainedSquishiness = 0.1f + squishinessFraction * 0.9f 253 bottom = top + (actualHeight * constrainedSquishiness).toInt() 254 scrollY = (actualHeight - height) / 2 255 } 256 257 override fun updateAccessibilityOrder(previousView: View?): View { 258 accessibilityTraversalAfter = previousView?.id ?: ID_NULL 259 return this 260 } 261 262 override fun getIcon(): QSIconView { 263 return _icon 264 } 265 266 override fun getIconWithBackground(): View { 267 return icon 268 } 269 270 override fun init(tile: QSTile) { 271 init( 272 { v: View? -> tile.click(this) }, 273 { view: View? -> 274 tile.longClick(this) 275 true 276 } 277 ) 278 } 279 280 private fun init( 281 click: OnClickListener?, 282 longClick: OnLongClickListener? 283 ) { 284 setOnClickListener(click) 285 onLongClickListener = longClick 286 } 287 288 override fun onStateChanged(state: QSTile.State) { 289 post { 290 handleStateChanged(state) 291 } 292 } 293 294 override fun getDetailY(): Int { 295 return top + height / 2 296 } 297 298 override fun hasOverlappingRendering(): Boolean { 299 // Avoid layers for this layout - we don't need them. 300 return false 301 } 302 303 override fun setClickable(clickable: Boolean) { 304 super.setClickable(clickable) 305 background = if (clickable && showRippleEffect) { 306 ripple.also { 307 // In case that the colorBackgroundDrawable was used as the background, make sure 308 // it has the correct callback instead of null 309 colorBackgroundDrawable.callback = it 310 } 311 } else { 312 colorBackgroundDrawable 313 } 314 } 315 316 override fun getLabelContainer(): View { 317 return labelContainer 318 } 319 320 override fun getSecondaryLabel(): View { 321 return secondaryLabel 322 } 323 324 override fun getSecondaryIcon(): View { 325 return sideView 326 } 327 328 override fun setShouldBlockVisibilityChanges(block: Boolean) { 329 blockVisibilityChanges = block 330 331 if (block) { 332 lastVisibility = visibility 333 } else { 334 visibility = lastVisibility 335 } 336 } 337 338 override fun setVisibility(visibility: Int) { 339 if (blockVisibilityChanges) { 340 lastVisibility = visibility 341 return 342 } 343 344 super.setVisibility(visibility) 345 } 346 347 override fun setTransitionVisibility(visibility: Int) { 348 if (blockVisibilityChanges) { 349 // View.setTransitionVisibility just sets the visibility flag, so we don't have to save 350 // the transition visibility separately from the normal visibility. 351 lastVisibility = visibility 352 return 353 } 354 355 super.setTransitionVisibility(visibility) 356 } 357 358 // Accessibility 359 360 override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { 361 super.onInitializeAccessibilityEvent(event) 362 if (!TextUtils.isEmpty(accessibilityClass)) { 363 event.className = accessibilityClass 364 } 365 if (event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION && 366 stateDescriptionDeltas != null) { 367 event.text.add(stateDescriptionDeltas) 368 stateDescriptionDeltas = null 369 } 370 } 371 372 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { 373 super.onInitializeAccessibilityNodeInfo(info) 374 // Clear selected state so it is not announce by talkback. 375 info.isSelected = false 376 if (!TextUtils.isEmpty(accessibilityClass)) { 377 info.className = accessibilityClass 378 if (Switch::class.java.name == accessibilityClass) { 379 val label = resources.getString( 380 if (tileState) R.string.switch_bar_on else R.string.switch_bar_off) 381 // Set the text here for tests in 382 // android.platform.test.scenario.sysui.quicksettings. Can be removed when 383 // UiObject2 has a new getStateDescription() API and tests are updated. 384 info.text = label 385 info.isChecked = tileState 386 info.isCheckable = true 387 if (isLongClickable) { 388 info.addAction( 389 AccessibilityNodeInfo.AccessibilityAction( 390 AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id, 391 resources.getString( 392 R.string.accessibility_long_click_tile))) 393 } 394 } 395 } 396 } 397 398 override fun toString(): String { 399 val sb = StringBuilder(javaClass.simpleName).append('[') 400 sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})") 401 sb.append(", iconView=$_icon") 402 sb.append(", tileState=$tileState") 403 sb.append("]") 404 return sb.toString() 405 } 406 407 // HANDLE STATE CHANGES RELATED METHODS 408 409 protected open fun handleStateChanged(state: QSTile.State) { 410 val allowAnimations = animationsEnabled() 411 showRippleEffect = state.showRippleEffect 412 isClickable = state.state != Tile.STATE_UNAVAILABLE 413 isLongClickable = state.handlesLongClick 414 icon.setIcon(state, allowAnimations) 415 contentDescription = state.contentDescription 416 417 // State handling and description 418 val stateDescription = StringBuilder() 419 val stateText = getStateText(state) 420 if (!TextUtils.isEmpty(stateText)) { 421 stateDescription.append(stateText) 422 if (TextUtils.isEmpty(state.secondaryLabel)) { 423 state.secondaryLabel = stateText 424 } 425 } 426 if (!TextUtils.isEmpty(state.stateDescription)) { 427 stateDescription.append(", ") 428 stateDescription.append(state.stateDescription) 429 if (lastState != INVALID && state.state == lastState && 430 state.stateDescription != lastStateDescription) { 431 stateDescriptionDeltas = state.stateDescription 432 } 433 } 434 435 setStateDescription(stateDescription.toString()) 436 lastStateDescription = state.stateDescription 437 438 accessibilityClass = if (state.state == Tile.STATE_UNAVAILABLE) { 439 null 440 } else { 441 state.expandedAccessibilityClassName 442 } 443 444 if (state is BooleanState) { 445 val newState = state.value 446 if (tileState != newState) { 447 tileState = newState 448 } 449 } 450 // 451 452 // Labels 453 if (!Objects.equals(label.text, state.label)) { 454 label.text = state.label 455 } 456 if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) { 457 secondaryLabel.text = state.secondaryLabel 458 secondaryLabel.visibility = if (TextUtils.isEmpty(state.secondaryLabel)) { 459 GONE 460 } else { 461 VISIBLE 462 } 463 } 464 465 // Colors 466 if (state.state != lastState) { 467 singleAnimator.cancel() 468 if (allowAnimations) { 469 singleAnimator.setValues( 470 colorValuesHolder( 471 BACKGROUND_NAME, 472 paintColor, 473 getBackgroundColorForState(state.state) 474 ), 475 colorValuesHolder( 476 LABEL_NAME, 477 label.currentTextColor, 478 getLabelColorForState(state.state) 479 ), 480 colorValuesHolder( 481 SECONDARY_LABEL_NAME, 482 secondaryLabel.currentTextColor, 483 getSecondaryLabelColorForState(state.state) 484 ), 485 colorValuesHolder( 486 CHEVRON_NAME, 487 chevronView.imageTintList?.defaultColor ?: 0, 488 getChevronColorForState(state.state) 489 ) 490 ) 491 singleAnimator.start() 492 } else { 493 setAllColors( 494 getBackgroundColorForState(state.state), 495 getLabelColorForState(state.state), 496 getSecondaryLabelColorForState(state.state), 497 getChevronColorForState(state.state) 498 ) 499 } 500 } 501 502 // Right side icon 503 loadSideViewDrawableIfNecessary(state) 504 505 label.isEnabled = !state.disabledByPolicy 506 507 lastState = state.state 508 } 509 510 private fun setAllColors( 511 backgroundColor: Int, 512 labelColor: Int, 513 secondaryLabelColor: Int, 514 chevronColor: Int 515 ) { 516 setColor(backgroundColor) 517 setLabelColor(labelColor) 518 setSecondaryLabelColor(secondaryLabelColor) 519 setChevronColor(chevronColor) 520 } 521 522 private fun setColor(color: Int) { 523 colorBackgroundDrawable.mutate().setTint(color) 524 paintColor = color 525 } 526 527 private fun setLabelColor(color: Int) { 528 label.setTextColor(color) 529 } 530 531 private fun setSecondaryLabelColor(color: Int) { 532 secondaryLabel.setTextColor(color) 533 } 534 535 private fun setChevronColor(color: Int) { 536 chevronView.imageTintList = ColorStateList.valueOf(color) 537 } 538 539 private fun loadSideViewDrawableIfNecessary(state: QSTile.State) { 540 if (state.sideViewCustomDrawable != null) { 541 customDrawableView.setImageDrawable(state.sideViewCustomDrawable) 542 customDrawableView.visibility = VISIBLE 543 chevronView.visibility = GONE 544 } else if (state !is BooleanState || state.forceExpandIcon) { 545 customDrawableView.setImageDrawable(null) 546 customDrawableView.visibility = GONE 547 chevronView.visibility = VISIBLE 548 } else { 549 customDrawableView.setImageDrawable(null) 550 customDrawableView.visibility = GONE 551 chevronView.visibility = GONE 552 } 553 } 554 555 private fun getStateText(state: QSTile.State): String { 556 if (state.disabledByPolicy) { 557 return context.getString(R.string.tile_disabled) 558 } 559 560 return if (state.state == Tile.STATE_UNAVAILABLE || state is BooleanState) { 561 var arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec) 562 val array = resources.getStringArray(arrayResId) 563 array[state.state] 564 } else { 565 "" 566 } 567 } 568 569 /* 570 * The view should not be animated if it's not on screen and no part of it is visible. 571 */ 572 protected open fun animationsEnabled(): Boolean { 573 if (!isShown) { 574 return false 575 } 576 if (alpha != 1f) { 577 return false 578 } 579 getLocationOnScreen(locInScreen) 580 return locInScreen.get(1) >= -height 581 } 582 583 private fun getBackgroundColorForState(state: Int): Int { 584 return when (state) { 585 Tile.STATE_ACTIVE -> colorActive 586 Tile.STATE_INACTIVE -> colorInactive 587 Tile.STATE_UNAVAILABLE -> colorUnavailable 588 else -> { 589 Log.e(TAG, "Invalid state $state") 590 0 591 } 592 } 593 } 594 595 private fun getLabelColorForState(state: Int): Int { 596 return when (state) { 597 Tile.STATE_ACTIVE -> colorLabelActive 598 Tile.STATE_INACTIVE -> colorLabelInactive 599 Tile.STATE_UNAVAILABLE -> colorLabelUnavailable 600 else -> { 601 Log.e(TAG, "Invalid state $state") 602 0 603 } 604 } 605 } 606 607 private fun getSecondaryLabelColorForState(state: Int): Int { 608 return when (state) { 609 Tile.STATE_ACTIVE -> colorSecondaryLabelActive 610 Tile.STATE_INACTIVE -> colorSecondaryLabelInactive 611 Tile.STATE_UNAVAILABLE -> colorSecondaryLabelUnavailable 612 else -> { 613 Log.e(TAG, "Invalid state $state") 614 0 615 } 616 } 617 } 618 619 private fun getChevronColorForState(state: Int): Int = getSecondaryLabelColorForState(state) 620 } 621 622 @VisibleForTesting 623 internal object SubtitleArrayMapping { 624 private val subtitleIdsMap = mapOf<String?, Int>( 625 "internet" to R.array.tile_states_internet, 626 "wifi" to R.array.tile_states_wifi, 627 "cell" to R.array.tile_states_cell, 628 "battery" to R.array.tile_states_battery, 629 "dnd" to R.array.tile_states_dnd, 630 "flashlight" to R.array.tile_states_flashlight, 631 "rotation" to R.array.tile_states_rotation, 632 "bt" to R.array.tile_states_bt, 633 "airplane" to R.array.tile_states_airplane, 634 "location" to R.array.tile_states_location, 635 "hotspot" to R.array.tile_states_hotspot, 636 "inversion" to R.array.tile_states_inversion, 637 "saver" to R.array.tile_states_saver, 638 "dark" to R.array.tile_states_dark, 639 "work" to R.array.tile_states_work, 640 "cast" to R.array.tile_states_cast, 641 "night" to R.array.tile_states_night, 642 "screenrecord" to R.array.tile_states_screenrecord, 643 "reverse" to R.array.tile_states_reverse, 644 "reduce_brightness" to R.array.tile_states_reduce_brightness, 645 "cameratoggle" to R.array.tile_states_cameratoggle, 646 "mictoggle" to R.array.tile_states_mictoggle, 647 "controls" to R.array.tile_states_controls, 648 "wallet" to R.array.tile_states_wallet, 649 "alarm" to R.array.tile_states_alarm 650 ) 651 652 fun getSubtitleId(spec: String?): Int { 653 return subtitleIdsMap.getOrDefault(spec, R.array.tile_states_default) 654 } 655 } 656 657 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder { 658 return PropertyValuesHolder.ofInt(name, *values).apply { 659 setEvaluator(ArgbEvaluator.getInstance()) 660 } 661 }