| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.accessibility; |
| |
| import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_NONE; |
| import static android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_WINDOW; |
| import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; |
| |
| import android.annotation.NonNull; |
| import android.annotation.UiContext; |
| import android.content.ComponentCallbacks; |
| import android.content.Context; |
| import android.content.pm.ActivityInfo; |
| import android.content.res.Configuration; |
| import android.graphics.Insets; |
| import android.graphics.PixelFormat; |
| import android.graphics.Rect; |
| import android.os.Bundle; |
| import android.provider.Settings; |
| import android.util.MathUtils; |
| import android.view.Gravity; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.WindowInsets; |
| import android.view.WindowManager; |
| import android.view.WindowManager.LayoutParams; |
| import android.view.WindowMetrics; |
| import android.view.accessibility.AccessibilityManager; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; |
| import android.widget.ImageView; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.graphics.SfVsyncFrameCallbackProvider; |
| import com.android.systemui.R; |
| |
| import java.util.Collections; |
| |
| /** |
| * Shows/hides a {@link android.widget.ImageView} on the screen and changes the values of |
| * {@link Settings.Secure#ACCESSIBILITY_MAGNIFICATION_MODE} when the UI is toggled. |
| * The button icon is movable by dragging and it would not overlap navigation bar window. |
| * And the button UI would automatically be dismissed after displaying for a period of time. |
| */ |
| class MagnificationModeSwitch implements MagnificationGestureDetector.OnGestureListener, |
| ComponentCallbacks { |
| |
| @VisibleForTesting |
| static final long FADING_ANIMATION_DURATION_MS = 300; |
| @VisibleForTesting |
| static final int DEFAULT_FADE_OUT_ANIMATION_DELAY_MS = 5000; |
| private int mUiTimeout; |
| private final Runnable mFadeInAnimationTask; |
| private final Runnable mFadeOutAnimationTask; |
| @VisibleForTesting |
| boolean mIsFadeOutAnimating = false; |
| |
| private final Context mContext; |
| private final AccessibilityManager mAccessibilityManager; |
| private final WindowManager mWindowManager; |
| private final ImageView mImageView; |
| private final Runnable mWindowInsetChangeRunnable; |
| private final SfVsyncFrameCallbackProvider mSfVsyncFrameProvider; |
| private int mMagnificationMode = ACCESSIBILITY_MAGNIFICATION_MODE_NONE; |
| private final LayoutParams mParams; |
| private final ClickListener mClickListener; |
| private final Configuration mConfiguration; |
| @VisibleForTesting |
| final Rect mDraggableWindowBounds = new Rect(); |
| private boolean mIsVisible = false; |
| private final MagnificationGestureDetector mGestureDetector; |
| private boolean mSingleTapDetected = false; |
| private boolean mToLeftScreenEdge = false; |
| |
| public interface ClickListener { |
| /** |
| * Called when the switch is clicked to change the magnification mode. |
| * @param displayId the display id of the display to which the view's window has been |
| * attached |
| */ |
| void onClick(int displayId); |
| } |
| |
| MagnificationModeSwitch(@UiContext Context context, ClickListener clickListener) { |
| this(context, createView(context), new SfVsyncFrameCallbackProvider(), clickListener); |
| } |
| |
| @VisibleForTesting |
| MagnificationModeSwitch(Context context, @NonNull ImageView imageView, |
| SfVsyncFrameCallbackProvider sfVsyncFrameProvider, ClickListener clickListener) { |
| mContext = context; |
| mConfiguration = new Configuration(context.getResources().getConfiguration()); |
| mAccessibilityManager = mContext.getSystemService(AccessibilityManager.class); |
| mWindowManager = mContext.getSystemService(WindowManager.class); |
| mSfVsyncFrameProvider = sfVsyncFrameProvider; |
| mClickListener = clickListener; |
| mParams = createLayoutParams(context); |
| mImageView = imageView; |
| mImageView.setOnTouchListener(this::onTouch); |
| mImageView.setAccessibilityDelegate(new View.AccessibilityDelegate() { |
| @Override |
| public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { |
| super.onInitializeAccessibilityNodeInfo(host, info); |
| info.setStateDescription(formatStateDescription()); |
| info.setContentDescription(mContext.getResources().getString( |
| R.string.magnification_mode_switch_description)); |
| final AccessibilityAction clickAction = new AccessibilityAction( |
| AccessibilityAction.ACTION_CLICK.getId(), mContext.getResources().getString( |
| R.string.magnification_open_settings_click_label)); |
| info.addAction(clickAction); |
| info.setClickable(true); |
| info.addAction(new AccessibilityAction(R.id.accessibility_action_move_up, |
| mContext.getString(R.string.accessibility_control_move_up))); |
| info.addAction(new AccessibilityAction(R.id.accessibility_action_move_down, |
| mContext.getString(R.string.accessibility_control_move_down))); |
| info.addAction(new AccessibilityAction(R.id.accessibility_action_move_left, |
| mContext.getString(R.string.accessibility_control_move_left))); |
| info.addAction(new AccessibilityAction(R.id.accessibility_action_move_right, |
| mContext.getString(R.string.accessibility_control_move_right))); |
| } |
| |
| @Override |
| public boolean performAccessibilityAction(View host, int action, Bundle args) { |
| if (performA11yAction(action)) { |
| return true; |
| } |
| return super.performAccessibilityAction(host, action, args); |
| } |
| |
| private boolean performA11yAction(int action) { |
| final Rect windowBounds = mWindowManager.getCurrentWindowMetrics().getBounds(); |
| if (action == AccessibilityAction.ACTION_CLICK.getId()) { |
| handleSingleTap(); |
| } else if (action == R.id.accessibility_action_move_up) { |
| moveButton(0, -windowBounds.height()); |
| } else if (action == R.id.accessibility_action_move_down) { |
| moveButton(0, windowBounds.height()); |
| } else if (action == R.id.accessibility_action_move_left) { |
| moveButton(-windowBounds.width(), 0); |
| } else if (action == R.id.accessibility_action_move_right) { |
| moveButton(windowBounds.width(), 0); |
| } else { |
| return false; |
| } |
| return true; |
| } |
| }); |
| mWindowInsetChangeRunnable = this::onWindowInsetChanged; |
| mImageView.setOnApplyWindowInsetsListener((v, insets) -> { |
| // Adds a pending post check to avoiding redundant calculation because this callback |
| // is sent frequently when the switch icon window dragged by the users. |
| if (!mImageView.getHandler().hasCallbacks(mWindowInsetChangeRunnable)) { |
| mImageView.getHandler().post(mWindowInsetChangeRunnable); |
| } |
| return v.onApplyWindowInsets(insets); |
| }); |
| |
| mFadeInAnimationTask = () -> { |
| mImageView.animate() |
| .alpha(1f) |
| .setDuration(FADING_ANIMATION_DURATION_MS) |
| .start(); |
| }; |
| mFadeOutAnimationTask = () -> { |
| mImageView.animate() |
| .alpha(0f) |
| .setDuration(FADING_ANIMATION_DURATION_MS) |
| .withEndAction(() -> removeButton()) |
| .start(); |
| mIsFadeOutAnimating = true; |
| }; |
| mGestureDetector = new MagnificationGestureDetector(context, |
| context.getMainThreadHandler(), this); |
| } |
| |
| private CharSequence formatStateDescription() { |
| final int stringId = mMagnificationMode == ACCESSIBILITY_MAGNIFICATION_MODE_WINDOW |
| ? R.string.magnification_mode_switch_state_window |
| : R.string.magnification_mode_switch_state_full_screen; |
| return mContext.getResources().getString(stringId); |
| } |
| |
| private void applyResourcesValuesWithDensityChanged() { |
| final int size = mContext.getResources().getDimensionPixelSize( |
| R.dimen.magnification_switch_button_size); |
| mParams.height = size; |
| mParams.width = size; |
| if (mIsVisible) { |
| stickToScreenEdge(mToLeftScreenEdge); |
| // Reset button to make its window layer always above the mirror window. |
| removeButton(); |
| showButton(mMagnificationMode, /* resetPosition= */false); |
| } |
| } |
| |
| private boolean onTouch(View v, MotionEvent event) { |
| if (!mIsVisible) { |
| return false; |
| } |
| return mGestureDetector.onTouch(v, event); |
| } |
| |
| @Override |
| public boolean onSingleTap(View v) { |
| mSingleTapDetected = true; |
| handleSingleTap(); |
| return true; |
| } |
| |
| @Override |
| public boolean onDrag(View v, float offsetX, float offsetY) { |
| moveButton(offsetX, offsetY); |
| return true; |
| } |
| |
| @Override |
| public boolean onStart(float x, float y) { |
| stopFadeOutAnimation(); |
| return true; |
| } |
| |
| @Override |
| public boolean onFinish(float xOffset, float yOffset) { |
| if (mIsVisible) { |
| final int windowWidth = mWindowManager.getCurrentWindowMetrics().getBounds().width(); |
| final int halfWindowWidth = windowWidth / 2; |
| mToLeftScreenEdge = (mParams.x < halfWindowWidth); |
| stickToScreenEdge(mToLeftScreenEdge); |
| } |
| if (!mSingleTapDetected) { |
| showButton(mMagnificationMode); |
| } |
| mSingleTapDetected = false; |
| return true; |
| } |
| |
| private void stickToScreenEdge(boolean toLeftScreenEdge) { |
| mParams.x = toLeftScreenEdge |
| ? mDraggableWindowBounds.left : mDraggableWindowBounds.right; |
| updateButtonViewLayoutIfNeeded(); |
| } |
| |
| private void moveButton(float offsetX, float offsetY) { |
| mSfVsyncFrameProvider.postFrameCallback(l -> { |
| mParams.x += offsetX; |
| mParams.y += offsetY; |
| updateButtonViewLayoutIfNeeded(); |
| }); |
| } |
| |
| void removeButton() { |
| if (!mIsVisible) { |
| return; |
| } |
| // Reset button status. |
| mImageView.removeCallbacks(mFadeInAnimationTask); |
| mImageView.removeCallbacks(mFadeOutAnimationTask); |
| mImageView.animate().cancel(); |
| mIsFadeOutAnimating = false; |
| mImageView.setAlpha(0f); |
| mWindowManager.removeView(mImageView); |
| mContext.unregisterComponentCallbacks(this); |
| mIsVisible = false; |
| } |
| |
| void showButton(int mode) { |
| showButton(mode, true); |
| } |
| |
| /** |
| * Shows magnification switch button for the specified magnification mode. |
| * When the button is going to be visible by calling this method, the layout position can be |
| * reset depending on the flag. |
| * |
| * @param mode The magnification mode |
| * @param resetPosition if the button position needs be reset |
| */ |
| private void showButton(int mode, boolean resetPosition) { |
| if (mode != Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_FULLSCREEN) { |
| return; |
| } |
| if (mMagnificationMode != mode) { |
| mMagnificationMode = mode; |
| mImageView.setImageResource(getIconResId(mMagnificationMode)); |
| } |
| if (!mIsVisible) { |
| onConfigurationChanged(mContext.getResources().getConfiguration()); |
| mContext.registerComponentCallbacks(this); |
| if (resetPosition) { |
| mDraggableWindowBounds.set(getDraggableWindowBounds()); |
| mParams.x = mDraggableWindowBounds.right; |
| mParams.y = mDraggableWindowBounds.bottom; |
| mToLeftScreenEdge = false; |
| } |
| mWindowManager.addView(mImageView, mParams); |
| // Exclude magnification switch button from system gesture area. |
| setSystemGestureExclusion(); |
| mIsVisible = true; |
| mImageView.postOnAnimation(mFadeInAnimationTask); |
| mUiTimeout = mAccessibilityManager.getRecommendedTimeoutMillis( |
| DEFAULT_FADE_OUT_ANIMATION_DELAY_MS, |
| AccessibilityManager.FLAG_CONTENT_ICONS |
| | AccessibilityManager.FLAG_CONTENT_CONTROLS); |
| } |
| // Refresh the time slot of the fade-out task whenever this method is called. |
| stopFadeOutAnimation(); |
| mImageView.postOnAnimationDelayed(mFadeOutAnimationTask, mUiTimeout); |
| } |
| |
| private void stopFadeOutAnimation() { |
| mImageView.removeCallbacks(mFadeOutAnimationTask); |
| if (mIsFadeOutAnimating) { |
| mImageView.animate().cancel(); |
| mImageView.setAlpha(1f); |
| mIsFadeOutAnimating = false; |
| } |
| } |
| |
| @Override |
| public void onConfigurationChanged(@NonNull Configuration newConfig) { |
| final int configDiff = newConfig.diff(mConfiguration); |
| mConfiguration.setTo(newConfig); |
| onConfigurationChanged(configDiff); |
| } |
| |
| @Override |
| public void onLowMemory() { |
| } |
| |
| void onConfigurationChanged(int configDiff) { |
| if (configDiff == 0) { |
| return; |
| } |
| if ((configDiff & (ActivityInfo.CONFIG_ORIENTATION | ActivityInfo.CONFIG_SCREEN_SIZE)) |
| != 0) { |
| final Rect previousDraggableBounds = new Rect(mDraggableWindowBounds); |
| mDraggableWindowBounds.set(getDraggableWindowBounds()); |
| // Keep the Y position with the same height ratio before the window bounds and |
| // draggable bounds are changed. |
| final float windowHeightFraction = (float) (mParams.y - previousDraggableBounds.top) |
| / previousDraggableBounds.height(); |
| mParams.y = (int) (windowHeightFraction * mDraggableWindowBounds.height()) |
| + mDraggableWindowBounds.top; |
| stickToScreenEdge(mToLeftScreenEdge); |
| return; |
| } |
| if ((configDiff & ActivityInfo.CONFIG_DENSITY) != 0) { |
| applyResourcesValuesWithDensityChanged(); |
| return; |
| } |
| if ((configDiff & ActivityInfo.CONFIG_LOCALE) != 0) { |
| updateAccessibilityWindowTitle(); |
| return; |
| } |
| } |
| |
| private void onWindowInsetChanged() { |
| final Rect newBounds = getDraggableWindowBounds(); |
| if (mDraggableWindowBounds.equals(newBounds)) { |
| return; |
| } |
| mDraggableWindowBounds.set(newBounds); |
| stickToScreenEdge(mToLeftScreenEdge); |
| } |
| |
| private void updateButtonViewLayoutIfNeeded() { |
| if (mIsVisible) { |
| mParams.x = MathUtils.constrain(mParams.x, mDraggableWindowBounds.left, |
| mDraggableWindowBounds.right); |
| mParams.y = MathUtils.constrain(mParams.y, mDraggableWindowBounds.top, |
| mDraggableWindowBounds.bottom); |
| mWindowManager.updateViewLayout(mImageView, mParams); |
| } |
| } |
| |
| private void updateAccessibilityWindowTitle() { |
| mParams.accessibilityTitle = getAccessibilityWindowTitle(mContext); |
| if (mIsVisible) { |
| mWindowManager.updateViewLayout(mImageView, mParams); |
| } |
| } |
| |
| private void handleSingleTap() { |
| removeButton(); |
| mClickListener.onClick(mContext.getDisplayId()); |
| } |
| |
| private static ImageView createView(Context context) { |
| ImageView imageView = new ImageView(context); |
| imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); |
| imageView.setClickable(true); |
| imageView.setFocusable(true); |
| imageView.setAlpha(0f); |
| return imageView; |
| } |
| |
| @VisibleForTesting |
| static int getIconResId(int mode) { // TODO(b/242233514): delete non used param |
| return R.drawable.ic_open_in_new_window; |
| } |
| |
| private static LayoutParams createLayoutParams(Context context) { |
| final int size = context.getResources().getDimensionPixelSize( |
| R.dimen.magnification_switch_button_size); |
| final LayoutParams params = new LayoutParams( |
| size, |
| size, |
| LayoutParams.TYPE_ACCESSIBILITY_MAGNIFICATION_OVERLAY, |
| LayoutParams.FLAG_NOT_FOCUSABLE, |
| PixelFormat.TRANSPARENT); |
| params.gravity = Gravity.TOP | Gravity.LEFT; |
| params.accessibilityTitle = getAccessibilityWindowTitle(context); |
| params.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; |
| return params; |
| } |
| |
| private Rect getDraggableWindowBounds() { |
| final int layoutMargin = mContext.getResources().getDimensionPixelSize( |
| R.dimen.magnification_switch_button_margin); |
| final WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics(); |
| final Insets windowInsets = windowMetrics.getWindowInsets().getInsetsIgnoringVisibility( |
| WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); |
| final Rect boundRect = new Rect(windowMetrics.getBounds()); |
| boundRect.offsetTo(0, 0); |
| boundRect.inset(0, 0, mParams.width, mParams.height); |
| boundRect.inset(windowInsets); |
| boundRect.inset(layoutMargin, layoutMargin); |
| return boundRect; |
| } |
| |
| private static String getAccessibilityWindowTitle(Context context) { |
| return context.getString(com.android.internal.R.string.android_system_label); |
| } |
| |
| private void setSystemGestureExclusion() { |
| mImageView.post(() -> { |
| mImageView.setSystemGestureExclusionRects( |
| Collections.singletonList( |
| new Rect(0, 0, mImageView.getWidth(), mImageView.getHeight()))); |
| }); |
| } |
| } |