/* * 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()))); }); } }