1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.accessibility.floatingmenu;
18 
19 import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
20 import static android.util.MathUtils.constrain;
21 import static android.util.MathUtils.sq;
22 import static android.view.WindowInsets.Type.displayCutout;
23 import static android.view.WindowInsets.Type.ime;
24 import static android.view.WindowInsets.Type.systemBars;
25 import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_EXCLUDE_FROM_SCREEN_MAGNIFICATION;
26 
27 import static java.util.Objects.requireNonNull;
28 
29 import android.animation.Animator;
30 import android.animation.AnimatorListenerAdapter;
31 import android.animation.ValueAnimator;
32 import android.annotation.FloatRange;
33 import android.annotation.IntDef;
34 import android.content.Context;
35 import android.content.pm.ActivityInfo;
36 import android.content.res.Configuration;
37 import android.content.res.Resources;
38 import android.graphics.Insets;
39 import android.graphics.PixelFormat;
40 import android.graphics.Rect;
41 import android.graphics.drawable.Drawable;
42 import android.graphics.drawable.GradientDrawable;
43 import android.graphics.drawable.LayerDrawable;
44 import android.os.Handler;
45 import android.os.Looper;
46 import android.view.Gravity;
47 import android.view.MotionEvent;
48 import android.view.ViewConfiguration;
49 import android.view.ViewGroup;
50 import android.view.WindowInsets;
51 import android.view.WindowManager;
52 import android.view.WindowMetrics;
53 import android.view.animation.Animation;
54 import android.view.animation.OvershootInterpolator;
55 import android.view.animation.TranslateAnimation;
56 import android.widget.FrameLayout;
57 
58 import androidx.annotation.DimenRes;
59 import androidx.annotation.NonNull;
60 import androidx.core.view.AccessibilityDelegateCompat;
61 import androidx.recyclerview.widget.LinearLayoutManager;
62 import androidx.recyclerview.widget.RecyclerView;
63 import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate;
64 
65 import com.android.internal.accessibility.dialog.AccessibilityTarget;
66 import com.android.internal.annotations.VisibleForTesting;
67 import com.android.systemui.R;
68 
69 import java.lang.annotation.Retention;
70 import java.lang.annotation.RetentionPolicy;
71 import java.util.ArrayList;
72 import java.util.Collections;
73 import java.util.List;
74 import java.util.Optional;
75 
76 /**
77  * Accessibility floating menu is used for the actions of accessibility features, it's also the
78  * action set.
79  *
80  * <p>The number of items would depend on strings key
81  * {@link android.provider.Settings.Secure#ACCESSIBILITY_BUTTON_TARGETS}.
82  */
83 public class AccessibilityFloatingMenuView extends FrameLayout
84         implements RecyclerView.OnItemTouchListener {
85     private static final int INDEX_MENU_ITEM = 0;
86     private static final int FADE_OUT_DURATION_MS = 1000;
87     private static final int FADE_EFFECT_DURATION_MS = 3000;
88     private static final int SNAP_TO_LOCATION_DURATION_MS = 150;
89     private static final int MIN_WINDOW_Y = 0;
90 
91     private static final int ANIMATION_START_OFFSET = 600;
92     private static final int ANIMATION_DURATION_MS = 600;
93     private static final float ANIMATION_TO_X_VALUE = 0.5f;
94 
95     private boolean mIsFadeEffectEnabled;
96     private boolean mIsShowing;
97     private boolean mIsDownInEnlargedTouchArea;
98     private boolean mIsDragging = false;
99     @Alignment
100     private int mAlignment;
101     @SizeType
102     private int mSizeType = SizeType.SMALL;
103     @VisibleForTesting
104     @ShapeType
105     int mShapeType = ShapeType.OVAL;
106     private int mTemporaryShapeType;
107     @RadiusType
108     private int mRadiusType;
109     private int mMargin;
110     private int mPadding;
111     // The display width excludes the window insets of the system bar and display cutout.
112     private int mDisplayHeight;
113     // The display Height excludes the window insets of the system bar and display cutout.
114     private int mDisplayWidth;
115     private int mIconWidth;
116     private int mIconHeight;
117     private int mInset;
118     private int mDownX;
119     private int mDownY;
120     private int mRelativeToPointerDownX;
121     private int mRelativeToPointerDownY;
122     private float mRadius;
123     private final Rect mDisplayInsetsRect = new Rect();
124     private final Rect mImeInsetsRect = new Rect();
125     private final Position mPosition;
126     private float mSquareScaledTouchSlop;
127     private final Configuration mLastConfiguration;
128     private Optional<OnDragEndListener> mOnDragEndListener = Optional.empty();
129     private final RecyclerView mListView;
130     private final AccessibilityTargetAdapter mAdapter;
131     private float mFadeOutValue;
132     private final ValueAnimator mFadeOutAnimator;
133     @VisibleForTesting
134     final ValueAnimator mDragAnimator;
135     private final Handler mUiHandler;
136     @VisibleForTesting
137     final WindowManager.LayoutParams mCurrentLayoutParams;
138     private final WindowManager mWindowManager;
139     private final List<AccessibilityTarget> mTargets = new ArrayList<>();
140 
141     @IntDef({
142             SizeType.SMALL,
143             SizeType.LARGE
144     })
145     @Retention(RetentionPolicy.SOURCE)
146     @interface SizeType {
147         int SMALL = 0;
148         int LARGE = 1;
149     }
150 
151     @IntDef({
152             ShapeType.OVAL,
153             ShapeType.HALF_OVAL
154     })
155     @Retention(RetentionPolicy.SOURCE)
156     @interface ShapeType {
157         int OVAL = 0;
158         int HALF_OVAL = 1;
159     }
160 
161     @IntDef({
162             RadiusType.LEFT_HALF_OVAL,
163             RadiusType.OVAL,
164             RadiusType.RIGHT_HALF_OVAL
165     })
166     @Retention(RetentionPolicy.SOURCE)
167     @interface RadiusType {
168         int LEFT_HALF_OVAL = 0;
169         int OVAL = 1;
170         int RIGHT_HALF_OVAL = 2;
171     }
172 
173     @IntDef({
174             Alignment.LEFT,
175             Alignment.RIGHT
176     })
177     @Retention(RetentionPolicy.SOURCE)
178     @interface Alignment {
179         int LEFT = 0;
180         int RIGHT = 1;
181     }
182 
183     /**
184      * Interface for a callback to be invoked when the floating menu was dragging.
185      */
186     interface OnDragEndListener {
187 
188         /**
189          * Called when a drag is completed.
190          *
191          * @param position Stores information about the position
192          */
onDragEnd(Position position)193         void onDragEnd(Position position);
194     }
195 
AccessibilityFloatingMenuView(Context context, @NonNull Position position)196     public AccessibilityFloatingMenuView(Context context, @NonNull Position position) {
197         this(context, position, new RecyclerView(context));
198     }
199 
200     @VisibleForTesting
AccessibilityFloatingMenuView(Context context, @NonNull Position position, RecyclerView listView)201     AccessibilityFloatingMenuView(Context context, @NonNull Position position,
202             RecyclerView listView) {
203         super(context);
204 
205         mListView = listView;
206         mWindowManager = context.getSystemService(WindowManager.class);
207         mLastConfiguration = new Configuration(getResources().getConfiguration());
208         mAdapter = new AccessibilityTargetAdapter(mTargets);
209         mUiHandler = createUiHandler();
210         mPosition = position;
211         mAlignment = transformToAlignment(mPosition.getPercentageX());
212         mRadiusType = (mAlignment == Alignment.RIGHT)
213                 ? RadiusType.LEFT_HALF_OVAL
214                 : RadiusType.RIGHT_HALF_OVAL;
215 
216         updateDimensions();
217 
218         mCurrentLayoutParams = createDefaultLayoutParams();
219 
220         mFadeOutAnimator = ValueAnimator.ofFloat(1.0f, mFadeOutValue);
221         mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS);
222         mFadeOutAnimator.addUpdateListener(
223                 (animation) -> setAlpha((float) animation.getAnimatedValue()));
224 
225         mDragAnimator = ValueAnimator.ofFloat(0.0f, 1.0f);
226         mDragAnimator.setDuration(SNAP_TO_LOCATION_DURATION_MS);
227         mDragAnimator.setInterpolator(new OvershootInterpolator());
228         mDragAnimator.addListener(new AnimatorListenerAdapter() {
229             @Override
230             public void onAnimationEnd(Animator animation) {
231                 mPosition.update(transformCurrentPercentageXToEdge(),
232                         calculateCurrentPercentageY());
233                 mAlignment = transformToAlignment(mPosition.getPercentageX());
234 
235                 updateLocationWith(mPosition);
236 
237                 updateInsetWith(getResources().getConfiguration().uiMode, mAlignment);
238 
239                 mRadiusType = (mAlignment == Alignment.RIGHT)
240                         ? RadiusType.LEFT_HALF_OVAL
241                         : RadiusType.RIGHT_HALF_OVAL;
242                 updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
243 
244                 fadeOut();
245 
246                 mOnDragEndListener.ifPresent(
247                         onDragEndListener -> onDragEndListener.onDragEnd(mPosition));
248             }
249         });
250 
251 
252         initListView();
253         updateStrokeWith(getResources().getConfiguration().uiMode, mAlignment);
254     }
255 
256     @Override
onInterceptTouchEvent(@onNull RecyclerView recyclerView, @NonNull MotionEvent event)257     public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView,
258             @NonNull MotionEvent event) {
259         final int currentRawX = (int) event.getRawX();
260         final int currentRawY = (int) event.getRawY();
261 
262         switch (event.getAction()) {
263             case MotionEvent.ACTION_DOWN:
264                 fadeIn();
265 
266                 mDownX = currentRawX;
267                 mDownY = currentRawY;
268                 mRelativeToPointerDownX = mCurrentLayoutParams.x - mDownX;
269                 mRelativeToPointerDownY = mCurrentLayoutParams.y - mDownY;
270                 mListView.animate().translationX(0);
271                 break;
272             case MotionEvent.ACTION_MOVE:
273                 if (mIsDragging
274                         || hasExceededTouchSlop(mDownX, mDownY, currentRawX, currentRawY)) {
275                     if (!mIsDragging) {
276                         mIsDragging = true;
277                         setRadius(mRadius, RadiusType.OVAL);
278                         setInset(0, 0);
279                     }
280 
281                     mTemporaryShapeType =
282                             isMovingTowardsScreenEdge(mAlignment, currentRawX, mDownX)
283                                     ? ShapeType.HALF_OVAL
284                                     : ShapeType.OVAL;
285                     final int newWindowX = currentRawX + mRelativeToPointerDownX;
286                     final int newWindowY = currentRawY + mRelativeToPointerDownY;
287                     mCurrentLayoutParams.x =
288                             constrain(newWindowX, getMinWindowX(), getMaxWindowX());
289                     mCurrentLayoutParams.y = constrain(newWindowY, MIN_WINDOW_Y, getMaxWindowY());
290                     mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
291                 }
292                 break;
293             case MotionEvent.ACTION_UP:
294             case MotionEvent.ACTION_CANCEL:
295                 if (mIsDragging) {
296                     mIsDragging = false;
297 
298                     final int minX = getMinWindowX();
299                     final int maxX = getMaxWindowX();
300                     final int endX = mCurrentLayoutParams.x > ((minX + maxX) / 2)
301                             ? maxX : minX;
302                     final int endY = mCurrentLayoutParams.y;
303                     snapToLocation(endX, endY);
304 
305                     setShapeType(mTemporaryShapeType);
306 
307                     // Avoid triggering the listener of the item.
308                     return true;
309                 }
310 
311                 // Must switch the oval shape type before tapping the corresponding item in the
312                 // list view, otherwise it can't work on it.
313                 if (!isOvalShape()) {
314                     setShapeType(ShapeType.OVAL);
315 
316                     return true;
317                 }
318 
319                 fadeOut();
320                 break;
321             default: // Do nothing
322         }
323 
324         // not consume all the events here because keeping the scroll behavior of list view.
325         return false;
326     }
327 
328     @Override
onTouchEvent(@onNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent)329     public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
330         // Do Nothing
331     }
332 
333     @Override
onRequestDisallowInterceptTouchEvent(boolean b)334     public void onRequestDisallowInterceptTouchEvent(boolean b) {
335         // Do Nothing
336     }
337 
show()338     void show() {
339         if (isShowing()) {
340             return;
341         }
342 
343         mIsShowing = true;
344         mWindowManager.addView(this, mCurrentLayoutParams);
345 
346         setOnApplyWindowInsetsListener((view, insets) -> onWindowInsetsApplied(insets));
347         setSystemGestureExclusion();
348     }
349 
hide()350     void hide() {
351         if (!isShowing()) {
352             return;
353         }
354 
355         mIsShowing = false;
356         mWindowManager.removeView(this);
357 
358         setOnApplyWindowInsetsListener(null);
359         setSystemGestureExclusion();
360     }
361 
isShowing()362     boolean isShowing() {
363         return mIsShowing;
364     }
365 
isOvalShape()366     boolean isOvalShape() {
367         return mShapeType == ShapeType.OVAL;
368     }
369 
onTargetsChanged(List<AccessibilityTarget> newTargets)370     void onTargetsChanged(List<AccessibilityTarget> newTargets) {
371         fadeIn();
372 
373         mTargets.clear();
374         mTargets.addAll(newTargets);
375         onEnabledFeaturesChanged();
376 
377         updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
378         updateScrollModeWith(hasExceededMaxLayoutHeight());
379         setSystemGestureExclusion();
380 
381         fadeOut();
382     }
383 
setSizeType(@izeType int newSizeType)384     void setSizeType(@SizeType int newSizeType) {
385         fadeIn();
386 
387         mSizeType = newSizeType;
388 
389         updateItemViewWith(newSizeType);
390         updateRadiusWith(newSizeType, mRadiusType, mTargets.size());
391 
392         // When the icon sized changed, the menu size and location will be impacted.
393         updateLocationWith(mPosition);
394         updateScrollModeWith(hasExceededMaxLayoutHeight());
395         updateOffsetWith(mShapeType, mAlignment);
396         setSystemGestureExclusion();
397 
398         fadeOut();
399     }
400 
setShapeType(@hapeType int newShapeType)401     void setShapeType(@ShapeType int newShapeType) {
402         fadeIn();
403 
404         mShapeType = newShapeType;
405 
406         updateOffsetWith(newShapeType, mAlignment);
407 
408         setOnTouchListener(
409                 newShapeType == ShapeType.OVAL
410                         ? null
411                         : (view, event) -> onTouched(event));
412 
413         fadeOut();
414     }
415 
setOnDragEndListener(OnDragEndListener onDragEndListener)416     public void setOnDragEndListener(OnDragEndListener onDragEndListener) {
417         mOnDragEndListener = Optional.ofNullable(onDragEndListener);
418     }
419 
startTranslateXAnimation()420     void startTranslateXAnimation() {
421         fadeIn();
422 
423         final float toXValue = (mAlignment == Alignment.RIGHT)
424                 ? ANIMATION_TO_X_VALUE
425                 : -ANIMATION_TO_X_VALUE;
426         final TranslateAnimation animation =
427                 new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0,
428                         Animation.RELATIVE_TO_SELF, toXValue,
429                         Animation.RELATIVE_TO_SELF, 0,
430                         Animation.RELATIVE_TO_SELF, 0);
431         animation.setDuration(ANIMATION_DURATION_MS);
432         animation.setRepeatMode(Animation.REVERSE);
433         animation.setInterpolator(new OvershootInterpolator());
434         animation.setRepeatCount(Animation.INFINITE);
435         animation.setStartOffset(ANIMATION_START_OFFSET);
436         mListView.startAnimation(animation);
437     }
438 
stopTranslateXAnimation()439     void stopTranslateXAnimation() {
440         mListView.clearAnimation();
441 
442         fadeOut();
443     }
444 
getWindowLocationOnScreen()445     Rect getWindowLocationOnScreen() {
446         final int left = mCurrentLayoutParams.x;
447         final int top = mCurrentLayoutParams.y;
448         return new Rect(left, top, left + getWindowWidth(), top + getWindowHeight());
449     }
450 
updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue)451     void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) {
452         mIsFadeEffectEnabled = isFadeEffectEnabled;
453         mFadeOutValue = newOpacityValue;
454 
455         mFadeOutAnimator.cancel();
456         mFadeOutAnimator.setFloatValues(1.0f, mFadeOutValue);
457         setAlpha(mIsFadeEffectEnabled ? mFadeOutValue : /* completely opaque */ 1.0f);
458     }
459 
onEnabledFeaturesChanged()460     void onEnabledFeaturesChanged() {
461         mAdapter.notifyDataSetChanged();
462     }
463 
464     @VisibleForTesting
fadeIn()465     void fadeIn() {
466         if (!mIsFadeEffectEnabled) {
467             return;
468         }
469 
470         mFadeOutAnimator.cancel();
471         mUiHandler.removeCallbacksAndMessages(null);
472         mUiHandler.post(() -> setAlpha(/* completely opaque */ 1.0f));
473     }
474 
475     @VisibleForTesting
fadeOut()476     void fadeOut() {
477         if (!mIsFadeEffectEnabled) {
478             return;
479         }
480 
481         mUiHandler.postDelayed(() -> mFadeOutAnimator.start(), FADE_EFFECT_DURATION_MS);
482     }
483 
onTouched(MotionEvent event)484     private boolean onTouched(MotionEvent event) {
485         final int action = event.getAction();
486         final int currentX = (int) event.getX();
487         final int currentY = (int) event.getY();
488 
489         final int marginStartEnd = getMarginStartEndWith(mLastConfiguration);
490         final Rect touchDelegateBounds =
491                 new Rect(marginStartEnd, mMargin, marginStartEnd + getLayoutWidth(),
492                         mMargin + getLayoutHeight());
493         if (action == MotionEvent.ACTION_DOWN
494                 && touchDelegateBounds.contains(currentX, currentY)) {
495             mIsDownInEnlargedTouchArea = true;
496         }
497 
498         if (!mIsDownInEnlargedTouchArea) {
499             return false;
500         }
501 
502         if (action == MotionEvent.ACTION_UP
503                 || action == MotionEvent.ACTION_CANCEL) {
504             mIsDownInEnlargedTouchArea = false;
505         }
506 
507         // In order to correspond to the correct item of list view.
508         event.setLocation(currentX - mMargin, currentY - mMargin);
509         return mListView.dispatchTouchEvent(event);
510     }
511 
onWindowInsetsApplied(WindowInsets insets)512     private WindowInsets onWindowInsetsApplied(WindowInsets insets) {
513         final WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics();
514         final Rect displayWindowInsetsRect = getDisplayInsets(windowMetrics).toRect();
515         if (!displayWindowInsetsRect.equals(mDisplayInsetsRect)) {
516             updateDisplaySizeWith(windowMetrics);
517             updateLocationWith(mPosition);
518         }
519 
520         final Rect imeInsetsRect = windowMetrics.getWindowInsets().getInsets(ime()).toRect();
521         if (!imeInsetsRect.equals(mImeInsetsRect)) {
522             if (isImeVisible(imeInsetsRect)) {
523                 mImeInsetsRect.set(imeInsetsRect);
524             } else {
525                 mImeInsetsRect.setEmpty();
526             }
527 
528             updateLocationWith(mPosition);
529         }
530 
531         return insets;
532     }
533 
isMovingTowardsScreenEdge(@lignment int side, int currentRawX, int downX)534     private boolean isMovingTowardsScreenEdge(@Alignment int side, int currentRawX, int downX) {
535         return (side == Alignment.RIGHT && currentRawX > downX)
536                 || (side == Alignment.LEFT && downX > currentRawX);
537     }
538 
isImeVisible(Rect imeInsetsRect)539     private boolean isImeVisible(Rect imeInsetsRect) {
540         return imeInsetsRect.left != 0 || imeInsetsRect.top != 0 || imeInsetsRect.right != 0
541                 || imeInsetsRect.bottom != 0;
542     }
543 
hasExceededTouchSlop(int startX, int startY, int endX, int endY)544     private boolean hasExceededTouchSlop(int startX, int startY, int endX, int endY) {
545         return (sq(endX - startX) + sq(endY - startY)) > mSquareScaledTouchSlop;
546     }
547 
setRadius(float radius, @RadiusType int type)548     private void setRadius(float radius, @RadiusType int type) {
549         getMenuGradientDrawable().setCornerRadii(createRadii(radius, type));
550     }
551 
createRadii(float radius, @RadiusType int type)552     private float[] createRadii(float radius, @RadiusType int type) {
553         if (type == RadiusType.LEFT_HALF_OVAL) {
554             return new float[]{radius, radius, 0.0f, 0.0f, 0.0f, 0.0f, radius, radius};
555         }
556 
557         if (type == RadiusType.RIGHT_HALF_OVAL) {
558             return new float[]{0.0f, 0.0f, radius, radius, radius, radius, 0.0f, 0.0f};
559         }
560 
561         return new float[]{radius, radius, radius, radius, radius, radius, radius, radius};
562     }
563 
createUiHandler()564     private Handler createUiHandler() {
565         return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null"));
566     }
567 
updateDimensions()568     private void updateDimensions() {
569         final Resources res = getResources();
570 
571         updateDisplaySizeWith(mWindowManager.getCurrentWindowMetrics());
572 
573         mMargin =
574                 res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_margin);
575         mInset =
576                 res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_inset);
577 
578         mSquareScaledTouchSlop =
579                 sq(ViewConfiguration.get(getContext()).getScaledTouchSlop());
580 
581         updateItemViewDimensionsWith(mSizeType);
582     }
583 
updateDisplaySizeWith(WindowMetrics metrics)584     private void updateDisplaySizeWith(WindowMetrics metrics) {
585         final Rect displayBounds = metrics.getBounds();
586         final Insets displayInsets = getDisplayInsets(metrics);
587         mDisplayInsetsRect.set(displayInsets.toRect());
588         displayBounds.inset(displayInsets);
589         mDisplayWidth = displayBounds.width();
590         mDisplayHeight = displayBounds.height();
591     }
592 
updateItemViewDimensionsWith(@izeType int sizeType)593     private void updateItemViewDimensionsWith(@SizeType int sizeType) {
594         final Resources res = getResources();
595         final int paddingResId =
596                 sizeType == SizeType.SMALL
597                         ? R.dimen.accessibility_floating_menu_small_padding
598                         : R.dimen.accessibility_floating_menu_large_padding;
599         mPadding = res.getDimensionPixelSize(paddingResId);
600 
601         final int iconResId =
602                 sizeType == SizeType.SMALL
603                         ? R.dimen.accessibility_floating_menu_small_width_height
604                         : R.dimen.accessibility_floating_menu_large_width_height;
605         mIconWidth = res.getDimensionPixelSize(iconResId);
606         mIconHeight = mIconWidth;
607     }
608 
updateItemViewWith(@izeType int sizeType)609     private void updateItemViewWith(@SizeType int sizeType) {
610         updateItemViewDimensionsWith(sizeType);
611 
612         mAdapter.setItemPadding(mPadding);
613         mAdapter.setIconWidthHeight(mIconWidth);
614         mAdapter.notifyDataSetChanged();
615     }
616 
initListView()617     private void initListView() {
618         final Drawable background =
619                 getContext().getDrawable(R.drawable.accessibility_floating_menu_background);
620         final LinearLayoutManager layoutManager = new LinearLayoutManager(getContext());
621         final LayoutParams layoutParams =
622                 new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
623                         ViewGroup.LayoutParams.WRAP_CONTENT);
624         mListView.setLayoutParams(layoutParams);
625         final InstantInsetLayerDrawable layerDrawable =
626                 new InstantInsetLayerDrawable(new Drawable[]{background});
627         mListView.setBackground(layerDrawable);
628         mListView.setAdapter(mAdapter);
629         mListView.setLayoutManager(layoutManager);
630         mListView.addOnItemTouchListener(this);
631         mListView.animate().setInterpolator(new OvershootInterpolator());
632         mListView.setAccessibilityDelegateCompat(new RecyclerViewAccessibilityDelegate(mListView) {
633             @NonNull
634             @Override
635             public AccessibilityDelegateCompat getItemDelegate() {
636                 return new ItemDelegateCompat(this,
637                         AccessibilityFloatingMenuView.this);
638             }
639         });
640 
641         updateListViewWith(mLastConfiguration);
642 
643         addView(mListView);
644     }
645 
updateListViewWith(Configuration configuration)646     private void updateListViewWith(Configuration configuration) {
647         updateMarginWith(configuration);
648 
649         final int elevation =
650                 getResources().getDimensionPixelSize(R.dimen.accessibility_floating_menu_elevation);
651         mListView.setElevation(elevation);
652     }
653 
createDefaultLayoutParams()654     private WindowManager.LayoutParams createDefaultLayoutParams() {
655         final WindowManager.LayoutParams params = new WindowManager.LayoutParams(
656                 WindowManager.LayoutParams.WRAP_CONTENT,
657                 WindowManager.LayoutParams.WRAP_CONTENT,
658                 WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL,
659                 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
660                         | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
661                 PixelFormat.TRANSLUCENT);
662         params.receiveInsetsIgnoringZOrder = true;
663         params.privateFlags |= PRIVATE_FLAG_EXCLUDE_FROM_SCREEN_MAGNIFICATION;
664         params.windowAnimations = android.R.style.Animation_Translucent;
665         params.gravity = Gravity.START | Gravity.TOP;
666         params.x = (mAlignment == Alignment.RIGHT) ? getMaxWindowX() : getMinWindowX();
667 //        params.y = (int) (mPosition.getPercentageY() * getMaxWindowY());
668         final int currentLayoutY = (int) (mPosition.getPercentageY() * getMaxWindowY());
669         params.y = Math.max(MIN_WINDOW_Y, currentLayoutY - getInterval());
670         updateAccessibilityTitle(params);
671         return params;
672     }
673 
674     @Override
onConfigurationChanged(Configuration newConfig)675     protected void onConfigurationChanged(Configuration newConfig) {
676         super.onConfigurationChanged(newConfig);
677         mLastConfiguration.setTo(newConfig);
678 
679         final int diff = newConfig.diff(mLastConfiguration);
680         if ((diff & ActivityInfo.CONFIG_LOCALE) != 0) {
681             updateAccessibilityTitle(mCurrentLayoutParams);
682         }
683 
684         updateDimensions();
685         updateListViewWith(newConfig);
686         updateItemViewWith(mSizeType);
687         updateColor();
688         updateStrokeWith(newConfig.uiMode, mAlignment);
689         updateLocationWith(mPosition);
690         updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
691         updateScrollModeWith(hasExceededMaxLayoutHeight());
692         setSystemGestureExclusion();
693     }
694 
695     @VisibleForTesting
snapToLocation(int endX, int endY)696     void snapToLocation(int endX, int endY) {
697         mDragAnimator.cancel();
698         mDragAnimator.removeAllUpdateListeners();
699         mDragAnimator.addUpdateListener(anim -> onDragAnimationUpdate(anim, endX, endY));
700         mDragAnimator.start();
701     }
702 
onDragAnimationUpdate(ValueAnimator animator, int endX, int endY)703     private void onDragAnimationUpdate(ValueAnimator animator, int endX, int endY) {
704         float value = (float) animator.getAnimatedValue();
705         final int newX = (int) (((1 - value) * mCurrentLayoutParams.x) + (value * endX));
706         final int newY = (int) (((1 - value) * mCurrentLayoutParams.y) + (value * endY));
707 
708         mCurrentLayoutParams.x = newX;
709         mCurrentLayoutParams.y = newY;
710         mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
711     }
712 
getMinWindowX()713     private int getMinWindowX() {
714         return -getMarginStartEndWith(mLastConfiguration);
715     }
716 
getMaxWindowX()717     private int getMaxWindowX() {
718         return mDisplayWidth - getMarginStartEndWith(mLastConfiguration) - getLayoutWidth();
719     }
720 
getMaxWindowY()721     private int getMaxWindowY() {
722         return mDisplayHeight - getWindowHeight();
723     }
724 
getMenuLayerDrawable()725     private InstantInsetLayerDrawable getMenuLayerDrawable() {
726         return (InstantInsetLayerDrawable) mListView.getBackground();
727     }
728 
getMenuGradientDrawable()729     private GradientDrawable getMenuGradientDrawable() {
730         return (GradientDrawable) getMenuLayerDrawable().getDrawable(INDEX_MENU_ITEM);
731     }
732 
getDisplayInsets(WindowMetrics metrics)733     private Insets getDisplayInsets(WindowMetrics metrics) {
734         return metrics.getWindowInsets().getInsetsIgnoringVisibility(
735                 systemBars() | displayCutout());
736     }
737 
738     /**
739      * Updates the floating menu to be fixed at the side of the display.
740      */
updateLocationWith(Position position)741     private void updateLocationWith(Position position) {
742         final @Alignment int alignment = transformToAlignment(position.getPercentageX());
743         mCurrentLayoutParams.x = (alignment == Alignment.RIGHT) ? getMaxWindowX() : getMinWindowX();
744         final int currentLayoutY = (int) (position.getPercentageY() * getMaxWindowY());
745         mCurrentLayoutParams.y = Math.max(MIN_WINDOW_Y, currentLayoutY - getInterval());
746         mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
747     }
748 
749     /**
750      * Gets the moving interval to not overlap between the keyboard and menu view.
751      *
752      * @return the moving interval if they overlap each other, otherwise 0.
753      */
getInterval()754     private int getInterval() {
755         final int currentLayoutY = (int) (mPosition.getPercentageY() * getMaxWindowY());
756         final int imeY = mDisplayHeight - mImeInsetsRect.bottom;
757         final int layoutBottomY = currentLayoutY + getWindowHeight();
758 
759         return layoutBottomY > imeY ? (layoutBottomY - imeY) : 0;
760     }
761 
updateMarginWith(Configuration configuration)762     private void updateMarginWith(Configuration configuration) {
763         // Avoid overlapping with system bars under landscape mode, update the margins of the menu
764         // to align the edge of system bars.
765         final int marginStartEnd = getMarginStartEndWith(configuration);
766         final LayoutParams layoutParams = (FrameLayout.LayoutParams) mListView.getLayoutParams();
767         layoutParams.setMargins(marginStartEnd, mMargin, marginStartEnd, mMargin);
768         mListView.setLayoutParams(layoutParams);
769     }
770 
updateOffsetWith(@hapeType int shapeType, @Alignment int side)771     private void updateOffsetWith(@ShapeType int shapeType, @Alignment int side) {
772         final float halfWidth = getLayoutWidth() / 2.0f;
773         final float offset = (shapeType == ShapeType.OVAL) ? 0 : halfWidth;
774         mListView.animate().translationX(side == Alignment.RIGHT ? offset : -offset);
775     }
776 
updateScrollModeWith(boolean hasExceededMaxLayoutHeight)777     private void updateScrollModeWith(boolean hasExceededMaxLayoutHeight) {
778         mListView.setOverScrollMode(hasExceededMaxLayoutHeight
779                 ? OVER_SCROLL_ALWAYS
780                 : OVER_SCROLL_NEVER);
781     }
782 
updateColor()783     private void updateColor() {
784         final int menuColorResId = R.color.accessibility_floating_menu_background;
785         getMenuGradientDrawable().setColor(getResources().getColor(menuColorResId));
786     }
787 
updateStrokeWith(int uiMode, @Alignment int side)788     private void updateStrokeWith(int uiMode, @Alignment int side) {
789         updateInsetWith(uiMode, side);
790 
791         final boolean isNightMode =
792                 (uiMode & Configuration.UI_MODE_NIGHT_MASK)
793                         == Configuration.UI_MODE_NIGHT_YES;
794         final Resources res = getResources();
795         final int width =
796                 res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_width);
797         final int strokeWidth = isNightMode ? width : 0;
798         final int strokeColor = res.getColor(R.color.accessibility_floating_menu_stroke_dark);
799         getMenuGradientDrawable().setStroke(strokeWidth, strokeColor);
800     }
801 
updateRadiusWith(@izeType int sizeType, @RadiusType int radiusType, int itemCount)802     private void updateRadiusWith(@SizeType int sizeType, @RadiusType int radiusType,
803             int itemCount) {
804         mRadius =
805                 getResources().getDimensionPixelSize(getRadiusResId(sizeType, itemCount));
806         setRadius(mRadius, radiusType);
807     }
808 
updateInsetWith(int uiMode, @Alignment int side)809     private void updateInsetWith(int uiMode, @Alignment int side) {
810         final boolean isNightMode =
811                 (uiMode & Configuration.UI_MODE_NIGHT_MASK)
812                         == Configuration.UI_MODE_NIGHT_YES;
813 
814         final int layerInset = isNightMode ? mInset : 0;
815         final int insetLeft = (side == Alignment.LEFT) ? layerInset : 0;
816         final int insetRight = (side == Alignment.RIGHT) ? layerInset : 0;
817         setInset(insetLeft, insetRight);
818     }
819 
updateAccessibilityTitle(WindowManager.LayoutParams params)820     private void updateAccessibilityTitle(WindowManager.LayoutParams params) {
821         params.accessibilityTitle = getResources().getString(
822                 com.android.internal.R.string.accessibility_select_shortcut_menu_title);
823     }
824 
setInset(int left, int right)825     private void setInset(int left, int right) {
826         final LayerDrawable layerDrawable = getMenuLayerDrawable();
827         if (layerDrawable.getLayerInsetLeft(INDEX_MENU_ITEM) == left
828                 && layerDrawable.getLayerInsetRight(INDEX_MENU_ITEM) == right) {
829             return;
830         }
831 
832         layerDrawable.setLayerInset(INDEX_MENU_ITEM, left, 0, right, 0);
833     }
834 
835     @VisibleForTesting
hasExceededMaxLayoutHeight()836     boolean hasExceededMaxLayoutHeight() {
837         return calculateActualLayoutHeight() > getMaxLayoutHeight();
838     }
839 
840     @Alignment
transformToAlignment(@loatRangefrom = 0.0, to = 1.0) float percentageX)841     private int transformToAlignment(@FloatRange(from = 0.0, to = 1.0) float percentageX) {
842         return (percentageX < 0.5f) ? Alignment.LEFT : Alignment.RIGHT;
843     }
844 
transformCurrentPercentageXToEdge()845     private float transformCurrentPercentageXToEdge() {
846         final float percentageX = calculateCurrentPercentageX();
847         return (percentageX < 0.5) ? 0.0f : 1.0f;
848     }
849 
calculateCurrentPercentageX()850     private float calculateCurrentPercentageX() {
851         return mCurrentLayoutParams.x / (float) getMaxWindowX();
852     }
853 
calculateCurrentPercentageY()854     private float calculateCurrentPercentageY() {
855         return mCurrentLayoutParams.y / (float) getMaxWindowY();
856     }
857 
calculateActualLayoutHeight()858     private int calculateActualLayoutHeight() {
859         return (mPadding + mIconHeight) * mTargets.size() + mPadding;
860     }
861 
getMarginStartEndWith(Configuration configuration)862     private int getMarginStartEndWith(Configuration configuration) {
863         return configuration != null
864                 && configuration.orientation == ORIENTATION_PORTRAIT
865                 ? mMargin : 0;
866     }
867 
getRadiusResId(@izeType int sizeType, int itemCount)868     private @DimenRes int getRadiusResId(@SizeType int sizeType, int itemCount) {
869         return sizeType == SizeType.SMALL
870                 ? getSmallSizeResIdWith(itemCount)
871                 : getLargeSizeResIdWith(itemCount);
872     }
873 
getSmallSizeResIdWith(int itemCount)874     private int getSmallSizeResIdWith(int itemCount) {
875         return itemCount > 1
876                 ? R.dimen.accessibility_floating_menu_small_multiple_radius
877                 : R.dimen.accessibility_floating_menu_small_single_radius;
878     }
879 
getLargeSizeResIdWith(int itemCount)880     private int getLargeSizeResIdWith(int itemCount) {
881         return itemCount > 1
882                 ? R.dimen.accessibility_floating_menu_large_multiple_radius
883                 : R.dimen.accessibility_floating_menu_large_single_radius;
884     }
885 
886     @VisibleForTesting
getAvailableBounds()887     Rect getAvailableBounds() {
888         return new Rect(0, 0, mDisplayWidth - getWindowWidth(),
889                 mDisplayHeight - getWindowHeight());
890     }
891 
getMaxLayoutHeight()892     private int getMaxLayoutHeight() {
893         return mDisplayHeight - mMargin * 2;
894     }
895 
getLayoutWidth()896     private int getLayoutWidth() {
897         return mPadding * 2 + mIconWidth;
898     }
899 
getLayoutHeight()900     private int getLayoutHeight() {
901         return Math.min(getMaxLayoutHeight(), calculateActualLayoutHeight());
902     }
903 
getWindowWidth()904     private int getWindowWidth() {
905         return getMarginStartEndWith(mLastConfiguration) * 2 + getLayoutWidth();
906     }
907 
getWindowHeight()908     private int getWindowHeight() {
909         return Math.min(mDisplayHeight, mMargin * 2 + getLayoutHeight());
910     }
911 
setSystemGestureExclusion()912     private void setSystemGestureExclusion() {
913         final Rect excludeZone =
914                 new Rect(0, 0, getWindowWidth(), getWindowHeight());
915         post(() -> setSystemGestureExclusionRects(
916                 mIsShowing
917                         ? Collections.singletonList(excludeZone)
918                         : Collections.emptyList()));
919     }
920 }
921