diff --git a/packages/SystemUI/res/drawable/accessibility_floating_menu_background.xml b/packages/SystemUI/res/drawable/accessibility_floating_menu_background.xml
new file mode 100644
index 0000000000000..5148668328da3
--- /dev/null
+++ b/packages/SystemUI/res/drawable/accessibility_floating_menu_background.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/packages/SystemUI/res/layout/accessibility_floating_menu_item.xml b/packages/SystemUI/res/layout/accessibility_floating_menu_item.xml
new file mode 100644
index 0000000000000..f7357b27af5f0
--- /dev/null
+++ b/packages/SystemUI/res/layout/accessibility_floating_menu_item.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/SystemUI/res/values-night/colors.xml b/packages/SystemUI/res/values-night/colors.xml
index 37ec576be4be3..d571f2f174978 100644
--- a/packages/SystemUI/res/values-night/colors.xml
+++ b/packages/SystemUI/res/values-night/colors.xml
@@ -102,4 +102,6 @@
#81C995
#FCAD70
+
+ #B3000000
diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml
index 55365bd3c6924..0076c51326738 100644
--- a/packages/SystemUI/res/values/colors.xml
+++ b/packages/SystemUI/res/values/colors.xml
@@ -274,4 +274,8 @@
#1E8E3E
#E8710A
+
+
+ #CCFFFFFF
+ #26FFFFFF
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index 6e270a75a6b90..b050945068f22 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1356,6 +1356,19 @@
20dp
6dp
+
+ 5dp
+ 1dp
+ -2dp
+ 16dp
+ 6dp
+ 36dp
+ 25dp
+ 20dp
+ 56dp
+ 33dp
+ 35dp
+
44dp
22dp
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenu.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenu.java
new file mode 100644
index 0000000000000..7b4ce61d2cfe1
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenu.java
@@ -0,0 +1,163 @@
+/*
+ * 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.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED;
+import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_ICON_TYPE;
+import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_OPACITY;
+import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_SIZE;
+import static android.view.accessibility.AccessibilityManager.ACCESSIBILITY_BUTTON;
+
+import static com.android.internal.accessibility.dialog.AccessibilityTargetHelper.getTargets;
+import static com.android.systemui.accessibility.floatingmenu.AccessibilityFloatingMenuView.ShapeType;
+import static com.android.systemui.accessibility.floatingmenu.AccessibilityFloatingMenuView.SizeType;
+
+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 static final int DEFAULT_FADE_EFFECT_ENABLED = 1;
+ private static final float DEFAULT_OPACITY_VALUE = 0.55f;
+ private final Context mContext;
+ private final AccessibilityFloatingMenuView mMenuView;
+ private final Handler mHandler = new Handler(Looper.getMainLooper());
+
+ private final ContentObserver mContentObserver =
+ new ContentObserver(mHandler) {
+ @Override
+ public void onChange(boolean selfChange) {
+ mMenuView.onTargetsChanged(getTargets(mContext, ACCESSIBILITY_BUTTON));
+ }
+ };
+
+ private final ContentObserver mSizeContentObserver =
+ new ContentObserver(mHandler) {
+ @Override
+ public void onChange(boolean selfChange) {
+ mMenuView.setSizeType(getSizeType(mContext));
+ }
+ };
+
+ private final ContentObserver mFadeOutContentObserver =
+ new ContentObserver(mHandler) {
+ @Override
+ public void onChange(boolean selfChange) {
+ mMenuView.updateOpacityWith(isFadeEffectEnabled(mContext),
+ getOpacityValue(mContext));
+ }
+ };
+
+ 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.show();
+ mMenuView.onTargetsChanged(getTargets(mContext, ACCESSIBILITY_BUTTON));
+ mMenuView.updateOpacityWith(isFadeEffectEnabled(mContext),
+ getOpacityValue(mContext));
+ mMenuView.setSizeType(getSizeType(mContext));
+ mMenuView.setShapeType(getShapeType(mContext));
+
+ registerContentObservers();
+ }
+
+ @Override
+ public void hide() {
+ if (!isShowing()) {
+ return;
+ }
+
+ mMenuView.hide();
+
+ unregisterContentObservers();
+ }
+
+ private static boolean isFadeEffectEnabled(Context context) {
+ return Settings.Secure.getInt(
+ context.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED,
+ DEFAULT_FADE_EFFECT_ENABLED) == /* enable */ 1;
+ }
+
+ private static float getOpacityValue(Context context) {
+ return Settings.Secure.getFloat(
+ context.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_OPACITY,
+ DEFAULT_OPACITY_VALUE);
+ }
+
+ private static int getSizeType(Context context) {
+ return Settings.Secure.getInt(
+ context.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_SIZE, SizeType.SMALL);
+ }
+
+ private static int getShapeType(Context context) {
+ return Settings.Secure.getInt(
+ context.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_ICON_TYPE,
+ ShapeType.OVAL);
+ }
+
+ private void registerContentObservers() {
+ mContext.getContentResolver().registerContentObserver(
+ Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_BUTTON_TARGETS),
+ /* notifyForDescendants */ false, mContentObserver,
+ UserHandle.USER_CURRENT);
+ mContext.getContentResolver().registerContentObserver(
+ Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_FLOATING_MENU_SIZE),
+ /* notifyForDescendants */ false, mSizeContentObserver,
+ UserHandle.USER_CURRENT);
+ mContext.getContentResolver().registerContentObserver(
+ Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED),
+ /* notifyForDescendants */ false, mFadeOutContentObserver,
+ UserHandle.USER_CURRENT);
+ mContext.getContentResolver().registerContentObserver(
+ Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_FLOATING_MENU_OPACITY),
+ /* notifyForDescendants */ false, mFadeOutContentObserver,
+ UserHandle.USER_CURRENT);
+ }
+
+ private void unregisterContentObservers() {
+ mContext.getContentResolver().unregisterContentObserver(mContentObserver);
+ mContext.getContentResolver().unregisterContentObserver(mSizeContentObserver);
+ mContext.getContentResolver().unregisterContentObserver(mFadeOutContentObserver);
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuView.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuView.java
new file mode 100644
index 0000000000000..ab05c2a273ad4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuView.java
@@ -0,0 +1,690 @@
+/*
+ * 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.util.MathUtils.constrain;
+import static android.util.MathUtils.sq;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.annotation.IntDef;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.LayerDrawable;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.animation.OvershootInterpolator;
+import android.widget.FrameLayout;
+
+import androidx.annotation.DimenRes;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.internal.accessibility.dialog.AccessibilityTarget;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.systemui.R;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Accessibility floating menu is used for the actions of accessibility features, it's also the
+ * action set.
+ *
+ *
The number of items would depend on strings key
+ * {@link android.provider.Settings.Secure#ACCESSIBILITY_BUTTON_TARGETS}.
+ */
+public class AccessibilityFloatingMenuView extends FrameLayout
+ implements RecyclerView.OnItemTouchListener {
+ private static final int INDEX_MENU_ITEM = 0;
+ private static final int FADE_OUT_DURATION_MS = 1000;
+ private static final int FADE_EFFECT_DURATION_MS = 3000;
+ private static final int SNAP_TO_LOCATION_DURATION_MS = 150;
+ private static final int MIN_WINDOW_X = 0;
+ private static final int MIN_WINDOW_Y = 0;
+ private static final float LOCATION_Y_PERCENTAGE = 0.8f;
+
+ private boolean mIsFadeEffectEnabled;
+ private boolean mIsShowing;
+ private boolean mIsDownInEnlargedTouchArea;
+ private boolean mIsDragging = false;
+ @Alignment
+ private int mAlignment = Alignment.RIGHT;
+ @SizeType
+ private int mSizeType = SizeType.SMALL;
+ @VisibleForTesting
+ @ShapeType
+ int mShapeType = ShapeType.OVAL;
+ private int mTemporaryShapeType;
+ @RadiusType
+ private int mRadiusType = RadiusType.LEFT_HALF_OVAL;
+ private int mMargin;
+ private int mPadding;
+ private int mScreenHeight;
+ private int mScreenWidth;
+ private int mIconWidth;
+ private int mIconHeight;
+ private int mInset;
+ private int mDownX;
+ private int mDownY;
+ private int mRelativeToPointerDownX;
+ private int mRelativeToPointerDownY;
+ private float mRadius;
+ private float mPercentageY = LOCATION_Y_PERCENTAGE;
+ private float mSquareScaledTouchSlop;
+ private final RecyclerView mListView;
+ private final AccessibilityTargetAdapter mAdapter;
+ private float mFadeOutValue;
+ private final ValueAnimator mFadeOutAnimator;
+ @VisibleForTesting
+ final ValueAnimator mDragAnimator;
+ private final Handler mUiHandler;
+ @VisibleForTesting
+ final WindowManager.LayoutParams mCurrentLayoutParams;
+ private final WindowManager mWindowManager;
+ private final List mTargets = new ArrayList<>();
+
+ @IntDef({
+ SizeType.SMALL,
+ SizeType.LARGE
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @interface SizeType {
+ int SMALL = 0;
+ int LARGE = 1;
+ }
+
+ @IntDef({
+ ShapeType.OVAL,
+ ShapeType.HALF_OVAL
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @interface ShapeType {
+ int OVAL = 0;
+ int HALF_OVAL = 1;
+ }
+
+ @IntDef({
+ RadiusType.LEFT_HALF_OVAL,
+ RadiusType.OVAL,
+ RadiusType.RIGHT_HALF_OVAL
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @interface RadiusType {
+ int LEFT_HALF_OVAL = 0;
+ int OVAL = 1;
+ int RIGHT_HALF_OVAL = 2;
+ }
+
+ @IntDef({
+ Alignment.LEFT,
+ Alignment.RIGHT
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @interface Alignment {
+ int LEFT = 0;
+ int RIGHT = 1;
+ }
+
+ public AccessibilityFloatingMenuView(Context context) {
+ this(context, new RecyclerView(context));
+ }
+
+ @VisibleForTesting
+ AccessibilityFloatingMenuView(Context context,
+ RecyclerView listView) {
+ super(context);
+
+ mListView = listView;
+ mWindowManager = context.getSystemService(WindowManager.class);
+ mCurrentLayoutParams = createDefaultLayoutParams();
+ mAdapter = new AccessibilityTargetAdapter(mTargets);
+ mUiHandler = createUiHandler();
+
+ mFadeOutAnimator = ValueAnimator.ofFloat(1.0f, mFadeOutValue);
+ mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS);
+ mFadeOutAnimator.addUpdateListener(
+ (animation) -> setAlpha((float) animation.getAnimatedValue()));
+
+ mDragAnimator = ValueAnimator.ofFloat(0.0f, 1.0f);
+ mDragAnimator.setDuration(SNAP_TO_LOCATION_DURATION_MS);
+ mDragAnimator.setInterpolator(new OvershootInterpolator());
+ mDragAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ mAlignment = calculateCurrentAlignment();
+ mPercentageY = calculateCurrentPercentageY();
+
+ updateLocationWith(mAlignment, mPercentageY);
+ updateMarginsWith(mAlignment);
+
+ updateInsetWith(getResources().getConfiguration().uiMode, mAlignment);
+
+ mRadiusType = (mAlignment == Alignment.RIGHT)
+ ? RadiusType.LEFT_HALF_OVAL
+ : RadiusType.RIGHT_HALF_OVAL;
+ updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
+
+ fadeOut();
+ }
+ });
+
+ updateDimensions();
+ initListView();
+ updateStrokeWith(getResources().getConfiguration().uiMode, mAlignment);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView,
+ @NonNull MotionEvent event) {
+ final int currentRawX = (int) event.getRawX();
+ final int currentRawY = (int) event.getRawY();
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ fadeIn();
+
+ mDownX = currentRawX;
+ mDownY = currentRawY;
+ mRelativeToPointerDownX = mCurrentLayoutParams.x - mDownX;
+ mRelativeToPointerDownY = mCurrentLayoutParams.y - mDownY;
+ mListView.animate().translationX(0);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mIsDragging
+ || hasExceededTouchSlop(mDownX, mDownY, currentRawX, currentRawY)) {
+ if (!mIsDragging) {
+ mIsDragging = true;
+ setRadius(mRadius, RadiusType.OVAL);
+ setInset(0, 0);
+ }
+
+ mTemporaryShapeType =
+ isMovingTowardsScreenEdge(mAlignment, currentRawX, mDownX)
+ ? ShapeType.HALF_OVAL
+ : ShapeType.OVAL;
+ final int newWindowX = currentRawX + mRelativeToPointerDownX;
+ final int newWindowY = currentRawY + mRelativeToPointerDownY;
+ mCurrentLayoutParams.x = constrain(newWindowX, MIN_WINDOW_X, getMaxWindowX());
+ mCurrentLayoutParams.y = constrain(newWindowY, MIN_WINDOW_Y, getMaxWindowY());
+ mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ if (mIsDragging) {
+ mIsDragging = false;
+
+ final int maxX = getMaxWindowX();
+ final int endX = mCurrentLayoutParams.x > ((MIN_WINDOW_X + maxX) / 2)
+ ? maxX : MIN_WINDOW_X;
+ final int endY = mCurrentLayoutParams.y;
+ snapToLocation(endX, endY);
+
+ setShapeType(mTemporaryShapeType);
+
+ // Avoid triggering the listener of the item.
+ return true;
+ }
+
+ // Must switch the oval shape type before tapping the corresponding item in the
+ // list view, otherwise it can't work on it.
+ if (mShapeType == ShapeType.HALF_OVAL) {
+ setShapeType(ShapeType.OVAL);
+
+ return true;
+ }
+
+ fadeOut();
+ break;
+ default: // Do nothing
+ }
+
+ // not consume all the events here because keeping the scroll behavior of list view.
+ return false;
+ }
+
+ @Override
+ public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
+ // Do Nothing
+ }
+
+ @Override
+ public void onRequestDisallowInterceptTouchEvent(boolean b) {
+ // Do Nothing
+ }
+
+ void show() {
+ if (isShowing()) {
+ return;
+ }
+
+ mIsShowing = true;
+ mWindowManager.addView(this, mCurrentLayoutParams);
+ setSystemGestureExclusion();
+ }
+
+ void hide() {
+ if (!isShowing()) {
+ return;
+ }
+
+ mIsShowing = false;
+ mWindowManager.removeView(this);
+ setSystemGestureExclusion();
+ }
+
+ boolean isShowing() {
+ return mIsShowing;
+ }
+
+ void onTargetsChanged(List newTargets) {
+ fadeIn();
+
+ mTargets.clear();
+ mTargets.addAll(newTargets);
+ mAdapter.notifyDataSetChanged();
+
+ updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
+ setSystemGestureExclusion();
+
+ fadeOut();
+ }
+
+ void setSizeType(@SizeType int newSizeType) {
+ fadeIn();
+
+ mSizeType = newSizeType;
+
+ updateIconSizeWith(newSizeType);
+ updateRadiusWith(newSizeType, mRadiusType, mTargets.size());
+
+ // When the icon sized changed, the menu size and location will be impacted.
+ updateLocationWith(mAlignment, mPercentageY);
+ setSystemGestureExclusion();
+
+ fadeOut();
+ }
+
+ void setShapeType(@ShapeType int newShapeType) {
+ fadeIn();
+
+ mShapeType = newShapeType;
+
+ updateOffsetWith(newShapeType, mAlignment);
+
+ setOnTouchListener(
+ newShapeType == ShapeType.OVAL
+ ? null
+ : (view, event) -> onTouched(event));
+
+ fadeOut();
+ }
+
+ void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) {
+ mIsFadeEffectEnabled = isFadeEffectEnabled;
+ mFadeOutValue = newOpacityValue;
+
+ mFadeOutAnimator.cancel();
+ mFadeOutAnimator.setFloatValues(1.0f, mFadeOutValue);
+ setAlpha(mIsFadeEffectEnabled ? mFadeOutValue : /* completely opaque */ 1.0f);
+ }
+
+ @VisibleForTesting
+ void fadeIn() {
+ if (!mIsFadeEffectEnabled) {
+ return;
+ }
+
+ mFadeOutAnimator.cancel();
+ mUiHandler.removeCallbacksAndMessages(null);
+ mUiHandler.post(() -> setAlpha(/* completely opaque */ 1.0f));
+ }
+
+ @VisibleForTesting
+ void fadeOut() {
+ if (!mIsFadeEffectEnabled) {
+ return;
+ }
+
+ mUiHandler.postDelayed(() -> mFadeOutAnimator.start(), FADE_EFFECT_DURATION_MS);
+ }
+
+ private boolean onTouched(MotionEvent event) {
+ final int action = event.getAction();
+ final int currentX = (int) event.getX();
+ final int currentY = (int) event.getY();
+
+ final int menuHalfWidth = getLayoutWidth() / 2;
+ final Rect touchDelegateBounds =
+ new Rect(mMargin, mMargin, mMargin + menuHalfWidth, mMargin + getLayoutHeight());
+ if (action == MotionEvent.ACTION_DOWN
+ && touchDelegateBounds.contains(currentX, currentY)) {
+ mIsDownInEnlargedTouchArea = true;
+ }
+
+ if (!mIsDownInEnlargedTouchArea) {
+ return false;
+ }
+
+ if (action == MotionEvent.ACTION_UP
+ || action == MotionEvent.ACTION_CANCEL) {
+ mIsDownInEnlargedTouchArea = false;
+ }
+
+ // In order to correspond to the correct item of list view.
+ event.setLocation(currentX - mMargin, currentY - mMargin);
+ return mListView.dispatchTouchEvent(event);
+ }
+
+ private boolean isMovingTowardsScreenEdge(@Alignment int side, int currentRawX, int downX) {
+ return (side == Alignment.RIGHT && currentRawX > downX)
+ || (side == Alignment.LEFT && downX > currentRawX);
+ }
+
+ private boolean hasExceededTouchSlop(int startX, int startY, int endX, int endY) {
+ return (sq(endX - startX) + sq(endY - startY)) > mSquareScaledTouchSlop;
+ }
+
+ private void setRadius(float radius, @RadiusType int type) {
+ getMenuGradientDrawable().setCornerRadii(createRadii(radius, type));
+ }
+
+ private float[] createRadii(float radius, @RadiusType int type) {
+ if (type == RadiusType.LEFT_HALF_OVAL) {
+ return new float[]{radius, radius, 0.0f, 0.0f, 0.0f, 0.0f, radius, radius};
+ }
+
+ if (type == RadiusType.RIGHT_HALF_OVAL) {
+ return new float[]{0.0f, 0.0f, radius, radius, radius, radius, 0.0f, 0.0f};
+ }
+
+ return new float[]{radius, radius, radius, radius, radius, radius, radius, radius};
+ }
+
+ private Handler createUiHandler() {
+ final Looper looper = Looper.myLooper();
+ if (looper == null) {
+ throw new IllegalArgumentException("looper must not be null");
+ }
+ return new Handler(looper);
+ }
+
+ private void updateDimensions() {
+ final Resources res = getResources();
+ final DisplayMetrics dm = res.getDisplayMetrics();
+ mScreenWidth = dm.widthPixels;
+ mScreenHeight = dm.heightPixels;
+ mMargin =
+ res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_margin);
+ mPadding =
+ res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_padding);
+ mInset =
+ res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_inset);
+
+ mSquareScaledTouchSlop =
+ sq(ViewConfiguration.get(getContext()).getScaledTouchSlop());
+ }
+
+ private void updateIconSizeWith(@SizeType int sizeType) {
+ final Resources res = getResources();
+ final int iconResId =
+ sizeType == SizeType.SMALL
+ ? R.dimen.accessibility_floating_menu_small_width_height
+ : R.dimen.accessibility_floating_menu_large_width_height;
+ mIconWidth = res.getDimensionPixelSize(iconResId);
+ mIconHeight = mIconWidth;
+
+ mAdapter.setIconWidthHeight(mIconWidth);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ private void initListView() {
+ final Drawable background =
+ getContext().getDrawable(R.drawable.accessibility_floating_menu_background);
+ final LinearLayoutManager layoutManager = new LinearLayoutManager(getContext());
+ final LayoutParams layoutParams =
+ new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ mListView.setLayoutParams(layoutParams);
+ final InstantInsetLayerDrawable layerDrawable =
+ new InstantInsetLayerDrawable(new Drawable[]{background});
+ mListView.setBackground(layerDrawable);
+ mListView.setAdapter(mAdapter);
+ mListView.setLayoutManager(layoutManager);
+ mListView.addOnItemTouchListener(this);
+ mListView.animate().setInterpolator(new OvershootInterpolator());
+ updateListView();
+
+ addView(mListView);
+ }
+
+ private void updateListView() {
+ final int elevation =
+ getResources().getDimensionPixelSize(R.dimen.accessibility_floating_menu_elevation);
+ mListView.setElevation(elevation);
+
+ updateMarginsWith(mAlignment);
+ }
+
+ 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);
+ params.windowAnimations = android.R.style.Animation_Translucent;
+ params.gravity = Gravity.START | Gravity.TOP;
+ params.x = getMaxWindowX();
+ params.y = (int) (getMaxWindowY() * mPercentageY);
+
+ return params;
+ }
+
+ @Override
+ protected void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ updateDimensions();
+ updateListView();
+ updateIconSizeWith(mSizeType);
+ updateColor();
+ updateStrokeWith(newConfig.uiMode, mAlignment);
+ updateLocationWith(mAlignment, mPercentageY);
+ }
+
+ private void snapToLocation(int endX, int endY) {
+ mDragAnimator.cancel();
+ mDragAnimator.removeAllUpdateListeners();
+ mDragAnimator.addUpdateListener(anim -> onDragAnimationUpdate(anim, endX, endY));
+ mDragAnimator.start();
+ }
+
+ private void onDragAnimationUpdate(ValueAnimator animator, int endX, int endY) {
+ float value = (float) animator.getAnimatedValue();
+ final int newX = (int) (((1 - value) * mCurrentLayoutParams.x) + (value * endX));
+ final int newY = (int) (((1 - value) * mCurrentLayoutParams.y) + (value * endY));
+
+ mCurrentLayoutParams.x = newX;
+ mCurrentLayoutParams.y = newY;
+ mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
+ }
+
+ private int getMaxWindowX() {
+ return mScreenWidth - mMargin - getLayoutWidth();
+ }
+
+ private int getMaxWindowY() {
+ return mScreenHeight - getWindowHeight();
+ }
+
+ private InstantInsetLayerDrawable getMenuLayerDrawable() {
+ return (InstantInsetLayerDrawable) mListView.getBackground();
+ }
+
+ private GradientDrawable getMenuGradientDrawable() {
+ return (GradientDrawable) getMenuLayerDrawable().getDrawable(INDEX_MENU_ITEM);
+ }
+
+ /**
+ * Updates the floating menu to be fixed at the side of the screen.
+ */
+ private void updateLocationWith(@Alignment int side, float percentageCurrentY) {
+ mCurrentLayoutParams.x = (side == Alignment.RIGHT) ? getMaxWindowX() : MIN_WINDOW_X;
+ mCurrentLayoutParams.y = (int) (percentageCurrentY * getMaxWindowY());
+ mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
+ }
+
+ private void updateOffsetWith(@ShapeType int shapeType, @Alignment int side) {
+ final float halfWidth = getLayoutWidth() / 2.0f;
+ final float offset = (shapeType == ShapeType.OVAL) ? 0 : halfWidth;
+ mListView.animate().translationX(side == Alignment.RIGHT ? offset : -offset);
+ }
+
+ private void updateMarginsWith(@Alignment int side) {
+ final LayoutParams layoutParams = (LayoutParams) mListView.getLayoutParams();
+ final int marginLeft = (side == Alignment.LEFT) ? 0 : mMargin;
+ final int marginRight = (side == Alignment.RIGHT) ? 0 : mMargin;
+
+ if (marginLeft == layoutParams.leftMargin
+ && marginRight == layoutParams.rightMargin) {
+ return;
+ }
+
+ layoutParams.setMargins(marginLeft, mMargin, marginRight, mMargin);
+ mListView.setLayoutParams(layoutParams);
+ }
+
+ private void updateColor() {
+ final int menuColorResId = R.color.accessibility_floating_menu_background;
+ getMenuGradientDrawable().setColor(getResources().getColor(menuColorResId));
+ }
+
+ private void updateStrokeWith(int uiMode, @Alignment int side) {
+ updateInsetWith(uiMode, side);
+
+ final boolean isNightMode =
+ (uiMode & Configuration.UI_MODE_NIGHT_MASK)
+ == Configuration.UI_MODE_NIGHT_YES;
+ final Resources res = getResources();
+ 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);
+ }
+
+ private void updateRadiusWith(@SizeType int sizeType, @RadiusType int radiusType,
+ int itemCount) {
+ mRadius =
+ getResources().getDimensionPixelSize(getRadiusResId(sizeType, itemCount));
+ setRadius(mRadius, radiusType);
+ }
+
+ private void updateInsetWith(int uiMode, @Alignment int side) {
+ final boolean isNightMode =
+ (uiMode & Configuration.UI_MODE_NIGHT_MASK)
+ == Configuration.UI_MODE_NIGHT_YES;
+
+ final int layerInset = isNightMode ? mInset : 0;
+ final int insetLeft = (side == Alignment.LEFT) ? layerInset : 0;
+ final int insetRight = (side == Alignment.RIGHT) ? layerInset : 0;
+ setInset(insetLeft, insetRight);
+ }
+
+ private void setInset(int left, int right) {
+ final LayerDrawable layerDrawable = getMenuLayerDrawable();
+ if (layerDrawable.getLayerInsetLeft(INDEX_MENU_ITEM) == left
+ && layerDrawable.getLayerInsetRight(INDEX_MENU_ITEM) == right) {
+ return;
+ }
+
+ layerDrawable.setLayerInset(INDEX_MENU_ITEM, left, 0, right, 0);
+ }
+
+ @Alignment
+ private int calculateCurrentAlignment() {
+ return mCurrentLayoutParams.x >= ((MIN_WINDOW_X + getMaxWindowX()) / 2)
+ ? Alignment.RIGHT
+ : Alignment.LEFT;
+ }
+
+ private float calculateCurrentPercentageY() {
+ return mCurrentLayoutParams.y / (float) getMaxWindowY();
+ }
+
+ private @DimenRes int getRadiusResId(@SizeType int sizeType, int itemCount) {
+ return sizeType == SizeType.SMALL
+ ? getSmallSizeResIdWith(itemCount)
+ : getLargeSizeResIdWith(itemCount);
+ }
+
+ private int getSmallSizeResIdWith(int itemCount) {
+ return itemCount > 1
+ ? R.dimen.accessibility_floating_menu_small_multiple_radius
+ : R.dimen.accessibility_floating_menu_small_single_radius;
+ }
+
+ private int getLargeSizeResIdWith(int itemCount) {
+ return itemCount > 1
+ ? R.dimen.accessibility_floating_menu_large_multiple_radius
+ : R.dimen.accessibility_floating_menu_large_single_radius;
+ }
+
+ private int getLayoutWidth() {
+ return mPadding * 2 + mIconWidth;
+ }
+
+ private int getLayoutHeight() {
+ return Math.min(mScreenHeight - mMargin * 2,
+ (mPadding + mIconHeight) * mTargets.size() + mPadding);
+ }
+
+ private int getWindowWidth() {
+ return mMargin + getLayoutWidth();
+ }
+
+ private int getWindowHeight() {
+ return Math.min(mScreenHeight, mMargin * 2 + getLayoutHeight());
+ }
+
+ private void setSystemGestureExclusion() {
+ final Rect excludeZone =
+ new Rect(0, 0, getWindowWidth(), getWindowHeight());
+ post(() -> setSystemGestureExclusionRects(
+ mIsShowing
+ ? Collections.singletonList(excludeZone)
+ : Collections.emptyList()));
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityTargetAdapter.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityTargetAdapter.java
new file mode 100644
index 0000000000000..bb4038e92ff43
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/AccessibilityTargetAdapter.java
@@ -0,0 +1,150 @@
+/*
+ * 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 static android.view.View.GONE;
+
+import static com.android.systemui.accessibility.floatingmenu.AccessibilityTargetAdapter.ItemType.FIRST_ITEM;
+import static com.android.systemui.accessibility.floatingmenu.AccessibilityTargetAdapter.ItemType.LAST_ITEM;
+import static com.android.systemui.accessibility.floatingmenu.AccessibilityTargetAdapter.ItemType.REGULAR_ITEM;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.IntDef;
+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.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+
+/**
+ * An adapter which shows the set of accessibility targets that can be performed.
+ */
+public class AccessibilityTargetAdapter extends Adapter {
+ private int mIconWidthHeight;
+ private final List mTargets;
+
+ @IntDef({
+ FIRST_ITEM,
+ REGULAR_ITEM,
+ LAST_ITEM
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @interface ItemType {
+ int FIRST_ITEM = 0;
+ int REGULAR_ITEM = 1;
+ int LAST_ITEM = 2;
+ }
+
+ public AccessibilityTargetAdapter(List targets) {
+ mTargets = targets;
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, @ItemType int itemType) {
+ final View root = LayoutInflater.from(parent.getContext()).inflate(
+ R.layout.accessibility_floating_menu_item, parent,
+ /* attachToRoot= */ false);
+
+ if (itemType == FIRST_ITEM) {
+ return new TopViewHolder(root);
+ }
+
+ if (itemType == LAST_ITEM) {
+ return new BottomViewHolder(root);
+ }
+
+ return new ViewHolder(root);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.mIconView.setBackground(mTargets.get(position).getIcon());
+ holder.updateIconWidthHeight(mIconWidthHeight);
+ holder.itemView.setOnClickListener((v) -> mTargets.get(position).onSelected());
+ }
+
+ @ItemType
+ @Override
+ public int getItemViewType(int position) {
+ if (position == 0) {
+ return FIRST_ITEM;
+ }
+
+ if (position == (getItemCount() - 1)) {
+ return LAST_ITEM;
+ }
+
+ return REGULAR_ITEM;
+ }
+
+ @Override
+ public int getItemCount() {
+ return mTargets.size();
+ }
+
+ public void setIconWidthHeight(int iconWidthHeight) {
+ mIconWidthHeight = iconWidthHeight;
+ }
+
+ 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);
+ }
+
+ void updateIconWidthHeight(int newValue) {
+ final ViewGroup.LayoutParams layoutParams = mIconView.getLayoutParams();
+ if (layoutParams.width == newValue) {
+ return;
+ }
+ layoutParams.width = newValue;
+ layoutParams.height = newValue;
+ mIconView.setLayoutParams(layoutParams);
+ }
+ }
+
+ static class TopViewHolder extends ViewHolder {
+ TopViewHolder(View itemView) {
+ super(itemView);
+ final int padding = itemView.getPaddingStart();
+ itemView.setPaddingRelative(padding, padding, padding, 0);
+ }
+ }
+
+ static class BottomViewHolder extends ViewHolder {
+ BottomViewHolder(View itemView) {
+ super(itemView);
+ mDivider.setVisibility(GONE);
+ final int padding = itemView.getPaddingStart();
+ itemView.setPaddingRelative(padding, 0, padding, padding);
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/IAccessibilityFloatingMenu.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/IAccessibilityFloatingMenu.java
new file mode 100644
index 0000000000000..62f02a0444855
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/IAccessibilityFloatingMenu.java
@@ -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();
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/InstantInsetLayerDrawable.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/InstantInsetLayerDrawable.java
new file mode 100644
index 0000000000000..6c021a6f3c7ae
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/InstantInsetLayerDrawable.java
@@ -0,0 +1,37 @@
+/*
+ * 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.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+
+/**
+ * A drawable that forces to update the bounds {@link #onBoundsChange(Rect)} immediately after
+ * {@link #setLayerInset} dynamically.
+ */
+public class InstantInsetLayerDrawable extends LayerDrawable {
+ public InstantInsetLayerDrawable(Drawable[] layers) {
+ super(layers);
+ }
+
+ @Override
+ public void setLayerInset(int index, int l, int t, int r, int b) {
+ super.setLayerInset(index, l, t, r, b);
+ onBoundsChange(getBounds());
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java
index 92dad9bdb1204..550e77d63c3b0 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java
@@ -23,11 +23,11 @@ import com.android.internal.annotations.GuardedBy;
import java.util.ArrayList;
import java.util.List;
-class MotionEventHelper {
+public class MotionEventHelper {
@GuardedBy("this")
private final List mMotionEvents = new ArrayList<>();
- void recycleEvents() {
+ public void recycleEvents() {
for (MotionEvent event:mMotionEvents) {
event.recycle();
}
@@ -36,7 +36,7 @@ class MotionEventHelper {
}
}
- MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, float x,
+ public MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, float x,
float y) {
MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0);
synchronized (this) {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuTest.java
new file mode 100644
index 0000000000000..337d97e1dc0bd
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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 static org.mockito.Mockito.mock;
+
+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.internal.accessibility.dialog.AccessibilityTarget;
+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 mTargets = new ArrayList<>();
+ mTargets.add(mock(AccessibilityTarget.class));
+
+ final List 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();
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuViewTest.java
new file mode 100644
index 0000000000000..8683dd6c33bd8
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityFloatingMenuViewTest.java
@@ -0,0 +1,365 @@
+/*
+ * 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.anyFloat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.LayerDrawable;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+import android.view.WindowManager;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.accessibility.dialog.AccessibilityTarget;
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.accessibility.MotionEventHelper;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Tests for {@link AccessibilityFloatingMenuView}. */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
+public class AccessibilityFloatingMenuViewTest extends SysuiTestCase {
+ private AccessibilityFloatingMenuView mMenuView;
+
+ @Mock
+ private WindowManager mWindowManager;
+
+ @Mock
+ private ViewPropertyAnimator mAnimator;
+
+ private MotionEvent mInterceptMotionEvent;
+
+ private RecyclerView mListView;
+
+ private int mMenuHalfWidth;
+ private int mMenuHalfHeight;
+ private int mScreenHalfWidth;
+ private int mScreenHalfHeight;
+ private int mMaxWindowX;
+
+ private final MotionEventHelper mMotionEventHelper = new MotionEventHelper();
+ private final List 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));
+ mListView = new RecyclerView(mContext);
+ mMenuView = new AccessibilityFloatingMenuView(mContext, mListView);
+
+ final Resources res = mContext.getResources();
+ final int margin =
+ res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_margin);
+ final int padding =
+ res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_padding);
+ final int iconWidthHeight =
+ res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_small_width_height);
+ final int menuWidth = padding * 2 + iconWidthHeight;
+ final int menuHeight = (padding + iconWidthHeight) * mTargets.size() + padding;
+ final int screenWidth = mContext.getResources().getDisplayMetrics().widthPixels;
+ final int screenHeight = mContext.getResources().getDisplayMetrics().heightPixels;
+ mMenuHalfWidth = menuWidth / 2;
+ mMenuHalfHeight = menuHeight / 2;
+ mScreenHalfWidth = screenWidth / 2;
+ mScreenHalfHeight = screenHeight / 2;
+ mMaxWindowX = screenWidth - margin - menuWidth;
+ }
+
+ @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().getDimensionPixelSize(
+ R.dimen.accessibility_floating_menu_small_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);
+ }
+
+ @Test
+ public void setSizeType_largeSize_matchResult() {
+ final int shapeType = 2;
+ final float radius = getContext().getResources().getDimensionPixelSize(
+ R.dimen.accessibility_floating_menu_large_single_radius);
+ final float[] expectedRadii =
+ new float[]{radius, radius, 0.0f, 0.0f, 0.0f, 0.0f, radius, radius};
+ final Drawable listViewBackground =
+ mContext.getDrawable(R.drawable.accessibility_floating_menu_background);
+ mListView = spy(new RecyclerView(mContext));
+ mListView.setBackground(listViewBackground);
+
+ mMenuView = new AccessibilityFloatingMenuView(mContext, mListView);
+ mMenuView.setSizeType(shapeType);
+ final LayerDrawable layerDrawable =
+ (LayerDrawable) mListView.getBackground();
+ final GradientDrawable gradientDrawable =
+ (GradientDrawable) layerDrawable.getDrawable(0);
+
+ assertThat(gradientDrawable.getCornerRadii()).isEqualTo(expectedRadii);
+ }
+
+ @Test
+ public void setShapeType_halfCircle_translationX() {
+ final RecyclerView listView = spy(new RecyclerView(mContext));
+ final AccessibilityFloatingMenuView menuView =
+ new AccessibilityFloatingMenuView(mContext, listView);
+ final int shapeType = 2;
+ doReturn(mAnimator).when(listView).animate();
+
+ menuView.setShapeType(shapeType);
+
+ verify(mAnimator).translationX(anyFloat());
+ }
+
+ @Test
+ public void onTargetsChanged_fadeInOut() {
+ final AccessibilityFloatingMenuView menuView = spy(mMenuView);
+ final InOrder inOrderMenuView = inOrder(menuView);
+
+ menuView.onTargetsChanged(mTargets);
+
+ inOrderMenuView.verify(menuView).fadeIn();
+ inOrderMenuView.verify(menuView).fadeOut();
+ }
+
+ @Test
+ public void setSizeType_fadeInOut() {
+ final AccessibilityFloatingMenuView menuView = spy(mMenuView);
+ final InOrder inOrderMenuView = inOrder(menuView);
+ final int smallSize = 0;
+ menuView.setSizeType(smallSize);
+
+ inOrderMenuView.verify(menuView).fadeIn();
+ inOrderMenuView.verify(menuView).fadeOut();
+ }
+
+ @Test
+ public void tapOnAndDragMenu_interceptUpEvent() {
+ final RecyclerView listView = new RecyclerView(mContext);
+ final TestAccessibilityFloatingMenu menuView =
+ new TestAccessibilityFloatingMenu(mContext, listView);
+
+ menuView.show();
+ menuView.onTargetsChanged(mTargets);
+ menuView.setSizeType(0);
+ menuView.setShapeType(0);
+ final int currentWindowX = mMenuView.mCurrentLayoutParams.x;
+ final int currentWindowY = mMenuView.mCurrentLayoutParams.y;
+ final MotionEvent downEvent =
+ mMotionEventHelper.obtainMotionEvent(0, 1,
+ MotionEvent.ACTION_DOWN,
+ currentWindowX + /* offsetXToMenuCenterX */ mMenuHalfWidth,
+ currentWindowY + /* offsetYToMenuCenterY */ mMenuHalfHeight);
+ final MotionEvent moveEvent =
+ mMotionEventHelper.obtainMotionEvent(2, 3,
+ MotionEvent.ACTION_MOVE,
+ /* screenCenterX */mScreenHalfWidth
+ - /* offsetXToScreenLeftHalfRegion */ 10,
+ /* screenCenterY */ mScreenHalfHeight);
+ final MotionEvent upEvent =
+ mMotionEventHelper.obtainMotionEvent(4, 5,
+ MotionEvent.ACTION_UP,
+ /* screenCenterX */ mScreenHalfWidth
+ - /* offsetXToScreenLeftHalfRegion */ 10,
+ /* screenCenterY */ mScreenHalfHeight);
+ listView.dispatchTouchEvent(downEvent);
+ listView.dispatchTouchEvent(moveEvent);
+ listView.dispatchTouchEvent(upEvent);
+
+ assertThat(mInterceptMotionEvent.getAction()).isEqualTo(MotionEvent.ACTION_UP);
+ }
+
+ @Test
+ public void tapOnAndDragMenu_matchLocation() {
+ mMenuView.show();
+ mMenuView.onTargetsChanged(mTargets);
+ mMenuView.setSizeType(0);
+ mMenuView.setShapeType(0);
+ final int currentWindowX = mMenuView.mCurrentLayoutParams.x;
+ final int currentWindowY = mMenuView.mCurrentLayoutParams.y;
+ final MotionEvent downEvent =
+ mMotionEventHelper.obtainMotionEvent(0, 1,
+ MotionEvent.ACTION_DOWN,
+ currentWindowX + /* offsetXToMenuCenterX */ mMenuHalfWidth,
+ currentWindowY + /* offsetYToMenuCenterY */ mMenuHalfHeight);
+ final MotionEvent moveEvent =
+ mMotionEventHelper.obtainMotionEvent(2, 3,
+ MotionEvent.ACTION_MOVE,
+ /* screenCenterX */mScreenHalfWidth
+ + /* offsetXToScreenRightHalfRegion */ 10,
+ /* screenCenterY */ mScreenHalfHeight);
+ final MotionEvent upEvent =
+ mMotionEventHelper.obtainMotionEvent(4, 5,
+ MotionEvent.ACTION_UP,
+ /* screenCenterX */ mScreenHalfWidth
+ + /* offsetXToScreenRightHalfRegion */ 10,
+ /* screenCenterY */ mScreenHalfHeight);
+ mListView.dispatchTouchEvent(downEvent);
+ mListView.dispatchTouchEvent(moveEvent);
+ mListView.dispatchTouchEvent(upEvent);
+ mMenuView.mDragAnimator.end();
+
+ assertThat(mMenuView.mCurrentLayoutParams.x).isEqualTo(mMaxWindowX);
+ assertThat(mMenuView.mCurrentLayoutParams.y).isEqualTo(
+ /* newWindowY = screenCenterY - offsetY */ mScreenHalfHeight - mMenuHalfHeight);
+ }
+
+
+ @Test
+ public void tapOnAndDragMenuToScreenSide_transformShapeHalfOval() {
+ mMenuView.show();
+ mMenuView.onTargetsChanged(mTargets);
+ mMenuView.setSizeType(0);
+ mMenuView.setShapeType(/* oval */ 0);
+ final int currentWindowX = mMenuView.mCurrentLayoutParams.x;
+ final int currentWindowY = mMenuView.mCurrentLayoutParams.y;
+ final MotionEvent downEvent =
+ mMotionEventHelper.obtainMotionEvent(0, 1,
+ MotionEvent.ACTION_DOWN,
+ currentWindowX + /* offsetXToMenuCenterX */ mMenuHalfWidth,
+ currentWindowY + /* offsetYToMenuCenterY */ mMenuHalfHeight);
+ final MotionEvent moveEvent =
+ mMotionEventHelper.obtainMotionEvent(2, 3,
+ MotionEvent.ACTION_MOVE,
+ /* downX */(currentWindowX + mMenuHalfWidth)
+ + /* offsetXToScreenRightSide */ mMenuHalfWidth,
+ /* downY */ (currentWindowY + mMenuHalfHeight));
+ final MotionEvent upEvent =
+ mMotionEventHelper.obtainMotionEvent(4, 5,
+ MotionEvent.ACTION_UP,
+ /* downX */(currentWindowX + mMenuHalfWidth)
+ + /* offsetXToScreenRightSide */ mMenuHalfWidth,
+ /* downY */ (currentWindowY + mMenuHalfHeight));
+ mListView.dispatchTouchEvent(downEvent);
+ mListView.dispatchTouchEvent(moveEvent);
+ mListView.dispatchTouchEvent(upEvent);
+
+ assertThat(mMenuView.mShapeType).isEqualTo(/* halfOval */ 1);
+ }
+
+ @After
+ public void tearDown() {
+ mInterceptMotionEvent = null;
+ mMotionEventHelper.recycleEvents();
+ }
+
+ private class TestAccessibilityFloatingMenu extends AccessibilityFloatingMenuView {
+ TestAccessibilityFloatingMenu(Context context, RecyclerView listView) {
+ super(context, listView);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView,
+ @NonNull MotionEvent event) {
+ final boolean intercept = super.onInterceptTouchEvent(recyclerView, event);
+
+ if (intercept) {
+ mInterceptMotionEvent = event;
+ }
+
+ return intercept;
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityTargetAdapterTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityTargetAdapterTest.java
new file mode 100644
index 0000000000000..899625eee7d9b
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/AccessibilityTargetAdapterTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.Mockito.when;
+
+import android.graphics.drawable.Drawable;
+import android.testing.AndroidTestingRunner;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.accessibility.dialog.AccessibilityTarget;
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.accessibility.floatingmenu.AccessibilityTargetAdapter.ViewHolder;
+
+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 AccessibilityTargetAdapter}. */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class AccessibilityTargetAdapterTest extends SysuiTestCase {
+ @Mock
+ private AccessibilityTarget mAccessibilityTarget;
+
+ @Mock
+ private Drawable mIcon;
+
+ @Mock
+ private Drawable.ConstantState mConstantState;
+
+ private ViewHolder mViewHolder;
+ private AccessibilityTargetAdapter mAdapter;
+ private final List mTargets = new ArrayList<>();
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ mTargets.add(mAccessibilityTarget);
+ mAdapter = new AccessibilityTargetAdapter(mTargets);
+
+ final View root = LayoutInflater.from(mContext).inflate(
+ R.layout.accessibility_floating_menu_item, null);
+ mViewHolder = new ViewHolder(root);
+ when(mAccessibilityTarget.getIcon()).thenReturn(mIcon);
+ when(mIcon.getConstantState()).thenReturn(mConstantState);
+ }
+
+ @Test
+ public void onBindViewHolder_setIconWidthHeight_matchResult() {
+ final int iconWidthHeight = 50;
+ mAdapter.setIconWidthHeight(iconWidthHeight);
+
+ mAdapter.onBindViewHolder(mViewHolder, 0);
+ final int actualIconWith = mViewHolder.mIconView.getLayoutParams().width;
+
+ assertThat(actualIconWith).isEqualTo(iconWidthHeight);
+ }
+}