1 /*
<lambda>null2 * 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 package com.android.systemui.biometrics
17
18 import android.animation.Animator
19 import android.animation.AnimatorListenerAdapter
20 import android.app.ActivityTaskManager
21 import android.content.Context
22 import android.content.res.Configuration
23 import android.graphics.Color
24 import android.graphics.PixelFormat
25 import android.graphics.PorterDuff
26 import android.graphics.PorterDuffColorFilter
27 import android.graphics.Rect
28 import android.hardware.biometrics.BiometricOverlayConstants
29 import android.hardware.biometrics.BiometricOverlayConstants.REASON_AUTH_KEYGUARD
30 import android.hardware.biometrics.BiometricOverlayConstants.REASON_AUTH_SETTINGS
31 import android.hardware.biometrics.SensorLocationInternal
32 import android.hardware.display.DisplayManager
33 import android.hardware.fingerprint.FingerprintManager
34 import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
35 import android.hardware.fingerprint.ISidefpsController
36 import android.os.Handler
37 import android.util.Log
38 import android.util.RotationUtils
39 import android.view.Display
40 import android.view.Gravity
41 import android.view.LayoutInflater
42 import android.view.Surface
43 import android.view.View
44 import android.view.View.AccessibilityDelegate
45 import android.view.ViewPropertyAnimator
46 import android.view.WindowInsets
47 import android.view.WindowManager
48 import android.view.WindowManager.LayoutParams.PRIVATE_FLAG_NO_MOVE_ANIMATION
49 import android.view.WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY
50 import android.view.accessibility.AccessibilityEvent
51 import androidx.annotation.RawRes
52 import com.airbnb.lottie.LottieAnimationView
53 import com.airbnb.lottie.LottieProperty
54 import com.airbnb.lottie.model.KeyPath
55 import com.android.internal.annotations.VisibleForTesting
56 import com.android.systemui.Dumpable
57 import com.android.systemui.R
58 import com.android.systemui.dagger.SysUISingleton
59 import com.android.systemui.dagger.qualifiers.Application
60 import com.android.systemui.dagger.qualifiers.Main
61 import com.android.systemui.dump.DumpManager
62 import com.android.systemui.flags.FeatureFlags
63 import com.android.systemui.flags.Flags
64 import com.android.systemui.keyguard.domain.interactor.AlternateBouncerInteractor
65 import com.android.systemui.recents.OverviewProxyService
66 import com.android.systemui.util.concurrency.DelayableExecutor
67 import com.android.systemui.util.traceSection
68 import java.io.PrintWriter
69 import javax.inject.Inject
70 import kotlinx.coroutines.CoroutineScope
71 import kotlinx.coroutines.launch
72
73 private const val TAG = "SideFpsController"
74
75 /**
76 * Shows and hides the side fingerprint sensor (side-fps) overlay and handles side fps touch events.
77 */
78 @SysUISingleton
79 class SideFpsController
80 @Inject
81 constructor(
82 private val context: Context,
83 private val layoutInflater: LayoutInflater,
84 fingerprintManager: FingerprintManager?,
85 private val windowManager: WindowManager,
86 private val activityTaskManager: ActivityTaskManager,
87 overviewProxyService: OverviewProxyService,
88 displayManager: DisplayManager,
89 @Main private val mainExecutor: DelayableExecutor,
90 @Main private val handler: Handler,
91 private val alternateBouncerInteractor: AlternateBouncerInteractor,
92 @Application private val scope: CoroutineScope,
93 private val featureFlags: FeatureFlags,
94 dumpManager: DumpManager
95 ) : Dumpable {
96 private val requests: HashSet<SideFpsUiRequestSource> = HashSet()
97
98 @VisibleForTesting
99 val sensorProps: FingerprintSensorPropertiesInternal =
100 fingerprintManager?.sideFpsSensorProperties
101 ?: throw IllegalStateException("no side fingerprint sensor")
102
103 @VisibleForTesting
104 val orientationReasonListener =
105 OrientationReasonListener(
106 context,
107 displayManager,
108 handler,
109 sensorProps,
110 { reason -> onOrientationChanged(reason) },
111 BiometricOverlayConstants.REASON_UNKNOWN
112 )
113
114 @VisibleForTesting val orientationListener = orientationReasonListener.orientationListener
115
116 @VisibleForTesting
117 val overviewProxyListener =
118 object : OverviewProxyService.OverviewProxyListener {
119 override fun onTaskbarStatusUpdated(visible: Boolean, stashed: Boolean) {
120 overlayView?.let { view ->
121 handler.postDelayed({ updateOverlayVisibility(view) }, 500)
122 }
123 }
124 }
125
126 private val animationDuration =
127 context.resources.getInteger(android.R.integer.config_mediumAnimTime).toLong()
128
129 private val isReverseDefaultRotation =
130 context.resources.getBoolean(com.android.internal.R.bool.config_reverseDefaultRotation)
131
132 private var overlayHideAnimator: ViewPropertyAnimator? = null
133
134 private var overlayView: View? = null
135 set(value) {
136 field?.let { oldView ->
137 windowManager.removeView(oldView)
138 orientationListener.disable()
139 }
140 overlayHideAnimator?.cancel()
141 overlayHideAnimator = null
142
143 field = value
144 field?.let { newView ->
145 windowManager.addView(newView, overlayViewParams)
146 updateOverlayVisibility(newView)
147 orientationListener.enable()
148 }
149 }
150 @VisibleForTesting
151 internal var overlayOffsets: SensorLocationInternal = SensorLocationInternal.DEFAULT
152
153 private val overlayViewParams =
154 WindowManager.LayoutParams(
155 WindowManager.LayoutParams.WRAP_CONTENT,
156 WindowManager.LayoutParams.WRAP_CONTENT,
157 WindowManager.LayoutParams.TYPE_KEYGUARD_DIALOG,
158 Utils.FINGERPRINT_OVERLAY_LAYOUT_PARAM_FLAGS,
159 PixelFormat.TRANSLUCENT
160 )
161 .apply {
162 title = TAG
163 fitInsetsTypes = 0 // overrides default, avoiding status bars during layout
164 gravity = Gravity.TOP or Gravity.LEFT
165 layoutInDisplayCutoutMode =
166 WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
167 privateFlags = PRIVATE_FLAG_TRUSTED_OVERLAY or PRIVATE_FLAG_NO_MOVE_ANIMATION
168 }
169
170 init {
171 fingerprintManager?.setSidefpsController(
172 object : ISidefpsController.Stub() {
173 override fun show(
174 sensorId: Int,
175 @BiometricOverlayConstants.ShowReason reason: Int
176 ) =
177 if (reason.isReasonToAutoShow(activityTaskManager)) {
178 show(SideFpsUiRequestSource.AUTO_SHOW, reason)
179 } else {
180 hide(SideFpsUiRequestSource.AUTO_SHOW)
181 }
182
183 override fun hide(sensorId: Int) = hide(SideFpsUiRequestSource.AUTO_SHOW)
184 }
185 )
186 overviewProxyService.addCallback(overviewProxyListener)
187 listenForAlternateBouncerVisibility()
188
189 dumpManager.registerDumpable(this)
190 }
191
192 private fun listenForAlternateBouncerVisibility() {
193 alternateBouncerInteractor.setAlternateBouncerUIAvailable(true)
194 if (featureFlags.isEnabled(Flags.MODERN_ALTERNATE_BOUNCER)) {
195 scope.launch {
196 alternateBouncerInteractor.isVisible.collect { isVisible: Boolean ->
197 if (isVisible) {
198 show(SideFpsUiRequestSource.ALTERNATE_BOUNCER, REASON_AUTH_KEYGUARD)
199 } else {
200 hide(SideFpsUiRequestSource.ALTERNATE_BOUNCER)
201 }
202 }
203 }
204 }
205 }
206
207 /** Shows the side fps overlay if not already shown. */
208 fun show(
209 request: SideFpsUiRequestSource,
210 @BiometricOverlayConstants.ShowReason reason: Int = BiometricOverlayConstants.REASON_UNKNOWN
211 ) {
212 requests.add(request)
213 mainExecutor.execute {
214 if (overlayView == null) {
215 traceSection("SideFpsController#show(request=${request.name}, reason=$reason") {
216 createOverlayForDisplay(reason)
217 }
218 } else {
219 Log.v(TAG, "overlay already shown")
220 }
221 }
222 }
223
224 /** Hides the fps overlay if shown. */
225 fun hide(request: SideFpsUiRequestSource) {
226 requests.remove(request)
227 mainExecutor.execute {
228 if (requests.isEmpty()) {
229 traceSection("SideFpsController#hide(${request.name}") { overlayView = null }
230 }
231 }
232 }
233
234 override fun dump(pw: PrintWriter, args: Array<out String>) {
235 pw.println("requests:")
236 for (requestSource in requests) {
237 pw.println(" $requestSource.name")
238 }
239 }
240
241 private fun onOrientationChanged(@BiometricOverlayConstants.ShowReason reason: Int) {
242 if (overlayView != null) {
243 createOverlayForDisplay(reason)
244 }
245 }
246
247 private fun createOverlayForDisplay(@BiometricOverlayConstants.ShowReason reason: Int) {
248 val view = layoutInflater.inflate(R.layout.sidefps_view, null, false)
249 overlayView = view
250 val display = context.display!!
251 val offsets =
252 sensorProps.getLocation(display.uniqueId).let { location ->
253 if (location == null) {
254 Log.w(TAG, "No location specified for display: ${display.uniqueId}")
255 }
256 location ?: sensorProps.location
257 }
258 overlayOffsets = offsets
259
260 val lottie = view.findViewById(R.id.sidefps_animation) as LottieAnimationView
261 view.rotation =
262 display.asSideFpsAnimationRotation(
263 offsets.isYAligned(),
264 getRotationFromDefault(display.rotation)
265 )
266 lottie.setAnimation(
267 display.asSideFpsAnimation(
268 offsets.isYAligned(),
269 getRotationFromDefault(display.rotation)
270 )
271 )
272 lottie.addLottieOnCompositionLoadedListener {
273 // Check that view is not stale, and that overlayView has not been hidden/removed
274 if (overlayView != null && overlayView == view) {
275 updateOverlayParams(display, it.bounds)
276 }
277 }
278 orientationReasonListener.reason = reason
279 lottie.addOverlayDynamicColor(context, reason)
280
281 /**
282 * Intercepts TYPE_WINDOW_STATE_CHANGED accessibility event, preventing Talkback from
283 * speaking @string/accessibility_fingerprint_label twice when sensor location indicator is
284 * in focus
285 */
286 view.setAccessibilityDelegate(
287 object : AccessibilityDelegate() {
288 override fun dispatchPopulateAccessibilityEvent(
289 host: View,
290 event: AccessibilityEvent
291 ): Boolean {
292 return if (
293 event.getEventType() === AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
294 ) {
295 true
296 } else {
297 super.dispatchPopulateAccessibilityEvent(host, event)
298 }
299 }
300 }
301 )
302 }
303
304 @VisibleForTesting
305 internal fun updateOverlayParams(display: Display, bounds: Rect) {
306 val isNaturalOrientation = display.isNaturalOrientation()
307 val isDefaultOrientation =
308 if (isReverseDefaultRotation) !isNaturalOrientation else isNaturalOrientation
309 val size = windowManager.maximumWindowMetrics.bounds
310
311 val displayWidth = if (isDefaultOrientation) size.width() else size.height()
312 val displayHeight = if (isDefaultOrientation) size.height() else size.width()
313 val boundsWidth = if (isDefaultOrientation) bounds.width() else bounds.height()
314 val boundsHeight = if (isDefaultOrientation) bounds.height() else bounds.width()
315
316 val sensorBounds =
317 if (overlayOffsets.isYAligned()) {
318 Rect(
319 displayWidth - boundsWidth,
320 overlayOffsets.sensorLocationY,
321 displayWidth,
322 overlayOffsets.sensorLocationY + boundsHeight
323 )
324 } else {
325 Rect(
326 overlayOffsets.sensorLocationX,
327 0,
328 overlayOffsets.sensorLocationX + boundsWidth,
329 boundsHeight
330 )
331 }
332
333 RotationUtils.rotateBounds(
334 sensorBounds,
335 Rect(0, 0, displayWidth, displayHeight),
336 getRotationFromDefault(display.rotation)
337 )
338
339 overlayViewParams.x = sensorBounds.left
340 overlayViewParams.y = sensorBounds.top
341
342 windowManager.updateViewLayout(overlayView, overlayViewParams)
343 }
344
345 private fun updateOverlayVisibility(view: View) {
346 if (view != overlayView) {
347 return
348 }
349 // hide after a few seconds if the sensor is oriented down and there are
350 // large overlapping system bars
351 var rotation = context.display?.rotation
352
353 if (rotation != null) {
354 rotation = getRotationFromDefault(rotation)
355 }
356
357 if (
358 windowManager.currentWindowMetrics.windowInsets.hasBigNavigationBar() &&
359 ((rotation == Surface.ROTATION_270 && overlayOffsets.isYAligned()) ||
360 (rotation == Surface.ROTATION_180 && !overlayOffsets.isYAligned()))
361 ) {
362 overlayHideAnimator =
363 view
364 .animate()
365 .alpha(0f)
366 .setStartDelay(3_000)
367 .setDuration(animationDuration)
368 .setListener(
369 object : AnimatorListenerAdapter() {
370 override fun onAnimationEnd(animation: Animator) {
371 view.visibility = View.GONE
372 overlayHideAnimator = null
373 }
374 }
375 )
376 } else {
377 overlayHideAnimator?.cancel()
378 overlayHideAnimator = null
379 view.alpha = 1f
380 view.visibility = View.VISIBLE
381 }
382 }
383
384 private fun getRotationFromDefault(rotation: Int): Int =
385 if (isReverseDefaultRotation) (rotation + 1) % 4 else rotation
386 }
387
388 private val FingerprintManager?.sideFpsSensorProperties: FingerprintSensorPropertiesInternal?
<lambda>null389 get() = this?.sensorPropertiesInternal?.firstOrNull { it.isAnySidefpsType }
390
391 /** Returns [True] when the device has a side fingerprint sensor. */
FingerprintManagernull392 fun FingerprintManager?.hasSideFpsSensor(): Boolean = this?.sideFpsSensorProperties != null
393
394 @BiometricOverlayConstants.ShowReason
395 private fun Int.isReasonToAutoShow(activityTaskManager: ActivityTaskManager): Boolean =
396 when (this) {
397 REASON_AUTH_KEYGUARD -> false
398 REASON_AUTH_SETTINGS ->
399 when (activityTaskManager.topClass()) {
400 // TODO(b/186176653): exclude fingerprint overlays from this list view
401 "com.android.settings.biometrics.fingerprint.FingerprintSettings" -> false
402 else -> true
403 }
404 else -> true
405 }
406
topClassnull407 private fun ActivityTaskManager.topClass(): String =
408 getTasks(1).firstOrNull()?.topActivity?.className ?: ""
409
410 @RawRes
411 private fun Display.asSideFpsAnimation(yAligned: Boolean, rotationFromDefault: Int): Int =
412 when (rotationFromDefault) {
413 Surface.ROTATION_0 -> if (yAligned) R.raw.sfps_pulse else R.raw.sfps_pulse_landscape
414 Surface.ROTATION_180 -> if (yAligned) R.raw.sfps_pulse else R.raw.sfps_pulse_landscape
415 else -> if (yAligned) R.raw.sfps_pulse_landscape else R.raw.sfps_pulse
416 }
417
asSideFpsAnimationRotationnull418 private fun Display.asSideFpsAnimationRotation(yAligned: Boolean, rotationFromDefault: Int): Float =
419 when (rotationFromDefault) {
420 Surface.ROTATION_90 -> if (yAligned) 0f else 180f
421 Surface.ROTATION_180 -> 180f
422 Surface.ROTATION_270 -> if (yAligned) 180f else 0f
423 else -> 0f
424 }
425
SensorLocationInternalnull426 private fun SensorLocationInternal.isYAligned(): Boolean = sensorLocationY != 0
427
428 private fun Display.isNaturalOrientation(): Boolean =
429 rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180
430
431 private fun WindowInsets.hasBigNavigationBar(): Boolean =
432 getInsets(WindowInsets.Type.navigationBars()).bottom >= 70
433
434 private fun LottieAnimationView.addOverlayDynamicColor(
435 context: Context,
436 @BiometricOverlayConstants.ShowReason reason: Int
437 ) {
438 fun update() {
439 val isKeyguard = reason == REASON_AUTH_KEYGUARD
440 if (isKeyguard) {
441 val color = context.getColor(R.color.numpad_key_color_secondary) // match bouncer color
442 val chevronFill =
443 com.android.settingslib.Utils.getColorAttrDefaultColor(
444 context,
445 android.R.attr.textColorPrimaryInverse
446 )
447 for (key in listOf(".blue600", ".blue400")) {
448 addValueCallback(KeyPath(key, "**"), LottieProperty.COLOR_FILTER) {
449 PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP)
450 }
451 }
452 addValueCallback(KeyPath(".black", "**"), LottieProperty.COLOR_FILTER) {
453 PorterDuffColorFilter(chevronFill, PorterDuff.Mode.SRC_ATOP)
454 }
455 } else if (!isDarkMode(context)) {
456 addValueCallback(KeyPath(".black", "**"), LottieProperty.COLOR_FILTER) {
457 PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP)
458 }
459 } else if (isDarkMode(context)) {
460 for (key in listOf(".blue600", ".blue400")) {
461 addValueCallback(KeyPath(key, "**"), LottieProperty.COLOR_FILTER) {
462 PorterDuffColorFilter(
463 context.getColor(R.color.settingslib_color_blue400),
464 PorterDuff.Mode.SRC_ATOP
465 )
466 }
467 }
468 }
469 }
470
471 if (composition != null) {
472 update()
473 } else {
474 addLottieOnCompositionLoadedListener { update() }
475 }
476 }
477
isDarkModenull478 private fun isDarkMode(context: Context): Boolean {
479 val darkMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
480 return darkMode == Configuration.UI_MODE_NIGHT_YES
481 }
482
483 @VisibleForTesting
484 class OrientationReasonListener(
485 context: Context,
486 displayManager: DisplayManager,
487 handler: Handler,
488 sensorProps: FingerprintSensorPropertiesInternal,
489 onOrientationChanged: (reason: Int) -> Unit,
490 @BiometricOverlayConstants.ShowReason var reason: Int
491 ) {
492 val orientationListener =
493 BiometricDisplayListener(
494 context,
495 displayManager,
496 handler,
497 BiometricDisplayListener.SensorType.SideFingerprint(sensorProps)
<lambda>null498 ) {
499 onOrientationChanged(reason)
500 }
501 }
502
503 /**
504 * The source of a request to show the side fps visual indicator. This is distinct from
505 * [BiometricOverlayConstants] which corrresponds with the reason fingerprint authentication is
506 * requested.
507 */
508 enum class SideFpsUiRequestSource {
509 /** see [isReasonToAutoShow] */
510 AUTO_SHOW,
511 /** Pin, pattern or password bouncer */
512 PRIMARY_BOUNCER,
513 ALTERNATE_BOUNCER
514 }
515