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

Commit 52ce865f authored by András Kurucz's avatar András Kurucz
Browse files

[flexiglass] Scroll the shade with TalkBack

Populate accessibility nodes with the current scroll state, to restore
the pre-flexiglass functionality of navigating through notifications
by just swiping from left to right (with TalkBack). Before this change
TalkBack stops at the last visible notification and doesn't scroll down
to the next, if the shade has enough notifications to scroll.

Bug: 363260043
Test: use TalkBack to navigate through notifications and footer buttons in the shade
Flag: com.android.systemui.scene_container

Change-Id: I38f07e75afdb988f5aca4c81930a2dc5c7915759
parent 60d45114
Loading
Loading
Loading
Loading
+59 −4
Original line number Diff line number Diff line
@@ -50,9 +50,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -60,6 +62,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -95,13 +98,17 @@ import com.android.systemui.scene.shared.model.Overlays
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.shade.shared.flag.DualShade
import com.android.systemui.shade.ui.composable.ShadeHeader
import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimRounding
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState
import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView
import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_CORNER_RADIUS
import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_SCRIM_ALPHA
import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

@@ -315,6 +322,12 @@ fun SceneScope.NotificationScrollingStack(
     */
    val stackHeight = remember { mutableIntStateOf(0) }

    /**
     * Space available for the notification stack on the screen. These bounds don't scroll off the
     * screen, and respect the scrim paddings, scrim clipping.
     */
    val stackBoundsOnScreen = remember { mutableStateOf(Rect.Zero) }

    val scrimRounding =
        viewModel.shadeScrimRounding.collectAsStateWithLifecycle(ShadeScrimRounding())

@@ -348,12 +361,19 @@ fun SceneScope.NotificationScrollingStack(
    // The top y bound of the IME.
    val imeTop = remember { mutableFloatStateOf(0f) }

    val shadeScrollState by remember {
        derivedStateOf {
            ShadeScrollState(
                // we are not scrolled to the top unless the scrim is at its maximum offset.
    LaunchedEffect(viewModel, scrimOffset) {
        snapshotFlow { scrimOffset.value >= 0f }
            .collect { isScrolledToTop -> viewModel.setScrolledToTop(isScrolledToTop) }
                isScrolledToTop = scrimOffset.value >= 0f,
                scrollPosition = scrollState.value,
                maxScrollPosition = scrollState.maxValue,
            )
        }
    }

    LaunchedEffect(shadeScrollState) { viewModel.setScrollState(shadeScrollState) }

    // if contentHeight drops below minimum visible scrim height while scrim is
    // expanded, reset scrim offset.
    LaunchedEffect(stackHeight, scrimOffset) {
@@ -395,6 +415,38 @@ fun SceneScope.NotificationScrollingStack(
            }
    }

    // TalkBack sends a scroll event, when it wants to navigate to an item that is not displayed in
    // the current viewport.
    LaunchedEffect(viewModel) {
        viewModel.setAccessibilityScrollEventConsumer { event ->
            // scroll up, or down by the height of the visible portion of the notification stack
            val direction =
                when (event) {
                    AccessibilityScrollEvent.SCROLL_UP -> -1
                    AccessibilityScrollEvent.SCROLL_DOWN -> 1
                }
            val viewPortHeight = stackBoundsOnScreen.value.height
            val scrollStep = max(0f, viewPortHeight - stackScrollView.stackBottomInset)
            val scrollPosition = scrollState.value.toFloat()
            val scrollRange = scrollState.maxValue.toFloat()
            val targetScroll = (scrollPosition + direction * scrollStep).coerceIn(0f, scrollRange)
            coroutineScope.launch {
                scrollNotificationStack(
                    delta = targetScroll - scrollPosition,
                    animate = false,
                    scrimOffset = scrimOffset,
                    minScrimOffset = minScrimOffset,
                    scrollState = scrollState,
                )
            }
        }
        try {
            awaitCancellation()
        } finally {
            viewModel.setAccessibilityScrollEventConsumer(null)
        }
    }

    val scrimNestedScrollConnection =
        shadeSession.rememberSession(
            scrimOffset,
@@ -520,6 +572,9 @@ fun SceneScope.NotificationScrollingStack(
                        .verticalScroll(scrollState)
                        .padding(top = topPadding)
                        .fillMaxWidth()
                        .onGloballyPositioned { coordinates ->
                            stackBoundsOnScreen.value = coordinates.boundsInWindow()
                        }
            ) {
                NotificationPlaceholder(
                    stackScrollView = stackScrollView,
+58 −21
Original line number Diff line number Diff line
@@ -25,6 +25,8 @@ import static com.android.internal.jank.InteractionJankMonitor.CUJ_SHADE_CLEAR_A
import static com.android.systemui.Flags.notificationOverExpansionClippingFix;
import static com.android.systemui.statusbar.notification.stack.NotificationPriorityBucketKt.BUCKET_SILENT;
import static com.android.systemui.statusbar.notification.stack.StackStateAnimator.ANIMATION_DURATION_SWIPE;
import static com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent.SCROLL_DOWN;
import static com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent.SCROLL_UP;
import static com.android.systemui.util.DumpUtilsKt.println;
import static com.android.systemui.util.DumpUtilsKt.visibilityString;

@@ -118,8 +120,10 @@ import com.android.systemui.statusbar.notification.shared.NotificationHeadsUpCyc
import com.android.systemui.statusbar.notification.shared.NotificationThrottleHun;
import com.android.systemui.statusbar.notification.shared.NotificationsImprovedHunAnimation;
import com.android.systemui.statusbar.notification.shared.NotificationsLiveDataStoreRefactor;
import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent;
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds;
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimShape;
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState;
import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView;
import com.android.systemui.statusbar.phone.HeadsUpAppearanceController;
import com.android.systemui.statusbar.phone.ScreenOffAnimationController;
@@ -609,7 +613,7 @@ public class NotificationStackScrollLayout
        @Override
        public boolean isScrolledToTop() {
            if (SceneContainerFlag.isEnabled()) {
                return mScrollViewFields.isScrolledToTop();
                return mScrollViewFields.getScrollState().isScrolledToTop();
            } else {
                return mOwnScrollY == 0;
            }
@@ -1247,9 +1251,25 @@ public class NotificationStackScrollLayout
    }

    @Override
    public void setScrolledToTop(boolean scrolledToTop) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mScrollViewFields.setScrolledToTop(scrolledToTop);
    public void setScrollState(@NonNull ShadeScrollState scrollState) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) {
            return;
        }

        boolean forwardScrollable =
                scrollState.getScrollPosition() < scrollState.getMaxScrollPosition();
        boolean backwardScrollable = scrollState.getScrollPosition() > 0;
        mScrollable = forwardScrollable || backwardScrollable;
        mForwardScrollable = forwardScrollable;
        mBackwardScrollable = backwardScrollable;

        boolean scrollPositionChanged = mScrollViewFields.getScrollState().getScrollPosition()
                != scrollState.getScrollPosition();
        mScrollViewFields.setScrollState(scrollState);

        if (scrollPositionChanged) {
            sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SCROLLED);
        }
    }

    @Override
@@ -1294,6 +1314,12 @@ public class NotificationStackScrollLayout
        mScrollViewFields.setSyntheticScrollConsumer(consumer);
    }

    @Override
    public void setAccessibilityScrollEventConsumer(
            @Nullable Consumer<AccessibilityScrollEvent> consumer) {
        mScrollViewFields.setAccessibilityScrollEventConsumer(consumer);
    }

    @Override
    public void setCurrentGestureOverscrollConsumer(@Nullable Consumer<Boolean> consumer) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
@@ -2645,6 +2671,11 @@ public class NotificationStackScrollLayout
        return mHeadsUpInset;
    }

    @Override
    public int getStackBottomInset() {
        return mPaddingBetweenElements + mShelf.getIntrinsicHeight();
    }

    /**
     * Calculate the gap height between two different views
     *
@@ -4243,17 +4274,27 @@ public class NotificationStackScrollLayout
     */
    @Override
    public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
        // Don't handle scroll accessibility events from the NSSL, when SceneContainer enabled.
        if (SceneContainerFlag.isEnabled()) {
            return super.performAccessibilityActionInternal(action, arguments);
        }

        if (super.performAccessibilityActionInternal(action, arguments)) {
            return true;
        }
        if (!isEnabled()) {
            return false;
        }

        if (SceneContainerFlag.isEnabled()) {
            switch (action) {
                case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
                case android.R.id.accessibilityActionScrollDown:
                    mScrollViewFields.sendAccessibilityScrollEvent(SCROLL_DOWN);
                    return true;
                case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
                case android.R.id.accessibilityActionScrollUp:
                    mScrollViewFields.sendAccessibilityScrollEvent(SCROLL_UP);
                    return true;
            }
            return false;
        }

        int direction = -1;
        switch (action) {
            case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
@@ -5029,25 +5070,21 @@ public class NotificationStackScrollLayout
    @Override
    public void onInitializeAccessibilityEventInternal(AccessibilityEvent event) {
        super.onInitializeAccessibilityEventInternal(event);
        // Don't handle scroll accessibility events from the NSSL, when SceneContainer enabled.
        if (SceneContainerFlag.isEnabled()) {
            return;
        }

        event.setScrollable(mScrollable);
        event.setMaxScrollX(mScrollX);

        if (SceneContainerFlag.isEnabled()) {
            event.setScrollY(mScrollViewFields.getScrollState().getScrollPosition());
            event.setMaxScrollY(mScrollViewFields.getScrollState().getMaxScrollPosition());
        } else {
            event.setScrollY(mOwnScrollY);
            event.setMaxScrollY(getScrollRange());
        }
    }

    @Override
    public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
        super.onInitializeAccessibilityNodeInfoInternal(info);
        // Don't handle scroll accessibility events from the NSSL, when SceneContainer enabled.
        if (SceneContainerFlag.isEnabled()) {
            return;
        }

        if (mScrollable) {
            info.setScrollable(true);
            if (mBackwardScrollable) {
+18 −3
Original line number Diff line number Diff line
@@ -17,7 +17,9 @@
package com.android.systemui.statusbar.notification.stack

import android.util.IndentingPrintWriter
import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimShape
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState
import com.android.systemui.util.printSection
import com.android.systemui.util.println
import java.util.function.Consumer
@@ -32,8 +34,9 @@ import java.util.function.Consumer
class ScrollViewFields {
    /** Used to produce the clipping path */
    var scrimClippingShape: ShadeScrimShape? = null
    /** Whether the notifications are scrolled all the way to the top (i.e. when freshly opened) */
    var isScrolledToTop: Boolean = true

    /** Scroll state of the notification shade. */
    var scrollState: ShadeScrollState = ShadeScrollState()

    /**
     * Height in view pixels at which the Notification Stack would like to be laid out, including
@@ -47,6 +50,13 @@ class ScrollViewFields {
     * placeholder
     */
    var syntheticScrollConsumer: Consumer<Float>? = null

    /**
     * When the NSSL navigates through the notifications with TalkBack, it can send scroll events
     * here, to be able to browse through the whole list of notifications in the shade.
     */
    var accessibilityScrollEventConsumer: Consumer<AccessibilityScrollEvent>? = null

    /**
     * When a gesture is consumed internally by NSSL but needs to be handled by other elements (such
     * as the notif scrim) as overscroll, we can notify the placeholder through here.
@@ -86,10 +96,15 @@ class ScrollViewFields {
    fun sendRemoteInputRowBottomBound(bottomY: Float?) =
        remoteInputRowBottomBoundConsumer?.accept(bottomY)

    /** send an [AccessibilityScrollEvent] to the [accessibilityScrollEventConsumer] if present */
    fun sendAccessibilityScrollEvent(event: AccessibilityScrollEvent) {
        accessibilityScrollEventConsumer?.accept(event)
    }

    fun dump(pw: IndentingPrintWriter) {
        pw.printSection("StackViewStates") {
            pw.println("scrimClippingShape", scrimClippingShape)
            pw.println("isScrolledToTop", isScrolledToTop)
            pw.println("scrollState", scrollState)
        }
    }
}
+8 −5
Original line number Diff line number Diff line
@@ -17,7 +17,10 @@
package com.android.systemui.statusbar.notification.stack.data.repository

import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState
import java.util.function.Consumer
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow

@@ -44,9 +47,9 @@ class NotificationPlaceholderRepository @Inject constructor() {
    /** height made available to the notifications in the size-constrained mode of lock screen. */
    val constrainedAvailableSpace = MutableStateFlow(0)

    /**
     * Whether the notification stack is scrolled to the top; i.e., it cannot be scrolled down any
     * further.
     */
    val scrolledToTop = MutableStateFlow(true)
    /** Scroll state of the notification shade. */
    val shadeScrollState = MutableStateFlow(ShadeScrollState())

    /** A consumer of [AccessibilityScrollEvent]s. */
    var accessibilityScrollEventConsumer: Consumer<AccessibilityScrollEvent>? = null
}
+19 −8
Original line number Diff line number Diff line
@@ -23,8 +23,11 @@ import com.android.systemui.shade.domain.interactor.ShadeInteractor
import com.android.systemui.shade.shared.model.ShadeMode
import com.android.systemui.statusbar.notification.stack.data.repository.NotificationPlaceholderRepository
import com.android.systemui.statusbar.notification.stack.data.repository.NotificationViewHeightRepository
import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimRounding
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState
import java.util.function.Consumer
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -78,11 +81,9 @@ constructor(
    val constrainedAvailableSpace: StateFlow<Int> =
        placeholderRepository.constrainedAvailableSpace.asStateFlow()

    /**
     * Whether the notification stack is scrolled to the top; i.e., it cannot be scrolled down any
     * further.
     */
    val scrolledToTop: StateFlow<Boolean> = placeholderRepository.scrolledToTop.asStateFlow()
    /** Scroll state of the notification shade. */
    val shadeScrollState: StateFlow<ShadeScrollState> =
        placeholderRepository.shadeScrollState.asStateFlow()

    /**
     * The amount in px that the notification stack should scroll due to internal expansion. This
@@ -123,9 +124,9 @@ constructor(
        placeholderRepository.shadeScrimBounds.value = bounds
    }

    /** Sets whether the notification stack is scrolled to the top. */
    fun setScrolledToTop(scrolledToTop: Boolean) {
        placeholderRepository.scrolledToTop.value = scrolledToTop
    /** Updates the current scroll state of the notification shade. */
    fun setScrollState(shadeScrollState: ShadeScrollState) {
        placeholderRepository.shadeScrollState.value = shadeScrollState
    }

    /** Sets the amount (px) that the notification stack should scroll due to internal expansion. */
@@ -133,6 +134,16 @@ constructor(
        viewHeightRepository.syntheticScroll.value = delta
    }

    /** Sends an [AccessibilityScrollEvent] to scroll the stack up or down. */
    fun sendAccessibilityScrollEvent(accessibilityScrollEvent: AccessibilityScrollEvent) {
        placeholderRepository.accessibilityScrollEventConsumer?.accept(accessibilityScrollEvent)
    }

    /** Set a consumer for the [AccessibilityScrollEvent]s to be handled by the placeholder. */
    fun setAccessibilityScrollEventConsumer(consumer: Consumer<AccessibilityScrollEvent>?) {
        placeholderRepository.accessibilityScrollEventConsumer = consumer
    }

    /** Sets whether the current touch gesture is overscroll. */
    fun setCurrentGestureOverscroll(isOverscroll: Boolean) {
        viewHeightRepository.isCurrentGestureOverscroll.value = isOverscroll
Loading