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

Commit cf9a0559 authored by Grace Cheng's avatar Grace Cheng
Browse files

Clear UDFPS lockscreen and alternate bouncer a11y messages after read by talkback

Clears UDFPS guidance messages after focus moves to the next view for
lockscreen, and after hover_exit for alternate bouncer, in
order to prevent stale messages during any subsequent linear a11y
navigation, and to allow sending the same message multiple times in
succession (e.g. "Move left" twice in a row)

Flag: NONE bug fix
Fixes: 404940015
Fixes: 404939346
Fixes: 404938162
Fixes: 383230658
Test: (manual) verified Talkback reads expected messages
Test: atest UdfpsAccessibilityOverlayViewModelTest
Change-Id: Ic99266c911e0f40f8096fe1d65113864e26be774
parent d0ac6337
Loading
Loading
Loading
Loading
+79 −2
Original line number Diff line number Diff line
@@ -16,13 +16,17 @@

package com.android.systemui.deviceentry.domain.ui.viewmodel

import android.graphics.Point
import android.platform.test.flag.junit.FlagsParameterization
import android.view.MotionEvent
import android.view.View
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.accessibility.data.repository.fakeAccessibilityRepository
import com.android.systemui.biometrics.data.repository.fingerprintPropertyRepository
import com.android.systemui.biometrics.udfpsUtils
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository
import com.android.systemui.deviceentry.data.ui.viewmodel.alternateBouncerUdfpsAccessibilityOverlayViewModel
import com.android.systemui.deviceentry.data.ui.viewmodel.deviceEntryUdfpsAccessibilityOverlayViewModel
import com.android.systemui.deviceentry.ui.viewmodel.DeviceEntryUdfpsAccessibilityOverlayViewModel
import com.android.systemui.flags.Flags.FULL_SCREEN_USER_SWITCHER
@@ -34,6 +38,7 @@ import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepos
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.keyguard.ui.viewmodel.accessibilityActionsViewModelKosmos
import com.android.systemui.keyguard.ui.viewmodel.fakeDeviceEntryIconViewModelTransition
import com.android.systemui.kosmos.testScope
import com.android.systemui.res.R
@@ -41,14 +46,22 @@ import com.android.systemui.shade.shadeTestUtil
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(ParameterizedAndroidJunit4::class)
class UdfpsAccessibilityOverlayViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
@@ -63,7 +76,6 @@ class UdfpsAccessibilityOverlayViewModelTest(flags: FlagsParameterization) : Sys
    private val keyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository
    private val fingerprintPropertyRepository = kosmos.fingerprintPropertyRepository
    private val deviceEntryFingerprintAuthRepository = kosmos.deviceEntryFingerprintAuthRepository
    private val deviceEntryRepository = kosmos.fakeDeviceEntryRepository

    private val shadeTestUtil by lazy { kosmos.shadeTestUtil }

@@ -83,6 +95,22 @@ class UdfpsAccessibilityOverlayViewModelTest(flags: FlagsParameterization) : Sys

    @Before
    fun setup() {
        whenever(kosmos.udfpsUtils.isWithinSensorArea(any(), any(), any())).thenReturn(false)
        whenever(
                kosmos.udfpsUtils.getTouchInNativeCoordinates(anyInt(), any(), any(), anyBoolean())
            )
            .thenReturn(Point(0, 0))
        whenever(
                kosmos.udfpsUtils.onTouchOutsideOfSensorArea(
                    anyBoolean(),
                    eq(null),
                    anyInt(),
                    anyInt(),
                    any(),
                    anyBoolean(),
                )
            )
            .thenReturn("Move left")
        underTest = kosmos.deviceEntryUdfpsAccessibilityOverlayViewModel
        overrideResource(R.integer.udfps_padding_debounce_duration, 0)
    }
@@ -100,6 +128,55 @@ class UdfpsAccessibilityOverlayViewModelTest(flags: FlagsParameterization) : Sys
            assertThat(visible).isTrue()
        }

    @Test
    fun contentDescription_setOnUdfpsTouchOutsideSensorArea() =
        testScope.runTest {
            val contentDescription by collectLastValue(underTest.contentDescription)
            setupVisibleStateOnLockscreen()
            underTest.onHoverEvent(mock<View>(), mock<MotionEvent>())
            runCurrent()
            assertThat(contentDescription).isEqualTo("Move left")
        }

    @Test
    fun clearAccessibilityOverlayMessageReason_updatesWhenFocusChangesFromUdfpsOverlayToLockscreen() =
        testScope.runTest {
            val clearAccessibilityOverlayMessageReason by
                collectLastValue(underTest.clearAccessibilityOverlayMessageReason)
            val contentDescription by collectLastValue(underTest.contentDescription)
            setupVisibleStateOnLockscreen()
            kosmos.accessibilityActionsViewModelKosmos.clearUdfpsAccessibilityOverlayMessage("test")
            runCurrent()
            assertThat(clearAccessibilityOverlayMessageReason).isEqualTo("test")

            // UdfpsAccessibilityOverlayViewBinder collects clearAccessibilityOverlayMessageReason
            // and calls
            // viewModel.setContentDescription(null) - mock this here
            underTest.setContentDescription(null)
            runCurrent()
            assertThat(contentDescription).isNull()
        }

    @Test
    fun clearAccessibilityOverlayMessageReason_updatesAfterUdfpsOverlayFocusOnAlternateBouncer() =
        testScope.runTest {
            val clearAccessibilityOverlayMessageReason by
                collectLastValue(underTest.clearAccessibilityOverlayMessageReason)
            val contentDescription by collectLastValue(underTest.contentDescription)
            setupVisibleStateOnLockscreen()
            kosmos.alternateBouncerUdfpsAccessibilityOverlayViewModel
                .clearUdfpsAccessibilityOverlayMessage("test")
            runCurrent()
            assertThat(clearAccessibilityOverlayMessageReason).isEqualTo("test")

            // UdfpsAccessibilityOverlayViewBinder collects clearAccessibilityOverlayMessageReason
            // and calls
            // viewModel.setContentDescription(null) - mock this here
            underTest.setContentDescription(null)
            runCurrent()
            assertThat(contentDescription).isNull()
        }

    @Test
    fun touchExplorationNotEnabled_overlayNotVisible() =
        testScope.runTest {
+24 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.systemui.biometrics.domain.interactor

import android.annotation.SuppressLint
import android.content.Context
import android.hardware.fingerprint.FingerprintManager
import android.util.Log
@@ -32,10 +33,14 @@ import javax.inject.Inject
import kotlin.math.max
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@@ -131,6 +136,25 @@ constructor(
            }
            .distinctUntilChanged()

    /**
     * Event flow that emits every time the user taps the screen and a UDFPS guidance message is
     * surfaced and then cleared. Modeled as a SharedFlow because a StateFlow fails to emit every
     * event to the subscriber, causing missed Talkback feedback and incorrect focusability state of
     * the UDFPS accessibility overlay.
     */
    @SuppressLint("SharedFlowCreation")
    private val _clearAccessibilityOverlayMessageReason = MutableSharedFlow<String?>()

    /** Indicates the reason for clearing the UDFPS accessibility overlay content description */
    val clearAccessibilityOverlayMessageReason: SharedFlow<String?> =
        _clearAccessibilityOverlayMessageReason.asSharedFlow()

    suspend fun clearUdfpsAccessibilityOverlayMessage(reason: String) {
        // Add delay to make sure we read the guidance message before clearing it
        delay(1000)
        _clearAccessibilityOverlayMessageReason.emit(reason)
    }

    companion object {
        private const val TAG = "UdfpsOverlayInteractor"
    }
+37 −6
Original line number Diff line number Diff line
@@ -18,27 +18,58 @@
package com.android.systemui.deviceentry.ui.binder

import android.annotation.SuppressLint
import android.util.Log
import android.view.MotionEvent
import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO
import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES
import androidx.core.view.isInvisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.deviceentry.ui.view.UdfpsAccessibilityOverlay
import com.android.systemui.deviceentry.ui.viewmodel.UdfpsAccessibilityOverlayViewModel
import com.android.systemui.lifecycle.repeatWhenAttached

object UdfpsAccessibilityOverlayBinder {
    private const val TAG = "UdfpsAccessibilityOverlayBinder"

    /** Forwards hover events to the view model to make guided announcements for accessibility. */
    @SuppressLint("ClickableViewAccessibility")
    @JvmStatic
    fun bind(
        view: UdfpsAccessibilityOverlay,
        viewModel: UdfpsAccessibilityOverlayViewModel,
    ) {
        view.setOnHoverListener { v, event -> viewModel.onHoverEvent(v, event) }
    fun bind(view: UdfpsAccessibilityOverlay, viewModel: UdfpsAccessibilityOverlayViewModel) {
        view.repeatWhenAttached {
            // Repeat on CREATED because we update the visibility of the view
            repeatOnLifecycle(Lifecycle.State.CREATED) {
                viewModel.visible.collect { visible -> view.isInvisible = !visible }
                view.setOnHoverListener { v, event ->
                    if (event.action == MotionEvent.ACTION_HOVER_ENTER) {
                        launch { viewModel.onHoverEvent(v, event) }
                    }
                    false
                }

                launch { viewModel.visible.collect { visible -> view.isInvisible = !visible } }

                launch {
                    viewModel.contentDescription.collect { contentDescription ->
                        if (contentDescription != null) {
                            view.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
                            view.contentDescription = contentDescription
                        }
                    }
                }

                launch {
                    viewModel.clearAccessibilityOverlayMessageReason.collect { reason ->
                        Log.d(
                            TAG,
                            "clearing content description of UDFPS accessibility overlay " +
                                "for reason: $reason",
                        )
                        view.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
                        view.contentDescription = null
                        viewModel.setContentDescription(null)
                    }
                }
            }
        }
    }
+2 −0
Original line number Diff line number Diff line
@@ -23,5 +23,7 @@ import android.view.View
class UdfpsAccessibilityOverlay(context: Context?) : View(context) {
    init {
        accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_ASSERTIVE
        importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
        isClickable = false
    }
}
+12 −1
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
package com.android.systemui.deviceentry.ui.viewmodel

import com.android.systemui.accessibility.domain.interactor.AccessibilityInteractor
import com.android.systemui.biometrics.UdfpsUtils
import com.android.systemui.biometrics.domain.interactor.UdfpsOverlayInteractor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@@ -26,13 +27,23 @@ import kotlinx.coroutines.flow.flowOf
class AlternateBouncerUdfpsAccessibilityOverlayViewModel
@Inject
constructor(
    udfpsOverlayInteractor: UdfpsOverlayInteractor,
    private val udfpsOverlayInteractor: UdfpsOverlayInteractor,
    accessibilityInteractor: AccessibilityInteractor,
    udfpsUtils: UdfpsUtils,
) :
    UdfpsAccessibilityOverlayViewModel(
        udfpsOverlayInteractor,
        accessibilityInteractor,
        udfpsUtils,
    ) {
    /** Overlay is always visible if touch exploration is enabled on the alternate bouncer. */
    override fun isVisibleWhenTouchExplorationEnabled(): Flow<Boolean> = flowOf(true)

    /**
     * Clears the content description to prevent the view from storing stale UDFPS directional
     * guidance messages for accessibility.
     */
    suspend fun clearUdfpsAccessibilityOverlayMessage(reason: String) {
        udfpsOverlayInteractor.clearUdfpsAccessibilityOverlayMessage(reason)
    }
}
Loading