blob: f7d87fc69e55bc77f4a5aad145ad2a0db4763d9c [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.content.res.Configuration
import android.graphics.Color
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.WindowManager.LayoutParams.PRIVATE_FLAG_NO_MOVE_ANIMATION
import android.view.WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY
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.Dumpable
import com.android.systemui.R
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.dump.DumpManager
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.keyguard.domain.interactor.AlternateBouncerInteractor
import com.android.systemui.recents.OverviewProxyService
import com.android.systemui.util.concurrency.DelayableExecutor
import java.io.PrintWriter
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
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,
private val alternateBouncerInteractor: AlternateBouncerInteractor,
@Application private val scope: CoroutineScope,
private val featureFlags: FeatureFlags,
dumpManager: DumpManager
) : Dumpable {
private val requests: HashSet<SideFpsUiRequestSource> = HashSet()
@VisibleForTesting
val sensorProps: FingerprintSensorPropertiesInternal =
fingerprintManager?.sideFpsSensorProperties
?: throw IllegalStateException("no side fingerprint sensor")
@VisibleForTesting
val orientationReasonListener =
OrientationReasonListener(
context,
displayManager,
handler,
sensorProps,
{ reason -> onOrientationChanged(reason) },
BiometricOverlayConstants.REASON_UNKNOWN
)
@VisibleForTesting val orientationListener = orientationReasonListener.orientationListener
@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 val isReverseDefaultRotation =
context.resources.getBoolean(com.android.internal.R.bool.config_reverseDefaultRotation)
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_KEYGUARD_DIALOG,
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 = PRIVATE_FLAG_TRUSTED_OVERLAY or PRIVATE_FLAG_NO_MOVE_ANIMATION
}
init {
fingerprintManager?.setSidefpsController(
object : ISidefpsController.Stub() {
override fun show(
sensorId: Int,
@BiometricOverlayConstants.ShowReason reason: Int
) {
if (
reason.isReasonToAutoShow(activityTaskManager) &&
!context.isInRearDisplayMode()
) {
show(SideFpsUiRequestSource.AUTO_SHOW, reason)
} else {
hide(SideFpsUiRequestSource.AUTO_SHOW)
}
}
override fun hide(sensorId: Int) = hide(SideFpsUiRequestSource.AUTO_SHOW)
}
)
overviewProxyService.addCallback(overviewProxyListener)
listenForAlternateBouncerVisibility()
dumpManager.registerDumpable(this)
}
private fun listenForAlternateBouncerVisibility() {
alternateBouncerInteractor.setAlternateBouncerUIAvailable(true)
if (featureFlags.isEnabled(Flags.MODERN_ALTERNATE_BOUNCER)) {
scope.launch {
alternateBouncerInteractor.isVisible.collect { isVisible: Boolean ->
if (isVisible) {
show(SideFpsUiRequestSource.ALTERNATE_BOUNCER)
} else {
hide(SideFpsUiRequestSource.ALTERNATE_BOUNCER)
}
}
}
}
}
/** Shows the side fps overlay if not already shown. */
fun show(
request: SideFpsUiRequestSource,
@BiometricOverlayConstants.ShowReason reason: Int = BiometricOverlayConstants.REASON_UNKNOWN
) {
requests.add(request)
mainExecutor.execute {
if (overlayView == null) {
createOverlayForDisplay(reason)
} else {
Log.v(TAG, "overlay already shown")
}
}
}
/** Hides the fps overlay if shown. */
fun hide(request: SideFpsUiRequestSource) {
requests.remove(request)
mainExecutor.execute {
if (requests.isEmpty()) {
overlayView = null
}
}
}
override fun dump(pw: PrintWriter, args: Array<out String>) {
pw.println("requests:")
for (requestSource in requests) {
pw.println(" $requestSource.name")
}
}
private fun onOrientationChanged(@BiometricOverlayConstants.ShowReason reason: Int) {
if (overlayView != null) {
createOverlayForDisplay(reason)
}
}
private fun createOverlayForDisplay(@BiometricOverlayConstants.ShowReason reason: Int) {
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(),
getRotationFromDefault(display.rotation)
)
lottie.setAnimation(
display.asSideFpsAnimation(
offsets.isYAligned(),
getRotationFromDefault(display.rotation)
)
)
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)
}
}
orientationReasonListener.reason = reason
lottie.addOverlayDynamicColor(context, reason)
/**
* 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 isDefaultOrientation =
if (isReverseDefaultRotation) !isNaturalOrientation else isNaturalOrientation
val size = windowManager.maximumWindowMetrics.bounds
val displayWidth = if (isDefaultOrientation) size.width() else size.height()
val displayHeight = if (isDefaultOrientation) size.height() else size.width()
val boundsWidth = if (isDefaultOrientation) bounds.width() else bounds.height()
val boundsHeight = if (isDefaultOrientation) 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),
getRotationFromDefault(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
var rotation = context.display?.rotation
if (rotation != null) {
rotation = getRotationFromDefault(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 fun getRotationFromDefault(rotation: Int): Int =
if (isReverseDefaultRotation) (rotation + 1) % 4 else rotation
}
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.isReasonToAutoShow(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, rotationFromDefault: Int): Int =
when (rotationFromDefault) {
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, rotationFromDefault: Int): Float =
when (rotationFromDefault) {
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,
@BiometricOverlayConstants.ShowReason reason: Int
) {
fun update() {
val c = context.getColor(R.color.biometric_dialog_accent)
val chevronFill = context.getColor(R.color.sfps_chevron_fill)
val isKeyguard = reason == REASON_AUTH_KEYGUARD
if (isKeyguard) {
for (key in listOf(".blue600", ".blue400")) {
addValueCallback(KeyPath(key, "**"), LottieProperty.COLOR_FILTER) {
PorterDuffColorFilter(c, PorterDuff.Mode.SRC_ATOP)
}
}
addValueCallback(KeyPath(".black", "**"), LottieProperty.COLOR_FILTER) {
PorterDuffColorFilter(chevronFill, PorterDuff.Mode.SRC_ATOP)
}
} else if (!isDarkMode(context)) {
addValueCallback(KeyPath(".black", "**"), LottieProperty.COLOR_FILTER) {
PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP)
}
} else if (isDarkMode(context)) {
for (key in listOf(".blue600", ".blue400")) {
addValueCallback(KeyPath(key, "**"), LottieProperty.COLOR_FILTER) {
PorterDuffColorFilter(
context.getColor(R.color.settingslib_color_blue400),
PorterDuff.Mode.SRC_ATOP
)
}
}
}
}
if (composition != null) {
update()
} else {
addLottieOnCompositionLoadedListener { update() }
}
}
private fun isDarkMode(context: Context): Boolean {
val darkMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return darkMode == Configuration.UI_MODE_NIGHT_YES
}
@VisibleForTesting
class OrientationReasonListener(
context: Context,
displayManager: DisplayManager,
handler: Handler,
sensorProps: FingerprintSensorPropertiesInternal,
onOrientationChanged: (reason: Int) -> Unit,
@BiometricOverlayConstants.ShowReason var reason: Int
) {
val orientationListener =
BiometricDisplayListener(
context,
displayManager,
handler,
BiometricDisplayListener.SensorType.SideFingerprint(sensorProps)
) {
onOrientationChanged(reason)
}
}
/**
* The source of a request to show the side fps visual indicator. This is distinct from
* [BiometricOverlayConstants] which corrresponds with the reason fingerprint authentication is
* requested.
*/
enum class SideFpsUiRequestSource {
/** see [isReasonToAutoShow] */
AUTO_SHOW,
/** Pin, pattern or password bouncer */
PRIMARY_BOUNCER,
ALTERNATE_BOUNCER
}