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