Add new floating action menu for Accessibility targets. (1/n)

Goals:
- Trigger actions of accessibility features would be grouped into the floating menu.

Patch action:
1. Use current Accessibility Target functions to get the corresponding data.
2. The position of the floating menu is at the bottom-right corner of the screen initially.
3. Support dark theme.

Bug: 173958541
Test: atest AccessibilityFloatingMenuTest AccessibilityFloatingMenuViewTest
Change-Id: I28e729520d4eb8ecb0bcc41d4de9d1499380e7f5
This commit is contained in:
Peter_Liang
2020-12-10 01:00:37 +08:00
parent 2b416cd6c2
commit 2be49c9be9
11 changed files with 711 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 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.
-->
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/menu_background_item">
<shape android:shape="rectangle">
<corners
android:bottomLeftRadius="@dimen/accessibility_floating_menu_single_radius"
android:bottomRightRadius="0dp"
android:topLeftRadius="@dimen/accessibility_floating_menu_single_radius"
android:topRightRadius="0dp"/>
<solid
android:color="@color/accessibility_floating_menu_background"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2021 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.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/accessibility_floating_menu_padding"
android:orientation="vertical"
android:gravity="center">
<View
android:id="@+id/icon_view"
android:layout_width="@dimen/accessibility_floating_menu_width_height"
android:layout_height="@dimen/accessibility_floating_menu_width_height"/>
<View
android:id="@+id/transparent_divider"
android:layout_width="match_parent"
android:layout_height="@dimen/accessibility_floating_menu_padding"/>
</LinearLayout>

View File

@@ -102,4 +102,6 @@
<color name="privacy_circle_camera">#81C995</color> <!-- g300 -->
<color name="privacy_circle_microphone_location">#FCAD70</color> <!--o300 -->
<!-- Accessibility floating menu -->
<color name="accessibility_floating_menu_background">#B3000000</color> <!-- 70% -->
</resources>

View File

@@ -275,4 +275,8 @@
<!-- TODO(b/178093014) Colors for privacy dialog. These should be changed to the new palette -->
<color name="privacy_circle_camera">#1E8E3E</color> <!-- g600 -->
<color name="privacy_circle_microphone_location">#E8710A</color> <!--o600 -->
<!-- Accessibility floating menu -->
<color name="accessibility_floating_menu_background">#CCFFFFFF</color> <!-- 80% -->
<color name="accessibility_floating_menu_stroke_dark">#26FFFFFF</color> <!-- 15% -->
</resources>

View File

@@ -1352,6 +1352,16 @@
<dimen name="people_space_image_radius">20dp</dimen>
<dimen name="people_space_widget_background_padding">6dp</dimen>
<!-- Accessibility floating menu -->
<dimen name="accessibility_floating_menu_elevation">5dp</dimen>
<dimen name="accessibility_floating_menu_stroke_width">1dp</dimen>
<dimen name="accessibility_floating_menu_stroke_inset">-2dp</dimen>
<dimen name="accessibility_floating_menu_margin">16dp</dimen>
<dimen name="accessibility_floating_menu_padding">6dp</dimen>
<dimen name="accessibility_floating_menu_width_height">36dp</dimen>
<dimen name="accessibility_floating_menu_single_radius">25dp</dimen>
<dimen name="accessibility_floating_menu_multiple_radius">20dp</dimen>
<dimen name="rounded_slider_height">48dp</dimen>
<!-- rounded_slider_height / 2 -->
<dimen name="rounded_slider_corner_radius">24dp</dimen>

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2021 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.accessibility.floatingmenu;
import static android.view.accessibility.AccessibilityManager.ACCESSIBILITY_BUTTON;
import static com.android.internal.accessibility.dialog.AccessibilityTargetHelper.getTargets;
import android.content.Context;
import android.database.ContentObserver;
import android.os.Handler;
import android.os.Looper;
import android.os.UserHandle;
import android.provider.Settings;
import com.android.internal.annotations.VisibleForTesting;
/**
* Contains logic for an accessibility floating menu view.
*/
public class AccessibilityFloatingMenu implements IAccessibilityFloatingMenu {
private final Context mContext;
private final AccessibilityFloatingMenuView mMenuView;
private final ContentObserver mContentObserver =
new ContentObserver(new Handler(Looper.getMainLooper())) {
@Override
public void onChange(boolean selfChange) {
mMenuView.onTargetsChanged(getTargets(mContext, ACCESSIBILITY_BUTTON));
}
};
public AccessibilityFloatingMenu(Context context) {
mContext = context;
mMenuView = new AccessibilityFloatingMenuView(context);
}
@VisibleForTesting
AccessibilityFloatingMenu(Context context, AccessibilityFloatingMenuView menuView) {
mContext = context;
mMenuView = menuView;
}
@Override
public boolean isShowing() {
return mMenuView.isShowing();
}
@Override
public void show() {
if (isShowing()) {
return;
}
mMenuView.onTargetsChanged(getTargets(mContext, ACCESSIBILITY_BUTTON));
mMenuView.show();
mContext.getContentResolver().registerContentObserver(
Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_BUTTON_TARGETS),
/* notifyForDescendants */ false, mContentObserver,
UserHandle.USER_CURRENT);
}
@Override
public void hide() {
if (!isShowing()) {
return;
}
mMenuView.hide();
mContext.getContentResolver().unregisterContentObserver(mContentObserver);
}
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright (C) 2021 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.accessibility.floatingmenu;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.PixelFormat;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.LayerDrawable;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.FrameLayout;
import androidx.annotation.DimenRes;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.internal.accessibility.dialog.AccessibilityTarget;
import com.android.systemui.R;
import java.util.ArrayList;
import java.util.List;
/**
* Accessibility floating menu is used for the actions of accessibility features, it's also the
* action set.
*
* <p>The number of items would depend on strings key
* {@link android.provider.Settings.Secure#ACCESSIBILITY_BUTTON_TARGETS}.
*/
public class AccessibilityFloatingMenuView extends FrameLayout {
private static final float DEFAULT_LOCATION_Y_PERCENTAGE = 0.8f;
private static final int INDEX_MENU_ITEM = 0;
private boolean mIsShowing;
private RecyclerView mListView;
private final WindowManager.LayoutParams mLayoutParams;
private final WindowManager mWindowManager;
private final AccessibilityTargetAdapter mAdapter;
private final List<AccessibilityTarget> mTargets = new ArrayList<>();
public AccessibilityFloatingMenuView(Context context) {
super(context);
mWindowManager = context.getSystemService(WindowManager.class);
mLayoutParams = createDefaultLayoutParams();
mAdapter = new AccessibilityTargetAdapter(mTargets);
initListView(context, mAdapter);
updateStroke();
}
void show() {
if (isShowing()) {
return;
}
mIsShowing = true;
mWindowManager.addView(this, mLayoutParams);
}
void hide() {
if (!isShowing()) {
return;
}
mIsShowing = false;
mWindowManager.removeView(this);
}
boolean isShowing() {
return mIsShowing;
}
void onTargetsChanged(List<AccessibilityTarget> newTargets) {
mTargets.clear();
mTargets.addAll(newTargets);
mAdapter.notifyDataSetChanged();
final boolean hasMoreItems = mAdapter.getItemCount() > 1;
final int resId = hasMoreItems
? R.dimen.accessibility_floating_menu_multiple_radius
: R.dimen.accessibility_floating_menu_single_radius;
setRadius(resId);
}
private void setRadius(@DimenRes int radiusResId) {
final float radius = getResources().getDimension(radiusResId);
final float[] radii = new float[]{radius, radius, 0.0f, 0.0f, 0.0f, 0.0f, radius, radius};
getMenuGradientDrawable().setCornerRadii(radii);
}
private void initListView(Context context, AccessibilityTargetAdapter adapter) {
final Resources res = context.getResources();
final int margin =
res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_margin);
final int elevation =
res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_elevation);
final Drawable listViewBackground =
context.getDrawable(R.drawable.accessibility_floating_menu_background);
mListView = new RecyclerView(context);
final LinearLayoutManager layoutManager = new LinearLayoutManager(context);
final LayoutParams layoutParams =
new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
layoutParams.setMarginsRelative(margin, margin, /* end= */ 0, margin);
mListView.setLayoutParams(layoutParams);
mListView.setElevation(elevation);
mListView.setBackground(listViewBackground);
mListView.setAdapter(adapter);
mListView.setLayoutManager(layoutManager);
addView(mListView);
}
private WindowManager.LayoutParams createDefaultLayoutParams() {
final WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);
final DisplayMetrics dm = getResources().getDisplayMetrics();
params.gravity = Gravity.START | Gravity.TOP;
params.x = dm.widthPixels;
params.y = (int) (dm.heightPixels * DEFAULT_LOCATION_Y_PERCENTAGE);
return params;
}
@Override
protected void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
updateLocation();
updateColor();
updateStroke();
}
private LayerDrawable getMenuLayerDrawable() {
return (LayerDrawable) mListView.getBackground();
}
private GradientDrawable getMenuGradientDrawable() {
return (GradientDrawable) getMenuLayerDrawable().getDrawable(INDEX_MENU_ITEM);
}
private void updateLocation() {
final DisplayMetrics dm = getResources().getDisplayMetrics();
mLayoutParams.x = dm.widthPixels;
mLayoutParams.y = (int) (dm.heightPixels * DEFAULT_LOCATION_Y_PERCENTAGE);
mWindowManager.updateViewLayout(this, mLayoutParams);
}
private void updateColor() {
final int menuColorResId = R.color.accessibility_floating_menu_background;
getMenuGradientDrawable().setColor(getResources().getColor(menuColorResId));
}
private void updateStroke() {
final Resources res = getResources();
final boolean isNightMode =
(res.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
== Configuration.UI_MODE_NIGHT_YES;
final int inset =
res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_inset);
final int insetRight = isNightMode ? inset : 0;
getMenuLayerDrawable().setLayerInset(INDEX_MENU_ITEM, 0, 0, insetRight, 0);
final int width =
res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_width);
final int strokeWidth = isNightMode ? width : 0;
final int strokeColor = res.getColor(R.color.accessibility_floating_menu_stroke_dark);
getMenuGradientDrawable().setStroke(strokeWidth, strokeColor);
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2020 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.accessibility.floatingmenu;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.Adapter;
import com.android.internal.accessibility.dialog.AccessibilityTarget;
import com.android.systemui.R;
import com.android.systemui.accessibility.floatingmenu.AccessibilityTargetAdapter.ViewHolder;
import java.util.List;
/**
* An adapter which shows the set of accessibility targets that can be performed.
*/
public class AccessibilityTargetAdapter extends Adapter<ViewHolder> {
private final List<AccessibilityTarget> mTargets;
public AccessibilityTargetAdapter(List<AccessibilityTarget> targets) {
mTargets = targets;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int i) {
final View root = LayoutInflater.from(parent.getContext()).inflate(
R.layout.accessibility_floating_menu_item, parent,
/* attachToRoot= */ false);
return new ViewHolder(root);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.mIconView.setBackground(mTargets.get(position).getIcon());
final boolean isFirstItem = (position == 0);
final boolean isLastItem = (position == (getItemCount() - 1));
final int padding = holder.itemView.getPaddingStart();
final int paddingTop = isFirstItem ? padding : 0;
final int paddingBottom = isLastItem ? padding : 0;
holder.itemView.setPaddingRelative(padding, paddingTop, padding, paddingBottom);
holder.itemView.setOnClickListener((v) -> mTargets.get(position).onSelected());
holder.mDivider.setVisibility(isLastItem ? View.GONE : View.VISIBLE);
}
@Override
public int getItemCount() {
return mTargets.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
final View mIconView;
final View mDivider;
ViewHolder(View itemView) {
super(itemView);
mIconView = itemView.findViewById(R.id.icon_view);
mDivider = itemView.findViewById(R.id.transparent_divider);
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2021 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.accessibility.floatingmenu;
/**
* Interface for managing the accessibility targets menu component.
*/
public interface IAccessibilityFloatingMenu {
/**
* Checks if the menu was shown.
*/
boolean isShowing();
/**
* Shows the accessibility targets menu.
*/
void show();
/**
* Hides the accessibility targets menu.
*/
void hide();
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2021 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.accessibility.floatingmenu;
import static com.android.internal.accessibility.AccessibilityShortcutController.MAGNIFICATION_CONTROLLER_NAME;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doReturn;
import android.content.Context;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.view.accessibility.AccessibilityManager;
import androidx.test.filters.SmallTest;
import com.android.systemui.SysuiTestCase;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.List;
/** Tests for {@link AccessibilityFloatingMenu}. */
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
public class AccessibilityFloatingMenuTest extends SysuiTestCase {
@Mock
private AccessibilityManager mAccessibilityManager;
private AccessibilityFloatingMenuView mMenuView;
private AccessibilityFloatingMenu mMenu;
@Before
public void initMenu() {
MockitoAnnotations.initMocks(this);
final List<String> assignedTargets = new ArrayList<>();
mContext.addMockSystemService(Context.ACCESSIBILITY_SERVICE, mAccessibilityManager);
assignedTargets.add(MAGNIFICATION_CONTROLLER_NAME);
doReturn(assignedTargets).when(mAccessibilityManager).getAccessibilityShortcutTargets(
anyInt());
mMenuView = new AccessibilityFloatingMenuView(mContext);
mMenu = new AccessibilityFloatingMenu(mContext, mMenuView);
}
@Test
public void showMenuView_success() {
mMenu.show();
assertThat(mMenuView.isShowing()).isTrue();
}
@Test
public void hideMenuView_success() {
mMenu.show();
mMenu.hide();
assertThat(mMenuView.isShowing()).isFalse();
}
@After
public void tearDown() {
mMenu.hide();
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2021 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.accessibility.floatingmenu;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import android.content.Context;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.LayerDrawable;
import android.testing.AndroidTestingRunner;
import android.view.View;
import android.view.WindowManager;
import androidx.test.filters.SmallTest;
import com.android.internal.accessibility.dialog.AccessibilityTarget;
import com.android.systemui.R;
import com.android.systemui.SysuiTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.List;
/** Tests for {@link AccessibilityFloatingMenuView}. */
@SmallTest
@RunWith(AndroidTestingRunner.class)
public class AccessibilityFloatingMenuViewTest extends SysuiTestCase {
private AccessibilityFloatingMenuView mMenuView;
@Mock
private WindowManager mWindowManager;
private final List<AccessibilityTarget> mTargets = new ArrayList<>();
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
final WindowManager wm = mContext.getSystemService(WindowManager.class);
doAnswer(invocation -> wm.getMaximumWindowMetrics()).when(
mWindowManager).getMaximumWindowMetrics();
mContext.addMockSystemService(Context.WINDOW_SERVICE, mWindowManager);
mTargets.add(mock(AccessibilityTarget.class));
mMenuView = new AccessibilityFloatingMenuView(mContext);
}
@Test
public void initListView_success() {
assertThat(mMenuView.getChildCount()).isEqualTo(1);
}
@Test
public void showMenuView_success() {
mMenuView.show();
assertThat(mMenuView.isShowing()).isTrue();
verify(mWindowManager).addView(eq(mMenuView), any(WindowManager.LayoutParams.class));
}
@Test
public void showMenuView_showTwice_addViewOnce() {
mMenuView.show();
mMenuView.show();
assertThat(mMenuView.isShowing()).isTrue();
verify(mWindowManager, times(1)).addView(eq(mMenuView),
any(WindowManager.LayoutParams.class));
}
@Test
public void hideMenuView_success() {
mMenuView.show();
mMenuView.hide();
assertThat(mMenuView.isShowing()).isFalse();
verify(mWindowManager).removeView(eq(mMenuView));
}
@Test
public void hideMenuView_hideTwice_removeViewOnce() {
mMenuView.show();
mMenuView.hide();
mMenuView.hide();
assertThat(mMenuView.isShowing()).isFalse();
verify(mWindowManager, times(1)).removeView(eq(mMenuView));
}
@Test
public void updateListViewRadius_singleTarget_matchResult() {
final float radius =
getContext().getResources().getDimension(
R.dimen.accessibility_floating_menu_single_radius);
final float[] expectedRadii =
new float[] {radius, radius, 0.0f, 0.0f, 0.0f, 0.0f, radius, radius};
mMenuView.onTargetsChanged(mTargets);
final View view = mMenuView.getChildAt(0);
final LayerDrawable layerDrawable = (LayerDrawable) view.getBackground();
final GradientDrawable gradientDrawable =
(GradientDrawable) layerDrawable.getDrawable(0);
final float[] actualRadii = gradientDrawable.getCornerRadii();
assertThat(actualRadii).isEqualTo(expectedRadii);
}
}