Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 06261f54 authored by Grace Cheng's avatar Grace Cheng Committed by Android (Google) Code Review
Browse files

Merge "Replaces BP UDFPS guidance disruptive accessibility announcements" into main

parents 66f7bfff 0c0389e5
Loading
Loading
Loading
Loading
+234 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.deviceentry.domain.ui.viewmodel

import android.graphics.Point
import android.hardware.biometrics.PromptInfo
import android.hardware.fingerprint.FingerprintSensorProperties
import android.view.MotionEvent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.accessibility.data.repository.fakeAccessibilityRepository
import com.android.systemui.accessibility.domain.interactor.accessibilityInteractor
import com.android.systemui.biometrics.data.repository.fingerprintPropertyRepository
import com.android.systemui.biometrics.data.repository.promptRepository
import com.android.systemui.biometrics.domain.interactor.promptSelectorInteractor
import com.android.systemui.biometrics.fingerprintSensorPropertiesInternal
import com.android.systemui.biometrics.shared.model.BiometricModalities
import com.android.systemui.biometrics.shared.model.BiometricModality
import com.android.systemui.biometrics.shared.model.PromptKind
import com.android.systemui.biometrics.udfpsUtils
import com.android.systemui.biometrics.ui.viewmodel.BiometricPromptUdfpsAccessibilityOverlayViewModel
import com.android.systemui.biometrics.ui.viewmodel.biometricPromptUdfpsAccessibilityOverlayViewModel
import com.android.systemui.biometrics.ui.viewmodel.promptViewModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.keyguard.data.repository.deviceEntryFingerprintAuthRepository
import com.android.systemui.keyguard.data.repository.fakeBiometricSettingsRepository
import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.kosmos.testScope
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
import org.mockito.kotlin.any
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
class BiometricPromptUdfpsAccessibilityOverlayViewModelTest() : SysuiTestCase() {
    @JvmField @Rule val mockito = MockitoJUnit.rule()

    private val kosmos = testKosmos()

    @Mock private lateinit var motionEvent: MotionEvent
    private val testScope = kosmos.testScope
    private val biometricSettingsRepository = kosmos.fakeBiometricSettingsRepository
    private val accessibilityRepository = kosmos.fakeAccessibilityRepository
    private val fingerprintPropertyRepository = kosmos.fingerprintPropertyRepository

    private lateinit var underTest: BiometricPromptUdfpsAccessibilityOverlayViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(kosmos.testDispatcher)
        // A11y enabled
        accessibilityRepository.isTouchExplorationEnabled.value = true

        // Listening for UDFPS
        biometricSettingsRepository.setIsFingerprintAuthEnrolledAndEnabled(true)

        underTest = kosmos.biometricPromptUdfpsAccessibilityOverlayViewModel
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun visible() =
        testScope.runTest {
            val isTouchExplorationEnabled by
                collectLastValue(kosmos.accessibilityInteractor.isTouchExplorationEnabled)
            val modalities by collectLastValue(kosmos.promptViewModel.modalities)
            val authState by collectLastValue(kosmos.promptViewModel.isAuthenticated)
            val visible by collectLastValue(underTest.visible)

            setupPrompt(FingerprintSensorProperties.TYPE_UDFPS_OPTICAL)
            runCurrent()
            assertThat(isTouchExplorationEnabled).isTrue()
            assertThat(modalities?.hasUdfps).isTrue()
            assertThat(authState?.isAuthenticated).isFalse()
            assertThat(visible).isTrue()
        }

    @Test
    fun touchExplorationNotEnabled_overlayNotVisible() =
        testScope.runTest {
            val visible by collectLastValue(underTest.visible)
            setupPrompt(FingerprintSensorProperties.TYPE_UDFPS_OPTICAL)
            accessibilityRepository.isTouchExplorationEnabled.value = false
            assertThat(visible).isFalse()
        }

    @Test
    fun nonUdfpsModality_overlayNotVisible() =
        testScope.runTest {
            val visible by collectLastValue(underTest.visible)
            setupPrompt(FingerprintSensorProperties.TYPE_POWER_BUTTON)
            accessibilityRepository.isTouchExplorationEnabled.value = false
            assertThat(visible).isFalse()
        }

    @Test
    fun afterAuthenticated_overlayNotVisible() =
        testScope.runTest {
            val visible by collectLastValue(underTest.visible)
            setupPrompt(FingerprintSensorProperties.TYPE_UDFPS_OPTICAL)
            kosmos.promptViewModel.showAuthenticated(
                modality = BiometricModality.Fingerprint,
                dismissAfterDelay = 1000L,
            )
            runCurrent()
            assertThat(visible).isFalse()
        }

    @Test
    fun udfpsDirectionalFeedbackReturnsNull_ifNotListeningForUdfps() =
        testScope.runTest {
            val isListeningForUdfps by collectLastValue(underTest.isListeningForUdfps)
            setupPrompt(FingerprintSensorProperties.TYPE_UDFPS_OPTICAL, false)
            setupMotionEvent(MotionEvent.ACTION_HOVER_ENTER)
            runCurrent()
            assertThat(isListeningForUdfps).isEqualTo(false)
            assertThat(underTest.getUdfpsDirectionalFeedbackOnHoverEnterOrMove(motionEvent))
                .isEqualTo(null)
        }

    @Test
    fun udfpsDirectionalFeedback_onTouchOutsideSensorArea() =
        testScope.runTest {
            val promptKind by collectLastValue(kosmos.promptRepository.promptKind)
            val prompt by collectLastValue(kosmos.promptSelectorInteractor.prompt)
            val modalities by collectLastValue(kosmos.promptViewModel.modalities)
            val isListeningForUdfps by collectLastValue(underTest.isListeningForUdfps)
            setupPrompt(FingerprintSensorProperties.TYPE_UDFPS_OPTICAL)
            setupMotionEvent(MotionEvent.ACTION_HOVER_ENTER)
            runCurrent()
            assertThat(promptKind?.isBiometric()).isEqualTo(true)
            assertThat((promptKind as PromptKind.Biometric).activeModalities.hasUdfps)
                .isEqualTo(true)
            assertThat(prompt?.modalities?.hasUdfps).isEqualTo(true)
            assertThat(modalities?.hasUdfps).isEqualTo(true)
            assertThat(isListeningForUdfps).isEqualTo(true)
            assertThat(underTest.getUdfpsDirectionalFeedbackOnHoverEnterOrMove(motionEvent))
                .isEqualTo("Move left")

            setupMotionEvent(MotionEvent.ACTION_HOVER_MOVE)
            runCurrent()
            assertThat(underTest.getUdfpsDirectionalFeedbackOnHoverEnterOrMove(motionEvent))
                .isEqualTo("Move left")
        }

    private fun setupUdfpsUtils() {
        whenever(kosmos.udfpsUtils.getTouchInNativeCoordinates(any(), any(), any(), anyBoolean()))
            .thenReturn(Point(0, 0))
        whenever(kosmos.udfpsUtils.isWithinSensorArea(any(), any(), any(), anyBoolean()))
            .thenReturn(false)
        whenever(
                kosmos.udfpsUtils.onTouchOutsideOfSensorArea(
                    any(),
                    any(),
                    any(),
                    any(),
                    any(),
                    anyBoolean(),
                )
            )
            .thenReturn("Move left")
    }

    private fun setupPrompt(sensorType: Int?, isSensorListening: Boolean = true) {
        var activeModalities = BiometricModalities()

        if (
            sensorType == FingerprintSensorProperties.TYPE_UDFPS_OPTICAL ||
                sensorType == FingerprintSensorProperties.TYPE_UDFPS_ULTRASONIC
        ) {
            fingerprintPropertyRepository.supportsUdfps()
            if (isSensorListening) {
                setupUdfpsUtils()
            }
        } else if (sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
            fingerprintPropertyRepository.supportsSideFps()
        }
        kosmos.deviceEntryFingerprintAuthRepository.setIsRunning(isSensorListening)

        if (sensorType != null && isSensorListening) {
            activeModalities =
                BiometricModalities(
                    fingerprintSensorPropertiesInternal(sensorType = sensorType).first()
                )
        }

        kosmos.promptRepository.setPrompt(
            PromptInfo(),
            0,
            0,
            0L,
            PromptKind.Biometric(activeModalities = activeModalities),
            false,
            "test",
        )
    }

    private fun setupMotionEvent(eventType: Int) {
        whenever(motionEvent.action).thenReturn(eventType)
    }
}
+12 −0
Original line number Diff line number Diff line
@@ -28,6 +28,18 @@
        app:layout_constraintTop_toTopOf="@id/topBarrier"
        app:layout_constraintWidth_max="@dimen/biometric_prompt_panel_max_width" />

    <com.android.systemui.deviceentry.ui.view.UdfpsAccessibilityOverlay
        android:id="@+id/biometric_prompt_udfps_accessibility_overlay"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:accessibilityLiveRegion="assertive"
        android:importantForAccessibility="no"
        android:clickable="false"
        app:layout_constraintBottom_toBottomOf="@id/panel"
        app:layout_constraintEnd_toEndOf="@id/panel"
        app:layout_constraintStart_toStartOf="@id/panel"
        app:layout_constraintTop_toTopOf="@id/panel" />

    <include
        android:id="@+id/button_bar"
        layout="@layout/biometric_prompt_button_bar"
+12 −0
Original line number Diff line number Diff line
@@ -31,6 +31,18 @@ android:layout_height="match_parent">
        app:layout_constraintLeft_toLeftOf="@+id/leftGuideline"
        app:layout_constraintTop_toTopOf="@+id/topGuideline" />

    <com.android.systemui.deviceentry.ui.view.UdfpsAccessibilityOverlay
        android:id="@+id/biometric_prompt_udfps_accessibility_overlay"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:accessibilityLiveRegion="assertive"
        android:importantForAccessibility="no"
        android:clickable="false"
        app:layout_constraintBottom_toBottomOf="@id/panel"
        app:layout_constraintEnd_toEndOf="@id/panel"
        app:layout_constraintStart_toStartOf="@id/panel"
        app:layout_constraintTop_toTopOf="@id/panel" />

    <com.android.systemui.biometrics.BiometricPromptLottieViewWrapper
        android:id="@+id/biometric_icon"
        android:layout_width="wrap_content"
+13 −17
Original line number Diff line number Diff line
@@ -54,6 +54,8 @@ import com.android.systemui.biometrics.ui.viewmodel.PromptMessage
import com.android.systemui.biometrics.ui.viewmodel.PromptSize
import com.android.systemui.biometrics.ui.viewmodel.PromptViewModel
import com.android.systemui.common.ui.view.onTouchListener
import com.android.systemui.deviceentry.ui.binder.UdfpsAccessibilityOverlayBinder
import com.android.systemui.deviceentry.ui.view.UdfpsAccessibilityOverlay
import com.android.systemui.lifecycle.repeatWhenAttached
import com.android.systemui.res.R
import com.android.systemui.statusbar.VibratorHelper
@@ -103,7 +105,10 @@ object BiometricViewBinder {
        val descriptionView = view.requireViewById<TextView>(R.id.description)
        val customizedViewContainer =
            view.requireViewById<LinearLayout>(R.id.customized_view_container)
        val udfpsGuidanceView = view.requireViewById<View>(R.id.panel)
        val udfpsGuidanceView =
            view.requireViewById<UdfpsAccessibilityOverlay>(
                R.id.biometric_prompt_udfps_accessibility_overlay
            )

        // set selected to enable marquee unless a screen reader is enabled
        titleView.isSelected =
@@ -186,6 +191,13 @@ object BiometricViewBinder {
            subtitleView.text = viewModel.subtitle.first()
            descriptionView.text = viewModel.description.first()

            if (modalities.hasUdfps) {
                UdfpsAccessibilityOverlayBinder.bind(
                    udfpsGuidanceView,
                    viewModel.udfpsAccessibilityOverlayViewModel,
                )
            }

            BiometricCustomizedViewBinder.bind(
                customizedViewContainer,
                viewModel.contentView.first(),
@@ -393,22 +405,6 @@ object BiometricViewBinder {
                    }
                }

                // Talkback directional guidance
                udfpsGuidanceView.setOnHoverListener { _, event ->
                    launch {
                        viewModel.onAnnounceAccessibilityHint(
                            event,
                            accessibilityManager.isTouchExplorationEnabled,
                        )
                    }
                    false
                }
                launch {
                    viewModel.accessibilityHint.collect { message ->
                        if (message.isNotBlank()) view.announceForAccessibility(message)
                    }
                }

                // Play haptics
                launch {
                    viewModel.hapticsToPlay.collect { haptics ->
+66 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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 androidx.lifecycle.viewModelScope
import com.android.systemui.accessibility.domain.interactor.AccessibilityInteractor
import com.android.systemui.biometrics.UdfpsUtils
import com.android.systemui.biometrics.domain.interactor.UdfpsOverlayInteractor
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.deviceentry.domain.interactor.DeviceEntryUdfpsInteractor
import com.android.systemui.deviceentry.ui.viewmodel.UdfpsAccessibilityOverlayViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/** Models the UI state for the biometric prompt UDFPS accessibility overlay */
class BiometricPromptUdfpsAccessibilityOverlayViewModel(
    @Application private val applicationContext: Context,
    deviceEntryUdfpsInteractor: DeviceEntryUdfpsInteractor,
    udfpsOverlayInteractor: UdfpsOverlayInteractor,
    udfpsUtils: UdfpsUtils,
    accessibilityInteractor: AccessibilityInteractor,
    private val promptViewModel: PromptViewModel,
) :
    UdfpsAccessibilityOverlayViewModel(
        applicationContext,
        udfpsOverlayInteractor,
        deviceEntryUdfpsInteractor,
        udfpsUtils,
        accessibilityInteractor,
    ) {

    /** Whether the under display fingerprint sensor is currently running. */
    override val isListeningForUdfps: StateFlow<Boolean> =
        promptViewModel.modalities
            .map { it.hasUdfps }
            .stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = false)

    /**
     * Overlay is only visible if the prompt modalities include UDFPS and the user is not yet
     * authenticated.
     */
    override fun isVisibleWhenTouchExplorationEnabled(): Flow<Boolean> =
        combine(promptViewModel.modalities, promptViewModel.isAuthenticated) { modalities, authState
            ->
            modalities.hasUdfps && !authState.isAuthenticated
        }
}
Loading