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.animation.Animator
19 import android.animation.AnimatorListenerAdapter
20 import android.app.ActivityTaskManager
21 import android.content.Context
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.display.DisplayManager
30 import android.hardware.fingerprint.FingerprintManager
31 import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
32 import android.hardware.fingerprint.ISidefpsController
33 import android.os.Handler
34 import android.util.Log
35 import android.view.Display
36 import android.view.Gravity
37 import android.view.LayoutInflater
38 import android.view.Surface
39 import android.view.View
40 import android.view.ViewPropertyAnimator
41 import android.view.WindowInsets
42 import android.view.WindowManager
43 import androidx.annotation.RawRes
44 import com.airbnb.lottie.LottieAnimationView
45 import com.airbnb.lottie.LottieProperty
46 import com.airbnb.lottie.model.KeyPath
47 import com.android.internal.annotations.VisibleForTesting
48 import com.android.systemui.R
49 import com.android.systemui.dagger.SysUISingleton
50 import com.android.systemui.dagger.qualifiers.Main
51 import com.android.systemui.recents.OverviewProxyService
52 import com.android.systemui.util.concurrency.DelayableExecutor
53 import javax.inject.Inject
54 
55 private const val TAG = "SidefpsController"
56 
57 /**
58  * Shows and hides the side fingerprint sensor (side-fps) overlay and handles side fps touch events.
59  */
60 @SysUISingleton
61 class SidefpsController @Inject constructor(
62     private val context: Context,
63     private val layoutInflater: LayoutInflater,
64     fingerprintManager: FingerprintManager?,
65     private val windowManager: WindowManager,
66     private val activityTaskManager: ActivityTaskManager,
67     overviewProxyService: OverviewProxyService,
68     displayManager: DisplayManager,
69     @Main mainExecutor: DelayableExecutor,
70     @Main private val handler: Handler
71 ) {
72     @VisibleForTesting
73     val sensorProps: FingerprintSensorPropertiesInternal = fingerprintManager
74         ?.sensorPropertiesInternal
75         ?.firstOrNull { it.isAnySidefpsType }
76         ?: throw IllegalStateException("no side fingerprint sensor")
77 
78     @VisibleForTesting
79     val orientationListener = BiometricDisplayListener(
80         context,
81         displayManager,
82         handler,
83         BiometricDisplayListener.SensorType.SideFingerprint(sensorProps)
84     ) { onOrientationChanged() }
85 
86     @VisibleForTesting
87     val overviewProxyListener = object : OverviewProxyService.OverviewProxyListener {
88         override fun onTaskbarStatusUpdated(visible: Boolean, stashed: Boolean) {
89             overlayView?.let { view ->
90                 handler.postDelayed({ updateOverlayVisibility(view) }, 500)
91             }
92         }
93     }
94 
95     private val animationDuration =
96         context.resources.getInteger(android.R.integer.config_mediumAnimTime).toLong()
97 
98     private var overlayHideAnimator: ViewPropertyAnimator? = null
99 
100     private var overlayView: View? = null
101         set(value) {
102             field?.let { oldView ->
103                 windowManager.removeView(oldView)
104                 orientationListener.disable()
105             }
106             overlayHideAnimator?.cancel()
107             overlayHideAnimator = null
108 
109             field = value
110             field?.let { newView ->
111                 windowManager.addView(newView, overlayViewParams)
112                 updateOverlayVisibility(newView)
113                 orientationListener.enable()
114             }
115         }
116 
117     private val overlayViewParams = WindowManager.LayoutParams(
118         WindowManager.LayoutParams.WRAP_CONTENT,
119         WindowManager.LayoutParams.WRAP_CONTENT,
120         WindowManager.LayoutParams.TYPE_SYSTEM_ERROR,
121         Utils.FINGERPRINT_OVERLAY_LAYOUT_PARAM_FLAGS,
122         PixelFormat.TRANSLUCENT
123     ).apply {
124         title = TAG
125         fitInsetsTypes = 0 // overrides default, avoiding status bars during layout
126         gravity = Gravity.TOP or Gravity.LEFT
127         layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
128         privateFlags = WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY
129     }
130 
131     init {
132         fingerprintManager?.setSidefpsController(object : ISidefpsController.Stub() {
133             override fun show(
134                 sensorId: Int,
135                 @BiometricOverlayConstants.ShowReason reason: Int
136             ) = if (reason.isReasonToShow(activityTaskManager)) doShow() else hide(sensorId)
137 
138             private fun doShow() = mainExecutor.execute {
139                 if (overlayView == null) {
140                     overlayView = createOverlayForDisplay()
141                 } else {
142                     Log.v(TAG, "overlay already shown")
143                 }
144             }
145 
146             override fun hide(sensorId: Int) = mainExecutor.execute { overlayView = null }
147         })
148         overviewProxyService.addCallback(overviewProxyListener)
149     }
150 
151     private fun onOrientationChanged() {
152         if (overlayView != null) {
153             overlayView = createOverlayForDisplay()
154         }
155     }
156 
157     private fun createOverlayForDisplay(): View {
158         val view = layoutInflater.inflate(R.layout.sidefps_view, null, false)
159         val display = context.display!!
160 
161         val lottie = view.findViewById(R.id.sidefps_animation) as LottieAnimationView
162         lottie.setAnimation(display.asSideFpsAnimation())
163         view.rotation = display.asSideFpsAnimationRotation()
164 
165         updateOverlayParams(display, lottie.composition?.bounds ?: Rect())
166         lottie.addLottieOnCompositionLoadedListener {
167             if (overlayView == view) {
168                 updateOverlayParams(display, it.bounds)
169                 windowManager.updateViewLayout(overlayView, overlayViewParams)
170             }
171         }
172         lottie.addOverlayDynamicColor(context)
173 
174         return view
175     }
176 
177     private fun updateOverlayParams(display: Display, bounds: Rect) {
178         val isPortrait = display.isPortrait()
179         val size = windowManager.maximumWindowMetrics.bounds
180         val displayWidth = if (isPortrait) size.width() else size.height()
181         val displayHeight = if (isPortrait) size.height() else size.width()
182         val offsets = sensorProps.getLocation(display.uniqueId).let { location ->
183             if (location == null) {
184                 Log.w(TAG, "No location specified for display: ${display.uniqueId}")
185             }
186             location ?: sensorProps.location
187         }
188 
189         // ignore sensorLocationX and sensorRadius since it's assumed to be on the side
190         // of the device and centered at sensorLocationY
191         val (x, y) = when (display.rotation) {
192             Surface.ROTATION_90 ->
193                 Pair(offsets.sensorLocationY, 0)
194             Surface.ROTATION_270 ->
195                 Pair(displayHeight - offsets.sensorLocationY - bounds.width(), displayWidth)
196             Surface.ROTATION_180 ->
197                 Pair(0, displayHeight - offsets.sensorLocationY - bounds.height())
198             else ->
199                 Pair(displayWidth, offsets.sensorLocationY)
200         }
201         overlayViewParams.x = x
202         overlayViewParams.y = y
203     }
204 
205     private fun updateOverlayVisibility(view: View) {
206         if (view != overlayView) {
207             return
208         }
209 
210         // hide after a few seconds if the sensor is oriented down and there are
211         // large overlapping system bars
212         if ((context.display?.rotation == Surface.ROTATION_270) &&
213             windowManager.currentWindowMetrics.windowInsets.hasBigNavigationBar()) {
214             overlayHideAnimator = view.animate()
215                 .alpha(0f)
216                 .setStartDelay(3_000)
217                 .setDuration(animationDuration)
218                 .setListener(object : AnimatorListenerAdapter() {
219                     override fun onAnimationEnd(animation: Animator) {
220                         view.visibility = View.GONE
221                         overlayHideAnimator = null
222                     }
223                 })
224         } else {
225             overlayHideAnimator?.cancel()
226             overlayHideAnimator = null
227             view.alpha = 1f
228             view.visibility = View.VISIBLE
229         }
230     }
231 }
232 
233 @BiometricOverlayConstants.ShowReason
234 private fun Int.isReasonToShow(activityTaskManager: ActivityTaskManager): Boolean = when (this) {
235     REASON_AUTH_KEYGUARD -> false
236     REASON_AUTH_SETTINGS -> when (activityTaskManager.topClass()) {
237         // TODO(b/186176653): exclude fingerprint overlays from this list view
238         "com.android.settings.biometrics.fingerprint.FingerprintSettings" -> false
239         else -> true
240     }
241     else -> true
242 }
243 
244 private fun ActivityTaskManager.topClass(): String =
245     getTasks(1).firstOrNull()?.topActivity?.className ?: ""
246 
247 @RawRes
248 private fun Display.asSideFpsAnimation(): Int = when (rotation) {
249     Surface.ROTATION_0 -> R.raw.sfps_pulse
250     Surface.ROTATION_180 -> R.raw.sfps_pulse
251     else -> R.raw.sfps_pulse_landscape
252 }
253 
254 private fun Display.asSideFpsAnimationRotation(): Float = when (rotation) {
255     Surface.ROTATION_180 -> 180f
256     Surface.ROTATION_270 -> 180f
257     else -> 0f
258 }
259 
260 private fun Display.isPortrait(): Boolean =
261     rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180
262 
263 private fun WindowInsets.hasBigNavigationBar(): Boolean =
264     getInsets(WindowInsets.Type.navigationBars()).bottom >= 70
265 
266 private fun LottieAnimationView.addOverlayDynamicColor(context: Context) {
267     fun update() {
268         val c = context.getColor(R.color.biometric_dialog_accent)
269         for (key in listOf(".blue600", ".blue400")) {
270             addValueCallback(
271                 KeyPath(key, "**"),
272                 LottieProperty.COLOR_FILTER
273             ) { PorterDuffColorFilter(c, PorterDuff.Mode.SRC_ATOP) }
274         }
275     }
276 
277     if (composition != null) {
278         update()
279     } else {
280         addLottieOnCompositionLoadedListener { update() }
281     }
282 }
283