1 /*
<lambda>null2 * Copyright (C) 2023 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.biometrics.ui.binder
18
19 import android.animation.Animator
20 import android.animation.AnimatorSet
21 import android.animation.ValueAnimator
22 import android.graphics.Outline
23 import android.graphics.Rect
24 import android.transition.AutoTransition
25 import android.transition.TransitionManager
26 import android.util.TypedValue
27 import android.view.Surface
28 import android.view.View
29 import android.view.ViewGroup
30 import android.view.ViewOutlineProvider
31 import android.view.WindowInsets
32 import android.view.WindowManager
33 import android.view.accessibility.AccessibilityManager
34 import android.widget.ImageView
35 import android.widget.TextView
36 import androidx.constraintlayout.widget.ConstraintLayout
37 import androidx.constraintlayout.widget.ConstraintSet
38 import androidx.constraintlayout.widget.Guideline
39 import androidx.core.animation.addListener
40 import androidx.core.view.doOnLayout
41 import androidx.core.view.isGone
42 import androidx.lifecycle.lifecycleScope
43 import com.android.systemui.Flags.constraintBp
44 import com.android.systemui.biometrics.AuthPanelController
45 import com.android.systemui.biometrics.Utils
46 import com.android.systemui.biometrics.ui.viewmodel.PromptPosition
47 import com.android.systemui.biometrics.ui.viewmodel.PromptSize
48 import com.android.systemui.biometrics.ui.viewmodel.PromptViewModel
49 import com.android.systemui.biometrics.ui.viewmodel.isLarge
50 import com.android.systemui.biometrics.ui.viewmodel.isLeft
51 import com.android.systemui.biometrics.ui.viewmodel.isMedium
52 import com.android.systemui.biometrics.ui.viewmodel.isNullOrNotSmall
53 import com.android.systemui.biometrics.ui.viewmodel.isSmall
54 import com.android.systemui.biometrics.ui.viewmodel.isTop
55 import com.android.systemui.lifecycle.repeatWhenAttached
56 import com.android.systemui.res.R
57 import kotlin.math.abs
58 import kotlinx.coroutines.flow.combine
59 import kotlinx.coroutines.flow.first
60 import kotlinx.coroutines.launch
61
62 /** Helper for [BiometricViewBinder] to handle resize transitions. */
63 object BiometricViewSizeBinder {
64
65 private const val ANIMATE_SMALL_TO_MEDIUM_DURATION_MS = 150
66 // TODO(b/201510778): make private when related misuse is fixed
67 const val ANIMATE_MEDIUM_TO_LARGE_DURATION_MS = 450
68
69 /** Resizes [BiometricPromptLayout] and the [panelViewController] via the [PromptViewModel]. */
70 fun bind(
71 view: View,
72 viewModel: PromptViewModel,
73 viewsToHideWhenSmall: List<View>,
74 viewsToFadeInOnSizeChange: List<View>,
75 panelViewController: AuthPanelController?,
76 jankListener: BiometricJankListener,
77 ) {
78 val windowManager = requireNotNull(view.context.getSystemService(WindowManager::class.java))
79 val accessibilityManager =
80 requireNotNull(view.context.getSystemService(AccessibilityManager::class.java))
81
82 fun notifyAccessibilityChanged() {
83 Utils.notifyAccessibilityContentChanged(accessibilityManager, view as ViewGroup)
84 }
85
86 fun startMonitoredAnimation(animators: List<Animator>) {
87 with(AnimatorSet()) {
88 addListener(jankListener)
89 addListener(onEnd = { notifyAccessibilityChanged() })
90 play(animators.first()).apply { animators.drop(1).forEach { next -> with(next) } }
91 start()
92 }
93 }
94
95 if (constraintBp()) {
96 val leftGuideline = view.requireViewById<Guideline>(R.id.leftGuideline)
97 val topGuideline = view.requireViewById<Guideline>(R.id.topGuideline)
98 val rightGuideline = view.requireViewById<Guideline>(R.id.rightGuideline)
99 val midGuideline = view.findViewById<Guideline>(R.id.midGuideline)
100
101 val iconHolderView = view.requireViewById<View>(R.id.biometric_icon)
102 val panelView = view.requireViewById<View>(R.id.panel)
103 val cornerRadius = view.resources.getDimension(R.dimen.biometric_dialog_corner_size)
104 val pxToDp =
105 TypedValue.applyDimension(
106 TypedValue.COMPLEX_UNIT_DIP,
107 1f,
108 view.resources.displayMetrics
109 )
110 val cornerRadiusPx = (pxToDp * cornerRadius).toInt()
111
112 var currentSize: PromptSize? = null
113 var currentPosition: PromptPosition = PromptPosition.Bottom
114 panelView.outlineProvider =
115 object : ViewOutlineProvider() {
116 override fun getOutline(view: View, outline: Outline) {
117 when (currentPosition) {
118 PromptPosition.Right -> {
119 outline.setRoundRect(
120 0,
121 0,
122 view.width + cornerRadiusPx,
123 view.height,
124 cornerRadiusPx.toFloat()
125 )
126 }
127 PromptPosition.Left -> {
128 outline.setRoundRect(
129 -cornerRadiusPx,
130 0,
131 view.width,
132 view.height,
133 cornerRadiusPx.toFloat()
134 )
135 }
136 PromptPosition.Bottom,
137 PromptPosition.Top -> {
138 outline.setRoundRect(
139 0,
140 0,
141 view.width,
142 view.height + cornerRadiusPx,
143 cornerRadiusPx.toFloat()
144 )
145 }
146 }
147 }
148 }
149
150 // ConstraintSets for animating between prompt sizes
151 val mediumConstraintSet = ConstraintSet()
152 mediumConstraintSet.clone(view as ConstraintLayout)
153
154 val smallConstraintSet = ConstraintSet()
155 smallConstraintSet.clone(mediumConstraintSet)
156
157 val largeConstraintSet = ConstraintSet()
158 largeConstraintSet.clone(mediumConstraintSet)
159 largeConstraintSet.constrainMaxWidth(R.id.panel, 0)
160 largeConstraintSet.setGuidelineBegin(R.id.leftGuideline, 0)
161 largeConstraintSet.setGuidelineEnd(R.id.rightGuideline, 0)
162
163 // TODO: Investigate better way to handle 180 rotations
164 val flipConstraintSet = ConstraintSet()
165
166 view.doOnLayout {
167 fun setVisibilities(hideSensorIcon: Boolean, size: PromptSize) {
168 viewsToHideWhenSmall.forEach { it.showContentOrHide(forceHide = size.isSmall) }
169 largeConstraintSet.setVisibility(iconHolderView.id, View.GONE)
170 largeConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE)
171 largeConstraintSet.setVisibility(R.id.indicator, View.GONE)
172 largeConstraintSet.setVisibility(R.id.scrollView, View.GONE)
173
174 if (hideSensorIcon) {
175 smallConstraintSet.setVisibility(iconHolderView.id, View.GONE)
176 smallConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE)
177 smallConstraintSet.setVisibility(R.id.indicator, View.GONE)
178 mediumConstraintSet.setVisibility(iconHolderView.id, View.GONE)
179 mediumConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE)
180 mediumConstraintSet.setVisibility(R.id.indicator, View.GONE)
181 }
182 }
183
184 view.repeatWhenAttached {
185 lifecycleScope.launch {
186 viewModel.iconPosition.collect { position ->
187 if (position != Rect()) {
188 val iconParams =
189 iconHolderView.layoutParams as ConstraintLayout.LayoutParams
190
191 if (position.left != 0) {
192 iconParams.endToEnd = ConstraintSet.UNSET
193 iconParams.leftMargin = position.left
194 mediumConstraintSet.clear(
195 R.id.biometric_icon,
196 ConstraintSet.RIGHT
197 )
198 mediumConstraintSet.connect(
199 R.id.biometric_icon,
200 ConstraintSet.LEFT,
201 ConstraintSet.PARENT_ID,
202 ConstraintSet.LEFT
203 )
204 mediumConstraintSet.setMargin(
205 R.id.biometric_icon,
206 ConstraintSet.LEFT,
207 position.left
208 )
209 smallConstraintSet.clear(
210 R.id.biometric_icon,
211 ConstraintSet.RIGHT
212 )
213 smallConstraintSet.connect(
214 R.id.biometric_icon,
215 ConstraintSet.LEFT,
216 ConstraintSet.PARENT_ID,
217 ConstraintSet.LEFT
218 )
219 smallConstraintSet.setMargin(
220 R.id.biometric_icon,
221 ConstraintSet.LEFT,
222 position.left
223 )
224 }
225 if (position.top != 0) {
226 iconParams.bottomToBottom = ConstraintSet.UNSET
227 iconParams.topMargin = position.top
228 mediumConstraintSet.clear(
229 R.id.biometric_icon,
230 ConstraintSet.BOTTOM
231 )
232 mediumConstraintSet.setMargin(
233 R.id.biometric_icon,
234 ConstraintSet.TOP,
235 position.top
236 )
237 smallConstraintSet.clear(
238 R.id.biometric_icon,
239 ConstraintSet.BOTTOM
240 )
241 smallConstraintSet.setMargin(
242 R.id.biometric_icon,
243 ConstraintSet.TOP,
244 position.top
245 )
246 }
247 if (position.right != 0) {
248 iconParams.startToStart = ConstraintSet.UNSET
249 iconParams.rightMargin = position.right
250 mediumConstraintSet.clear(
251 R.id.biometric_icon,
252 ConstraintSet.LEFT
253 )
254 mediumConstraintSet.connect(
255 R.id.biometric_icon,
256 ConstraintSet.RIGHT,
257 ConstraintSet.PARENT_ID,
258 ConstraintSet.RIGHT
259 )
260 mediumConstraintSet.setMargin(
261 R.id.biometric_icon,
262 ConstraintSet.RIGHT,
263 position.right
264 )
265 smallConstraintSet.clear(
266 R.id.biometric_icon,
267 ConstraintSet.LEFT
268 )
269 smallConstraintSet.connect(
270 R.id.biometric_icon,
271 ConstraintSet.RIGHT,
272 ConstraintSet.PARENT_ID,
273 ConstraintSet.RIGHT
274 )
275 smallConstraintSet.setMargin(
276 R.id.biometric_icon,
277 ConstraintSet.RIGHT,
278 position.right
279 )
280 }
281 if (position.bottom != 0) {
282 iconParams.topToTop = ConstraintSet.UNSET
283 iconParams.bottomMargin = position.bottom
284 mediumConstraintSet.clear(
285 R.id.biometric_icon,
286 ConstraintSet.TOP
287 )
288 mediumConstraintSet.setMargin(
289 R.id.biometric_icon,
290 ConstraintSet.BOTTOM,
291 position.bottom
292 )
293 smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.TOP)
294 smallConstraintSet.setMargin(
295 R.id.biometric_icon,
296 ConstraintSet.BOTTOM,
297 position.bottom
298 )
299 }
300 iconHolderView.layoutParams = iconParams
301 }
302 }
303 }
304
305 lifecycleScope.launch {
306 viewModel.iconSize.collect { iconSize ->
307 iconHolderView.layoutParams.width = iconSize.first
308 iconHolderView.layoutParams.height = iconSize.second
309 mediumConstraintSet.constrainWidth(R.id.biometric_icon, iconSize.first)
310 mediumConstraintSet.constrainHeight(
311 R.id.biometric_icon,
312 iconSize.second
313 )
314 }
315 }
316
317 lifecycleScope.launch {
318 viewModel.guidelineBounds.collect { bounds ->
319 val bottomInset =
320 windowManager.maximumWindowMetrics.windowInsets
321 .getInsets(WindowInsets.Type.navigationBars())
322 .bottom
323 mediumConstraintSet.setGuidelineEnd(R.id.bottomGuideline, bottomInset)
324
325 if (bounds.left >= 0) {
326 mediumConstraintSet.setGuidelineBegin(leftGuideline.id, bounds.left)
327 smallConstraintSet.setGuidelineBegin(leftGuideline.id, bounds.left)
328 } else if (bounds.left < 0) {
329 mediumConstraintSet.setGuidelineEnd(
330 leftGuideline.id,
331 abs(bounds.left)
332 )
333 smallConstraintSet.setGuidelineEnd(
334 leftGuideline.id,
335 abs(bounds.left)
336 )
337 }
338
339 if (bounds.right >= 0) {
340 mediumConstraintSet.setGuidelineEnd(rightGuideline.id, bounds.right)
341 smallConstraintSet.setGuidelineEnd(rightGuideline.id, bounds.right)
342 } else if (bounds.right < 0) {
343 mediumConstraintSet.setGuidelineBegin(
344 rightGuideline.id,
345 abs(bounds.right)
346 )
347 smallConstraintSet.setGuidelineBegin(
348 rightGuideline.id,
349 abs(bounds.right)
350 )
351 }
352
353 if (bounds.top >= 0) {
354 mediumConstraintSet.setGuidelineBegin(topGuideline.id, bounds.top)
355 smallConstraintSet.setGuidelineBegin(topGuideline.id, bounds.top)
356 } else if (bounds.top < 0) {
357 mediumConstraintSet.setGuidelineEnd(
358 topGuideline.id,
359 abs(bounds.top)
360 )
361 smallConstraintSet.setGuidelineEnd(topGuideline.id, abs(bounds.top))
362 }
363
364 if (midGuideline != null) {
365 val left =
366 if (bounds.left >= 0) {
367 abs(bounds.left)
368 } else {
369 view.width - abs(bounds.left)
370 }
371 val right =
372 if (bounds.right >= 0) {
373 view.width - abs(bounds.right)
374 } else {
375 abs(bounds.right)
376 }
377 val mid = (left + right) / 2
378 mediumConstraintSet.setGuidelineBegin(midGuideline.id, mid)
379 }
380 }
381 }
382
383 lifecycleScope.launch {
384 combine(viewModel.hideSensorIcon, viewModel.size, ::Pair).collect {
385 (hideSensorIcon, size) ->
386 setVisibilities(hideSensorIcon, size)
387 }
388 }
389
390 lifecycleScope.launch {
391 combine(viewModel.position, viewModel.size, ::Pair).collect {
392 (position, size) ->
393 if (position.isLeft) {
394 if (size.isSmall) {
395 flipConstraintSet.clone(smallConstraintSet)
396 } else {
397 flipConstraintSet.clone(mediumConstraintSet)
398 }
399
400 // Move all content to other panel
401 flipConstraintSet.connect(
402 R.id.scrollView,
403 ConstraintSet.LEFT,
404 R.id.midGuideline,
405 ConstraintSet.LEFT
406 )
407 flipConstraintSet.connect(
408 R.id.scrollView,
409 ConstraintSet.RIGHT,
410 R.id.rightGuideline,
411 ConstraintSet.RIGHT
412 )
413 } else if (position.isTop) {
414 // Top position is only used for 180 rotation Udfps
415 // Requires repositioning due to sensor location at top of screen
416 mediumConstraintSet.connect(
417 R.id.scrollView,
418 ConstraintSet.TOP,
419 R.id.indicator,
420 ConstraintSet.BOTTOM
421 )
422 mediumConstraintSet.connect(
423 R.id.scrollView,
424 ConstraintSet.BOTTOM,
425 R.id.button_bar,
426 ConstraintSet.TOP
427 )
428 mediumConstraintSet.connect(
429 R.id.panel,
430 ConstraintSet.TOP,
431 R.id.biometric_icon,
432 ConstraintSet.TOP
433 )
434 mediumConstraintSet.setMargin(
435 R.id.panel,
436 ConstraintSet.TOP,
437 (-24 * pxToDp).toInt()
438 )
439 mediumConstraintSet.setVerticalBias(R.id.scrollView, 0f)
440 }
441
442 when {
443 size.isSmall -> {
444 if (position.isLeft) {
445 flipConstraintSet.applyTo(view)
446 } else {
447 smallConstraintSet.applyTo(view)
448 }
449 }
450 size.isMedium && currentSize.isSmall -> {
451 val autoTransition = AutoTransition()
452 autoTransition.setDuration(
453 ANIMATE_SMALL_TO_MEDIUM_DURATION_MS.toLong()
454 )
455
456 TransitionManager.beginDelayedTransition(view, autoTransition)
457
458 if (position.isLeft) {
459 flipConstraintSet.applyTo(view)
460 } else {
461 mediumConstraintSet.applyTo(view)
462 }
463 }
464 size.isMedium -> {
465 if (position.isLeft) {
466 flipConstraintSet.applyTo(view)
467 } else {
468 mediumConstraintSet.applyTo(view)
469 }
470 }
471 size.isLarge && currentSize.isMedium -> {
472 val autoTransition = AutoTransition()
473 autoTransition.setDuration(
474 ANIMATE_MEDIUM_TO_LARGE_DURATION_MS.toLong()
475 )
476
477 TransitionManager.beginDelayedTransition(view, autoTransition)
478 largeConstraintSet.applyTo(view)
479 }
480 }
481
482 currentSize = size
483 currentPosition = position
484 notifyAccessibilityChanged()
485
486 panelView.invalidateOutline()
487 view.invalidate()
488 view.requestLayout()
489 }
490 }
491 }
492 }
493 } else if (panelViewController != null) {
494 val iconHolderView = view.requireViewById<View>(R.id.biometric_icon_frame)
495 val iconPadding = view.resources.getDimension(R.dimen.biometric_dialog_icon_padding)
496 val fullSizeYOffset =
497 view.resources.getDimension(
498 R.dimen.biometric_dialog_medium_to_large_translation_offset
499 )
500
501 // cache the original position of the icon view (as done in legacy view)
502 // this must happen before any size changes can be made
503 view.doOnLayout {
504 // TODO(b/251476085): this old way of positioning has proven itself unreliable
505 // remove this and associated thing like (UdfpsDialogMeasureAdapter) and
506 // pin to the physical sensor
507 val iconHolderOriginalY = iconHolderView.y
508
509 // bind to prompt
510 // TODO(b/251476085): migrate the legacy panel controller and simplify this
511 view.repeatWhenAttached {
512 var currentSize: PromptSize? = null
513 lifecycleScope.launch {
514 /**
515 * View is only set visible in BiometricViewSizeBinder once PromptSize is
516 * determined that accounts for iconView size, to prevent prompt resizing
517 * being visible to the user.
518 *
519 * TODO(b/288175072): May be able to remove isIconViewLoaded once constraint
520 * layout is implemented
521 */
522 combine(viewModel.isIconViewLoaded, viewModel.size, ::Pair).collect {
523 (isIconViewLoaded, size) ->
524 if (!isIconViewLoaded) {
525 return@collect
526 }
527
528 // prepare for animated size transitions
529 for (v in viewsToHideWhenSmall) {
530 v.showContentOrHide(forceHide = size.isSmall)
531 }
532
533 if (viewModel.hideSensorIcon.first()) {
534 iconHolderView.visibility = View.GONE
535 }
536
537 if (currentSize == null && size.isSmall) {
538 iconHolderView.alpha = 0f
539 }
540 if ((currentSize.isSmall && size.isMedium) || size.isSmall) {
541 viewsToFadeInOnSizeChange.forEach { it.alpha = 0f }
542 }
543
544 // propagate size changes to legacy panel controller and animate
545 // transitions
546 view.doOnLayout {
547 val width = view.measuredWidth
548 val height = view.measuredHeight
549
550 when {
551 size.isSmall -> {
552 iconHolderView.alpha = 1f
553 val bottomInset =
554 windowManager.maximumWindowMetrics.windowInsets
555 .getInsets(WindowInsets.Type.navigationBars())
556 .bottom
557 iconHolderView.y =
558 if (view.isLandscape()) {
559 (view.height -
560 iconHolderView.height -
561 bottomInset) / 2f
562 } else {
563 view.height -
564 iconHolderView.height -
565 iconPadding -
566 bottomInset
567 }
568 val newHeight =
569 iconHolderView.height + (2 * iconPadding.toInt()) -
570 iconHolderView.paddingTop -
571 iconHolderView.paddingBottom
572 panelViewController.updateForContentDimensions(
573 width,
574 newHeight + bottomInset,
575 0, /* animateDurationMs */
576 )
577 }
578 size.isMedium && currentSize.isSmall -> {
579 val duration = ANIMATE_SMALL_TO_MEDIUM_DURATION_MS
580 panelViewController.updateForContentDimensions(
581 width,
582 height,
583 duration,
584 )
585 startMonitoredAnimation(
586 listOf(
587 iconHolderView.asVerticalAnimator(
588 duration = duration.toLong(),
589 toY =
590 iconHolderOriginalY -
591 viewsToHideWhenSmall
592 .filter { it.isGone }
593 .sumOf { it.height },
594 ),
595 viewsToFadeInOnSizeChange.asFadeInAnimator(
596 duration = duration.toLong(),
597 delay = duration.toLong(),
598 ),
599 )
600 )
601 }
602 size.isMedium && currentSize.isNullOrNotSmall -> {
603 panelViewController.updateForContentDimensions(
604 width,
605 height,
606 0, /* animateDurationMs */
607 )
608 }
609 size.isLarge -> {
610 val duration = ANIMATE_MEDIUM_TO_LARGE_DURATION_MS
611 panelViewController.setUseFullScreen(true)
612 panelViewController.updateForContentDimensions(
613 panelViewController.containerWidth,
614 panelViewController.containerHeight,
615 duration,
616 )
617
618 startMonitoredAnimation(
619 listOf(
620 view.asVerticalAnimator(
621 duration.toLong() * 2 / 3,
622 toY = view.y - fullSizeYOffset
623 ),
624 listOf(view)
625 .asFadeInAnimator(
626 duration = duration.toLong() / 2,
627 delay = duration.toLong(),
628 ),
629 )
630 )
631 // TODO(b/251476085): clean up (copied from legacy)
632 if (view.isAttachedToWindow) {
633 val parent = view.parent as? ViewGroup
634 parent?.removeView(view)
635 }
636 }
637 }
638
639 currentSize = size
640 view.visibility = View.VISIBLE
641 viewModel.setIsIconViewLoaded(false)
642 notifyAccessibilityChanged()
643 }
644 }
645 }
646 }
647 }
648 }
649 }
650 }
651
isLandscapenull652 private fun View.isLandscape(): Boolean {
653 val r = context.display.rotation
654 return if (
655 context.resources.getBoolean(com.android.internal.R.bool.config_reverseDefaultRotation)
656 ) {
657 r == Surface.ROTATION_0 || r == Surface.ROTATION_180
658 } else {
659 r == Surface.ROTATION_90 || r == Surface.ROTATION_270
660 }
661 }
662
showContentOrHidenull663 private fun View.showContentOrHide(forceHide: Boolean = false) {
664 val isTextViewWithBlankText = this is TextView && this.text.isBlank()
665 val isImageViewWithoutImage = this is ImageView && this.drawable == null
666 visibility =
667 if (forceHide || isTextViewWithBlankText || isImageViewWithoutImage) {
668 View.GONE
669 } else {
670 View.VISIBLE
671 }
672 }
673
asVerticalAnimatornull674 private fun View.asVerticalAnimator(
675 duration: Long,
676 toY: Float,
677 fromY: Float = this.y
678 ): ValueAnimator {
679 val animator = ValueAnimator.ofFloat(fromY, toY)
680 animator.duration = duration
681 animator.addUpdateListener { y = it.animatedValue as Float }
682 return animator
683 }
684
asFadeInAnimatornull685 private fun List<View>.asFadeInAnimator(duration: Long, delay: Long): ValueAnimator {
686 forEach { it.alpha = 0f }
687 val animator = ValueAnimator.ofFloat(0f, 1f)
688 animator.duration = duration
689 animator.startDelay = delay
690 animator.addUpdateListener {
691 val alpha = it.animatedValue as Float
692 forEach { view -> view.alpha = alpha }
693 }
694 return animator
695 }
696