1 /* <lambda>null2 * Copyright (C) 2022 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.keyguard.ui.binder 18 19 import android.annotation.SuppressLint 20 import android.graphics.drawable.Animatable2 21 import android.os.VibrationEffect 22 import android.util.Size 23 import android.util.TypedValue 24 import android.view.MotionEvent 25 import android.view.View 26 import android.view.ViewConfiguration 27 import android.view.ViewGroup 28 import android.view.ViewPropertyAnimator 29 import android.widget.ImageView 30 import android.widget.TextView 31 import androidx.core.animation.CycleInterpolator 32 import androidx.core.animation.ObjectAnimator 33 import androidx.core.view.isVisible 34 import androidx.core.view.updateLayoutParams 35 import androidx.lifecycle.Lifecycle 36 import androidx.lifecycle.repeatOnLifecycle 37 import com.android.settingslib.Utils 38 import com.android.systemui.R 39 import com.android.systemui.animation.Expandable 40 import com.android.systemui.animation.Interpolators 41 import com.android.systemui.common.shared.model.Icon 42 import com.android.systemui.common.ui.binder.IconViewBinder 43 import com.android.systemui.keyguard.ui.viewmodel.KeyguardBottomAreaViewModel 44 import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceViewModel 45 import com.android.systemui.lifecycle.repeatWhenAttached 46 import com.android.systemui.plugins.FalsingManager 47 import com.android.systemui.statusbar.VibratorHelper 48 import kotlin.math.pow 49 import kotlin.math.sqrt 50 import kotlin.time.Duration.Companion.milliseconds 51 import kotlinx.coroutines.ExperimentalCoroutinesApi 52 import kotlinx.coroutines.flow.Flow 53 import kotlinx.coroutines.flow.MutableStateFlow 54 import kotlinx.coroutines.flow.combine 55 import kotlinx.coroutines.flow.flatMapLatest 56 import kotlinx.coroutines.flow.map 57 import kotlinx.coroutines.launch 58 59 /** 60 * Binds a keyguard bottom area view to its view-model. 61 * 62 * To use this properly, users should maintain a one-to-one relationship between the [View] and the 63 * view-binding, binding each view only once. It is okay and expected for the same instance of the 64 * view-model to be reused for multiple view/view-binder bindings. 65 */ 66 @OptIn(ExperimentalCoroutinesApi::class) 67 object KeyguardBottomAreaViewBinder { 68 69 private const val EXIT_DOZE_BUTTON_REVEAL_ANIMATION_DURATION_MS = 250L 70 private const val SCALE_SELECTED_BUTTON = 1.23f 71 private const val DIM_ALPHA = 0.3f 72 73 /** 74 * Defines interface for an object that acts as the binding between the view and its view-model. 75 * 76 * Users of the [KeyguardBottomAreaViewBinder] class should use this to control the binder after 77 * it is bound. 78 */ 79 interface Binding { 80 /** 81 * Returns a collection of [ViewPropertyAnimator] instances that can be used to animate the 82 * indication areas. 83 */ 84 fun getIndicationAreaAnimators(): List<ViewPropertyAnimator> 85 86 /** Notifies that device configuration has changed. */ 87 fun onConfigurationChanged() 88 89 /** 90 * Returns whether the keyguard bottom area should be constrained to the top of the lock 91 * icon 92 */ 93 fun shouldConstrainToTopOfLockIcon(): Boolean 94 } 95 96 /** Binds the view to the view-model, continuing to update the former based on the latter. */ 97 @JvmStatic 98 fun bind( 99 view: ViewGroup, 100 viewModel: KeyguardBottomAreaViewModel, 101 falsingManager: FalsingManager?, 102 vibratorHelper: VibratorHelper?, 103 messageDisplayer: (Int) -> Unit, 104 ): Binding { 105 val indicationArea: View = view.requireViewById(R.id.keyguard_indication_area) 106 val ambientIndicationArea: View? = view.findViewById(R.id.ambient_indication_container) 107 val startButton: ImageView = view.requireViewById(R.id.start_button) 108 val endButton: ImageView = view.requireViewById(R.id.end_button) 109 val overlayContainer: View = view.requireViewById(R.id.overlay_container) 110 val indicationText: TextView = view.requireViewById(R.id.keyguard_indication_text) 111 val indicationTextBottom: TextView = 112 view.requireViewById(R.id.keyguard_indication_text_bottom) 113 114 view.clipChildren = false 115 view.clipToPadding = false 116 117 val configurationBasedDimensions = MutableStateFlow(loadFromResources(view)) 118 119 view.repeatWhenAttached { 120 repeatOnLifecycle(Lifecycle.State.STARTED) { 121 launch { 122 viewModel.startButton.collect { buttonModel -> 123 updateButton( 124 view = startButton, 125 viewModel = buttonModel, 126 falsingManager = falsingManager, 127 messageDisplayer = messageDisplayer, 128 vibratorHelper = vibratorHelper, 129 ) 130 } 131 } 132 133 launch { 134 viewModel.endButton.collect { buttonModel -> 135 updateButton( 136 view = endButton, 137 viewModel = buttonModel, 138 falsingManager = falsingManager, 139 messageDisplayer = messageDisplayer, 140 vibratorHelper = vibratorHelper, 141 ) 142 } 143 } 144 145 launch { 146 viewModel.isOverlayContainerVisible.collect { isVisible -> 147 overlayContainer.visibility = 148 if (isVisible) { 149 View.VISIBLE 150 } else { 151 View.INVISIBLE 152 } 153 } 154 } 155 156 launch { 157 viewModel.alpha.collect { alpha -> 158 view.importantForAccessibility = 159 if (alpha == 0f) { 160 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS 161 } else { 162 View.IMPORTANT_FOR_ACCESSIBILITY_AUTO 163 } 164 165 ambientIndicationArea?.alpha = alpha 166 indicationArea.alpha = alpha 167 } 168 } 169 170 launch { 171 updateButtonAlpha( 172 view = startButton, 173 viewModel = viewModel.startButton, 174 alphaFlow = viewModel.alpha, 175 ) 176 } 177 178 launch { 179 updateButtonAlpha( 180 view = endButton, 181 viewModel = viewModel.endButton, 182 alphaFlow = viewModel.alpha, 183 ) 184 } 185 186 launch { 187 viewModel.indicationAreaTranslationX.collect { translationX -> 188 indicationArea.translationX = translationX 189 ambientIndicationArea?.translationX = translationX 190 } 191 } 192 193 launch { 194 combine( 195 viewModel.isIndicationAreaPadded, 196 configurationBasedDimensions.map { it.indicationAreaPaddingPx }, 197 ) { isPadded, paddingIfPaddedPx -> 198 if (isPadded) { 199 paddingIfPaddedPx 200 } else { 201 0 202 } 203 } 204 .collect { paddingPx -> 205 indicationArea.setPadding(paddingPx, 0, paddingPx, 0) 206 } 207 } 208 209 launch { 210 configurationBasedDimensions 211 .map { it.defaultBurnInPreventionYOffsetPx } 212 .flatMapLatest { defaultBurnInOffsetY -> 213 viewModel.indicationAreaTranslationY(defaultBurnInOffsetY) 214 } 215 .collect { translationY -> 216 indicationArea.translationY = translationY 217 ambientIndicationArea?.translationY = translationY 218 } 219 } 220 221 launch { 222 configurationBasedDimensions.collect { dimensions -> 223 indicationText.setTextSize( 224 TypedValue.COMPLEX_UNIT_PX, 225 dimensions.indicationTextSizePx.toFloat(), 226 ) 227 indicationTextBottom.setTextSize( 228 TypedValue.COMPLEX_UNIT_PX, 229 dimensions.indicationTextSizePx.toFloat(), 230 ) 231 232 startButton.updateLayoutParams<ViewGroup.LayoutParams> { 233 width = dimensions.buttonSizePx.width 234 height = dimensions.buttonSizePx.height 235 } 236 endButton.updateLayoutParams<ViewGroup.LayoutParams> { 237 width = dimensions.buttonSizePx.width 238 height = dimensions.buttonSizePx.height 239 } 240 } 241 } 242 } 243 } 244 245 return object : Binding { 246 override fun getIndicationAreaAnimators(): List<ViewPropertyAnimator> { 247 return listOf(indicationArea, ambientIndicationArea).mapNotNull { it?.animate() } 248 } 249 250 override fun onConfigurationChanged() { 251 configurationBasedDimensions.value = loadFromResources(view) 252 } 253 254 override fun shouldConstrainToTopOfLockIcon(): Boolean = 255 viewModel.shouldConstrainToTopOfLockIcon() 256 } 257 } 258 259 @SuppressLint("ClickableViewAccessibility") 260 private fun updateButton( 261 view: ImageView, 262 viewModel: KeyguardQuickAffordanceViewModel, 263 falsingManager: FalsingManager?, 264 messageDisplayer: (Int) -> Unit, 265 vibratorHelper: VibratorHelper?, 266 ) { 267 if (!viewModel.isVisible) { 268 view.isVisible = false 269 return 270 } 271 272 if (!view.isVisible) { 273 view.isVisible = true 274 if (viewModel.animateReveal) { 275 view.alpha = 0f 276 view.translationY = view.height / 2f 277 view 278 .animate() 279 .alpha(1f) 280 .translationY(0f) 281 .setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN) 282 .setDuration(EXIT_DOZE_BUTTON_REVEAL_ANIMATION_DURATION_MS) 283 .start() 284 } 285 } 286 287 IconViewBinder.bind(viewModel.icon, view) 288 289 (view.drawable as? Animatable2)?.let { animatable -> 290 (viewModel.icon as? Icon.Resource)?.res?.let { iconResourceId -> 291 // Always start the animation (we do call stop() below, if we need to skip it). 292 animatable.start() 293 294 if (view.tag != iconResourceId) { 295 // Here when we haven't run the animation on a previous update. 296 // 297 // Save the resource ID for next time, so we know not to re-animate the same 298 // animation again. 299 view.tag = iconResourceId 300 } else { 301 // Here when we've already done this animation on a previous update and want to 302 // skip directly to the final frame of the animation to avoid running it. 303 // 304 // By calling stop after start, we go to the final frame of the animation. 305 animatable.stop() 306 } 307 } 308 } 309 310 view.isActivated = viewModel.isActivated 311 view.drawable.setTint( 312 Utils.getColorAttrDefaultColor( 313 view.context, 314 if (viewModel.isActivated) { 315 com.android.internal.R.attr.textColorPrimaryInverse 316 } else { 317 com.android.internal.R.attr.textColorPrimary 318 }, 319 ) 320 ) 321 322 view.backgroundTintList = 323 if (!viewModel.isSelected) { 324 Utils.getColorAttr( 325 view.context, 326 if (viewModel.isActivated) { 327 com.android.internal.R.attr.colorAccentPrimary 328 } else { 329 com.android.internal.R.attr.colorSurface 330 } 331 ) 332 } else { 333 null 334 } 335 view 336 .animate() 337 .scaleX(if (viewModel.isSelected) SCALE_SELECTED_BUTTON else 1f) 338 .scaleY(if (viewModel.isSelected) SCALE_SELECTED_BUTTON else 1f) 339 .start() 340 341 view.isClickable = viewModel.isClickable 342 if (viewModel.isClickable) { 343 if (viewModel.useLongPress) { 344 view.setOnTouchListener( 345 OnTouchListener( 346 view, 347 viewModel, 348 messageDisplayer, 349 vibratorHelper, 350 falsingManager, 351 ) 352 ) 353 } else { 354 view.setOnClickListener(OnClickListener(viewModel, checkNotNull(falsingManager))) 355 } 356 } else { 357 view.setOnClickListener(null) 358 view.setOnTouchListener(null) 359 } 360 361 view.isSelected = viewModel.isSelected 362 } 363 364 private suspend fun updateButtonAlpha( 365 view: View, 366 viewModel: Flow<KeyguardQuickAffordanceViewModel>, 367 alphaFlow: Flow<Float>, 368 ) { 369 combine(viewModel.map { it.isDimmed }, alphaFlow) { isDimmed, alpha -> 370 if (isDimmed) DIM_ALPHA else alpha 371 } 372 .collect { view.alpha = it } 373 } 374 375 private class OnTouchListener( 376 private val view: View, 377 private val viewModel: KeyguardQuickAffordanceViewModel, 378 private val messageDisplayer: (Int) -> Unit, 379 private val vibratorHelper: VibratorHelper?, 380 private val falsingManager: FalsingManager?, 381 ) : View.OnTouchListener { 382 383 private val longPressDurationMs = ViewConfiguration.getLongPressTimeout().toLong() 384 private var longPressAnimator: ViewPropertyAnimator? = null 385 386 @SuppressLint("ClickableViewAccessibility") 387 override fun onTouch(v: View?, event: MotionEvent?): Boolean { 388 return when (event?.actionMasked) { 389 MotionEvent.ACTION_DOWN -> 390 if (viewModel.configKey != null) { 391 if (isUsingAccurateTool(event)) { 392 // For accurate tool types (stylus, mouse, etc.), we don't require a 393 // long-press. 394 } else { 395 // When not using a stylus, we require a long-press to activate the 396 // quick affordance, mostly to do "falsing" (e.g. protect from false 397 // clicks in the pocket/bag). 398 longPressAnimator = 399 view 400 .animate() 401 .scaleX(PRESSED_SCALE) 402 .scaleY(PRESSED_SCALE) 403 .setDuration(longPressDurationMs) 404 .withEndAction { 405 if ( 406 falsingManager 407 ?.isFalseLongTap( 408 FalsingManager.MODERATE_PENALTY 409 ) == false 410 ) { 411 dispatchClick(viewModel.configKey) 412 } 413 cancel() 414 } 415 } 416 true 417 } else { 418 false 419 } 420 MotionEvent.ACTION_MOVE -> { 421 if (!isUsingAccurateTool(event)) { 422 // Moving too far while performing a long-press gesture cancels that 423 // gesture. 424 val distanceMoved = distanceMoved(event) 425 if (distanceMoved > ViewConfiguration.getTouchSlop()) { 426 cancel() 427 } 428 } 429 true 430 } 431 MotionEvent.ACTION_UP -> { 432 if (isUsingAccurateTool(event)) { 433 // When using an accurate tool type (stylus, mouse, etc.), we don't require 434 // a long-press gesture to activate the quick affordance. Therefore, lifting 435 // the pointer performs a click. 436 if ( 437 viewModel.configKey != null && 438 distanceMoved(event) <= ViewConfiguration.getTouchSlop() && 439 falsingManager?.isFalseTap(FalsingManager.NO_PENALTY) == false 440 ) { 441 dispatchClick(viewModel.configKey) 442 } 443 } else { 444 // When not using a stylus, lifting the finger/pointer will actually cancel 445 // the long-press gesture. Calling cancel after the quick affordance was 446 // already long-press activated is a no-op, so it's safe to call from here. 447 cancel( 448 onAnimationEnd = 449 if (event.eventTime - event.downTime < longPressDurationMs) { 450 Runnable { 451 messageDisplayer.invoke( 452 R.string.keyguard_affordance_press_too_short 453 ) 454 val amplitude = 455 view.context.resources 456 .getDimensionPixelSize( 457 R.dimen.keyguard_affordance_shake_amplitude 458 ) 459 .toFloat() 460 val shakeAnimator = 461 ObjectAnimator.ofFloat( 462 view, 463 "translationX", 464 -amplitude / 2, 465 amplitude / 2, 466 ) 467 shakeAnimator.duration = 468 ShakeAnimationDuration.inWholeMilliseconds 469 shakeAnimator.interpolator = 470 CycleInterpolator(ShakeAnimationCycles) 471 shakeAnimator.start() 472 473 vibratorHelper?.vibrate(Vibrations.Shake) 474 } 475 } else { 476 null 477 } 478 ) 479 } 480 true 481 } 482 MotionEvent.ACTION_CANCEL -> { 483 cancel() 484 true 485 } 486 else -> false 487 } 488 } 489 490 private fun dispatchClick( 491 configKey: String, 492 ) { 493 view.setOnClickListener { 494 vibratorHelper?.vibrate( 495 if (viewModel.isActivated) { 496 Vibrations.Activated 497 } else { 498 Vibrations.Deactivated 499 } 500 ) 501 viewModel.onClicked( 502 KeyguardQuickAffordanceViewModel.OnClickedParameters( 503 configKey = configKey, 504 expandable = Expandable.fromView(view), 505 ) 506 ) 507 } 508 view.performClick() 509 view.setOnClickListener(null) 510 } 511 512 private fun cancel(onAnimationEnd: Runnable? = null) { 513 longPressAnimator?.cancel() 514 longPressAnimator = null 515 view.animate().scaleX(1f).scaleY(1f).withEndAction(onAnimationEnd) 516 } 517 518 companion object { 519 private const val PRESSED_SCALE = 1.5f 520 521 /** 522 * Returns `true` if the tool type at the given pointer index is an accurate tool (like 523 * stylus or mouse), which means we can trust it to not be a false click; `false` 524 * otherwise. 525 */ 526 private fun isUsingAccurateTool( 527 event: MotionEvent, 528 pointerIndex: Int = 0, 529 ): Boolean { 530 return when (event.getToolType(pointerIndex)) { 531 MotionEvent.TOOL_TYPE_STYLUS -> true 532 MotionEvent.TOOL_TYPE_MOUSE -> true 533 else -> false 534 } 535 } 536 537 /** 538 * Returns the amount of distance the pointer moved since the historical record at the 539 * [since] index. 540 */ 541 private fun distanceMoved( 542 event: MotionEvent, 543 since: Int = 0, 544 ): Float { 545 return if (event.historySize > 0) { 546 sqrt( 547 (event.y - event.getHistoricalY(since)).pow(2) + 548 (event.x - event.getHistoricalX(since)).pow(2) 549 ) 550 } else { 551 0f 552 } 553 } 554 } 555 } 556 557 private class OnClickListener( 558 private val viewModel: KeyguardQuickAffordanceViewModel, 559 private val falsingManager: FalsingManager, 560 ) : View.OnClickListener { 561 override fun onClick(view: View) { 562 if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { 563 return 564 } 565 566 if (viewModel.configKey != null) { 567 viewModel.onClicked( 568 KeyguardQuickAffordanceViewModel.OnClickedParameters( 569 configKey = viewModel.configKey, 570 expandable = Expandable.fromView(view), 571 ) 572 ) 573 } 574 } 575 } 576 577 private fun loadFromResources(view: View): ConfigurationBasedDimensions { 578 return ConfigurationBasedDimensions( 579 defaultBurnInPreventionYOffsetPx = 580 view.resources.getDimensionPixelOffset(R.dimen.default_burn_in_prevention_offset), 581 indicationAreaPaddingPx = 582 view.resources.getDimensionPixelOffset(R.dimen.keyguard_indication_area_padding), 583 indicationTextSizePx = 584 view.resources.getDimensionPixelSize( 585 com.android.internal.R.dimen.text_size_small_material, 586 ), 587 buttonSizePx = 588 Size( 589 view.resources.getDimensionPixelSize(R.dimen.keyguard_affordance_fixed_width), 590 view.resources.getDimensionPixelSize(R.dimen.keyguard_affordance_fixed_height), 591 ), 592 ) 593 } 594 595 private data class ConfigurationBasedDimensions( 596 val defaultBurnInPreventionYOffsetPx: Int, 597 val indicationAreaPaddingPx: Int, 598 val indicationTextSizePx: Int, 599 val buttonSizePx: Size, 600 ) 601 602 private val ShakeAnimationDuration = 300.milliseconds 603 private val ShakeAnimationCycles = 5f 604 605 object Vibrations { 606 607 private const val SmallVibrationScale = 0.3f 608 private const val BigVibrationScale = 0.6f 609 610 val Shake = 611 VibrationEffect.startComposition() 612 .apply { 613 val vibrationDelayMs = 614 (ShakeAnimationDuration.inWholeMilliseconds / (ShakeAnimationCycles * 2)) 615 .toInt() 616 val vibrationCount = ShakeAnimationCycles.toInt() * 2 617 repeat(vibrationCount) { 618 addPrimitive( 619 VibrationEffect.Composition.PRIMITIVE_TICK, 620 SmallVibrationScale, 621 vibrationDelayMs, 622 ) 623 } 624 } 625 .compose() 626 627 val Activated = 628 VibrationEffect.startComposition() 629 .addPrimitive( 630 VibrationEffect.Composition.PRIMITIVE_TICK, 631 BigVibrationScale, 632 0, 633 ) 634 .addPrimitive( 635 VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 636 0.1f, 637 0, 638 ) 639 .compose() 640 641 val Deactivated = 642 VibrationEffect.startComposition() 643 .addPrimitive( 644 VibrationEffect.Composition.PRIMITIVE_TICK, 645 BigVibrationScale, 646 0, 647 ) 648 .addPrimitive( 649 VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 650 0.1f, 651 0, 652 ) 653 .compose() 654 } 655 } 656