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