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 }