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