/* * 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.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.DisplayInfo 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.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.app.animation.Interpolators import com.android.internal.annotations.VisibleForTesting import com.android.keyguard.KeyguardPINView import com.android.systemui.Dumpable import com.android.systemui.R import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor import com.android.systemui.bouncer.domain.interactor.AlternateBouncerInteractor 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.util.boundsOnScreen 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, displayManager: DisplayManager, private val displayStateInteractor: DisplayStateInteractor, @Main private val mainExecutor: DelayableExecutor, @Main private val handler: Handler, private val alternateBouncerInteractor: AlternateBouncerInteractor, @Application private val scope: CoroutineScope, 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 private val isReverseDefaultRotation = context.resources.getBoolean(com.android.internal.R.bool.config_reverseDefaultRotation) private var overlayShowAnimator: ViewPropertyAnimator? = null private var overlayView: View? = null set(value) { field?.let { oldView -> val lottie = oldView.requireViewById(R.id.sidefps_animation) as LottieAnimationView lottie.pauseAnimation() windowManager.removeView(oldView) orientationListener.disable() } overlayShowAnimator?.cancel() overlayShowAnimator = null field = value field?.let { newView -> if (requests.contains(SideFpsUiRequestSource.PRIMARY_BOUNCER)) { newView.alpha = 0f overlayShowAnimator = newView .animate() .alpha(1f) .setDuration(KeyguardPINView.ANIMATION_DURATION) .setInterpolator(Interpolators.ALPHA_IN) } windowManager.addView(newView, overlayViewParams) orientationListener.enable() overlayShowAnimator?.start() } } @VisibleForTesting var overlayOffsets: SensorLocationInternal = SensorLocationInternal.DEFAULT private val displayInfo = DisplayInfo() private val overlayViewParams = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL, 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) } ) listenForAlternateBouncerVisibility() dumpManager.registerDumpable(this) } private fun listenForAlternateBouncerVisibility() { alternateBouncerInteractor.setAlternateBouncerUIAvailable(true, "SideFpsController") 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 ) { if (!displayStateInteractor.isInRearDisplayMode.value) { 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") } pw.println("overlayView:") pw.println(" width=${overlayView?.width}") pw.println(" height=${overlayView?.height}") pw.println(" boundsOnScreen=${overlayView?.boundsOnScreen}") pw.println("displayStateInteractor:") pw.println(" isInRearDisplayMode=${displayStateInteractor?.isInRearDisplayMode?.value}") pw.println("sensorProps:") pw.println(" displayId=${displayInfo.uniqueId}") pw.println(" sensorType=${sensorProps?.sensorType}") pw.println(" location=${sensorProps?.getLocation(displayInfo.uniqueId)}") pw.println("overlayOffsets=$overlayOffsets") pw.println("isReverseDefaultRotation=$isReverseDefaultRotation") pw.println("currentRotation=${displayInfo.rotation}") } 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!! // b/284098873 `context.display.rotation` may not up-to-date, we use displayInfo.rotation display.getDisplayInfo(displayInfo) 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.requireViewById(R.id.sidefps_animation) as LottieAnimationView view.rotation = display.asSideFpsAnimationRotation( offsets.isYAligned(), getRotationFromDefault(displayInfo.rotation) ) lottie.setAnimation( display.asSideFpsAnimation( offsets.isYAligned(), getRotationFromDefault(displayInfo.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 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 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 LottieAnimationView.addOverlayDynamicColor( context: Context, @BiometricOverlayConstants.ShowReason reason: Int ) { fun update() { val isKeyguard = reason == REASON_AUTH_KEYGUARD if (isKeyguard) { val color = com.android.settingslib.Utils.getColorAttrDefaultColor( context, com.android.internal.R.attr.materialColorPrimaryFixed ) val outerRimColor = com.android.settingslib.Utils.getColorAttrDefaultColor( context, com.android.internal.R.attr.materialColorPrimaryFixedDim ) val chevronFill = com.android.settingslib.Utils.getColorAttrDefaultColor( context, com.android.internal.R.attr.materialColorOnPrimaryFixed ) addValueCallback(KeyPath(".blue600", "**"), LottieProperty.COLOR_FILTER) { PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) } addValueCallback(KeyPath(".blue400", "**"), LottieProperty.COLOR_FILTER) { PorterDuffColorFilter(outerRimColor, 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) } } 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 }