1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.accessibility.floatingmenu;
18 
19 import static android.view.WindowInsets.Type.ime;
20 import static android.view.accessibility.AccessibilityManager.ACCESSIBILITY_SHORTCUT_KEY;
21 
22 import static androidx.core.view.WindowInsetsCompat.Type;
23 
24 import static com.android.internal.accessibility.AccessibilityShortcutController.ACCESSIBILITY_BUTTON_COMPONENT_NAME;
25 import static com.android.internal.accessibility.common.ShortcutConstants.AccessibilityFragmentType.INVISIBLE_TOGGLE;
26 import static com.android.internal.accessibility.util.AccessibilityUtils.getAccessibilityServiceFragmentType;
27 import static com.android.internal.accessibility.util.AccessibilityUtils.setAccessibilityServiceState;
28 import static com.android.systemui.accessibility.floatingmenu.MenuMessageView.Index;
29 import static com.android.systemui.util.PluralMessageFormaterKt.icuMessageFormat;
30 
31 import android.accessibilityservice.AccessibilityServiceInfo;
32 import android.annotation.IntDef;
33 import android.annotation.StringDef;
34 import android.annotation.SuppressLint;
35 import android.content.ComponentCallbacks;
36 import android.content.ComponentName;
37 import android.content.Context;
38 import android.content.Intent;
39 import android.content.res.Configuration;
40 import android.content.res.Resources;
41 import android.graphics.Rect;
42 import android.os.Handler;
43 import android.os.Looper;
44 import android.os.UserHandle;
45 import android.provider.Settings;
46 import android.view.MotionEvent;
47 import android.view.View;
48 import android.view.ViewTreeObserver;
49 import android.view.WindowInsets;
50 import android.view.WindowManager;
51 import android.view.WindowMetrics;
52 import android.view.accessibility.AccessibilityManager;
53 import android.widget.FrameLayout;
54 import android.widget.TextView;
55 
56 import androidx.annotation.NonNull;
57 import androidx.lifecycle.Observer;
58 
59 import com.android.internal.accessibility.dialog.AccessibilityTarget;
60 import com.android.internal.annotations.VisibleForTesting;
61 import com.android.internal.util.Preconditions;
62 import com.android.systemui.R;
63 import com.android.systemui.util.settings.SecureSettings;
64 import com.android.wm.shell.bubbles.DismissViewUtils;
65 import com.android.wm.shell.common.bubbles.DismissView;
66 import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
67 
68 import java.lang.annotation.Retention;
69 import java.lang.annotation.RetentionPolicy;
70 import java.util.List;
71 import java.util.Optional;
72 
73 /**
74  * The basic interactions with the child views {@link MenuView}, {@link DismissView}, and
75  * {@link MenuMessageView}. When dragging the menu view, the dismissed view would be shown at the
76  * same time. If the menu view overlaps on the dismissed circle view and drops out, the menu
77  * message view would be shown and allowed users to undo it.
78  */
79 @SuppressLint("ViewConstructor")
80 class MenuViewLayer extends FrameLayout implements
81         ViewTreeObserver.OnComputeInternalInsetsListener, View.OnClickListener, ComponentCallbacks {
82     private static final int SHOW_MESSAGE_DELAY_MS = 3000;
83 
84     private final WindowManager mWindowManager;
85     private final MenuView mMenuView;
86     private final MenuListViewTouchHandler mMenuListViewTouchHandler;
87     private final MenuMessageView mMessageView;
88     private final DismissView mDismissView;
89     private final MenuViewAppearance mMenuViewAppearance;
90     private final MenuAnimationController mMenuAnimationController;
91     private final AccessibilityManager mAccessibilityManager;
92     private final Handler mHandler = new Handler(Looper.getMainLooper());
93     private final IAccessibilityFloatingMenu mFloatingMenu;
94     private final SecureSettings mSecureSettings;
95     private final DismissAnimationController mDismissAnimationController;
96     private final MenuViewModel mMenuViewModel;
97     private final Observer<Boolean> mDockTooltipObserver =
98             this::onDockTooltipVisibilityChanged;
99     private final Observer<Boolean> mMigrationTooltipObserver =
100             this::onMigrationTooltipVisibilityChanged;
101     private final Rect mImeInsetsRect = new Rect();
102     private boolean mIsMigrationTooltipShowing;
103     private boolean mShouldShowDockTooltip;
104     private Optional<MenuEduTooltipView> mEduTooltipView = Optional.empty();
105 
106     @IntDef({
107             LayerIndex.MENU_VIEW,
108             LayerIndex.DISMISS_VIEW,
109             LayerIndex.MESSAGE_VIEW,
110             LayerIndex.TOOLTIP_VIEW,
111     })
112     @Retention(RetentionPolicy.SOURCE)
113     @interface LayerIndex {
114         int MENU_VIEW = 0;
115         int DISMISS_VIEW = 1;
116         int MESSAGE_VIEW = 2;
117         int TOOLTIP_VIEW = 3;
118     }
119 
120     @StringDef({
121             TooltipType.MIGRATION,
122             TooltipType.DOCK,
123     })
124     @Retention(RetentionPolicy.SOURCE)
125     @interface TooltipType {
126         String MIGRATION = "migration";
127         String DOCK = "dock";
128     }
129 
130     @VisibleForTesting
131     final Runnable mDismissMenuAction = new Runnable() {
132         @Override
133         public void run() {
134             mSecureSettings.putStringForUser(
135                     Settings.Secure.ACCESSIBILITY_BUTTON_TARGETS, /* value= */ "",
136                     UserHandle.USER_CURRENT);
137 
138             final List<ComponentName> hardwareKeyShortcutComponents =
139                     mAccessibilityManager.getAccessibilityShortcutTargets(
140                                     ACCESSIBILITY_SHORTCUT_KEY)
141                             .stream()
142                             .map(ComponentName::unflattenFromString)
143                             .toList();
144 
145             // Should disable the corresponding service when the fragment type is
146             // INVISIBLE_TOGGLE, which will enable service when the shortcut is on.
147             final List<AccessibilityServiceInfo> serviceInfoList =
148                     mAccessibilityManager.getEnabledAccessibilityServiceList(
149                             AccessibilityServiceInfo.FEEDBACK_ALL_MASK);
150             serviceInfoList.forEach(info -> {
151                 if (getAccessibilityServiceFragmentType(info) != INVISIBLE_TOGGLE) {
152                     return;
153                 }
154 
155                 final ComponentName serviceComponentName = info.getComponentName();
156                 if (hardwareKeyShortcutComponents.contains(serviceComponentName)) {
157                     return;
158                 }
159 
160                 setAccessibilityServiceState(getContext(), serviceComponentName, /* enabled= */
161                         false);
162             });
163 
164             mFloatingMenu.hide();
165         }
166     };
167 
MenuViewLayer(@onNull Context context, WindowManager windowManager, AccessibilityManager accessibilityManager, IAccessibilityFloatingMenu floatingMenu, SecureSettings secureSettings)168     MenuViewLayer(@NonNull Context context, WindowManager windowManager,
169             AccessibilityManager accessibilityManager, IAccessibilityFloatingMenu floatingMenu,
170             SecureSettings secureSettings) {
171         super(context);
172 
173         // Simplifies the translation positioning and animations
174         setLayoutDirection(LAYOUT_DIRECTION_LTR);
175 
176         mWindowManager = windowManager;
177         mAccessibilityManager = accessibilityManager;
178         mFloatingMenu = floatingMenu;
179         mSecureSettings = secureSettings;
180 
181         mMenuViewModel = new MenuViewModel(context, accessibilityManager, secureSettings);
182         mMenuViewAppearance = new MenuViewAppearance(context, windowManager);
183         mMenuView = new MenuView(context, mMenuViewModel, mMenuViewAppearance);
184         mMenuAnimationController = mMenuView.getMenuAnimationController();
185         mMenuAnimationController.setDismissCallback(this::hideMenuAndShowMessage);
186         mMenuAnimationController.setSpringAnimationsEndAction(this::onSpringAnimationsEndAction);
187         mDismissView = new DismissView(context);
188         DismissViewUtils.setup(mDismissView);
189         mDismissAnimationController = new DismissAnimationController(mDismissView, mMenuView);
190         mDismissAnimationController.setMagnetListener(new MagnetizedObject.MagnetListener() {
191             @Override
192             public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target) {
193                 mDismissAnimationController.animateDismissMenu(/* scaleUp= */ true);
194             }
195 
196             @Override
197             public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target,
198                     float velocityX, float velocityY, boolean wasFlungOut) {
199                 mDismissAnimationController.animateDismissMenu(/* scaleUp= */ false);
200             }
201 
202             @Override
203             public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) {
204                 hideMenuAndShowMessage();
205                 mDismissView.hide();
206                 mDismissAnimationController.animateDismissMenu(/* scaleUp= */ false);
207             }
208         });
209 
210         mMenuListViewTouchHandler = new MenuListViewTouchHandler(mMenuAnimationController,
211                 mDismissAnimationController);
212         mMenuView.addOnItemTouchListenerToList(mMenuListViewTouchHandler);
213 
214         mMessageView = new MenuMessageView(context);
215 
216         mMenuView.setOnTargetFeaturesChangeListener(newTargetFeatures -> {
217             if (newTargetFeatures.size() < 1) {
218                 return;
219             }
220 
221             // During the undo action period, the pending action will be canceled and undo back
222             // to the previous state if users did any action related to the accessibility features.
223             if (mMessageView.getVisibility() == VISIBLE) {
224                 undo();
225             }
226 
227             final TextView messageText = (TextView) mMessageView.getChildAt(Index.TEXT_VIEW);
228             messageText.setText(getMessageText(newTargetFeatures));
229         });
230 
231         addView(mMenuView, LayerIndex.MENU_VIEW);
232         addView(mDismissView, LayerIndex.DISMISS_VIEW);
233         addView(mMessageView, LayerIndex.MESSAGE_VIEW);
234     }
235 
236     @Override
onConfigurationChanged(@onNull Configuration newConfig)237     public void onConfigurationChanged(@NonNull Configuration newConfig) {
238         mDismissView.updateResources();
239         mDismissAnimationController.updateResources();
240     }
241 
242     @Override
onLowMemory()243     public void onLowMemory() {
244         // Do nothing.
245     }
246 
getMessageText(List<AccessibilityTarget> newTargetFeatures)247     private String getMessageText(List<AccessibilityTarget> newTargetFeatures) {
248         Preconditions.checkArgument(newTargetFeatures.size() > 0,
249                 "The list should at least have one feature.");
250 
251         final int featuresSize = newTargetFeatures.size();
252         final Resources resources = getResources();
253         if (featuresSize == 1) {
254             return resources.getString(
255                     R.string.accessibility_floating_button_undo_message_label_text,
256                     newTargetFeatures.get(0).getLabel());
257         }
258 
259         return icuMessageFormat(resources,
260                 R.string.accessibility_floating_button_undo_message_number_text, featuresSize);
261     }
262 
263     @Override
onInterceptTouchEvent(MotionEvent event)264     public boolean onInterceptTouchEvent(MotionEvent event) {
265         if (mMenuView.maybeMoveOutEdgeAndShow((int) event.getX(), (int) event.getY())) {
266             return true;
267         }
268 
269         return super.onInterceptTouchEvent(event);
270     }
271 
272     @Override
onAttachedToWindow()273     protected void onAttachedToWindow() {
274         super.onAttachedToWindow();
275 
276         mMenuView.show();
277         setOnClickListener(this);
278         setOnApplyWindowInsetsListener((view, insets) -> onWindowInsetsApplied(insets));
279         getViewTreeObserver().addOnComputeInternalInsetsListener(this);
280         mMenuViewModel.getDockTooltipVisibilityData().observeForever(mDockTooltipObserver);
281         mMenuViewModel.getMigrationTooltipVisibilityData().observeForever(
282                 mMigrationTooltipObserver);
283         mMessageView.setUndoListener(view -> undo());
284         getContext().registerComponentCallbacks(this);
285     }
286 
287     @Override
onDetachedFromWindow()288     protected void onDetachedFromWindow() {
289         super.onDetachedFromWindow();
290 
291         mMenuView.hide();
292         setOnClickListener(null);
293         setOnApplyWindowInsetsListener(null);
294         getViewTreeObserver().removeOnComputeInternalInsetsListener(this);
295         mMenuViewModel.getDockTooltipVisibilityData().removeObserver(mDockTooltipObserver);
296         mMenuViewModel.getMigrationTooltipVisibilityData().removeObserver(
297                 mMigrationTooltipObserver);
298         mHandler.removeCallbacksAndMessages(/* token= */ null);
299         getContext().unregisterComponentCallbacks(this);
300     }
301 
302     @Override
onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo)303     public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) {
304         inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
305 
306         if (mEduTooltipView.isPresent()) {
307             final int x = (int) getX();
308             final int y = (int) getY();
309             inoutInfo.touchableRegion.union(new Rect(x, y, x + getWidth(), y + getHeight()));
310         }
311     }
312 
313     @Override
onClick(View v)314     public void onClick(View v) {
315         mEduTooltipView.ifPresent(this::removeTooltip);
316     }
317 
onWindowInsetsApplied(WindowInsets insets)318     private WindowInsets onWindowInsetsApplied(WindowInsets insets) {
319         final WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics();
320         final WindowInsets windowInsets = windowMetrics.getWindowInsets();
321         final Rect imeInsetsRect = windowInsets.getInsets(ime()).toRect();
322         if (!imeInsetsRect.equals(mImeInsetsRect)) {
323             final Rect windowBounds = new Rect(windowMetrics.getBounds());
324             final Rect systemBarsAndDisplayCutoutInsetsRect =
325                     windowInsets.getInsetsIgnoringVisibility(
326                             Type.systemBars() | Type.displayCutout()).toRect();
327             final float imeTop =
328                     windowBounds.height() - systemBarsAndDisplayCutoutInsetsRect.top
329                             - imeInsetsRect.bottom;
330 
331             mMenuViewAppearance.onImeVisibilityChanged(windowInsets.isVisible(ime()), imeTop);
332 
333             mMenuView.onEdgeChanged();
334             mMenuView.onPositionChanged();
335 
336             mImeInsetsRect.set(imeInsetsRect);
337         }
338 
339         return insets;
340     }
341 
onMigrationTooltipVisibilityChanged(boolean visible)342     private void onMigrationTooltipVisibilityChanged(boolean visible) {
343         mIsMigrationTooltipShowing = visible;
344 
345         if (mIsMigrationTooltipShowing) {
346             mEduTooltipView = Optional.of(new MenuEduTooltipView(mContext, mMenuViewAppearance));
347             mEduTooltipView.ifPresent(
348                     view -> addTooltipView(view, getMigrationMessage(), TooltipType.MIGRATION));
349         }
350     }
351 
onDockTooltipVisibilityChanged(boolean hasSeenTooltip)352     private void onDockTooltipVisibilityChanged(boolean hasSeenTooltip) {
353         mShouldShowDockTooltip = !hasSeenTooltip;
354     }
355 
onSpringAnimationsEndAction()356     private void onSpringAnimationsEndAction() {
357         if (mShouldShowDockTooltip) {
358             mEduTooltipView = Optional.of(new MenuEduTooltipView(mContext, mMenuViewAppearance));
359             mEduTooltipView.ifPresent(view -> addTooltipView(view,
360                     getContext().getText(R.string.accessibility_floating_button_docking_tooltip),
361                     TooltipType.DOCK));
362 
363             mMenuAnimationController.startTuckedAnimationPreview();
364         }
365     }
366 
getMigrationMessage()367     private CharSequence getMigrationMessage() {
368         final Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_DETAILS_SETTINGS);
369         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
370         intent.putExtra(Intent.EXTRA_COMPONENT_NAME,
371                 ACCESSIBILITY_BUTTON_COMPONENT_NAME.flattenToShortString());
372 
373         final AnnotationLinkSpan.LinkInfo linkInfo = new AnnotationLinkSpan.LinkInfo(
374                 AnnotationLinkSpan.LinkInfo.DEFAULT_ANNOTATION,
375                 v -> {
376                     getContext().startActivity(intent);
377                     mEduTooltipView.ifPresent(this::removeTooltip);
378                 });
379 
380         final int textResId = R.string.accessibility_floating_button_migration_tooltip;
381 
382         return AnnotationLinkSpan.linkify(getContext().getText(textResId), linkInfo);
383     }
384 
addTooltipView(MenuEduTooltipView tooltipView, CharSequence message, CharSequence tag)385     private void addTooltipView(MenuEduTooltipView tooltipView, CharSequence message,
386             CharSequence tag) {
387         addView(tooltipView, LayerIndex.TOOLTIP_VIEW);
388 
389         tooltipView.show(message);
390         tooltipView.setTag(tag);
391 
392         mMenuListViewTouchHandler.setOnActionDownEndListener(
393                 () -> mEduTooltipView.ifPresent(this::removeTooltip));
394     }
395 
removeTooltip(View tooltipView)396     private void removeTooltip(View tooltipView) {
397         if (tooltipView.getTag().equals(TooltipType.MIGRATION)) {
398             mMenuViewModel.updateMigrationTooltipVisibility(/* visible= */ false);
399             mIsMigrationTooltipShowing = false;
400         }
401 
402         if (tooltipView.getTag().equals(TooltipType.DOCK)) {
403             mMenuViewModel.updateDockTooltipVisibility(/* hasSeen= */ true);
404             mMenuView.clearAnimation();
405             mShouldShowDockTooltip = false;
406         }
407 
408         removeView(tooltipView);
409 
410         mMenuListViewTouchHandler.setOnActionDownEndListener(null);
411         mEduTooltipView = Optional.empty();
412     }
413 
hideMenuAndShowMessage()414     private void hideMenuAndShowMessage() {
415         final int delayTime = mAccessibilityManager.getRecommendedTimeoutMillis(
416                 SHOW_MESSAGE_DELAY_MS,
417                 AccessibilityManager.FLAG_CONTENT_TEXT
418                         | AccessibilityManager.FLAG_CONTENT_CONTROLS);
419         mHandler.postDelayed(mDismissMenuAction, delayTime);
420         mMessageView.setVisibility(VISIBLE);
421         mMenuAnimationController.startShrinkAnimation(() -> mMenuView.setVisibility(GONE));
422     }
423 
undo()424     private void undo() {
425         mHandler.removeCallbacksAndMessages(/* token= */ null);
426         mMessageView.setVisibility(GONE);
427         mMenuView.onEdgeChanged();
428         mMenuView.onPositionChanged();
429         mMenuView.setVisibility(VISIBLE);
430         mMenuAnimationController.startGrowAnimation();
431     }
432 }
433