/* * 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 com.android.systemui.util.traceSection 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 = 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)) { 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, REASON_AUTH_KEYGUARD) } 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) { traceSection("SideFpsController#show(request=${request.name}, reason=$reason") { 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()) { traceSection("SideFpsController#hide(${request.name}") { overlayView = null } } } } override fun dump(pw: PrintWriter, args: Array) { 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 isKeyguard = reason == REASON_AUTH_KEYGUARD if (isKeyguard) { val color = context.getColor(R.color.numpad_key_color_secondary) // match bouncer color val chevronFill = com.android.settingslib.Utils.getColorAttrDefaultColor( context, android.R.attr.textColorPrimaryInverse ) for (key in listOf(".blue600", ".blue400")) { addValueCallback(KeyPath(key, "**"), LottieProperty.COLOR_FILTER) { PorterDuffColorFilter(color, 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 }