blob: bbffb73b7503dd9e0b4c6d6139ec847237a77056 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.biometrics
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.ActivityTaskManager
import android.content.Context
import android.graphics.PixelFormat
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.hardware.biometrics.BiometricOverlayConstants
import android.hardware.biometrics.BiometricOverlayConstants.REASON_AUTH_KEYGUARD
import android.hardware.biometrics.BiometricOverlayConstants.REASON_AUTH_SETTINGS
import android.hardware.biometrics.SensorLocationInternal
import android.hardware.display.DisplayManager
import android.hardware.fingerprint.FingerprintManager
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import android.hardware.fingerprint.ISidefpsController
import android.os.Handler
import android.util.Log
import android.util.RotationUtils
import android.view.Display
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Surface
import android.view.View
import android.view.View.AccessibilityDelegate
import android.view.ViewPropertyAnimator
import android.view.WindowInsets
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
import androidx.annotation.RawRes
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.model.KeyPath
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.R
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.recents.OverviewProxyService
import com.android.systemui.util.concurrency.DelayableExecutor
import javax.inject.Inject
private const val TAG = "SidefpsController"
/**
* Shows and hides the side fingerprint sensor (side-fps) overlay and handles side fps touch events.
*/
@SysUISingleton
class SidefpsController @Inject constructor(
private val context: Context,
private val layoutInflater: LayoutInflater,
fingerprintManager: FingerprintManager?,
private val windowManager: WindowManager,
private val activityTaskManager: ActivityTaskManager,
overviewProxyService: OverviewProxyService,
displayManager: DisplayManager,
@Main private val mainExecutor: DelayableExecutor,
@Main private val handler: Handler
) {
@VisibleForTesting
val sensorProps: FingerprintSensorPropertiesInternal = fingerprintManager
?.sideFpsSensorProperties
?: throw IllegalStateException("no side fingerprint sensor")
@VisibleForTesting
val orientationListener = BiometricDisplayListener(
context,
displayManager,
handler,
BiometricDisplayListener.SensorType.SideFingerprint(sensorProps)
) { onOrientationChanged() }
@VisibleForTesting
val overviewProxyListener = object : OverviewProxyService.OverviewProxyListener {
override fun onTaskbarStatusUpdated(visible: Boolean, stashed: Boolean) {
overlayView?.let { view ->
handler.postDelayed({ updateOverlayVisibility(view) }, 500)
}
}
}
private val animationDuration =
context.resources.getInteger(android.R.integer.config_mediumAnimTime).toLong()
private var overlayHideAnimator: ViewPropertyAnimator? = null
private var overlayView: View? = null
set(value) {
field?.let { oldView ->
windowManager.removeView(oldView)
orientationListener.disable()
}
overlayHideAnimator?.cancel()
overlayHideAnimator = null
field = value
field?.let { newView ->
windowManager.addView(newView, overlayViewParams)
updateOverlayVisibility(newView)
orientationListener.enable()
}
}
@VisibleForTesting
internal var overlayOffsets: SensorLocationInternal = SensorLocationInternal.DEFAULT
private val overlayViewParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_SYSTEM_ERROR,
Utils.FINGERPRINT_OVERLAY_LAYOUT_PARAM_FLAGS,
PixelFormat.TRANSLUCENT
).apply {
title = TAG
fitInsetsTypes = 0 // overrides default, avoiding status bars during layout
gravity = Gravity.TOP or Gravity.LEFT
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
privateFlags = WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY
}
init {
fingerprintManager?.setSidefpsController(
object : ISidefpsController.Stub() {
override fun show(
sensorId: Int,
@BiometricOverlayConstants.ShowReason reason: Int
) = if (reason.isReasonToShow(activityTaskManager)) show() else hide()
override fun hide(sensorId: Int) = hide()
})
overviewProxyService.addCallback(overviewProxyListener)
}
/** Shows the side fps overlay if not already shown. */
fun show() {
mainExecutor.execute {
if (overlayView == null) {
createOverlayForDisplay()
} else {
Log.v(TAG, "overlay already shown")
}
}
}
/** Hides the fps overlay if shown. */
fun hide() {
mainExecutor.execute { overlayView = null }
}
private fun onOrientationChanged() {
if (overlayView != null) {
createOverlayForDisplay()
}
}
private fun createOverlayForDisplay() {
val view = layoutInflater.inflate(R.layout.sidefps_view, null, false)
overlayView = view
val display = context.display!!
val offsets = sensorProps.getLocation(display.uniqueId).let { location ->
if (location == null) {
Log.w(TAG, "No location specified for display: ${display.uniqueId}")
}
location ?: sensorProps.location
}
overlayOffsets = offsets
val lottie = view.findViewById(R.id.sidefps_animation) as LottieAnimationView
view.rotation = display.asSideFpsAnimationRotation(offsets.isYAligned())
lottie.setAnimation(display.asSideFpsAnimation(offsets.isYAligned()))
lottie.addLottieOnCompositionLoadedListener {
// Check that view is not stale, and that overlayView has not been hidden/removed
if (overlayView != null && overlayView == view) {
updateOverlayParams(display, it.bounds)
}
}
lottie.addOverlayDynamicColor(context)
/**
* Intercepts TYPE_WINDOW_STATE_CHANGED accessibility event, preventing Talkback from
* speaking @string/accessibility_fingerprint_label twice when sensor location indicator
* is in focus
*/
view.setAccessibilityDelegate(object : AccessibilityDelegate() {
override fun dispatchPopulateAccessibilityEvent(
host: View,
event: AccessibilityEvent
): Boolean {
return if (event.getEventType() === AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
true
} else {
super.dispatchPopulateAccessibilityEvent(host, event)
}
}
})
}
@VisibleForTesting
internal fun updateOverlayParams(display: Display, bounds: Rect) {
val isNaturalOrientation = display.isNaturalOrientation()
val size = windowManager.maximumWindowMetrics.bounds
val displayWidth = if (isNaturalOrientation) size.width() else size.height()
val displayHeight = if (isNaturalOrientation) size.height() else size.width()
val boundsWidth = if (isNaturalOrientation) bounds.width() else bounds.height()
val boundsHeight = if (isNaturalOrientation) bounds.height() else bounds.width()
val sensorBounds = if (overlayOffsets.isYAligned()) {
Rect(
displayWidth - boundsWidth,
overlayOffsets.sensorLocationY,
displayWidth,
overlayOffsets.sensorLocationY + boundsHeight
)
} else {
Rect(
overlayOffsets.sensorLocationX,
0,
overlayOffsets.sensorLocationX + boundsWidth,
boundsHeight
)
}
RotationUtils.rotateBounds(
sensorBounds,
Rect(0, 0, displayWidth, displayHeight),
display.rotation
)
overlayViewParams.x = sensorBounds.left
overlayViewParams.y = sensorBounds.top
windowManager.updateViewLayout(overlayView, overlayViewParams)
}
private fun updateOverlayVisibility(view: View) {
if (view != overlayView) {
return
}
// hide after a few seconds if the sensor is oriented down and there are
// large overlapping system bars
val rotation = context.display?.rotation
if (windowManager.currentWindowMetrics.windowInsets.hasBigNavigationBar() &&
((rotation == Surface.ROTATION_270 && overlayOffsets.isYAligned()) ||
(rotation == Surface.ROTATION_180 && !overlayOffsets.isYAligned()))) {
overlayHideAnimator = view.animate()
.alpha(0f)
.setStartDelay(3_000)
.setDuration(animationDuration)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
view.visibility = View.GONE
overlayHideAnimator = null
}
})
} else {
overlayHideAnimator?.cancel()
overlayHideAnimator = null
view.alpha = 1f
view.visibility = View.VISIBLE
}
}
}
private val FingerprintManager?.sideFpsSensorProperties: FingerprintSensorPropertiesInternal?
get() = this?.sensorPropertiesInternal?.firstOrNull { it.isAnySidefpsType }
/** Returns [True] when the device has a side fingerprint sensor. */
fun FingerprintManager?.hasSideFpsSensor(): Boolean = this?.sideFpsSensorProperties != null
@BiometricOverlayConstants.ShowReason
private fun Int.isReasonToShow(activityTaskManager: ActivityTaskManager): Boolean = when (this) {
REASON_AUTH_KEYGUARD -> false
REASON_AUTH_SETTINGS -> when (activityTaskManager.topClass()) {
// TODO(b/186176653): exclude fingerprint overlays from this list view
"com.android.settings.biometrics.fingerprint.FingerprintSettings" -> false
else -> true
}
else -> true
}
private fun ActivityTaskManager.topClass(): String =
getTasks(1).firstOrNull()?.topActivity?.className ?: ""
@RawRes
private fun Display.asSideFpsAnimation(yAligned: Boolean): Int = when (rotation) {
Surface.ROTATION_0 -> if (yAligned) R.raw.sfps_pulse else R.raw.sfps_pulse_landscape
Surface.ROTATION_180 -> if (yAligned) R.raw.sfps_pulse else R.raw.sfps_pulse_landscape
else -> if (yAligned) R.raw.sfps_pulse_landscape else R.raw.sfps_pulse
}
private fun Display.asSideFpsAnimationRotation(yAligned: Boolean): Float = when (rotation) {
Surface.ROTATION_90 -> if (yAligned) 0f else 180f
Surface.ROTATION_180 -> 180f
Surface.ROTATION_270 -> if (yAligned) 180f else 0f
else -> 0f
}
private fun SensorLocationInternal.isYAligned(): Boolean = sensorLocationY != 0
private fun Display.isNaturalOrientation(): Boolean =
rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180
private fun WindowInsets.hasBigNavigationBar(): Boolean =
getInsets(WindowInsets.Type.navigationBars()).bottom >= 70
private fun LottieAnimationView.addOverlayDynamicColor(context: Context) {
fun update() {
val c = context.getColor(R.color.biometric_dialog_accent)
for (key in listOf(".blue600", ".blue400")) {
addValueCallback(
KeyPath(key, "**"),
LottieProperty.COLOR_FILTER
) { PorterDuffColorFilter(c, PorterDuff.Mode.SRC_ATOP) }
}
}
if (composition != null) {
update()
} else {
addLottieOnCompositionLoadedListener { update() }
}
}