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); + } +}