| /* |
| * 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.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; |
| import static com.android.systemui.accessibility.floatingmenu.MenuMessageView.Index; |
| import static com.android.systemui.util.PluralMessageFormaterKt.icuMessageFormat; |
| |
| import android.accessibilityservice.AccessibilityServiceInfo; |
| import android.annotation.IntDef; |
| import android.annotation.StringDef; |
| import android.annotation.SuppressLint; |
| import android.content.ComponentCallbacks; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.res.Configuration; |
| import android.content.res.Resources; |
| import android.graphics.Rect; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.UserHandle; |
| import android.provider.Settings; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewTreeObserver; |
| import android.view.WindowInsets; |
| import android.view.WindowManager; |
| import android.view.WindowMetrics; |
| import android.view.accessibility.AccessibilityManager; |
| 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; |
| import com.android.internal.util.Preconditions; |
| import com.android.systemui.R; |
| import com.android.systemui.util.settings.SecureSettings; |
| import com.android.wm.shell.bubbles.DismissView; |
| import com.android.wm.shell.common.magnetictarget.MagnetizedObject; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.List; |
| import java.util.Optional; |
| |
| /** |
| * The basic interactions with the child views {@link MenuView}, {@link DismissView}, and |
| * {@link MenuMessageView}. When dragging the menu view, the dismissed view would be shown at the |
| * same time. If the menu view overlaps on the dismissed circle view and drops out, the menu |
| * message view would be shown and allowed users to undo it. |
| */ |
| @SuppressLint("ViewConstructor") |
| class MenuViewLayer extends FrameLayout implements |
| ViewTreeObserver.OnComputeInternalInsetsListener, View.OnClickListener, ComponentCallbacks { |
| 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; |
| private final MenuAnimationController mMenuAnimationController; |
| private final AccessibilityManager mAccessibilityManager; |
| private final Handler mHandler = new Handler(Looper.getMainLooper()); |
| private final IAccessibilityFloatingMenu mFloatingMenu; |
| private final SecureSettings mSecureSettings; |
| private final DismissAnimationController mDismissAnimationController; |
| private final MenuViewModel mMenuViewModel; |
| private final Observer<Boolean> mDockTooltipObserver = |
| this::onDockTooltipVisibilityChanged; |
| private final Observer<Boolean> mMigrationTooltipObserver = |
| this::onMigrationTooltipVisibilityChanged; |
| private final Rect mImeInsetsRect = new Rect(); |
| private boolean mIsMigrationTooltipShowing; |
| private boolean mShouldShowDockTooltip; |
| private Optional<MenuEduTooltipView> 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 |
| final Runnable mDismissMenuAction = new Runnable() { |
| @Override |
| public void run() { |
| mSecureSettings.putStringForUser( |
| Settings.Secure.ACCESSIBILITY_BUTTON_TARGETS, /* value= */ "", |
| UserHandle.USER_CURRENT); |
| |
| // Should disable the corresponding service when the fragment type is |
| // INVISIBLE_TOGGLE, which will enable service when the shortcut is on. |
| final List<AccessibilityServiceInfo> serviceInfoList = |
| mAccessibilityManager.getEnabledAccessibilityServiceList( |
| AccessibilityServiceInfo.FEEDBACK_ALL_MASK); |
| serviceInfoList.forEach(info -> { |
| if (getAccessibilityServiceFragmentType(info) == INVISIBLE_TOGGLE) { |
| setAccessibilityServiceState(getContext(), |
| info.getComponentName(), /* enabled= */ false); |
| } |
| }); |
| |
| mFloatingMenu.hide(); |
| } |
| }; |
| |
| MenuViewLayer(@NonNull Context context, WindowManager windowManager, |
| AccessibilityManager accessibilityManager, IAccessibilityFloatingMenu floatingMenu, |
| SecureSettings secureSettings) { |
| super(context); |
| |
| // Simplifies the translation positioning and animations |
| setLayoutDirection(LAYOUT_DIRECTION_LTR); |
| |
| mWindowManager = windowManager; |
| mAccessibilityManager = accessibilityManager; |
| mFloatingMenu = floatingMenu; |
| mSecureSettings = secureSettings; |
| |
| mMenuViewModel = new MenuViewModel(context, accessibilityManager, secureSettings); |
| mMenuViewAppearance = new MenuViewAppearance(context, windowManager); |
| 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() { |
| @Override |
| public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target) { |
| mDismissAnimationController.animateDismissMenu(/* scaleUp= */ true); |
| } |
| |
| @Override |
| public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target, |
| float velocityX, float velocityY, boolean wasFlungOut) { |
| mDismissAnimationController.animateDismissMenu(/* scaleUp= */ false); |
| } |
| |
| @Override |
| public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) { |
| hideMenuAndShowMessage(); |
| mDismissView.hide(); |
| mDismissAnimationController.animateDismissMenu(/* scaleUp= */ false); |
| } |
| }); |
| |
| mMenuListViewTouchHandler = new MenuListViewTouchHandler(mMenuAnimationController, |
| mDismissAnimationController); |
| mMenuView.addOnItemTouchListenerToList(mMenuListViewTouchHandler); |
| |
| mMessageView = new MenuMessageView(context); |
| |
| mMenuView.setOnTargetFeaturesChangeListener(newTargetFeatures -> { |
| if (newTargetFeatures.size() < 1) { |
| return; |
| } |
| |
| // During the undo action period, the pending action will be canceled and undo back |
| // to the previous state if users did any action related to the accessibility features. |
| if (mMessageView.getVisibility() == VISIBLE) { |
| undo(); |
| } |
| |
| final TextView messageText = (TextView) mMessageView.getChildAt(Index.TEXT_VIEW); |
| messageText.setText(getMessageText(newTargetFeatures)); |
| }); |
| |
| addView(mMenuView, LayerIndex.MENU_VIEW); |
| addView(mDismissView, LayerIndex.DISMISS_VIEW); |
| addView(mMessageView, LayerIndex.MESSAGE_VIEW); |
| } |
| |
| @Override |
| public void onConfigurationChanged(@NonNull Configuration newConfig) { |
| mDismissView.updateResources(); |
| mDismissAnimationController.updateResources(); |
| } |
| |
| @Override |
| public void onLowMemory() { |
| // Do nothing. |
| } |
| |
| private String getMessageText(List<AccessibilityTarget> newTargetFeatures) { |
| Preconditions.checkArgument(newTargetFeatures.size() > 0, |
| "The list should at least have one feature."); |
| |
| final int featuresSize = newTargetFeatures.size(); |
| final Resources resources = getResources(); |
| if (featuresSize == 1) { |
| return resources.getString( |
| R.string.accessibility_floating_button_undo_message_label_text, |
| newTargetFeatures.get(0).getLabel()); |
| } |
| |
| return icuMessageFormat(resources, |
| R.string.accessibility_floating_button_undo_message_number_text, featuresSize); |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent event) { |
| if (mMenuView.maybeMoveOutEdgeAndShow((int) event.getX(), (int) event.getY())) { |
| return true; |
| } |
| |
| return super.onInterceptTouchEvent(event); |
| } |
| |
| @Override |
| protected void onAttachedToWindow() { |
| 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()); |
| getContext().registerComponentCallbacks(this); |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| |
| mMenuView.hide(); |
| setOnClickListener(null); |
| setOnApplyWindowInsetsListener(null); |
| getViewTreeObserver().removeOnComputeInternalInsetsListener(this); |
| mMenuViewModel.getDockTooltipVisibilityData().removeObserver(mDockTooltipObserver); |
| mMenuViewModel.getMigrationTooltipVisibilityData().removeObserver( |
| mMigrationTooltipObserver); |
| mHandler.removeCallbacksAndMessages(/* token= */ null); |
| getContext().unregisterComponentCallbacks(this); |
| } |
| |
| @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(); |
| final Rect imeInsetsRect = windowInsets.getInsets(ime()).toRect(); |
| if (!imeInsetsRect.equals(mImeInsetsRect)) { |
| final Rect windowBounds = new Rect(windowMetrics.getBounds()); |
| final Rect systemBarsAndDisplayCutoutInsetsRect = |
| windowInsets.getInsetsIgnoringVisibility( |
| Type.systemBars() | Type.displayCutout()).toRect(); |
| final float imeTop = |
| windowBounds.height() - systemBarsAndDisplayCutoutInsetsRect.top |
| - imeInsetsRect.bottom; |
| |
| mMenuViewAppearance.onImeVisibilityChanged(windowInsets.isVisible(ime()), imeTop); |
| |
| mMenuView.onEdgeChanged(); |
| mMenuView.onPositionChanged(); |
| |
| mImeInsetsRect.set(imeInsetsRect); |
| } |
| |
| 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, |
| AccessibilityManager.FLAG_CONTENT_TEXT |
| | AccessibilityManager.FLAG_CONTENT_CONTROLS); |
| mHandler.postDelayed(mDismissMenuAction, delayTime); |
| mMessageView.setVisibility(VISIBLE); |
| mMenuAnimationController.startShrinkAnimation(() -> mMenuView.setVisibility(GONE)); |
| } |
| |
| private void undo() { |
| mHandler.removeCallbacksAndMessages(/* token= */ null); |
| mMessageView.setVisibility(GONE); |
| mMenuView.onEdgeChanged(); |
| mMenuView.onPositionChanged(); |
| mMenuView.setVisibility(VISIBLE); |
| mMenuAnimationController.startGrowAnimation(); |
| } |
| } |