Add better a11y support in QS

* Add ACTION_PAGE_LEFT/RIGHT to PagedTileLayout and proper focus after
  changing pages.
* Add CollectionInfo to TileLayout and CollectionItemInfo to
  QSTileViewImpl to indicate the position of the currently focused tile.
* Properly use ACTION_COLLAPSE/EXPAND in QSPanel/QuickQSPanel so the
  proper announcement is made, as well as the proper action is provided
  in the contextual menu.

Test: manual
Test: atest com.android.systemui.qs
Bug: 204726862
Change-Id: I9bb085002c99e75ab0bb91f287ded62793b932bb
This commit is contained in:
Fabian Kozynski
2022-03-30 10:48:31 -04:00
parent 924b02bb03
commit b4b1692357
19 changed files with 285 additions and 70 deletions

View File

@@ -15,7 +15,6 @@
package com.android.systemui.plugins.qs;
import android.view.View;
import android.view.View.OnClickListener;
import com.android.systemui.plugins.FragmentBase;
import com.android.systemui.plugins.annotations.DependsOn;
@@ -34,7 +33,7 @@ public interface QS extends FragmentBase {
String ACTION = "com.android.systemui.action.PLUGIN_QS";
int VERSION = 13;
int VERSION = 14;
String TAG = "QS";
@@ -68,7 +67,12 @@ public interface QS extends FragmentBase {
void setHeaderListening(boolean listening);
void notifyCustomizeChanged();
void setContainerController(QSContainerController controller);
void setExpandClickListener(OnClickListener onClickListener);
/**
* Provide an action to collapse if expanded or expand if collapsed.
* @param action
*/
void setCollapseExpandAction(Runnable action);
/**
* Returns the height difference between the QSPanel container and the QuickQSPanel container

View File

@@ -26,7 +26,7 @@ import com.android.systemui.plugins.qs.QSTile.State;
@DependsOn(target = QSIconView.class)
@DependsOn(target = QSTile.class)
public abstract class QSTileView extends LinearLayout {
public static final int VERSION = 2;
public static final int VERSION = 3;
public QSTileView(Context context) {
super(context);
@@ -71,4 +71,7 @@ public abstract class QSTileView extends LinearLayout {
public View getSecondaryLabel() {
return null;
}
/** Sets the index of this tile in its layout */
public abstract void setPosition(int position);
}

View File

@@ -24,6 +24,8 @@
android:orientation="vertical"
android:layout_marginStart="@dimen/qs_label_container_margin"
android:layout_marginEnd="0dp"
android:focusable="false"
android:importantForAccessibility="no"
android:layout_gravity="center_vertical | start">
<com.android.systemui.util.SafeMarqueeTextView
@@ -35,6 +37,8 @@
android:ellipsize="marquee"
android:marqueeRepeatLimit="1"
android:singleLine="true"
android:focusable="false"
android:importantForAccessibility="no"
android:textAppearance="@style/TextAppearance.QS.TileLabel"/>
<com.android.systemui.util.SafeMarqueeTextView
@@ -47,6 +51,8 @@
android:marqueeRepeatLimit="1"
android:singleLine="true"
android:visibility="gone"
android:focusable="false"
android:importantForAccessibility="no"
android:textAppearance="@style/TextAppearance.QS.TileLabel.Secondary"
android:textColor="?android:attr/textColorSecondary"/>

View File

@@ -15,6 +15,7 @@ import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.Interpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.Scroller;
@@ -552,6 +553,51 @@ public class PagedTileLayout extends ViewPager implements QSTileLayout {
postInvalidateOnAnimation();
}
private int sanitizePageAction(int action) {
int pageLeftId = AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT.getId();
int pageRightId = AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT.getId();
if (action == pageLeftId || action == pageRightId) {
if (!isLayoutRtl()) {
if (action == pageLeftId) {
return AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD;
} else {
return AccessibilityNodeInfo.ACTION_SCROLL_FORWARD;
}
} else {
if (action == pageLeftId) {
return AccessibilityNodeInfo.ACTION_SCROLL_FORWARD;
} else {
return AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD;
}
}
}
return action;
}
@Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
action = sanitizePageAction(action);
boolean performed = super.performAccessibilityAction(action, arguments);
if (performed && (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
|| action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)) {
requestAccessibilityFocus();
}
return performed;
}
@Override
public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfoInternal(info);
// getCurrentItem does not respect RTL, so it works well together with page actions that
// use left/right positioning.
if (getCurrentItem() != 0) {
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT);
}
if (getCurrentItem() != mPages.size() - 1) {
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT);
}
}
private static Animator setupBounceAnimator(View view, int ordinal) {
view.setAlpha(0f);
view.setScaleX(0f);

View File

@@ -48,10 +48,5 @@ public interface QSFooter {
*/
void setKeyguardShowing(boolean keyguardShowing);
/**
* Sets the {@link android.view.View.OnClickListener to be used on elements that expend QS.
*/
void setExpandClickListener(View.OnClickListener onClickListener);
default void disable(int state1, int state2, boolean animate) {}
}

View File

@@ -23,13 +23,11 @@ import android.content.res.Configuration;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.UserHandle;
import android.provider.Settings;
import android.util.AttributeSet;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.FrameLayout;
import android.widget.TextView;
@@ -168,23 +166,6 @@ public class QSFooterView extends FrameLayout {
super.onDetachedFromWindow();
}
@Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
if (action == AccessibilityNodeInfo.ACTION_EXPAND) {
if (mExpandClickListener != null) {
mExpandClickListener.onClick(null);
return true;
}
}
return super.performAccessibilityAction(action, arguments);
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
}
void disable(int state2) {
final boolean disabled = (state2 & DISABLE2_QUICK_SETTINGS) != 0;
if (disabled == mQsDisabled) return;

View File

@@ -114,12 +114,6 @@ public class QSFooterViewController extends ViewController<QSFooterView> impleme
mView.setKeyguardShowing();
}
/** */
@Override
public void setExpandClickListener(View.OnClickListener onClickListener) {
mView.setExpandClickListener(onClickListener);
}
@Override
public void disable(int state1, int state2, boolean animate) {
mView.disable(state2);

View File

@@ -31,7 +31,6 @@ import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
@@ -717,8 +716,9 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca
}
@Override
public void setExpandClickListener(OnClickListener onClickListener) {
mFooter.setExpandClickListener(onClickListener);
public void setCollapseExpandAction(Runnable action) {
mQSPanelController.setCollapseExpandAction(action);
mQuickQSPanelController.setCollapseExpandAction(action);
}
@Override

View File

@@ -34,6 +34,7 @@ import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.LinearLayout;
import androidx.annotation.VisibleForTesting;
@@ -62,6 +63,8 @@ public class QSPanel extends LinearLayout implements Tunable {
private final int mMediaTopMargin;
private final int mMediaTotalBottomMargin;
private Runnable mCollapseExpandAction;
/**
* The index where the content starts that needs to be moved between parents
*/
@@ -678,6 +681,28 @@ public class QSPanel extends LinearLayout implements Tunable {
mShouldMoveMediaOnExpansion = shouldMoveMediaOnExpansion;
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
}
@Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
if (action == AccessibilityNodeInfo.ACTION_EXPAND
|| action == AccessibilityNodeInfo.ACTION_COLLAPSE) {
if (mCollapseExpandAction != null) {
mCollapseExpandAction.run();
return true;
}
}
return super.performAccessibilityAction(action, arguments);
}
public void setCollapseExpandAction(Runnable action) {
mCollapseExpandAction = action;
}
private class H extends Handler {
private static final int ANNOUNCE_FOR_ACCESSIBILITY = 1;

View File

@@ -424,6 +424,14 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr
return mView.getBrightnessView();
}
/**
* Set a listener to collapse/expand QS.
* @param action
*/
public void setCollapseExpandAction(Runnable action) {
mView.setCollapseExpandAction(action);
}
/** Sets whether we are currently on lock screen. */
public void setIsOnKeyguard(boolean isOnKeyguard) {
boolean isOnSplitShadeLockscreen = mShouldUseSplitNotificationShade && isOnKeyguard;

View File

@@ -20,6 +20,7 @@ import android.content.Context;
import android.content.res.Configuration;
import android.util.AttributeSet;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.LinearLayout;
import com.android.internal.logging.UiEventLogger;
@@ -167,6 +168,14 @@ public class QuickQSPanel extends QSPanel {
return QSEvent.QQS_TILE_VISIBLE;
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
// Remove the collapse action from QSPanel
info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
}
static class QQSSideLabelTileLayout extends SideLabelTileLayout {
private boolean mLastSelected;

View File

@@ -8,6 +8,7 @@ import android.provider.Settings;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import androidx.annotation.Nullable;
@@ -240,6 +241,7 @@ public class TileLayout extends ViewGroup implements QSTileLayout {
} else {
record.tileView.setLeftTopRightBottom(left, top, right, bottom);
}
record.tileView.setPosition(i);
mLastTileBottom = bottom;
}
}
@@ -296,4 +298,11 @@ public class TileLayout extends ViewGroup implements QSTileLayout {
}
}
}
@Override
public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfoInternal(info);
info.setCollectionInfo(
new AccessibilityNodeInfo.CollectionInfo(mRecords.size(), 1, false));
}
}

View File

@@ -69,6 +69,12 @@ open class QSTileViewImpl @JvmOverloads constructor(
internal const val TILE_STATE_RES_PREFIX = "tile_states_"
}
private var _position: Int = INVALID
override fun setPosition(position: Int) {
_position = position
}
override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
set(value) {
if (field == value) return
@@ -404,6 +410,10 @@ open class QSTileViewImpl @JvmOverloads constructor(
}
}
}
if (_position != INVALID) {
info.collectionItemInfo =
AccessibilityNodeInfo.CollectionItemInfo(_position, 1, 0, 1, false)
}
}
override fun toString(): String {

View File

@@ -239,7 +239,7 @@ public class NotificationPanelViewController extends PanelViewController {
private final DozeParameters mDozeParameters;
private final OnHeightChangedListener mOnHeightChangedListener = new OnHeightChangedListener();
private final OnClickListener mOnClickListener = new OnClickListener();
private final Runnable mCollapseExpandAction = new CollapseExpandAction();
private final OnOverscrollTopChangedListener
mOnOverscrollTopChangedListener =
new OnOverscrollTopChangedListener();
@@ -3570,7 +3570,7 @@ public class NotificationPanelViewController extends PanelViewController {
public void onFragmentViewCreated(String tag, Fragment fragment) {
mQs = (QS) fragment;
mQs.setPanelView(mHeightListener);
mQs.setExpandClickListener(mOnClickListener);
mQs.setCollapseExpandAction(mCollapseExpandAction);
mQs.setHeaderClickable(isQsExpansionEnabled());
mQs.setOverscrolling(mStackScrollerOverscrolling);
mQs.setInSplitShade(mShouldUseSplitNotificationShade);
@@ -4213,9 +4213,9 @@ public class NotificationPanelViewController extends PanelViewController {
}
}
private class OnClickListener implements View.OnClickListener {
private class CollapseExpandAction implements Runnable {
@Override
public void onClick(View v) {
public void run() {
onQsExpansionStarted();
if (mQsExpanded) {
flingSettings(0 /* vel */, FLING_COLLAPSE, null /* onFinishRunnable */,

View File

@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.app.Fragment;
@@ -304,6 +305,16 @@ public class QSFragmentTest extends SysuiBaseFragmentTest {
assertThat(mQsFragmentView.getY()).isEqualTo(-qsAbsoluteBottom);
}
@Test
public void setCollapseExpandAction_passedToControllers() {
Runnable action = () -> {};
QSFragment fragment = resumeAndGetFragment();
fragment.setCollapseExpandAction(action);
verify(mQSPanelController).setCollapseExpandAction(action);
verify(mQuickQSPanelController).setCollapseExpandAction(action);
}
@Override
protected Fragment instantiate(Context context, String className, Bundle arguments) {
MockitoAnnotations.initMocks(this);

View File

@@ -21,24 +21,19 @@ import android.testing.TestableLooper
import android.testing.TestableLooper.RunWithLooper
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.plugins.qs.QSTileView
import com.android.systemui.qs.QSPanelControllerBase.TileRecord
import com.android.systemui.qs.logging.QSLogger
import com.android.systemui.qs.tileimpl.QSTileImpl
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
import org.mockito.Mockito.`when` as whenever
@RunWith(AndroidTestingRunner::class)
@RunWithLooper
@@ -47,22 +42,8 @@ class QSPanelTest : SysuiTestCase() {
private lateinit var mTestableLooper: TestableLooper
private lateinit var mQsPanel: QSPanel
@Mock
private lateinit var mHost: QSTileHost
@Mock
private lateinit var dndTile: QSTileImpl<*>
@Mock
private lateinit var mDndTileRecord: TileRecord
@Mock
private lateinit var mQSLogger: QSLogger
private lateinit var mParentView: ViewGroup
@Mock
private lateinit var mQSTileView: QSTileView
private lateinit var mFooter: View
@Before
@@ -71,8 +52,6 @@ class QSPanelTest : SysuiTestCase() {
MockitoAnnotations.initMocks(this)
mTestableLooper = TestableLooper.get(this)
mDndTileRecord.tile = dndTile
mDndTileRecord.tileView = mQSTileView
mTestableLooper.runWithLooper {
mQsPanel = QSPanel(mContext, null)
mQsPanel.initialize()
@@ -86,11 +65,6 @@ class QSPanelTest : SysuiTestCase() {
mParentView = FrameLayout(mContext).apply {
addView(mQsPanel)
}
whenever(dndTile.tileSpec).thenReturn("dnd")
whenever(mHost.tiles).thenReturn(emptyList())
whenever(mHost.createTileView(any(), any(), anyBoolean())).thenReturn(mQSTileView)
mQsPanel.addTile(mDndTileRecord)
}
}
@@ -137,6 +111,24 @@ class QSPanelTest : SysuiTestCase() {
assertThat(mQsPanel.indexOfChild(mQsPanel.mSecurityFooter)).isEqualTo(-1)
}
@Test
fun testHasCollapseAccessibilityAction() {
val info = AccessibilityNodeInfo(mQsPanel)
mQsPanel.onInitializeAccessibilityNodeInfo(info)
assertThat(info.actions and AccessibilityNodeInfo.ACTION_COLLAPSE).isNotEqualTo(0)
assertThat(info.actions and AccessibilityNodeInfo.ACTION_EXPAND).isEqualTo(0)
}
@Test
fun testCollapseActionCallsRunnable() {
val mockRunnable = mock(Runnable::class.java)
mQsPanel.setCollapseExpandAction(mockRunnable)
mQsPanel.performAccessibilityAction(AccessibilityNodeInfo.ACTION_COLLAPSE, null)
verify(mockRunnable).run()
}
private fun getNewOrientationConfig(@Configuration.Orientation newOrientation: Int) =
context.resources.configuration.apply { orientation = newOrientation }
}

View File

@@ -0,0 +1,66 @@
package com.android.systemui.qs
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
@SmallTest
class QuickQSPanelTest : SysuiTestCase() {
private lateinit var testableLooper: TestableLooper
private lateinit var quickQSPanel: QuickQSPanel
private lateinit var parentView: ViewGroup
@Before
@Throws(Exception::class)
fun setup() {
MockitoAnnotations.initMocks(this)
testableLooper = TestableLooper.get(this)
testableLooper.runWithLooper {
quickQSPanel = QuickQSPanel(mContext, null)
quickQSPanel.initialize()
quickQSPanel.onFinishInflate()
quickQSPanel.setSecurityFooter(View(mContext), false)
quickQSPanel.setHeaderContainer(LinearLayout(mContext))
// Provides a parent with non-zero size for QSPanel
parentView = FrameLayout(mContext).apply {
addView(quickQSPanel)
}
}
}
@Test
fun testHasExpandAccessibilityAction() {
val info = AccessibilityNodeInfo(quickQSPanel)
quickQSPanel.onInitializeAccessibilityNodeInfo(info)
Truth.assertThat(info.actions and AccessibilityNodeInfo.ACTION_EXPAND).isNotEqualTo(0)
Truth.assertThat(info.actions and AccessibilityNodeInfo.ACTION_COLLAPSE).isEqualTo(0)
}
@Test
fun testExpandActionCallsRunnable() {
val mockRunnable = Mockito.mock(Runnable::class.java)
quickQSPanel.setCollapseExpandAction(mockRunnable)
quickQSPanel.performAccessibilityAction(AccessibilityNodeInfo.ACTION_EXPAND, null)
Mockito.verify(mockRunnable).run()
}
}

View File

@@ -28,6 +28,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import android.test.suitebuilder.annotation.SmallTest;
import android.view.accessibility.AccessibilityNodeInfo;
import androidx.test.runner.AndroidJUnit4;
@@ -170,4 +171,36 @@ public class TileLayoutTest extends SysuiTestCase {
mTileLayout.measure(mLayoutSizeForOneTile, mLayoutSizeForOneTile);
assertEquals(0, mTileLayout.getMeasuredHeight());
}
@Test
public void testCollectionInfo() {
QSPanelControllerBase.TileRecord tileRecord1 = createTileRecord();
QSPanelControllerBase.TileRecord tileRecord2 = createTileRecord();
AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain(mTileLayout);
mTileLayout.addTile(tileRecord1);
mTileLayout.onInitializeAccessibilityNodeInfo(info);
AccessibilityNodeInfo.CollectionInfo collectionInfo = info.getCollectionInfo();
assertEquals(1, collectionInfo.getRowCount());
assertEquals(1, collectionInfo.getColumnCount()); // always use one column
mTileLayout.addTile(tileRecord2);
mTileLayout.onInitializeAccessibilityNodeInfo(info);
collectionInfo = info.getCollectionInfo();
assertEquals(2, collectionInfo.getRowCount());
assertEquals(1, collectionInfo.getColumnCount()); // always use one column
}
@Test
public void testSetPositionOnTiles() {
QSPanelControllerBase.TileRecord tileRecord1 = createTileRecord();
QSPanelControllerBase.TileRecord tileRecord2 = createTileRecord();
mTileLayout.addTile(tileRecord1);
mTileLayout.addTile(tileRecord2);
mTileLayout.measure(mLayoutSizeForOneTile * 2, mLayoutSizeForOneTile * 2);
mTileLayout.layout(0, 0, mLayoutSizeForOneTile * 2, mLayoutSizeForOneTile * 2);
verify(tileRecord1.tileView).setPosition(0);
verify(tileRecord2.tileView).setPosition(1);
}
}

View File

@@ -23,6 +23,7 @@ import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.text.TextUtils
import android.view.View
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.TextView
import androidx.test.filters.SmallTest
import com.android.systemui.R
@@ -257,6 +258,28 @@ class QSTileViewImplTest : SysuiTestCase() {
assertThat((tileView.secondaryLabel as TextView).text).isEqualTo(onString)
}
@Test
fun testCollectionItemInfoHasPosition() {
val position = 5
tileView.setPosition(position)
val info = AccessibilityNodeInfo(tileView)
tileView.onInitializeAccessibilityNodeInfo(info)
assertThat(info.collectionItemInfo.rowIndex).isEqualTo(position)
assertThat(info.collectionItemInfo.rowSpan).isEqualTo(1)
assertThat(info.collectionItemInfo.columnIndex).isEqualTo(0)
assertThat(info.collectionItemInfo.columnSpan).isEqualTo(1)
}
@Test
fun testCollectionItemInfoNoPosition() {
val info = AccessibilityNodeInfo(tileView)
tileView.onInitializeAccessibilityNodeInfo(info)
assertThat(info.collectionItemInfo).isNull()
}
class FakeTileView(
context: Context,
icon: QSIconView,