• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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