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

Commit 2172fdc7 authored by Coco Duan's avatar Coco Duan
Browse files

Toggle widget focus based on hub visibility

When the hub is fully visible, widgets will be focusable and vice versa.
This change also makes the CommunalAppWidgetHostView accessible so that
widget name can be read out by screen reader on focus.
Also labeled the long press action on widgets to hint user on how to
enter the edit mode.

Bug: b/333567994
Test: atest CommunalViewModelTest
Test: manual with TalkBack, Voice, Switch Access
Flag: ACONFIG com.android.systemui.communal_hub TEAMFOOD
Change-Id: Ie566664b819051af6d9c6d8f6ea558ffdd1abcef
parent b19bd235
Loading
Loading
Loading
Loading
+16 −4
Original line number Diff line number Diff line
@@ -20,6 +20,8 @@ import android.appwidget.AppWidgetHostView
import android.graphics.drawable.Icon
import android.os.Bundle
import android.util.SizeF
import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
import android.widget.FrameLayout
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@@ -115,8 +117,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.core.view.setPadding
import androidx.window.layout.WindowMetricsCalculator
import com.android.compose.modifiers.height
import com.android.compose.modifiers.padding
import com.android.compose.modifiers.thenIf
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.compose.ui.graphics.painter.rememberDrawablePainter
@@ -374,7 +374,7 @@ private fun ScrollOnUpdatedLiveContentEffect(
            liveContentKeys.indexOfFirst { !prevLiveContentKeys.contains(it) }

        // Scroll if current position is behind the first updated content
        if (indexOfFirstUpdatedContent in 0..<gridState.firstVisibleItemIndex) {
        if (indexOfFirstUpdatedContent in 0 until gridState.firstVisibleItemIndex) {
            // Launching with a scope to prevent the job from being canceled in the case of a
            // recomposition during scrolling
            coroutineScope.launch { gridState.animateScrollToItem(indexOfFirstUpdatedContent) }
@@ -841,6 +841,8 @@ private fun WidgetContent(
    widgetConfigurator: WidgetConfigurator?,
    modifier: Modifier = Modifier,
) {
    val isFocusable by viewModel.isFocusable.collectAsState(initial = false)

    Box(
        modifier =
            modifier.thenIf(!viewModel.isEditMode && model.inQuietMode) {
@@ -865,6 +867,16 @@ private fun WidgetContent(
                        setPadding(0)
                    }
            },
            update = {
                it.apply {
                    importantForAccessibility =
                        if (isFocusable) {
                            IMPORTANT_FOR_ACCESSIBILITY_AUTO
                        } else {
                            IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
                        }
                }
            },
            // For reusing composition in lazy lists.
            onReset = {},
        )
+115 −0
Original line number Diff line number Diff line
@@ -24,17 +24,21 @@ import android.platform.test.flag.junit.FlagsParameterization
import android.provider.Settings
import android.widget.RemoteViews
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.ObservableTransitionState
import com.android.systemui.Flags.FLAG_COMMUNAL_HUB
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.data.repository.FakeCommunalMediaRepository
import com.android.systemui.communal.data.repository.FakeCommunalRepository
import com.android.systemui.communal.data.repository.FakeCommunalTutorialRepository
import com.android.systemui.communal.data.repository.FakeCommunalWidgetRepository
import com.android.systemui.communal.data.repository.fakeCommunalMediaRepository
import com.android.systemui.communal.data.repository.fakeCommunalRepository
import com.android.systemui.communal.data.repository.fakeCommunalTutorialRepository
import com.android.systemui.communal.data.repository.fakeCommunalWidgetRepository
import com.android.systemui.communal.domain.interactor.communalInteractor
import com.android.systemui.communal.domain.interactor.communalTutorialInteractor
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.shared.model.CommunalScenes
import com.android.systemui.communal.shared.model.CommunalWidgetContentModel
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel.Companion.POPUP_AUTO_HIDE_TIMEOUT_MS
@@ -45,8 +49,14 @@ import com.android.systemui.flags.Flags.COMMUNAL_SERVICE_ENABLED
import com.android.systemui.flags.andSceneContainer
import com.android.systemui.flags.fakeFeatureFlagsClassic
import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository
import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.keyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.StatusBarState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.kosmos.testScope
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
@@ -63,6 +73,7 @@ import com.android.systemui.user.data.repository.fakeUserRepository
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
@@ -94,6 +105,8 @@ class CommunalViewModelTest(flags: FlagsParameterization?) : SysuiTestCase() {
    private lateinit var mediaRepository: FakeCommunalMediaRepository
    private lateinit var userRepository: FakeUserRepository
    private lateinit var shadeTestUtil: ShadeTestUtil
    private lateinit var keyguardTransitionRepository: FakeKeyguardTransitionRepository
    private lateinit var communalRepository: FakeCommunalRepository

    private lateinit var underTest: CommunalViewModel

@@ -106,12 +119,14 @@ class CommunalViewModelTest(flags: FlagsParameterization?) : SysuiTestCase() {
        MockitoAnnotations.initMocks(this)

        keyguardRepository = kosmos.fakeKeyguardRepository
        keyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository
        tutorialRepository = kosmos.fakeCommunalTutorialRepository
        widgetRepository = kosmos.fakeCommunalWidgetRepository
        smartspaceRepository = kosmos.fakeSmartspaceRepository
        mediaRepository = kosmos.fakeCommunalMediaRepository
        userRepository = kosmos.fakeUserRepository
        shadeTestUtil = kosmos.shadeTestUtil
        communalRepository = kosmos.fakeCommunalRepository

        kosmos.fakeFeatureFlagsClassic.set(COMMUNAL_SERVICE_ENABLED, true)
        mSetFlagsRule.enableFlags(FLAG_COMMUNAL_HUB)
@@ -125,6 +140,7 @@ class CommunalViewModelTest(flags: FlagsParameterization?) : SysuiTestCase() {
        underTest =
            CommunalViewModel(
                testScope,
                kosmos.keyguardTransitionInteractor,
                kosmos.communalInteractor,
                kosmos.communalTutorialInteractor,
                kosmos.shadeInteractor,
@@ -326,6 +342,105 @@ class CommunalViewModelTest(flags: FlagsParameterization?) : SysuiTestCase() {
            assertThat(underTest.canChangeScene()).isFalse()
        }

    @Test
    fun isFocusable_isFalse_whenTransitioningAwayFromGlanceableHub() =
        testScope.runTest {
            val isFocusable by collectLastValue(underTest.isFocusable)

            // Shade not expanded.
            shadeTestUtil.setLockscreenShadeExpansion(0f)
            // On communal scene.
            communalRepository.setTransitionState(
                flowOf(ObservableTransitionState.Idle(CommunalScenes.Communal))
            )
            // Open bouncer.
            keyguardTransitionRepository.sendTransitionStep(
                TransitionStep(
                    from = KeyguardState.GLANCEABLE_HUB,
                    to = KeyguardState.PRIMARY_BOUNCER,
                    transitionState = TransitionState.STARTED,
                )
            )

            keyguardTransitionRepository.sendTransitionStep(
                from = KeyguardState.GLANCEABLE_HUB,
                to = KeyguardState.PRIMARY_BOUNCER,
                transitionState = TransitionState.RUNNING,
                value = 0.5f,
            )
            assertThat(isFocusable).isEqualTo(false)

            // Transitioned to bouncer.
            keyguardTransitionRepository.sendTransitionStep(
                from = KeyguardState.GLANCEABLE_HUB,
                to = KeyguardState.PRIMARY_BOUNCER,
                transitionState = TransitionState.FINISHED,
                value = 1f,
            )
            assertThat(isFocusable).isEqualTo(false)
        }

    @Test
    fun isFocusable_isFalse_whenNotOnCommunalScene() =
        testScope.runTest {
            val isFocusable by collectLastValue(underTest.isFocusable)

            keyguardTransitionRepository.sendTransitionSteps(
                from = KeyguardState.LOCKSCREEN,
                to = KeyguardState.GLANCEABLE_HUB,
                testScope = testScope,
            )
            shadeTestUtil.setLockscreenShadeExpansion(0f)
            // Transitioned away from communal scene.
            communalRepository.setTransitionState(
                flowOf(ObservableTransitionState.Idle(CommunalScenes.Blank))
            )

            assertThat(isFocusable).isEqualTo(false)
        }

    @Test
    fun isFocusable_isTrue_whenIdleOnCommunal_andShadeNotExpanded() =
        testScope.runTest {
            val isFocusable by collectLastValue(underTest.isFocusable)

            // On communal scene.
            communalRepository.setTransitionState(
                flowOf(ObservableTransitionState.Idle(CommunalScenes.Communal))
            )
            // Transitioned to Glanceable hub.
            keyguardTransitionRepository.sendTransitionSteps(
                from = KeyguardState.LOCKSCREEN,
                to = KeyguardState.GLANCEABLE_HUB,
                testScope = testScope,
            )
            // Shade not expanded.
            shadeTestUtil.setLockscreenShadeExpansion(0f)

            assertThat(isFocusable).isEqualTo(true)
        }

    @Test
    fun isFocusable_isFalse_whenQsIsExpanded() =
        testScope.runTest {
            val isFocusable by collectLastValue(underTest.isFocusable)

            // On communal scene.
            communalRepository.setTransitionState(
                flowOf(ObservableTransitionState.Idle(CommunalScenes.Communal))
            )
            // Transitioned to Glanceable hub.
            keyguardTransitionRepository.sendTransitionSteps(
                from = KeyguardState.LOCKSCREEN,
                to = KeyguardState.GLANCEABLE_HUB,
                testScope = testScope,
            )
            // Qs is expanded.
            shadeTestUtil.setQsExpansion(1f)

            assertThat(isFocusable).isEqualTo(false)
        }

    private suspend fun setIsMainUser(isMainUser: Boolean) {
        whenever(user.isMain).thenReturn(isMainUser)
        userRepository.setUserInfos(listOf(user))
+2 −2
Original line number Diff line number Diff line
@@ -37,8 +37,8 @@ abstract class BaseCommunalViewModel(
) {
    val currentScene: Flow<SceneKey> = communalInteractor.desiredScene

    /** Whether communal hub can be focused to enable accessibility actions. */
    val isFocusable: Flow<Boolean> = communalInteractor.isIdleOnCommunal
    /** Whether communal hub can be focused by accessibility tools. */
    open val isFocusable: Flow<Boolean> = MutableStateFlow(false)

    /** Whether widgets are currently being re-ordered. */
    open val reorderingWidgets: StateFlow<Boolean> = MutableStateFlow(false)
+15 −0
Original line number Diff line number Diff line
@@ -22,6 +22,8 @@ import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.log.LogBuffer
import com.android.systemui.log.core.Logger
import com.android.systemui.log.dagger.CommunalLog
@@ -54,6 +56,7 @@ class CommunalViewModel
@Inject
constructor(
    @Application private val scope: CoroutineScope,
    keyguardTransitionInteractor: KeyguardTransitionInteractor,
    private val communalInteractor: CommunalInteractor,
    tutorialInteractor: CommunalTutorialInteractor,
    private val shadeInteractor: ShadeInteractor,
@@ -93,6 +96,18 @@ constructor(
    private val _currentPopup: MutableStateFlow<PopupType?> = MutableStateFlow(null)
    override val currentPopup: Flow<PopupType?> = _currentPopup.asStateFlow()

    // The widget is focusable for accessibility when the hub is fully visible and shade is not
    // opened.
    override val isFocusable: Flow<Boolean> =
        combine(
                keyguardTransitionInteractor.isFinishedInState(KeyguardState.GLANCEABLE_HUB),
                communalInteractor.isIdleOnCommunal,
                shadeInteractor.isAnyFullyExpanded,
            ) { transitionedToGlanceableHub, isIdleOnCommunal, isAnyFullyExpanded ->
                transitionedToGlanceableHub && isIdleOnCommunal && !isAnyFullyExpanded
            }
            .distinctUntilChanged()

    private val _isEnableWidgetDialogShowing: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val isEnableWidgetDialogShowing: Flow<Boolean> = _isEnableWidgetDialogShowing.asStateFlow()

+21 −0
Original line number Diff line number Diff line
@@ -22,8 +22,10 @@ import android.graphics.Outline
import android.graphics.Rect
import android.view.View
import android.view.ViewOutlineProvider
import android.view.accessibility.AccessibilityNodeInfo
import com.android.systemui.animation.LaunchableView
import com.android.systemui.animation.LaunchableViewDelegate
import com.android.systemui.res.R

/** AppWidgetHostView that displays in communal hub with support for rounded corners. */
class CommunalAppWidgetHostView(context: Context) : AppWidgetHostView(context), LaunchableView {
@@ -42,6 +44,25 @@ class CommunalAppWidgetHostView(context: Context) : AppWidgetHostView(context),
    init {
        enforcedCornerRadius = RoundedCornerEnforcement.computeEnforcedRadius(context)
        enforcedRectangle = Rect()

        accessibilityDelegate =
            object : AccessibilityDelegate() {
                override fun onInitializeAccessibilityNodeInfo(
                    host: View,
                    info: AccessibilityNodeInfo
                ) {
                    super.onInitializeAccessibilityNodeInfo(host, info)
                    // Hint user to long press in order to enter edit mode
                    info.addAction(
                        AccessibilityNodeInfo.AccessibilityAction(
                            AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
                            context.getString(
                                R.string.accessibility_action_label_edit_widgets
                            ).lowercase()
                        )
                    )
                }
            }
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {