1 /*
2  * Copyright (C) 2020 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;
18 
19 import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_NONE;
20 import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_WINDOW;
21 import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
22 
23 import android.annotation.NonNull;
24 import android.annotation.UiContext;
25 import android.content.ComponentCallbacks;
26 import android.content.Context;
27 import android.content.pm.ActivityInfo;
28 import android.content.res.Configuration;
29 import android.graphics.Insets;
30 import android.graphics.PixelFormat;
31 import android.graphics.Rect;
32 import android.os.Bundle;
33 import android.provider.Settings;
34 import android.util.MathUtils;
35 import android.view.Gravity;
36 import android.view.MotionEvent;
37 import android.view.View;
38 import android.view.WindowInsets;
39 import android.view.WindowManager;
40 import android.view.WindowManager.LayoutParams;
41 import android.view.WindowMetrics;
42 import android.view.accessibility.AccessibilityManager;
43 import android.view.accessibility.AccessibilityNodeInfo;
44 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
45 import android.widget.ImageView;
46 
47 import com.android.internal.annotations.VisibleForTesting;
48 import com.android.internal.graphics.SfVsyncFrameCallbackProvider;
49 import com.android.systemui.R;
50 
51 import java.util.Collections;
52 
53 /**
54  * Shows/hides a {@link android.widget.ImageView} on the screen and changes the values of
55  * {@link Settings.Secure#ACCESSIBILITY_MAGNIFICATION_MODE} when the UI is toggled.
56  * The button icon is movable by dragging and it would not overlap navigation bar window.
57  * And the button UI would automatically be dismissed after displaying for a period of time.
58  */
59 class MagnificationModeSwitch implements MagnificationGestureDetector.OnGestureListener,
60         ComponentCallbacks {
61 
62     @VisibleForTesting
63     static final long FADING_ANIMATION_DURATION_MS = 300;
64     @VisibleForTesting
65     static final int DEFAULT_FADE_OUT_ANIMATION_DELAY_MS = 5000;
66     private int mUiTimeout;
67     private final Runnable mFadeInAnimationTask;
68     private final Runnable mFadeOutAnimationTask;
69     @VisibleForTesting
70     boolean mIsFadeOutAnimating = false;
71 
72     private final Context mContext;
73     private final AccessibilityManager mAccessibilityManager;
74     private final WindowManager mWindowManager;
75     private final ImageView mImageView;
76     private final Runnable mWindowInsetChangeRunnable;
77     private final SfVsyncFrameCallbackProvider mSfVsyncFrameProvider;
78     private int mMagnificationMode = ACCESSIBILITY_MAGNIFICATION_MODE_NONE;
79     private final LayoutParams mParams;
80     private final ClickListener mClickListener;
81     private final Configuration mConfiguration;
82     @VisibleForTesting
83     final Rect mDraggableWindowBounds = new Rect();
84     private boolean mIsVisible = false;
85     private final MagnificationGestureDetector mGestureDetector;
86     private boolean mSingleTapDetected = false;
87     private boolean mToLeftScreenEdge = false;
88 
89     public interface ClickListener {
90         /**
91          * Called when the switch is clicked to change the magnification mode.
92          * @param displayId the display id of the display to which the view's window has been
93          *                  attached
94          */
onClick(int displayId)95         void onClick(int displayId);
96     }
97 
MagnificationModeSwitch(@iContext Context context, ClickListener clickListener)98     MagnificationModeSwitch(@UiContext Context context, ClickListener clickListener) {
99         this(context, createView(context), new SfVsyncFrameCallbackProvider(), clickListener);
100     }
101 
102     @VisibleForTesting
MagnificationModeSwitch(Context context, @NonNull ImageView imageView, SfVsyncFrameCallbackProvider sfVsyncFrameProvider, ClickListener clickListener)103     MagnificationModeSwitch(Context context, @NonNull ImageView imageView,
104             SfVsyncFrameCallbackProvider sfVsyncFrameProvider, ClickListener clickListener) {
105         mContext = context;
106         mConfiguration = new Configuration(context.getResources().getConfiguration());
107         mAccessibilityManager = mContext.getSystemService(AccessibilityManager.class);
108         mWindowManager = mContext.getSystemService(WindowManager.class);
109         mSfVsyncFrameProvider = sfVsyncFrameProvider;
110         mClickListener = clickListener;
111         mParams = createLayoutParams(context);
112         mImageView = imageView;
113         mImageView.setOnTouchListener(this::onTouch);
114         mImageView.setAccessibilityDelegate(new View.AccessibilityDelegate() {
115             @Override
116             public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
117                 super.onInitializeAccessibilityNodeInfo(host, info);
118                 info.setStateDescription(formatStateDescription());
119                 info.setContentDescription(mContext.getResources().getString(
120                         R.string.magnification_mode_switch_description));
121                 final AccessibilityAction clickAction = new AccessibilityAction(
122                         AccessibilityAction.ACTION_CLICK.getId(), mContext.getResources().getString(
123                         R.string.magnification_open_settings_click_label));
124                 info.addAction(clickAction);
125                 info.setClickable(true);
126                 info.addAction(new AccessibilityAction(R.id.accessibility_action_move_up,
127                         mContext.getString(R.string.accessibility_control_move_up)));
128                 info.addAction(new AccessibilityAction(R.id.accessibility_action_move_down,
129                         mContext.getString(R.string.accessibility_control_move_down)));
130                 info.addAction(new AccessibilityAction(R.id.accessibility_action_move_left,
131                         mContext.getString(R.string.accessibility_control_move_left)));
132                 info.addAction(new AccessibilityAction(R.id.accessibility_action_move_right,
133                         mContext.getString(R.string.accessibility_control_move_right)));
134             }
135 
136             @Override
137             public boolean performAccessibilityAction(View host, int action, Bundle args) {
138                 if (performA11yAction(action)) {
139                     return true;
140                 }
141                 return super.performAccessibilityAction(host, action, args);
142             }
143 
144             private boolean performA11yAction(int action) {
145                 final Rect windowBounds = mWindowManager.getCurrentWindowMetrics().getBounds();
146                 if (action == AccessibilityAction.ACTION_CLICK.getId()) {
147                     handleSingleTap();
148                 } else if (action == R.id.accessibility_action_move_up) {
149                     moveButton(0, -windowBounds.height());
150                 } else if (action == R.id.accessibility_action_move_down) {
151                     moveButton(0, windowBounds.height());
152                 } else if (action == R.id.accessibility_action_move_left) {
153                     moveButton(-windowBounds.width(), 0);
154                 } else if (action == R.id.accessibility_action_move_right) {
155                     moveButton(windowBounds.width(), 0);
156                 } else {
157                     return false;
158                 }
159                 return true;
160             }
161         });
162         mWindowInsetChangeRunnable = this::onWindowInsetChanged;
163         mImageView.setOnApplyWindowInsetsListener((v, insets) -> {
164             // Adds a pending post check to avoiding redundant calculation because this callback
165             // is sent frequently when the switch icon window dragged by the users.
166             if (!mImageView.getHandler().hasCallbacks(mWindowInsetChangeRunnable)) {
167                 mImageView.getHandler().post(mWindowInsetChangeRunnable);
168             }
169             return v.onApplyWindowInsets(insets);
170         });
171 
172         mFadeInAnimationTask = () -> {
173             mImageView.animate()
174                     .alpha(1f)
175                     .setDuration(FADING_ANIMATION_DURATION_MS)
176                     .start();
177         };
178         mFadeOutAnimationTask = () -> {
179             mImageView.animate()
180                     .alpha(0f)
181                     .setDuration(FADING_ANIMATION_DURATION_MS)
182                     .withEndAction(() -> removeButton())
183                     .start();
184             mIsFadeOutAnimating = true;
185         };
186         mGestureDetector = new MagnificationGestureDetector(context,
187                 context.getMainThreadHandler(), this);
188     }
189 
formatStateDescription()190     private CharSequence formatStateDescription() {
191         final int stringId = mMagnificationMode == ACCESSIBILITY_MAGNIFICATION_MODE_WINDOW
192                 ? R.string.magnification_mode_switch_state_window
193                 : R.string.magnification_mode_switch_state_full_screen;
194         return mContext.getResources().getString(stringId);
195     }
196 
applyResourcesValuesWithDensityChanged()197     private void applyResourcesValuesWithDensityChanged() {
198         final int size = mContext.getResources().getDimensionPixelSize(
199                 R.dimen.magnification_switch_button_size);
200         mParams.height = size;
201         mParams.width = size;
202         if (mIsVisible) {
203             stickToScreenEdge(mToLeftScreenEdge);
204             // Reset button to make its window layer always above the mirror window.
205             removeButton();
206             showButton(mMagnificationMode, /* resetPosition= */false);
207         }
208     }
209 
onTouch(View v, MotionEvent event)210     private boolean onTouch(View v, MotionEvent event) {
211         if (!mIsVisible) {
212             return false;
213         }
214         return mGestureDetector.onTouch(v, event);
215     }
216 
217     @Override
onSingleTap(View v)218     public boolean onSingleTap(View v) {
219         mSingleTapDetected = true;
220         handleSingleTap();
221         return true;
222     }
223 
224     @Override
onDrag(View v, float offsetX, float offsetY)225     public boolean onDrag(View v, float offsetX, float offsetY) {
226         moveButton(offsetX, offsetY);
227         return true;
228     }
229 
230     @Override
onStart(float x, float y)231     public boolean onStart(float x, float y) {
232         stopFadeOutAnimation();
233         return true;
234     }
235 
236     @Override
onFinish(float xOffset, float yOffset)237     public boolean onFinish(float xOffset, float yOffset) {
238         if (mIsVisible) {
239             final int windowWidth = mWindowManager.getCurrentWindowMetrics().getBounds().width();
240             final int halfWindowWidth = windowWidth / 2;
241             mToLeftScreenEdge = (mParams.x < halfWindowWidth);
242             stickToScreenEdge(mToLeftScreenEdge);
243         }
244         if (!mSingleTapDetected) {
245             showButton(mMagnificationMode);
246         }
247         mSingleTapDetected = false;
248         return true;
249     }
250 
stickToScreenEdge(boolean toLeftScreenEdge)251     private void stickToScreenEdge(boolean toLeftScreenEdge) {
252         mParams.x = toLeftScreenEdge
253                 ? mDraggableWindowBounds.left : mDraggableWindowBounds.right;
254         updateButtonViewLayoutIfNeeded();
255     }
256 
moveButton(float offsetX, float offsetY)257     private void moveButton(float offsetX, float offsetY) {
258         mSfVsyncFrameProvider.postFrameCallback(l -> {
259             mParams.x += offsetX;
260             mParams.y += offsetY;
261             updateButtonViewLayoutIfNeeded();
262         });
263     }
264 
removeButton()265     void removeButton() {
266         if (!mIsVisible) {
267             return;
268         }
269         // Reset button status.
270         mImageView.removeCallbacks(mFadeInAnimationTask);
271         mImageView.removeCallbacks(mFadeOutAnimationTask);
272         mImageView.animate().cancel();
273         mIsFadeOutAnimating = false;
274         mImageView.setAlpha(0f);
275         mWindowManager.removeView(mImageView);
276         mContext.unregisterComponentCallbacks(this);
277         mIsVisible = false;
278     }
279 
showButton(int mode)280     void showButton(int mode) {
281         showButton(mode, true);
282     }
283 
284     /**
285      * Shows magnification switch button for the specified magnification mode.
286      * When the button is going to be visible by calling this method, the layout position can be
287      * reset depending on the flag.
288      *
289      * @param mode          The magnification mode
290      * @param resetPosition if the button position needs be reset
291      */
showButton(int mode, boolean resetPosition)292     private void showButton(int mode, boolean resetPosition) {
293         if (mode != Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_FULLSCREEN) {
294             return;
295         }
296         if (mMagnificationMode != mode) {
297             mMagnificationMode = mode;
298             mImageView.setImageResource(getIconResId(mMagnificationMode));
299         }
300         if (!mIsVisible) {
301             onConfigurationChanged(mContext.getResources().getConfiguration());
302             mContext.registerComponentCallbacks(this);
303             if (resetPosition) {
304                 mDraggableWindowBounds.set(getDraggableWindowBounds());
305                 mParams.x = mDraggableWindowBounds.right;
306                 mParams.y = mDraggableWindowBounds.bottom;
307                 mToLeftScreenEdge = false;
308             }
309             mWindowManager.addView(mImageView, mParams);
310             // Exclude magnification switch button from system gesture area.
311             setSystemGestureExclusion();
312             mIsVisible = true;
313             mImageView.postOnAnimation(mFadeInAnimationTask);
314             mUiTimeout = mAccessibilityManager.getRecommendedTimeoutMillis(
315                     DEFAULT_FADE_OUT_ANIMATION_DELAY_MS,
316                     AccessibilityManager.FLAG_CONTENT_ICONS
317                             | AccessibilityManager.FLAG_CONTENT_CONTROLS);
318         }
319         // Refresh the time slot of the fade-out task whenever this method is called.
320         stopFadeOutAnimation();
321         mImageView.postOnAnimationDelayed(mFadeOutAnimationTask, mUiTimeout);
322     }
323 
stopFadeOutAnimation()324     private void stopFadeOutAnimation() {
325         mImageView.removeCallbacks(mFadeOutAnimationTask);
326         if (mIsFadeOutAnimating) {
327             mImageView.animate().cancel();
328             mImageView.setAlpha(1f);
329             mIsFadeOutAnimating = false;
330         }
331     }
332 
333     @Override
onConfigurationChanged(@onNull Configuration newConfig)334     public void onConfigurationChanged(@NonNull Configuration newConfig) {
335         final int configDiff = newConfig.diff(mConfiguration);
336         mConfiguration.setTo(newConfig);
337         onConfigurationChanged(configDiff);
338     }
339 
340     @Override
onLowMemory()341     public void onLowMemory() {
342     }
343 
onConfigurationChanged(int configDiff)344     void onConfigurationChanged(int configDiff) {
345         if (configDiff == 0) {
346             return;
347         }
348         if ((configDiff & (ActivityInfo.CONFIG_ORIENTATION | ActivityInfo.CONFIG_SCREEN_SIZE))
349                 != 0) {
350             final Rect previousDraggableBounds = new Rect(mDraggableWindowBounds);
351             mDraggableWindowBounds.set(getDraggableWindowBounds());
352             // Keep the Y position with the same height ratio before the window bounds and
353             // draggable bounds are changed.
354             final float windowHeightFraction = (float) (mParams.y - previousDraggableBounds.top)
355                     / previousDraggableBounds.height();
356             mParams.y = (int) (windowHeightFraction * mDraggableWindowBounds.height())
357                     + mDraggableWindowBounds.top;
358             stickToScreenEdge(mToLeftScreenEdge);
359             return;
360         }
361         if ((configDiff & ActivityInfo.CONFIG_DENSITY) != 0) {
362             applyResourcesValuesWithDensityChanged();
363             return;
364         }
365         if ((configDiff & ActivityInfo.CONFIG_LOCALE) != 0) {
366             updateAccessibilityWindowTitle();
367             return;
368         }
369     }
370 
onWindowInsetChanged()371     private void onWindowInsetChanged() {
372         final Rect newBounds = getDraggableWindowBounds();
373         if (mDraggableWindowBounds.equals(newBounds)) {
374             return;
375         }
376         mDraggableWindowBounds.set(newBounds);
377         stickToScreenEdge(mToLeftScreenEdge);
378     }
379 
updateButtonViewLayoutIfNeeded()380     private void updateButtonViewLayoutIfNeeded() {
381         if (mIsVisible) {
382             mParams.x = MathUtils.constrain(mParams.x, mDraggableWindowBounds.left,
383                     mDraggableWindowBounds.right);
384             mParams.y = MathUtils.constrain(mParams.y, mDraggableWindowBounds.top,
385                     mDraggableWindowBounds.bottom);
386             mWindowManager.updateViewLayout(mImageView, mParams);
387         }
388     }
389 
updateAccessibilityWindowTitle()390     private void updateAccessibilityWindowTitle() {
391         mParams.accessibilityTitle = getAccessibilityWindowTitle(mContext);
392         if (mIsVisible) {
393             mWindowManager.updateViewLayout(mImageView, mParams);
394         }
395     }
396 
handleSingleTap()397     private void handleSingleTap() {
398         removeButton();
399         mClickListener.onClick(mContext.getDisplayId());
400     }
401 
createView(Context context)402     private static ImageView createView(Context context) {
403         ImageView imageView = new ImageView(context);
404         imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
405         imageView.setClickable(true);
406         imageView.setFocusable(true);
407         imageView.setAlpha(0f);
408         return imageView;
409     }
410 
411     @VisibleForTesting
getIconResId(int mode)412     static int getIconResId(int mode) { // TODO(b/242233514): delete non used param
413         return R.drawable.ic_open_in_new_window;
414     }
415 
createLayoutParams(Context context)416     private static LayoutParams createLayoutParams(Context context) {
417         final int size = context.getResources().getDimensionPixelSize(
418                 R.dimen.magnification_switch_button_size);
419         final LayoutParams params = new LayoutParams(
420                 size,
421                 size,
422                 LayoutParams.TYPE_ACCESSIBILITY_MAGNIFICATION_OVERLAY,
423                 LayoutParams.FLAG_NOT_FOCUSABLE,
424                 PixelFormat.TRANSPARENT);
425         params.gravity = Gravity.TOP | Gravity.LEFT;
426         params.accessibilityTitle = getAccessibilityWindowTitle(context);
427         params.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
428         return params;
429     }
430 
getDraggableWindowBounds()431     private Rect getDraggableWindowBounds() {
432         final int layoutMargin = mContext.getResources().getDimensionPixelSize(
433                 R.dimen.magnification_switch_button_margin);
434         final WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics();
435         final Insets windowInsets = windowMetrics.getWindowInsets().getInsetsIgnoringVisibility(
436                 WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
437         final Rect boundRect = new Rect(windowMetrics.getBounds());
438         boundRect.offsetTo(0, 0);
439         boundRect.inset(0, 0, mParams.width, mParams.height);
440         boundRect.inset(windowInsets);
441         boundRect.inset(layoutMargin, layoutMargin);
442         return boundRect;
443     }
444 
getAccessibilityWindowTitle(Context context)445     private static String getAccessibilityWindowTitle(Context context) {
446         return context.getString(com.android.internal.R.string.android_system_label);
447     }
448 
setSystemGestureExclusion()449     private void setSystemGestureExclusion() {
450         mImageView.post(() -> {
451             mImageView.setSystemGestureExclusionRects(
452                     Collections.singletonList(
453                             new Rect(0, 0, mImageView.getWidth(), mImageView.getHeight())));
454         });
455     }
456 }