diff options
10 files changed, 716 insertions, 423 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/SideFpsController.kt b/packages/SystemUI/src/com/android/systemui/biometrics/SideFpsController.kt index 37ce44488346..083e21fbdfba 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/SideFpsController.kt +++ b/packages/SystemUI/src/com/android/systemui/biometrics/SideFpsController.kt @@ -17,12 +17,7 @@ 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 @@ -33,27 +28,23 @@ 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.internal.annotations.VisibleForTesting import com.android.systemui.Dumpable import com.android.systemui.R import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor +import com.android.systemui.biometrics.ui.binder.SideFpsOverlayViewBinder +import com.android.systemui.biometrics.ui.viewmodel.SideFpsOverlayViewModel import com.android.systemui.bouncer.domain.interactor.AlternateBouncerInteractor import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application @@ -64,6 +55,7 @@ import com.android.systemui.util.concurrency.DelayableExecutor import com.android.systemui.util.traceSection import java.io.PrintWriter import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -86,6 +78,7 @@ constructor( @Main private val mainExecutor: DelayableExecutor, @Main private val handler: Handler, private val alternateBouncerInteractor: AlternateBouncerInteractor, + private val sideFpsOverlayViewModelFactory: Provider<SideFpsOverlayViewModel>, @Application private val scope: CoroutineScope, dumpManager: DumpManager ) : Dumpable { @@ -250,105 +243,15 @@ constructor( 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.findViewById(R.id.sidefps_animation) as LottieAnimationView - view.rotation = - display.asSideFpsAnimationRotation( - offsets.isYAligned(), - getRotationFromDefault(displayInfo.rotation) - ) - lottie.setAnimation( - display.asSideFpsAnimation( - offsets.isYAligned(), - getRotationFromDefault(displayInfo.rotation) - ) + SideFpsOverlayViewBinder.bind( + view = view, + viewModel = sideFpsOverlayViewModelFactory.get(), + overlayViewParams = overlayViewParams, + reason = reason, + context = context, ) - 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? @@ -373,89 +276,12 @@ private fun Int.isReasonToAutoShow(activityTaskManager: ActivityTaskManager): Bo 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( +public class OrientationReasonListener( context: Context, displayManager: DisplayManager, handler: Handler, diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/FingerprintPropertyRepository.kt b/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/FingerprintPropertyRepository.kt index c43722f2a0bb..efbde4c5985b 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/FingerprintPropertyRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/FingerprintPropertyRepository.kt @@ -16,8 +16,11 @@ package com.android.systemui.biometrics.data.repository +import android.hardware.biometrics.ComponentInfoInternal import android.hardware.biometrics.SensorLocationInternal +import android.hardware.biometrics.SensorProperties import android.hardware.fingerprint.FingerprintManager +import android.hardware.fingerprint.FingerprintSensorProperties import android.hardware.fingerprint.FingerprintSensorPropertiesInternal import android.hardware.fingerprint.IFingerprintAuthenticatorsRegisteredCallback import com.android.systemui.biometrics.shared.model.FingerprintSensorType @@ -30,10 +33,8 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn /** @@ -43,22 +44,17 @@ import kotlinx.coroutines.flow.shareIn */ interface FingerprintPropertyRepository { - /** - * If the repository is initialized or not. Other properties are defaults until this is true. - */ - val isInitialized: Flow<Boolean> - /** The id of fingerprint sensor. */ - val sensorId: StateFlow<Int> + val sensorId: Flow<Int> /** The security strength of sensor (convenience, weak, strong). */ - val strength: StateFlow<SensorStrength> + val strength: Flow<SensorStrength> /** The types of fingerprint sensor (rear, ultrasonic, optical, etc.). */ - val sensorType: StateFlow<FingerprintSensorType> + val sensorType: Flow<FingerprintSensorType> /** The sensor location relative to each physical display. */ - val sensorLocations: StateFlow<Map<String, SensorLocationInternal>> + val sensorLocations: Flow<Map<String, SensorLocationInternal>> } @SysUISingleton @@ -66,10 +62,10 @@ class FingerprintPropertyRepositoryImpl @Inject constructor( @Application private val applicationScope: CoroutineScope, - private val fingerprintManager: FingerprintManager + private val fingerprintManager: FingerprintManager? ) : FingerprintPropertyRepository { - override val isInitialized: Flow<Boolean> = + private val props: Flow<FingerprintSensorPropertiesInternal> = conflatedCallbackFlow { val callback = object : IFingerprintAuthenticatorsRegisteredCallback.Stub() { @@ -77,45 +73,47 @@ constructor( sensors: List<FingerprintSensorPropertiesInternal> ) { if (sensors.isNotEmpty()) { - setProperties(sensors[0]) - trySendWithFailureLogging(true, TAG, "initialize properties") + trySendWithFailureLogging(sensors[0], TAG, "initialize properties") + } else { + trySendWithFailureLogging( + DEFAULT_PROPS, + TAG, + "initialize with default properties" + ) } } } - fingerprintManager.addAuthenticatorsRegisteredCallback(callback) - trySendWithFailureLogging(false, TAG, "initial value defaulting to false") + fingerprintManager?.addAuthenticatorsRegisteredCallback(callback) + trySendWithFailureLogging(DEFAULT_PROPS, TAG, "initialize with default properties") awaitClose {} } .shareIn(scope = applicationScope, started = SharingStarted.Eagerly, replay = 1) - private val _sensorId: MutableStateFlow<Int> = MutableStateFlow(-1) - override val sensorId: StateFlow<Int> = _sensorId.asStateFlow() - - private val _strength: MutableStateFlow<SensorStrength> = - MutableStateFlow(SensorStrength.CONVENIENCE) - override val strength = _strength.asStateFlow() - - private val _sensorType: MutableStateFlow<FingerprintSensorType> = - MutableStateFlow(FingerprintSensorType.UNKNOWN) - override val sensorType = _sensorType.asStateFlow() - - private val _sensorLocations: MutableStateFlow<Map<String, SensorLocationInternal>> = - MutableStateFlow(mapOf("" to SensorLocationInternal.DEFAULT)) - override val sensorLocations: StateFlow<Map<String, SensorLocationInternal>> = - _sensorLocations.asStateFlow() - - private fun setProperties(prop: FingerprintSensorPropertiesInternal) { - _sensorId.value = prop.sensorId - _strength.value = sensorStrengthIntToObject(prop.sensorStrength) - _sensorType.value = sensorTypeIntToObject(prop.sensorType) - _sensorLocations.value = - prop.allLocations.associateBy { sensorLocationInternal -> + override val sensorId: Flow<Int> = props.map { it.sensorId } + override val strength: Flow<SensorStrength> = + props.map { sensorStrengthIntToObject(it.sensorStrength) } + override val sensorType: Flow<FingerprintSensorType> = + props.map { sensorTypeIntToObject(it.sensorType) } + override val sensorLocations: Flow<Map<String, SensorLocationInternal>> = + props.map { + it.allLocations.associateBy { sensorLocationInternal -> sensorLocationInternal.displayId } - } + } companion object { private const val TAG = "FingerprintPropertyRepositoryImpl" + private val DEFAULT_PROPS = + FingerprintSensorPropertiesInternal( + -1 /* sensorId */, + SensorProperties.STRENGTH_CONVENIENCE, + 0 /* maxEnrollmentsPerUser */, + listOf<ComponentInfoInternal>(), + FingerprintSensorProperties.TYPE_UNKNOWN, + false /* halControlsIllumination */, + true /* resetLockoutRequiresHardwareAuthToken */, + listOf<SensorLocationInternal>(SensorLocationInternal.DEFAULT) + ) } } diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractor.kt index aa85e5f3b21a..37f39cb5fe0e 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractor.kt @@ -17,16 +17,24 @@ package com.android.systemui.biometrics.domain.interactor import android.hardware.biometrics.SensorLocationInternal -import android.util.Log import com.android.systemui.biometrics.data.repository.FingerprintPropertyRepository import com.android.systemui.dagger.SysUISingleton import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine /** Business logic for SideFps overlay offsets. */ interface SideFpsOverlayInteractor { + /** The displayId of the current display. */ + val displayId: Flow<String> - /** Get the corresponding offsets based on different displayId. */ - fun getOverlayOffsets(displayId: String): SensorLocationInternal + /** The corresponding offsets based on different displayId. */ + val overlayOffsets: Flow<SensorLocationInternal> + + /** Update the displayId. */ + fun changeDisplay(displayId: String?) } @SysUISingleton @@ -35,14 +43,16 @@ class SideFpsOverlayInteractorImpl constructor(private val fingerprintPropertyRepository: FingerprintPropertyRepository) : SideFpsOverlayInteractor { - override fun getOverlayOffsets(displayId: String): SensorLocationInternal { - val offsets = fingerprintPropertyRepository.sensorLocations.value - return if (offsets.containsKey(displayId)) { - offsets[displayId]!! - } else { - Log.w(TAG, "No location specified for display: $displayId") - offsets[""]!! + private val _displayId: MutableStateFlow<String> = MutableStateFlow("") + override val displayId: Flow<String> = _displayId.asStateFlow() + + override val overlayOffsets: Flow<SensorLocationInternal> = + combine(displayId, fingerprintPropertyRepository.sensorLocations) { displayId, offsets -> + offsets[displayId] ?: SensorLocationInternal.DEFAULT } + + override fun changeDisplay(displayId: String?) { + _displayId.value = displayId ?: "" } companion object { diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinder.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinder.kt new file mode 100644 index 000000000000..0409519c9816 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinder.kt @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2023 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.ui.binder + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.hardware.biometrics.BiometricOverlayConstants +import android.view.View +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.airbnb.lottie.LottieAnimationView +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.model.KeyPath +import com.android.systemui.R +import com.android.systemui.biometrics.ui.viewmodel.SideFpsOverlayViewModel +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.lifecycle.repeatWhenAttached +import kotlinx.coroutines.launch + +/** Sub-binder for SideFpsOverlayView. */ +object SideFpsOverlayViewBinder { + + /** Bind the view. */ + @JvmStatic + fun bind( + view: View, + viewModel: SideFpsOverlayViewModel, + overlayViewParams: WindowManager.LayoutParams, + @BiometricOverlayConstants.ShowReason reason: Int, + @Application context: Context + ) { + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + val lottie = view.findViewById(R.id.sidefps_animation) as LottieAnimationView + + viewModel.changeDisplay() + + view.repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.sideFpsAnimationRotation.collect { rotation -> + view.rotation = rotation + } + } + + launch { + // TODO(b/221037350, wenhuiy): Create a separate ViewBinder for sideFpsAnimation + // in order to add scuba tests in the future. + viewModel.sideFpsAnimation.collect { animation -> + lottie.setAnimation(animation) + } + } + + launch { + viewModel.sensorBounds.collect { sensorBounds -> + overlayViewParams.x = sensorBounds.left + overlayViewParams.y = sensorBounds.top + + windowManager.updateViewLayout(view, overlayViewParams) + } + } + + launch { + viewModel.overlayOffsets.collect { overlayOffsets -> + lottie.addLottieOnCompositionLoadedListener { + viewModel.updateSensorBounds( + it.bounds, + windowManager.maximumWindowMetrics.bounds, + overlayOffsets + ) + } + } + } + } + } + + 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.accessibilityDelegate = + object : View.AccessibilityDelegate() { + override fun dispatchPopulateAccessibilityEvent( + host: View, + event: AccessibilityEvent + ): Boolean { + return if ( + event.getEventType() === AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + ) { + true + } else { + super.dispatchPopulateAccessibilityEvent(host, event) + } + } + } + } +} + +private fun LottieAnimationView.addOverlayDynamicColor( + context: Context, + @BiometricOverlayConstants.ShowReason reason: Int +) { + fun update() { + val isKeyguard = reason == BiometricOverlayConstants.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 +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModel.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModel.kt new file mode 100644 index 000000000000..e938b4efb68c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModel.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2023 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.ui.viewmodel + +import android.content.Context +import android.graphics.Rect +import android.hardware.biometrics.SensorLocationInternal +import android.util.RotationUtils +import android.view.Display +import android.view.DisplayInfo +import android.view.Surface +import androidx.annotation.RawRes +import com.android.systemui.R +import com.android.systemui.biometrics.domain.interactor.SideFpsOverlayInteractor +import com.android.systemui.dagger.qualifiers.Application +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +/** View-model for SideFpsOverlayView. */ +class SideFpsOverlayViewModel +@Inject +constructor( + @Application private val context: Context, + private val sideFpsOverlayInteractor: SideFpsOverlayInteractor, +) { + + private val isReverseDefaultRotation = + context.resources.getBoolean(com.android.internal.R.bool.config_reverseDefaultRotation) + + private val _sensorBounds: MutableStateFlow<Rect> = MutableStateFlow(Rect()) + val sensorBounds = _sensorBounds.asStateFlow() + + val overlayOffsets: Flow<SensorLocationInternal> = sideFpsOverlayInteractor.overlayOffsets + + /** Update the displayId. */ + fun changeDisplay() { + sideFpsOverlayInteractor.changeDisplay(context.display!!.uniqueId) + } + + /** Determine the rotation of the sideFps animation given the overlay offsets. */ + val sideFpsAnimationRotation: Flow<Float> = + overlayOffsets.map { overlayOffsets -> + val display = context.display!! + val displayInfo: DisplayInfo = DisplayInfo() + // b/284098873 `context.display.rotation` may not up-to-date, we use + // displayInfo.rotation + display.getDisplayInfo(displayInfo) + val yAligned: Boolean = overlayOffsets.isYAligned() + when (getRotationFromDefault(displayInfo.rotation)) { + Surface.ROTATION_90 -> if (yAligned) 0f else 180f + Surface.ROTATION_180 -> 180f + Surface.ROTATION_270 -> if (yAligned) 180f else 0f + else -> 0f + } + } + + /** Populate the sideFps animation from the overlay offsets. */ + @RawRes + val sideFpsAnimation: Flow<Int> = + overlayOffsets.map { overlayOffsets -> + val display = context.display!! + val displayInfo: DisplayInfo = DisplayInfo() + // b/284098873 `context.display.rotation` may not up-to-date, we use + // displayInfo.rotation + display.getDisplayInfo(displayInfo) + val yAligned: Boolean = overlayOffsets.isYAligned() + when (getRotationFromDefault(displayInfo.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 + } + } + + /** + * Calculate and update the bounds of the sensor based on the bounds of the overlay view, the + * maximum bounds of the window, and the offsets of the sensor location. + */ + fun updateSensorBounds( + bounds: Rect, + maximumWindowBounds: Rect, + offsets: SensorLocationInternal + ) { + val isNaturalOrientation = context.display!!.isNaturalOrientation() + val isDefaultOrientation = + if (isReverseDefaultRotation) !isNaturalOrientation else isNaturalOrientation + + val displayWidth = + if (isDefaultOrientation) maximumWindowBounds.width() else maximumWindowBounds.height() + val displayHeight = + if (isDefaultOrientation) maximumWindowBounds.height() else maximumWindowBounds.width() + val boundsWidth = if (isDefaultOrientation) bounds.width() else bounds.height() + val boundsHeight = if (isDefaultOrientation) bounds.height() else bounds.width() + + val sensorBounds = + if (offsets.isYAligned()) { + Rect( + displayWidth - boundsWidth, + offsets.sensorLocationY, + displayWidth, + offsets.sensorLocationY + boundsHeight + ) + } else { + Rect( + offsets.sensorLocationX, + 0, + offsets.sensorLocationX + boundsWidth, + boundsHeight + ) + } + + val displayInfo: DisplayInfo = DisplayInfo() + context.display!!.getDisplayInfo(displayInfo) + + RotationUtils.rotateBounds( + sensorBounds, + Rect(0, 0, displayWidth, displayHeight), + getRotationFromDefault(displayInfo.rotation) + ) + + _sensorBounds.value = sensorBounds + } + + private fun getRotationFromDefault(rotation: Int): Int = + if (isReverseDefaultRotation) (rotation + 1) % 4 else rotation +} + +private fun Display.isNaturalOrientation(): Boolean = + rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180 + +private fun SensorLocationInternal.isYAligned(): Boolean = sensorLocationY != 0 diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/SideFpsControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/SideFpsControllerTest.kt index 3169b091217b..ecc776b98c6c 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/SideFpsControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/SideFpsControllerTest.kt @@ -44,9 +44,6 @@ import android.view.View 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.WindowManager.LayoutParams.TYPE_KEYGUARD_DIALOG import android.view.WindowMetrics import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -56,9 +53,14 @@ import com.android.systemui.R import com.android.systemui.RoboPilotTest import com.android.systemui.SysuiTestCase import com.android.systemui.SysuiTestableContext +import com.android.systemui.biometrics.data.repository.FakeFingerprintPropertyRepository import com.android.systemui.biometrics.data.repository.FakeRearDisplayStateRepository import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractorImpl +import com.android.systemui.biometrics.domain.interactor.SideFpsOverlayInteractorImpl +import com.android.systemui.biometrics.shared.model.FingerprintSensorType +import com.android.systemui.biometrics.shared.model.SensorStrength +import com.android.systemui.biometrics.ui.viewmodel.SideFpsOverlayViewModel import com.android.systemui.bouncer.data.repository.FakeKeyguardBouncerRepository import com.android.systemui.bouncer.domain.interactor.AlternateBouncerInteractor import com.android.systemui.dump.DumpManager @@ -99,7 +101,7 @@ private const val REAR_DISPLAY_MODE_DEVICE_STATE = 3 @SmallTest @RoboPilotTest @RunWith(AndroidJUnit4::class) -@TestableLooper.RunWithLooper +@TestableLooper.RunWithLooper(setAsMainLooper = true) class SideFpsControllerTest : SysuiTestCase() { @JvmField @Rule var rule = MockitoJUnit.rule() @@ -118,6 +120,8 @@ class SideFpsControllerTest : SysuiTestCase() { private lateinit var keyguardBouncerRepository: FakeKeyguardBouncerRepository private lateinit var alternateBouncerInteractor: AlternateBouncerInteractor private lateinit var displayStateInteractor: DisplayStateInteractor + private lateinit var sideFpsOverlayViewModel: SideFpsOverlayViewModel + private val fingerprintRepository = FakeFingerprintPropertyRepository() private val executor = FakeExecutor(FakeSystemClock()) private val rearDisplayStateRepository = FakeRearDisplayStateRepository() @@ -159,6 +163,15 @@ class SideFpsControllerTest : SysuiTestCase() { executor, rearDisplayStateRepository ) + sideFpsOverlayViewModel = + SideFpsOverlayViewModel(context, SideFpsOverlayInteractorImpl(fingerprintRepository)) + + fingerprintRepository.setProperties( + sensorId = 1, + strength = SensorStrength.STRONG, + sensorType = FingerprintSensorType.REAR, + sensorLocations = mapOf("" to SensorLocationInternal("", 2500, 0, 0)) + ) context.addMockSystemService(DisplayManager::class.java, displayManager) context.addMockSystemService(WindowManager::class.java, windowManager) @@ -265,6 +278,7 @@ class SideFpsControllerTest : SysuiTestCase() { executor, handler, alternateBouncerInteractor, + { sideFpsOverlayViewModel }, TestCoroutineScope(), dumpManager ) @@ -683,106 +697,6 @@ class SideFpsControllerTest : SysuiTestCase() { verify(windowManager).removeView(any()) } - /** - * {@link SideFpsController#updateOverlayParams} calculates indicator placement for ROTATION_0, - * and uses RotateUtils.rotateBounds to map to the correct indicator location given the device - * rotation. Assuming RotationUtils.rotateBounds works correctly, tests for indicator placement - * in other rotations have been omitted. - */ - @Test - fun verifiesIndicatorPlacementForXAlignedSensor_0() = - testWithDisplay( - deviceConfig = DeviceConfig.X_ALIGNED, - isReverseDefaultRotation = false, - { rotation = Surface.ROTATION_0 } - ) { - sideFpsController.overlayOffsets = sensorLocation - - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - assertThat(overlayViewParamsCaptor.value.x).isEqualTo(sensorLocation.sensorLocationX) - assertThat(overlayViewParamsCaptor.value.y).isEqualTo(0) - } - - /** - * {@link SideFpsController#updateOverlayParams} calculates indicator placement for ROTATION_270 - * in reverse default rotation. It then uses RotateUtils.rotateBounds to map to the correct - * indicator location given the device rotation. Assuming RotationUtils.rotateBounds works - * correctly, tests for indicator placement in other rotations have been omitted. - */ - @Test - fun verifiesIndicatorPlacementForXAlignedSensor_InReverseDefaultRotation_270() = - testWithDisplay( - deviceConfig = DeviceConfig.X_ALIGNED, - isReverseDefaultRotation = true, - { rotation = Surface.ROTATION_270 } - ) { - sideFpsController.overlayOffsets = sensorLocation - - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - assertThat(overlayViewParamsCaptor.value.x).isEqualTo(sensorLocation.sensorLocationX) - assertThat(overlayViewParamsCaptor.value.y).isEqualTo(0) - } - - /** - * {@link SideFpsController#updateOverlayParams} calculates indicator placement for ROTATION_0, - * and uses RotateUtils.rotateBounds to map to the correct indicator location given the device - * rotation. Assuming RotationUtils.rotateBounds works correctly, tests for indicator placement - * in other rotations have been omitted. - */ - @Test - fun verifiesIndicatorPlacementForYAlignedSensor_0() = - testWithDisplay( - deviceConfig = DeviceConfig.Y_ALIGNED, - isReverseDefaultRotation = false, - { rotation = Surface.ROTATION_0 } - ) { - sideFpsController.overlayOffsets = sensorLocation - - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - assertThat(overlayViewParamsCaptor.value.x).isEqualTo(displayWidth - boundsWidth) - assertThat(overlayViewParamsCaptor.value.y).isEqualTo(sensorLocation.sensorLocationY) - } - - /** - * {@link SideFpsController#updateOverlayParams} calculates indicator placement for ROTATION_270 - * in reverse default rotation. It then uses RotateUtils.rotateBounds to map to the correct - * indicator location given the device rotation. Assuming RotationUtils.rotateBounds works - * correctly, tests for indicator placement in other rotations have been omitted. - */ - @Test - fun verifiesIndicatorPlacementForYAlignedSensor_InReverseDefaultRotation_270() = - testWithDisplay( - deviceConfig = DeviceConfig.Y_ALIGNED, - isReverseDefaultRotation = true, - { rotation = Surface.ROTATION_270 } - ) { - sideFpsController.overlayOffsets = sensorLocation - - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - assertThat(overlayViewParamsCaptor.value.x).isEqualTo(displayWidth - boundsWidth) - assertThat(overlayViewParamsCaptor.value.y).isEqualTo(sensorLocation.sensorLocationY) - } - @Test fun hasSideFpsSensor_withSensorProps_returnsTrue() = testWithDisplay { // By default all those tests assume the side fps sensor is available. @@ -795,51 +709,6 @@ class SideFpsControllerTest : SysuiTestCase() { assertThat(fingerprintManager.hasSideFpsSensor()).isFalse() } - - @Test - fun testLayoutParams_isKeyguardDialogType() = - testWithDisplay(deviceConfig = DeviceConfig.Y_ALIGNED) { - sideFpsController.overlayOffsets = sensorLocation - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - - val lpType = overlayViewParamsCaptor.value.type - - assertThat((lpType and TYPE_KEYGUARD_DIALOG) != 0).isTrue() - } - - @Test - fun testLayoutParams_hasNoMoveAnimationWindowFlag() = - testWithDisplay(deviceConfig = DeviceConfig.Y_ALIGNED) { - sideFpsController.overlayOffsets = sensorLocation - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - - val lpFlags = overlayViewParamsCaptor.value.privateFlags - - assertThat((lpFlags and PRIVATE_FLAG_NO_MOVE_ANIMATION) != 0).isTrue() - } - - @Test - fun testLayoutParams_hasTrustedOverlayWindowFlag() = - testWithDisplay(deviceConfig = DeviceConfig.Y_ALIGNED) { - sideFpsController.overlayOffsets = sensorLocation - sideFpsController.updateOverlayParams(windowManager.defaultDisplay, indicatorBounds) - overlayController.show(SENSOR_ID, REASON_UNKNOWN) - executor.runAllReady() - - verify(windowManager).updateViewLayout(any(), overlayViewParamsCaptor.capture()) - - val lpFlags = overlayViewParamsCaptor.value.privateFlags - - assertThat((lpFlags and PRIVATE_FLAG_TRUSTED_OVERLAY) != 0).isTrue() - } } private fun insetsForSmallNavbar() = insetsWithBottom(60) diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/FingerprintRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/FingerprintRepositoryImplTest.kt index 239e317b92f5..ea2561594793 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/FingerprintRepositoryImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/FingerprintRepositoryImplTest.kt @@ -73,10 +73,15 @@ class FingerprintRepositoryImplTest : SysuiTestCase() { @Test fun initializeProperties() = testScope.runTest { - val isInitialized = collectLastValue(repository.isInitialized) + val sensorId by collectLastValue(repository.sensorId) + val strength by collectLastValue(repository.strength) + val sensorType by collectLastValue(repository.sensorType) + val sensorLocations by collectLastValue(repository.sensorLocations) - assertDefaultProperties() - assertThat(isInitialized()).isFalse() + // Assert default properties. + assertThat(sensorId).isEqualTo(-1) + assertThat(strength).isEqualTo(SensorStrength.CONVENIENCE) + assertThat(sensorType).isEqualTo(FingerprintSensorType.UNKNOWN) val fingerprintProps = listOf( @@ -115,31 +120,24 @@ class FingerprintRepositoryImplTest : SysuiTestCase() { fingerprintAuthenticatorsCaptor.value.onAllAuthenticatorsRegistered(fingerprintProps) - assertThat(repository.sensorId.value).isEqualTo(1) - assertThat(repository.strength.value).isEqualTo(SensorStrength.STRONG) - assertThat(repository.sensorType.value).isEqualTo(FingerprintSensorType.REAR) + assertThat(sensorId).isEqualTo(1) + assertThat(strength).isEqualTo(SensorStrength.STRONG) + assertThat(sensorType).isEqualTo(FingerprintSensorType.REAR) - assertThat(repository.sensorLocations.value.size).isEqualTo(2) - assertThat(repository.sensorLocations.value).containsKey("display_id_1") - with(repository.sensorLocations.value["display_id_1"]!!) { + assertThat(sensorLocations?.size).isEqualTo(2) + assertThat(sensorLocations).containsKey("display_id_1") + with(sensorLocations?.get("display_id_1")!!) { assertThat(displayId).isEqualTo("display_id_1") assertThat(sensorLocationX).isEqualTo(100) assertThat(sensorLocationY).isEqualTo(300) assertThat(sensorRadius).isEqualTo(20) } - assertThat(repository.sensorLocations.value).containsKey("") - with(repository.sensorLocations.value[""]!!) { + assertThat(sensorLocations).containsKey("") + with(sensorLocations?.get("")!!) { assertThat(displayId).isEqualTo("") assertThat(sensorLocationX).isEqualTo(540) assertThat(sensorLocationY).isEqualTo(1636) assertThat(sensorRadius).isEqualTo(130) } - assertThat(isInitialized()).isTrue() } - - private fun assertDefaultProperties() { - assertThat(repository.sensorId.value).isEqualTo(-1) - assertThat(repository.strength.value).isEqualTo(SensorStrength.CONVENIENCE) - assertThat(repository.sensorType.value).isEqualTo(FingerprintSensorType.UNKNOWN) - } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractorTest.kt index fd96cf45504b..896f9b114679 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/SideFpsOverlayInteractorTest.kt @@ -22,6 +22,7 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.biometrics.data.repository.FakeFingerprintPropertyRepository import com.android.systemui.biometrics.shared.model.FingerprintSensorType import com.android.systemui.biometrics.shared.model.SensorStrength +import com.android.systemui.coroutines.collectLastValue import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -51,8 +52,9 @@ class SideFpsOverlayInteractorTest : SysuiTestCase() { } @Test - fun testGetOverlayOffsets() = + fun testGetOverlayoffsets() = testScope.runTest { + // Arrange. fingerprintRepository.setProperties( sensorId = 1, strength = SensorStrength.STRONG, @@ -76,16 +78,33 @@ class SideFpsOverlayInteractorTest : SysuiTestCase() { ) ) - var offsets = interactor.getOverlayOffsets("display_id_1") - assertThat(offsets.displayId).isEqualTo("display_id_1") - assertThat(offsets.sensorLocationX).isEqualTo(100) - assertThat(offsets.sensorLocationY).isEqualTo(300) - assertThat(offsets.sensorRadius).isEqualTo(20) + // Act. + val offsets by collectLastValue(interactor.overlayOffsets) + val displayId by collectLastValue(interactor.displayId) - offsets = interactor.getOverlayOffsets("invalid_display_id") - assertThat(offsets.displayId).isEqualTo("") - assertThat(offsets.sensorLocationX).isEqualTo(540) - assertThat(offsets.sensorLocationY).isEqualTo(1636) - assertThat(offsets.sensorRadius).isEqualTo(130) + // Assert offsets of empty displayId. + assertThat(displayId).isEqualTo("") + assertThat(offsets?.displayId).isEqualTo("") + assertThat(offsets?.sensorLocationX).isEqualTo(540) + assertThat(offsets?.sensorLocationY).isEqualTo(1636) + assertThat(offsets?.sensorRadius).isEqualTo(130) + + // Offsets should be updated correctly. + interactor.changeDisplay("display_id_1") + assertThat(displayId).isEqualTo("display_id_1") + assertThat(offsets?.displayId).isEqualTo("display_id_1") + assertThat(offsets?.sensorLocationX).isEqualTo(100) + assertThat(offsets?.sensorLocationY).isEqualTo(300) + assertThat(offsets?.sensorRadius).isEqualTo(20) + + // Should return default offset when the displayId is invalid. + interactor.changeDisplay("invalid_display_id") + assertThat(displayId).isEqualTo("invalid_display_id") + assertThat(offsets?.displayId).isEqualTo(SensorLocationInternal.DEFAULT.displayId) + assertThat(offsets?.sensorLocationX) + .isEqualTo(SensorLocationInternal.DEFAULT.sensorLocationX) + assertThat(offsets?.sensorLocationY) + .isEqualTo(SensorLocationInternal.DEFAULT.sensorLocationY) + assertThat(offsets?.sensorRadius).isEqualTo(SensorLocationInternal.DEFAULT.sensorRadius) } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt new file mode 100644 index 000000000000..a8593216e22a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright (C) 2023 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.ui.viewmodel + +import android.graphics.Rect +import android.hardware.biometrics.SensorLocationInternal +import android.hardware.display.DisplayManagerGlobal +import android.view.Display +import android.view.DisplayAdjustments +import android.view.DisplayInfo +import android.view.Surface +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.SysuiTestableContext +import com.android.systemui.biometrics.data.repository.FakeFingerprintPropertyRepository +import com.android.systemui.biometrics.domain.interactor.SideFpsOverlayInteractor +import com.android.systemui.biometrics.domain.interactor.SideFpsOverlayInteractorImpl +import com.android.systemui.biometrics.shared.model.FingerprintSensorType +import com.android.systemui.biometrics.shared.model.SensorStrength +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnit + +private const val DISPLAY_ID = 2 + +@SmallTest +@RunWith(JUnit4::class) +class SideFpsOverlayViewModelTest : SysuiTestCase() { + + @JvmField @Rule var mockitoRule = MockitoJUnit.rule() + private var testScope: TestScope = TestScope(StandardTestDispatcher()) + + private val fingerprintRepository = FakeFingerprintPropertyRepository() + private lateinit var interactor: SideFpsOverlayInteractor + private lateinit var viewModel: SideFpsOverlayViewModel + + enum class DeviceConfig { + X_ALIGNED, + Y_ALIGNED, + } + + private lateinit var deviceConfig: DeviceConfig + private lateinit var indicatorBounds: Rect + private lateinit var displayBounds: Rect + private lateinit var sensorLocation: SensorLocationInternal + private var displayWidth: Int = 0 + private var displayHeight: Int = 0 + private var boundsWidth: Int = 0 + private var boundsHeight: Int = 0 + + @Before + fun setup() { + interactor = SideFpsOverlayInteractorImpl(fingerprintRepository) + + fingerprintRepository.setProperties( + sensorId = 1, + strength = SensorStrength.STRONG, + sensorType = FingerprintSensorType.REAR, + sensorLocations = + mapOf( + "" to + SensorLocationInternal( + "" /* displayId */, + 540 /* sensorLocationX */, + 1636 /* sensorLocationY */, + 130 /* sensorRadius */ + ), + "display_id_1" to + SensorLocationInternal( + "display_id_1" /* displayId */, + 100 /* sensorLocationX */, + 300 /* sensorLocationY */, + 20 /* sensorRadius */ + ) + ) + ) + } + + @Test + fun testOverlayOffsets() = + testScope.runTest { + viewModel = SideFpsOverlayViewModel(mContext, interactor) + + val interactorOffsets by collectLastValue(interactor.overlayOffsets) + val viewModelOffsets by collectLastValue(viewModel.overlayOffsets) + + assertThat(viewModelOffsets).isEqualTo(interactorOffsets) + } + + private fun testWithDisplay( + deviceConfig: DeviceConfig = DeviceConfig.X_ALIGNED, + isReverseDefaultRotation: Boolean = false, + initInfo: DisplayInfo.() -> Unit = {}, + block: () -> Unit + ) { + this.deviceConfig = deviceConfig + + when (deviceConfig) { + DeviceConfig.X_ALIGNED -> { + displayWidth = 3000 + displayHeight = 1500 + sensorLocation = SensorLocationInternal("", 2500, 0, 0) + boundsWidth = 200 + boundsHeight = 100 + } + DeviceConfig.Y_ALIGNED -> { + displayWidth = 2500 + displayHeight = 2000 + sensorLocation = SensorLocationInternal("", 0, 300, 0) + boundsWidth = 100 + boundsHeight = 200 + } + } + + indicatorBounds = Rect(0, 0, boundsWidth, boundsHeight) + displayBounds = Rect(0, 0, displayWidth, displayHeight) + + val displayInfo = DisplayInfo() + displayInfo.initInfo() + + val dmGlobal = Mockito.mock(DisplayManagerGlobal::class.java) + val display = + Display( + dmGlobal, + DISPLAY_ID, + displayInfo, + DisplayAdjustments.DEFAULT_DISPLAY_ADJUSTMENTS + ) + + whenever(dmGlobal.getDisplayInfo(ArgumentMatchers.eq(DISPLAY_ID))).thenReturn(displayInfo) + + val sideFpsOverlayViewModelContext = + context.createDisplayContext(display) as SysuiTestableContext + sideFpsOverlayViewModelContext.orCreateTestableResources.addOverride( + com.android.internal.R.bool.config_reverseDefaultRotation, + isReverseDefaultRotation + ) + viewModel = SideFpsOverlayViewModel(sideFpsOverlayViewModelContext, interactor) + + block() + } + + /** + * {@link SideFpsOverlayViewModel#updateSensorBounds} calculates indicator placement for + * ROTATION_0, and uses RotateUtils.rotateBounds to map to the correct indicator location given + * the device rotation. Assuming RotationUtils.rotateBounds works correctly, tests for indicator + * placement in other rotations have been omitted. + */ + @Test + fun verifiesIndicatorPlacementForXAlignedSensor_0() = + testScope.runTest { + testWithDisplay( + deviceConfig = DeviceConfig.X_ALIGNED, + isReverseDefaultRotation = false, + { rotation = Surface.ROTATION_0 } + ) { + viewModel.updateSensorBounds(indicatorBounds, displayBounds, sensorLocation) + + val displayInfo: DisplayInfo = DisplayInfo() + context.display!!.getDisplayInfo(displayInfo) + assertThat(displayInfo.rotation).isEqualTo(Surface.ROTATION_0) + + assertThat(viewModel.sensorBounds.value).isNotNull() + assertThat(viewModel.sensorBounds.value.left) + .isEqualTo(sensorLocation.sensorLocationX) + assertThat(viewModel.sensorBounds.value.top).isEqualTo(0) + } + } + + /** + * {@link SideFpsOverlayViewModel#updateSensorBounds} calculates indicator placement for + * ROTATION_270 in reverse default rotation. It then uses RotateUtils.rotateBounds to map to the + * correct indicator location given the device rotation. Assuming RotationUtils.rotateBounds + * works correctly, tests for indicator placement in other rotations have been omitted. + */ + @Test + fun verifiesIndicatorPlacementForXAlignedSensor_InReverseDefaultRotation_270() = + testScope.runTest { + testWithDisplay( + deviceConfig = DeviceConfig.X_ALIGNED, + isReverseDefaultRotation = true, + { rotation = Surface.ROTATION_270 } + ) { + viewModel.updateSensorBounds(indicatorBounds, displayBounds, sensorLocation) + + assertThat(viewModel.sensorBounds.value).isNotNull() + assertThat(viewModel.sensorBounds.value.left) + .isEqualTo(sensorLocation.sensorLocationX) + assertThat(viewModel.sensorBounds.value.top).isEqualTo(0) + } + } + + /** + * {@link SideFpsOverlayViewModel#updateSensorBounds} calculates indicator placement for + * ROTATION_0, and uses RotateUtils.rotateBounds to map to the correct indicator location given + * the device rotation. Assuming RotationUtils.rotateBounds works correctly, tests for indicator + * placement in other rotations have been omitted. + */ + @Test + fun verifiesIndicatorPlacementForYAlignedSensor_0() = + testScope.runTest { + testWithDisplay( + deviceConfig = DeviceConfig.Y_ALIGNED, + isReverseDefaultRotation = false, + { rotation = Surface.ROTATION_0 } + ) { + viewModel.updateSensorBounds(indicatorBounds, displayBounds, sensorLocation) + + assertThat(viewModel.sensorBounds.value).isNotNull() + assertThat(viewModel.sensorBounds.value.left).isEqualTo(displayWidth - boundsWidth) + assertThat(viewModel.sensorBounds.value.top) + .isEqualTo(sensorLocation.sensorLocationY) + } + } + + /** + * {@link SideFpsOverlayViewModel#updateSensorBounds} calculates indicator placement for + * ROTATION_270 in reverse default rotation. It then uses RotateUtils.rotateBounds to map to the + * correct indicator location given the device rotation. Assuming RotationUtils.rotateBounds + * works correctly, tests for indicator placement in other rotations have been omitted. + */ + @Test + fun verifiesIndicatorPlacementForYAlignedSensor_InReverseDefaultRotation_270() = + testScope.runTest { + testWithDisplay( + deviceConfig = DeviceConfig.Y_ALIGNED, + isReverseDefaultRotation = true, + { rotation = Surface.ROTATION_270 } + ) { + viewModel.updateSensorBounds(indicatorBounds, displayBounds, sensorLocation) + + assertThat(viewModel.sensorBounds.value).isNotNull() + assertThat(viewModel.sensorBounds.value.left).isEqualTo(displayWidth - boundsWidth) + assertThat(viewModel.sensorBounds.value.top) + .isEqualTo(sensorLocation.sensorLocationY) + } + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakeFingerprintPropertyRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakeFingerprintPropertyRepository.kt index 2362a5241244..0c5e43809fab 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakeFingerprintPropertyRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakeFingerprintPropertyRepository.kt @@ -20,16 +20,12 @@ import android.hardware.biometrics.SensorLocationInternal import com.android.systemui.biometrics.shared.model.FingerprintSensorType import com.android.systemui.biometrics.shared.model.SensorStrength import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class FakeFingerprintPropertyRepository : FingerprintPropertyRepository { - private val _isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false) - override val isInitialized = _isInitialized.asStateFlow() - private val _sensorId: MutableStateFlow<Int> = MutableStateFlow(-1) - override val sensorId: StateFlow<Int> = _sensorId.asStateFlow() + override val sensorId = _sensorId.asStateFlow() private val _strength: MutableStateFlow<SensorStrength> = MutableStateFlow(SensorStrength.CONVENIENCE) @@ -37,12 +33,11 @@ class FakeFingerprintPropertyRepository : FingerprintPropertyRepository { private val _sensorType: MutableStateFlow<FingerprintSensorType> = MutableStateFlow(FingerprintSensorType.UNKNOWN) - override val sensorType: StateFlow<FingerprintSensorType> = _sensorType.asStateFlow() + override val sensorType = _sensorType.asStateFlow() private val _sensorLocations: MutableStateFlow<Map<String, SensorLocationInternal>> = MutableStateFlow(mapOf("" to SensorLocationInternal.DEFAULT)) - override val sensorLocations: StateFlow<Map<String, SensorLocationInternal>> = - _sensorLocations.asStateFlow() + override val sensorLocations = _sensorLocations.asStateFlow() fun setProperties( sensorId: Int, @@ -54,6 +49,5 @@ class FakeFingerprintPropertyRepository : FingerprintPropertyRepository { _strength.value = strength _sensorType.value = sensorType _sensorLocations.value = sensorLocations - _isInitialized.value = true } } |