diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java index 1e14763e57d54..e2a9d5456a8ed 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java @@ -25,6 +25,9 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; import android.view.View; +import android.view.animation.Animation; +import android.view.animation.OvershootInterpolator; +import android.view.animation.TranslateAnimation; import androidx.dynamicanimation.animation.DynamicAnimation; import androidx.dynamicanimation.animation.FlingAnimation; @@ -33,6 +36,7 @@ import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import androidx.recyclerview.widget.RecyclerView; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.Preconditions; import java.util.HashMap; @@ -55,7 +59,11 @@ class MenuAnimationController { private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f; private static final float SPRING_STIFFNESS = 700f; private static final float ESCAPE_VELOCITY = 750f; + // Make tucked animation by using translation X relative to the view itself. + private static final float ANIMATION_TO_X_VALUE = 0.5f; + private static final int ANIMATION_START_OFFSET_MS = 600; + private static final int ANIMATION_DURATION_MS = 600; private static final int FADE_OUT_DURATION_MS = 1000; private static final int FADE_EFFECT_DURATION_MS = 3000; @@ -64,10 +72,12 @@ class MenuAnimationController { private final Handler mHandler; private boolean mIsFadeEffectEnabled; private DismissAnimationController.DismissCallback mDismissCallback; + private Runnable mSpringAnimationsEndAction; // Cache the animations state of {@link DynamicAnimation.TRANSLATION_X} and {@link // DynamicAnimation.TRANSLATION_Y} to be well controlled by the touch handler - private final HashMap mPositionAnimations = + @VisibleForTesting + final HashMap mPositionAnimations = new HashMap<>(); MenuAnimationController(MenuView menuView) { @@ -102,6 +112,13 @@ class MenuAnimationController { } } + /** + * Sets the action to be called when the all dynamic animations are completed. + */ + void setSpringAnimationsEndAction(Runnable runnable) { + mSpringAnimationsEndAction = runnable; + } + void setDismissCallback( DismissAnimationController.DismissCallback dismissCallback) { mDismissCallback = dismissCallback; @@ -192,7 +209,7 @@ class MenuAnimationController { ? bounds.right : bounds.bottom; - final FlingAnimation flingAnimation = new FlingAnimation(mMenuView, menuPositionProperty); + final FlingAnimation flingAnimation = createFlingAnimation(mMenuView, menuPositionProperty); flingAnimation.setFriction(friction) .setStartVelocity(velocity) .setMinValue(Math.min(currentValue, min)) @@ -217,7 +234,14 @@ class MenuAnimationController { flingAnimation.start(); } - private void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, + @VisibleForTesting + FlingAnimation createFlingAnimation(MenuView menuView, + MenuPositionProperty menuPositionProperty) { + return new FlingAnimation(menuView, menuPositionProperty); + } + + @VisibleForTesting + void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, float velocity, float finalPosition) { final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); final SpringAnimation springAnimation = @@ -228,8 +252,13 @@ class MenuAnimationController { return; } - onSpringAnimationEnd(new PointF(mMenuView.getTranslationX(), - mMenuView.getTranslationY())); + final boolean areAnimationsRunning = + mPositionAnimations.values().stream().anyMatch( + DynamicAnimation::isRunning); + if (!areAnimationsRunning) { + onSpringAnimationsEnd(new PointF(mMenuView.getTranslationX(), + mMenuView.getTranslationY())); + } }) .setStartVelocity(velocity); @@ -332,11 +361,15 @@ class MenuAnimationController { .start(); } - private void onSpringAnimationEnd(PointF position) { + private void onSpringAnimationsEnd(PointF position) { mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); constrainPositionAndUpdate(position); fadeOutIfEnabled(); + + if (mSpringAnimationsEndAction != null) { + mSpringAnimationsEndAction.run(); + } } private void constrainPositionAndUpdate(PointF position) { @@ -387,6 +420,26 @@ class MenuAnimationController { mHandler.removeCallbacksAndMessages(/* token= */ null); } + void startTuckedAnimationPreview() { + fadeInNowIfEnabled(); + + final float toXValue = isOnLeftSide() + ? -ANIMATION_TO_X_VALUE + : ANIMATION_TO_X_VALUE; + final TranslateAnimation animation = + new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, toXValue, + Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0); + animation.setDuration(ANIMATION_DURATION_MS); + animation.setRepeatMode(Animation.REVERSE); + animation.setInterpolator(new OvershootInterpolator()); + animation.setRepeatCount(Animation.INFINITE); + animation.setStartOffset(ANIMATION_START_OFFSET_MS); + + mMenuView.startAnimation(animation); + } + private Handler createUiHandler() { return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null")); } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuEduTooltipView.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuEduTooltipView.java new file mode 100644 index 0000000000000..440053450d2f2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuEduTooltipView.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2022 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.floatingmenu; + +import static android.util.TypedValue.COMPLEX_UNIT_PX; +import static android.view.View.MeasureSpec.AT_MOST; +import static android.view.View.MeasureSpec.UNSPECIFIED; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.CornerPathEffect; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.android.settingslib.Utils; +import com.android.systemui.R; +import com.android.systemui.recents.TriangleShape; + +/** + * The tooltip view shows the information about the operation of the anchor view {@link MenuView} + * . It's just shown on the left or right of the anchor view. + */ +@SuppressLint("ViewConstructor") +class MenuEduTooltipView extends FrameLayout { + private int mFontSize; + private int mTextViewMargin; + private int mTextViewPadding; + private int mTextViewCornerRadius; + private int mArrowMargin; + private int mArrowWidth; + private int mArrowHeight; + private int mArrowCornerRadius; + private int mColorAccentPrimary; + private View mArrowLeftView; + private View mArrowRightView; + private TextView mMessageView; + private final MenuViewAppearance mMenuViewAppearance; + + MenuEduTooltipView(@NonNull Context context, MenuViewAppearance menuViewAppearance) { + super(context); + + mMenuViewAppearance = menuViewAppearance; + + updateResources(); + initViews(); + } + + @Override + protected void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + updateResources(); + updateMessageView(); + updateArrowView(); + + updateLocationAndVisibility(); + } + + void show(CharSequence message) { + mMessageView.setText(message); + + updateLocationAndVisibility(); + } + + void updateLocationAndVisibility() { + final boolean isTooltipOnRightOfAnchor = mMenuViewAppearance.isMenuOnLeftSide(); + updateArrowVisibilityWith(isTooltipOnRightOfAnchor); + updateLocationWith(getMenuBoundsInParent(), isTooltipOnRightOfAnchor); + } + + /** + * Gets the bounds of the {@link MenuView}. Besides, its parent view {@link MenuViewLayer} is + * also the root view of the tooltip view. + * + * @return The menu bounds based on its parent view. + */ + private Rect getMenuBoundsInParent() { + final Rect bounds = new Rect(); + final PointF position = mMenuViewAppearance.getMenuPosition(); + + bounds.set((int) position.x, (int) position.y, + (int) position.x + mMenuViewAppearance.getMenuWidth(), + (int) position.y + mMenuViewAppearance.getMenuHeight()); + + return bounds; + } + + private void updateResources() { + final Resources res = getResources(); + + mArrowWidth = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_arrow_width); + mArrowHeight = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_arrow_height); + mArrowMargin = + res.getDimensionPixelSize( + R.dimen.accessibility_floating_tooltip_arrow_margin); + mArrowCornerRadius = + res.getDimensionPixelSize( + R.dimen.accessibility_floating_tooltip_arrow_corner_radius); + mFontSize = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_font_size); + mTextViewMargin = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_margin); + mTextViewPadding = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_padding); + mTextViewCornerRadius = + res.getDimensionPixelSize( + R.dimen.accessibility_floating_tooltip_text_corner_radius); + mColorAccentPrimary = Utils.getColorAttrDefaultColor(getContext(), + com.android.internal.R.attr.colorAccentPrimary); + } + + private void updateLocationWith(Rect anchorBoundsInParent, boolean isTooltipOnRightOfAnchor) { + final int widthSpec = MeasureSpec.makeMeasureSpec( + getAvailableTextViewWidth(isTooltipOnRightOfAnchor), AT_MOST); + final int heightSpec = MeasureSpec.makeMeasureSpec(/* size= */ 0, UNSPECIFIED); + mMessageView.measure(widthSpec, heightSpec); + final LinearLayout.LayoutParams textViewParams = + (LinearLayout.LayoutParams) mMessageView.getLayoutParams(); + textViewParams.width = mMessageView.getMeasuredWidth(); + mMessageView.setLayoutParams(textViewParams); + + final int layoutWidth = mMessageView.getMeasuredWidth() + mArrowWidth + mArrowMargin; + setTranslationX(isTooltipOnRightOfAnchor + ? anchorBoundsInParent.right + : anchorBoundsInParent.left - layoutWidth); + + setTranslationY(anchorBoundsInParent.centerY() - (mMessageView.getMeasuredHeight() / 2.0f)); + } + + private void updateMessageView() { + mMessageView.setTextSize(COMPLEX_UNIT_PX, mFontSize); + mMessageView.setPadding(mTextViewPadding, mTextViewPadding, mTextViewPadding, + mTextViewPadding); + + final GradientDrawable gradientDrawable = (GradientDrawable) mMessageView.getBackground(); + gradientDrawable.setCornerRadius(mTextViewCornerRadius); + gradientDrawable.setColor(mColorAccentPrimary); + } + + private void updateArrowView() { + drawArrow(mArrowLeftView, /* isPointingLeft= */ true); + drawArrow(mArrowRightView, /* isPointingLeft= */ false); + } + + private void updateArrowVisibilityWith(boolean isTooltipOnRightOfAnchor) { + if (isTooltipOnRightOfAnchor) { + mArrowLeftView.setVisibility(VISIBLE); + mArrowRightView.setVisibility(GONE); + } else { + mArrowLeftView.setVisibility(GONE); + mArrowRightView.setVisibility(VISIBLE); + } + } + + private void drawArrow(View arrowView, boolean isPointingLeft) { + final TriangleShape triangleShape = + TriangleShape.createHorizontal(mArrowWidth, mArrowHeight, isPointingLeft); + final ShapeDrawable arrowDrawable = new ShapeDrawable(triangleShape); + final Paint arrowPaint = arrowDrawable.getPaint(); + arrowPaint.setColor(mColorAccentPrimary); + + final CornerPathEffect effect = new CornerPathEffect(mArrowCornerRadius); + arrowPaint.setPathEffect(effect); + + arrowView.setBackground(arrowDrawable); + } + + private void initViews() { + final View contentView = LayoutInflater.from(getContext()).inflate( + R.layout.accessibility_floating_menu_tooltip, /* root= */ this, /* attachToRoot= */ + false); + + mMessageView = contentView.findViewById(R.id.text); + mMessageView.setMovementMethod(LinkMovementMethod.getInstance()); + + mArrowLeftView = contentView.findViewById(R.id.arrow_left); + mArrowRightView = contentView.findViewById(R.id.arrow_right); + + updateMessageView(); + updateArrowView(); + + addView(contentView); + } + + private int getAvailableTextViewWidth(boolean isOnRightOfAnchor) { + final PointF position = mMenuViewAppearance.getMenuPosition(); + final int availableWidth = isOnRightOfAnchor + ? mMenuViewAppearance.getMenuDraggableBounds().width() - (int) position.x + : (int) position.x; + + return availableWidth - mArrowWidth - mArrowMargin - mTextViewMargin; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java index 5bc7406bb9d03..05e1d3f0e126b 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java @@ -17,6 +17,7 @@ package com.android.systemui.accessibility.floatingmenu; import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED; +import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_MIGRATION_TOOLTIP_PROMPT; import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_OPACITY; import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_SIZE; import static android.provider.Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES; @@ -28,6 +29,7 @@ import static com.android.systemui.accessibility.floatingmenu.MenuFadeEffectInfo import static com.android.systemui.accessibility.floatingmenu.MenuViewAppearance.MenuSizeType.SMALL; import android.annotation.FloatRange; +import android.annotation.IntDef; import android.content.Context; import android.database.ContentObserver; import android.os.Handler; @@ -40,6 +42,8 @@ import com.android.internal.accessibility.dialog.AccessibilityTarget; import com.android.internal.annotations.VisibleForTesting; import com.android.systemui.Prefs; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.List; /** @@ -52,12 +56,24 @@ class MenuInfoRepository { @FloatRange(from = 0.0, to = 1.0) private static final float DEFAULT_MENU_POSITION_Y_PERCENT = 0.77f; private static final boolean DEFAULT_MOVE_TO_TUCKED_VALUE = false; + private static final boolean DEFAULT_HAS_SEEN_DOCK_TOOLTIP_VALUE = false; + private static final int DEFAULT_MIGRATION_TOOLTIP_VALUE_PROMPT = MigrationPrompt.DISABLED; private final Context mContext; private final Handler mHandler = new Handler(Looper.getMainLooper()); private final OnSettingsContentsChanged mSettingsContentsCallback; private Position mPercentagePosition; + @IntDef({ + MigrationPrompt.DISABLED, + MigrationPrompt.ENABLED, + }) + @Retention(RetentionPolicy.SOURCE) + @interface MigrationPrompt { + int DISABLED = 0; + int ENABLED = 1; + } + private final ContentObserver mMenuTargetFeaturesContentObserver = new ContentObserver(mHandler) { @Override @@ -99,6 +115,19 @@ class MenuInfoRepository { DEFAULT_MOVE_TO_TUCKED_VALUE)); } + void loadDockTooltipVisibility(OnInfoReady callback) { + callback.onReady(Prefs.getBoolean(mContext, + Prefs.Key.HAS_SEEN_ACCESSIBILITY_FLOATING_MENU_DOCK_TOOLTIP, + DEFAULT_HAS_SEEN_DOCK_TOOLTIP_VALUE)); + } + + void loadMigrationTooltipVisibility(OnInfoReady callback) { + callback.onReady(Settings.Secure.getIntForUser(mContext.getContentResolver(), + ACCESSIBILITY_FLOATING_MENU_MIGRATION_TOOLTIP_PROMPT, + DEFAULT_MIGRATION_TOOLTIP_VALUE_PROMPT, UserHandle.USER_CURRENT) + == MigrationPrompt.ENABLED); + } + void loadMenuPosition(OnInfoReady callback) { callback.onReady(mPercentagePosition); } @@ -131,6 +160,18 @@ class MenuInfoRepository { percentagePosition.toString()); } + void updateDockTooltipVisibility(boolean hasSeen) { + Prefs.putBoolean(mContext, Prefs.Key.HAS_SEEN_ACCESSIBILITY_FLOATING_MENU_DOCK_TOOLTIP, + hasSeen); + } + + void updateMigrationTooltipVisibility(boolean visible) { + Settings.Secure.putIntForUser(mContext.getContentResolver(), + ACCESSIBILITY_FLOATING_MENU_MIGRATION_TOOLTIP_PROMPT, + visible ? MigrationPrompt.ENABLED : MigrationPrompt.DISABLED, + UserHandle.USER_CURRENT); + } + private Position getStartPosition() { final String absolutePositionString = Prefs.getString(mContext, Prefs.Key.ACCESSIBILITY_FLOATING_MENU_POSITION, /* defaultValue= */ null); diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java index bc3cf0a6bab03..8a31142a02122 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java @@ -25,6 +25,8 @@ import android.view.ViewConfiguration; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import java.util.Optional; + /** * Controls the all touch events of the accessibility target features view{@link RecyclerView} in * the {@link MenuView}. And then compute the gestures' velocity for fling and spring @@ -39,6 +41,7 @@ class MenuListViewTouchHandler implements RecyclerView.OnItemTouchListener { private boolean mIsDragging = false; private float mTouchSlop; private final DismissAnimationController mDismissAnimationController; + private Optional mOnActionDownEnd = Optional.empty(); MenuListViewTouchHandler(MenuAnimationController menuAnimationController, DismissAnimationController dismissAnimationController) { @@ -65,6 +68,8 @@ class MenuListViewTouchHandler implements RecyclerView.OnItemTouchListener { mMenuAnimationController.cancelAnimations(); mDismissAnimationController.maybeConsumeDownMotionEvent(motionEvent); + + mOnActionDownEnd.ifPresent(Runnable::run); break; case MotionEvent.ACTION_MOVE: if (mIsDragging || Math.hypot(dx, dy) > mTouchSlop) { @@ -125,6 +130,10 @@ class MenuListViewTouchHandler implements RecyclerView.OnItemTouchListener { // Do nothing } + void setOnActionDownEndListener(Runnable onActionDownEndListener) { + mOnActionDownEnd = Optional.ofNullable(onActionDownEndListener); + } + /** * Adds a movement to the velocity tracker using raw screen coordinates. */ diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java index a7cdeab7c1279..3cd250fb4575a 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java @@ -293,7 +293,7 @@ class MenuViewAppearance { return bounds; } - private boolean isMenuOnLeftSide() { + boolean isMenuOnLeftSide() { return mPercentagePosition.getPercentageX() < 0.5f; } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java index c42943cf56e28..6f5b39cc7d56c 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java @@ -20,6 +20,7 @@ import static android.view.WindowInsets.Type.ime; import static androidx.core.view.WindowInsetsCompat.Type; +import static com.android.internal.accessibility.AccessibilityShortcutController.ACCESSIBILITY_BUTTON_COMPONENT_NAME; import static com.android.internal.accessibility.common.ShortcutConstants.AccessibilityFragmentType.INVISIBLE_TOGGLE; import static com.android.internal.accessibility.util.AccessibilityUtils.getAccessibilityServiceFragmentType; import static com.android.internal.accessibility.util.AccessibilityUtils.setAccessibilityServiceState; @@ -27,8 +28,10 @@ import static com.android.systemui.accessibility.floatingmenu.MenuMessageView.In import android.accessibilityservice.AccessibilityServiceInfo; import android.annotation.IntDef; +import android.annotation.StringDef; import android.annotation.SuppressLint; import android.content.Context; +import android.content.Intent; import android.content.res.Configuration; import android.graphics.Rect; import android.os.Handler; @@ -37,6 +40,8 @@ import android.os.UserHandle; import android.provider.Settings; import android.util.PluralsMessageFormatter; import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.WindowManager; import android.view.WindowMetrics; @@ -45,6 +50,7 @@ import android.widget.FrameLayout; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.lifecycle.Observer; import com.android.internal.accessibility.dialog.AccessibilityTarget; import com.android.internal.annotations.VisibleForTesting; @@ -58,6 +64,7 @@ import java.lang.annotation.RetentionPolicy; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; /** * The basic interactions with the child views {@link MenuView}, {@link DismissView}, and @@ -66,11 +73,13 @@ import java.util.Map; * message view would be shown and allowed users to undo it. */ @SuppressLint("ViewConstructor") -class MenuViewLayer extends FrameLayout { +class MenuViewLayer extends FrameLayout implements + ViewTreeObserver.OnComputeInternalInsetsListener, View.OnClickListener { private static final int SHOW_MESSAGE_DELAY_MS = 3000; private final WindowManager mWindowManager; private final MenuView mMenuView; + private final MenuListViewTouchHandler mMenuListViewTouchHandler; private final MenuMessageView mMessageView; private final DismissView mDismissView; private final MenuViewAppearance mMenuViewAppearance; @@ -79,18 +88,38 @@ class MenuViewLayer extends FrameLayout { private final Handler mHandler = new Handler(Looper.getMainLooper()); private final IAccessibilityFloatingMenu mFloatingMenu; private final DismissAnimationController mDismissAnimationController; + private final MenuViewModel mMenuViewModel; + private final Observer mDockTooltipObserver = + this::onDockTooltipVisibilityChanged; + private final Observer mMigrationTooltipObserver = + this::onMigrationTooltipVisibilityChanged; private final Rect mImeInsetsRect = new Rect(); + private boolean mIsMigrationTooltipShowing; + private boolean mShouldShowDockTooltip; + private Optional mEduTooltipView = Optional.empty(); @IntDef({ LayerIndex.MENU_VIEW, LayerIndex.DISMISS_VIEW, LayerIndex.MESSAGE_VIEW, + LayerIndex.TOOLTIP_VIEW, }) @Retention(RetentionPolicy.SOURCE) @interface LayerIndex { int MENU_VIEW = 0; int DISMISS_VIEW = 1; int MESSAGE_VIEW = 2; + int TOOLTIP_VIEW = 3; + } + + @StringDef({ + TooltipType.MIGRATION, + TooltipType.DOCK, + }) + @Retention(RetentionPolicy.SOURCE) + @interface TooltipType { + String MIGRATION = "migration"; + String DOCK = "dock"; } @VisibleForTesting @@ -125,12 +154,12 @@ class MenuViewLayer extends FrameLayout { mAccessibilityManager = accessibilityManager; mFloatingMenu = floatingMenu; - final MenuViewModel menuViewModel = new MenuViewModel(context); + mMenuViewModel = new MenuViewModel(context); mMenuViewAppearance = new MenuViewAppearance(context, windowManager); - mMenuView = new MenuView(context, menuViewModel, mMenuViewAppearance); + mMenuView = new MenuView(context, mMenuViewModel, mMenuViewAppearance); mMenuAnimationController = mMenuView.getMenuAnimationController(); mMenuAnimationController.setDismissCallback(this::hideMenuAndShowMessage); - + mMenuAnimationController.setSpringAnimationsEndAction(this::onSpringAnimationsEndAction); mDismissView = new DismissView(context); mDismissAnimationController = new DismissAnimationController(mDismissView, mMenuView); mDismissAnimationController.setMagnetListener(new MagnetizedObject.MagnetListener() { @@ -153,9 +182,9 @@ class MenuViewLayer extends FrameLayout { } }); - final MenuListViewTouchHandler menuListViewTouchHandler = new MenuListViewTouchHandler( - mMenuAnimationController, mDismissAnimationController); - mMenuView.addOnItemTouchListenerToList(menuListViewTouchHandler); + mMenuListViewTouchHandler = new MenuListViewTouchHandler(mMenuAnimationController, + mDismissAnimationController); + mMenuView.addOnItemTouchListenerToList(mMenuListViewTouchHandler); mMessageView = new MenuMessageView(context); @@ -210,7 +239,12 @@ class MenuViewLayer extends FrameLayout { super.onAttachedToWindow(); mMenuView.show(); + setOnClickListener(this); setOnApplyWindowInsetsListener((view, insets) -> onWindowInsetsApplied(insets)); + getViewTreeObserver().addOnComputeInternalInsetsListener(this); + mMenuViewModel.getDockTooltipVisibilityData().observeForever(mDockTooltipObserver); + mMenuViewModel.getMigrationTooltipVisibilityData().observeForever( + mMigrationTooltipObserver); mMessageView.setUndoListener(view -> undo()); mContext.registerComponentCallbacks(mDismissAnimationController); } @@ -220,11 +254,32 @@ class MenuViewLayer extends FrameLayout { super.onDetachedFromWindow(); mMenuView.hide(); + setOnClickListener(null); setOnApplyWindowInsetsListener(null); + getViewTreeObserver().removeOnComputeInternalInsetsListener(this); + mMenuViewModel.getDockTooltipVisibilityData().removeObserver(mDockTooltipObserver); + mMenuViewModel.getMigrationTooltipVisibilityData().removeObserver( + mMigrationTooltipObserver); mHandler.removeCallbacksAndMessages(/* token= */ null); mContext.unregisterComponentCallbacks(mDismissAnimationController); } + @Override + public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) { + inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); + + if (mEduTooltipView.isPresent()) { + final int x = (int) getX(); + final int y = (int) getY(); + inoutInfo.touchableRegion.union(new Rect(x, y, x + getWidth(), y + getHeight())); + } + } + + @Override + public void onClick(View v) { + mEduTooltipView.ifPresent(this::removeTooltip); + } + private WindowInsets onWindowInsetsApplied(WindowInsets insets) { final WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics(); final WindowInsets windowInsets = windowMetrics.getWindowInsets(); @@ -249,6 +304,78 @@ class MenuViewLayer extends FrameLayout { return insets; } + private void onMigrationTooltipVisibilityChanged(boolean visible) { + mIsMigrationTooltipShowing = visible; + + if (mIsMigrationTooltipShowing) { + mEduTooltipView = Optional.of(new MenuEduTooltipView(mContext, mMenuViewAppearance)); + mEduTooltipView.ifPresent( + view -> addTooltipView(view, getMigrationMessage(), TooltipType.MIGRATION)); + } + } + + private void onDockTooltipVisibilityChanged(boolean hasSeenTooltip) { + mShouldShowDockTooltip = !hasSeenTooltip; + } + + private void onSpringAnimationsEndAction() { + if (mShouldShowDockTooltip) { + mEduTooltipView = Optional.of(new MenuEduTooltipView(mContext, mMenuViewAppearance)); + mEduTooltipView.ifPresent(view -> addTooltipView(view, + getContext().getText(R.string.accessibility_floating_button_docking_tooltip), + TooltipType.DOCK)); + + mMenuAnimationController.startTuckedAnimationPreview(); + } + } + + private CharSequence getMigrationMessage() { + final Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_DETAILS_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(Intent.EXTRA_COMPONENT_NAME, + ACCESSIBILITY_BUTTON_COMPONENT_NAME.flattenToShortString()); + + final AnnotationLinkSpan.LinkInfo linkInfo = new AnnotationLinkSpan.LinkInfo( + AnnotationLinkSpan.LinkInfo.DEFAULT_ANNOTATION, + v -> { + getContext().startActivity(intent); + mEduTooltipView.ifPresent(this::removeTooltip); + }); + + final int textResId = R.string.accessibility_floating_button_migration_tooltip; + + return AnnotationLinkSpan.linkify(getContext().getText(textResId), linkInfo); + } + + private void addTooltipView(MenuEduTooltipView tooltipView, CharSequence message, + CharSequence tag) { + addView(tooltipView, LayerIndex.TOOLTIP_VIEW); + + tooltipView.show(message); + tooltipView.setTag(tag); + + mMenuListViewTouchHandler.setOnActionDownEndListener( + () -> mEduTooltipView.ifPresent(this::removeTooltip)); + } + + private void removeTooltip(View tooltipView) { + if (tooltipView.getTag().equals(TooltipType.MIGRATION)) { + mMenuViewModel.updateMigrationTooltipVisibility(/* visible= */ false); + mIsMigrationTooltipShowing = false; + } + + if (tooltipView.getTag().equals(TooltipType.DOCK)) { + mMenuViewModel.updateDockTooltipVisibility(/* hasSeen= */ true); + mMenuView.clearAnimation(); + mShouldShowDockTooltip = false; + } + + removeView(tooltipView); + + mMenuListViewTouchHandler.setOnActionDownEndListener(null); + mEduTooltipView = Optional.empty(); + } + private void hideMenuAndShowMessage() { final int delayTime = mAccessibilityManager.getRecommendedTimeoutMillis( SHOW_MESSAGE_DELAY_MS, diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java index bd417877c1242..5fea3b0ba2f98 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java @@ -36,6 +36,8 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { private final MutableLiveData mFadeEffectInfoData = new MutableLiveData<>(); private final MutableLiveData mMoveToTuckedData = new MutableLiveData<>(); + private final MutableLiveData mDockTooltipData = new MutableLiveData<>(); + private final MutableLiveData mMigrationTooltipData = new MutableLiveData<>(); private final MutableLiveData mPercentagePositionData = new MutableLiveData<>(); private final MenuInfoRepository mInfoRepository; @@ -66,11 +68,29 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { mInfoRepository.updateMenuSavingPosition(percentagePosition); } + void updateDockTooltipVisibility(boolean hasSeen) { + mInfoRepository.updateDockTooltipVisibility(hasSeen); + } + + void updateMigrationTooltipVisibility(boolean visible) { + mInfoRepository.updateMigrationTooltipVisibility(visible); + } + LiveData getMoveToTuckedData() { mInfoRepository.loadMenuMoveToTucked(mMoveToTuckedData::setValue); return mMoveToTuckedData; } + LiveData getDockTooltipVisibilityData() { + mInfoRepository.loadDockTooltipVisibility(mDockTooltipData::setValue); + return mDockTooltipData; + } + + LiveData getMigrationTooltipVisibilityData() { + mInfoRepository.loadMigrationTooltipVisibility(mMigrationTooltipData::setValue); + return mMigrationTooltipData; + } + LiveData getPercentagePositionData() { mInfoRepository.loadMenuPosition(mPercentagePositionData::setValue); return mPercentagePositionData; diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java index b2c52668e057a..0cdd6e2ce85e7 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java @@ -20,8 +20,10 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; import android.graphics.PointF; import android.testing.AndroidTestingRunner; @@ -30,6 +32,10 @@ import android.view.View; import android.view.ViewPropertyAnimator; import android.view.WindowManager; +import androidx.dynamicanimation.animation.DynamicAnimation; +import androidx.dynamicanimation.animation.FlingAnimation; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; import androidx.test.filters.SmallTest; import com.android.systemui.Prefs; @@ -39,6 +45,9 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; /** Tests for {@link MenuAnimationController}. */ @RunWith(AndroidTestingRunner.class) @@ -47,9 +56,10 @@ import org.junit.runner.RunWith; public class MenuAnimationControllerTest extends SysuiTestCase { private boolean mLastIsMoveToTucked; + private ArgumentCaptor mEndListenerCaptor; private ViewPropertyAnimator mViewPropertyAnimator; private MenuView mMenuView; - private MenuAnimationController mMenuAnimationController; + private TestMenuAnimationController mMenuAnimationController; @Before public void setUp() throws Exception { @@ -62,15 +72,17 @@ public class MenuAnimationControllerTest extends SysuiTestCase { mViewPropertyAnimator = spy(mMenuView.animate()); doReturn(mViewPropertyAnimator).when(mMenuView).animate(); - mMenuAnimationController = new MenuAnimationController(mMenuView); + mMenuAnimationController = new TestMenuAnimationController(mMenuView); mLastIsMoveToTucked = Prefs.getBoolean(mContext, Prefs.Key.HAS_ACCESSIBILITY_FLOATING_MENU_TUCKED, /* defaultValue= */ false); + mEndListenerCaptor = ArgumentCaptor.forClass(DynamicAnimation.OnAnimationEndListener.class); } @After public void tearDown() throws Exception { Prefs.putBoolean(mContext, Prefs.Key.HAS_ACCESSIBILITY_FLOATING_MENU_TUCKED, mLastIsMoveToTucked); + mEndListenerCaptor.getAllValues().clear(); } @Test @@ -122,4 +134,115 @@ public class MenuAnimationControllerTest extends SysuiTestCase { assertThat(isMoveToTucked).isFalse(); } + + @Test + public void startTuckedAnimationPreview_hasAnimation() { + mMenuView.clearAnimation(); + + mMenuAnimationController.startTuckedAnimationPreview(); + + assertThat(mMenuView.getAnimation()).isNotNull(); + } + + @Test + public void startSpringAnimationsAndEndOneAnimation_notTriggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + setupAndRunSpringAnimations(); + final Optional anyAnimation = + mMenuAnimationController.mPositionAnimations.values().stream().findAny(); + anyAnimation.ifPresent(this::skipAnimationToEnd); + + verifyZeroInteractions(onSpringAnimationsEndCallback); + } + + @Test + public void startAndEndSpringAnimations_triggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + setupAndRunSpringAnimations(); + mMenuAnimationController.mPositionAnimations.values().forEach(this::skipAnimationToEnd); + + verify(onSpringAnimationsEndCallback).run(); + } + + @Test + public void flingThenSpringAnimationsAreEnded_triggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + mMenuAnimationController.flingMenuThenSpringToEdge(/* x= */ 0, /* velocityX= */ + 100, /* velocityY= */ 100); + mMenuAnimationController.mPositionAnimations.values() + .forEach(animation -> verify((FlingAnimation) animation).addEndListener( + mEndListenerCaptor.capture())); + mEndListenerCaptor.getAllValues() + .forEach(listener -> listener.onAnimationEnd(mock(DynamicAnimation.class), + /* canceled */ false, /* endValue */ 0, /* endVelocity */ 0)); + mMenuAnimationController.mPositionAnimations.values().forEach(this::skipAnimationToEnd); + + verify(onSpringAnimationsEndCallback).run(); + } + + @Test + public void existFlingIsRunningAndTheOtherAreEnd_notTriggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + mMenuAnimationController.flingMenuThenSpringToEdge(/* x= */ 0, /* velocityX= */ + 200, /* velocityY= */ 200); + mMenuAnimationController.mPositionAnimations.values() + .forEach(animation -> verify((FlingAnimation) animation).addEndListener( + mEndListenerCaptor.capture())); + final Optional anyAnimation = + mEndListenerCaptor.getAllValues().stream().findAny(); + anyAnimation.ifPresent( + listener -> listener.onAnimationEnd(mock(DynamicAnimation.class), /* canceled */ + false, /* endValue */ 0, /* endVelocity */ 0)); + mMenuAnimationController.mPositionAnimations.values() + .stream() + .filter(animation -> animation instanceof SpringAnimation) + .forEach(this::skipAnimationToEnd); + + verifyZeroInteractions(onSpringAnimationsEndCallback); + } + + private void setupAndRunSpringAnimations() { + final float stiffness = 700f; + final float dampingRatio = 0.85f; + final float velocity = 100f; + final float finalPosition = 300f; + + mMenuAnimationController.springMenuWith(DynamicAnimation.TRANSLATION_X, new SpringForce() + .setStiffness(stiffness) + .setDampingRatio(dampingRatio), velocity, finalPosition); + mMenuAnimationController.springMenuWith(DynamicAnimation.TRANSLATION_Y, new SpringForce() + .setStiffness(stiffness) + .setDampingRatio(dampingRatio), velocity, finalPosition); + } + + private void skipAnimationToEnd(DynamicAnimation animation) { + final SpringAnimation springAnimation = ((SpringAnimation) animation); + // The doAnimationFrame function is used for skipping animation to the end. + springAnimation.doAnimationFrame(100); + springAnimation.skipToEnd(); + springAnimation.doAnimationFrame(200); + } + + /** + * Wrapper class for testing. + */ + private static class TestMenuAnimationController extends MenuAnimationController { + TestMenuAnimationController(MenuView menuView) { + super(menuView); + } + + @Override + FlingAnimation createFlingAnimation(MenuView menuView, + MenuPositionProperty menuPositionProperty) { + return spy(super.createFlingAnimation(menuView, menuPositionProperty)); + } + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuEduTooltipViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuEduTooltipViewTest.java new file mode 100644 index 0000000000000..42b610a18bc03 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuEduTooltipViewTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2022 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.floatingmenu; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.res.Resources; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.view.WindowManager; +import android.widget.TextView; + +import androidx.test.filters.SmallTest; + +import com.android.systemui.R; +import com.android.systemui.SysuiTestCase; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link MenuEduTooltipView}. */ +@SmallTest +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper +public class MenuEduTooltipViewTest extends SysuiTestCase { + private MenuViewAppearance mMenuViewAppearance; + private MenuEduTooltipView mMenuEduTooltipView; + + @Before + public void setUp() throws Exception { + final WindowManager windowManager = mContext.getSystemService(WindowManager.class); + mMenuViewAppearance = new MenuViewAppearance(mContext, windowManager); + mMenuEduTooltipView = new MenuEduTooltipView(mContext, mMenuViewAppearance); + } + + @Test + public void show_matchMessageText() { + final CharSequence text = "Message"; + + mMenuEduTooltipView.show(text); + + final TextView messageView = mMenuEduTooltipView.findViewById(R.id.text); + assertThat(messageView.getText().toString().contentEquals(text)).isTrue(); + } + + @Test + public void show_menuOnLeft_onRightOfAnchor() { + mMenuViewAppearance.setPercentagePosition( + new Position(/* percentageX= */ 0.0f, /* percentageY= */ 0.0f)); + final CharSequence text = "Message"; + + mMenuEduTooltipView.show(text); + final int tooltipViewX = (int) (mMenuViewAppearance.getMenuPosition().x + + mMenuViewAppearance.getMenuWidth()); + + assertThat(mMenuEduTooltipView.getX()).isEqualTo(tooltipViewX); + } + + @Test + public void show_menuCloseToLeftOfCenter_onLeftOfAnchor() { + mMenuViewAppearance.setPercentagePosition( + new Position(/* percentageX= */ 0.4f, /* percentageY= */ 0.0f)); + final CharSequence text = "Message"; + + mMenuEduTooltipView.show(text); + final int tooltipViewX = (int) (mMenuViewAppearance.getMenuPosition().x + + mMenuViewAppearance.getMenuWidth()); + + assertThat(mMenuEduTooltipView.getX()).isEqualTo(tooltipViewX); + } + + @Test + public void show_menuOnRight_onLeftOfAnchor() { + mMenuViewAppearance.setPercentagePosition( + new Position(/* percentageX= */ 1.0f, /* percentageY= */ 0.0f)); + final Resources res = getContext().getResources(); + final int arrowWidth = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_arrow_width); + final int arrowMargin = + res.getDimensionPixelSize(R.dimen.accessibility_floating_tooltip_arrow_margin); + final CharSequence text = "Message"; + + mMenuEduTooltipView.show(text); + final TextView messageView = mMenuEduTooltipView.findViewById(R.id.text); + final int layoutWidth = messageView.getMeasuredWidth() + arrowWidth + arrowMargin; + + assertThat(mMenuEduTooltipView.getX()).isEqualTo( + mMenuViewAppearance.getMenuPosition().x - layoutWidth); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java index 4acb394bee95d..d29ebb86686f5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java @@ -181,6 +181,20 @@ public class MenuListViewTouchHandlerTest extends SysuiTestCase { verify(mMenuAnimationController).moveToEdgeAndHide(); } + @Test + public void receiveActionDownMotionEvent_verifyOnActionDownEnd() { + final Runnable onActionDownEnd = mock(Runnable.class); + mTouchHandler.setOnActionDownEndListener(onActionDownEnd); + + final MotionEvent stubDownEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 1, + MotionEvent.ACTION_DOWN, mStubMenuView.getTranslationX(), + mStubMenuView.getTranslationY()); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubDownEvent); + + verify(onActionDownEnd).run(); + } + @After public void tearDown() { mMotionEventHelper.recycleEvents();