1 package com.android.launcher3.accessibility;
2 
3 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK;
4 
5 import static com.android.launcher3.LauncherState.NORMAL;
6 
7 import android.app.AlertDialog;
8 import android.appwidget.AppWidgetProviderInfo;
9 import android.content.DialogInterface;
10 import android.graphics.Rect;
11 import android.os.Bundle;
12 import android.os.Handler;
13 import android.text.TextUtils;
14 import android.util.Log;
15 import android.util.SparseArray;
16 import android.view.View;
17 import android.view.View.AccessibilityDelegate;
18 import android.view.accessibility.AccessibilityNodeInfo;
19 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
20 
21 import com.android.launcher3.AppInfo;
22 import com.android.launcher3.AppWidgetResizeFrame;
23 import com.android.launcher3.BubbleTextView;
24 import com.android.launcher3.ButtonDropTarget;
25 import com.android.launcher3.CellLayout;
26 import com.android.launcher3.DropTarget.DragObject;
27 import com.android.launcher3.FolderInfo;
28 import com.android.launcher3.ItemInfo;
29 import com.android.launcher3.Launcher;
30 import com.android.launcher3.LauncherAppWidgetInfo;
31 import com.android.launcher3.LauncherSettings;
32 import com.android.launcher3.LauncherSettings.Favorites;
33 import com.android.launcher3.PendingAddItemInfo;
34 import com.android.launcher3.R;
35 import com.android.launcher3.Workspace;
36 import com.android.launcher3.WorkspaceItemInfo;
37 import com.android.launcher3.dragndrop.DragController.DragListener;
38 import com.android.launcher3.dragndrop.DragOptions;
39 import com.android.launcher3.folder.Folder;
40 import com.android.launcher3.keyboard.CustomActionsPopup;
41 import com.android.launcher3.notification.NotificationListener;
42 import com.android.launcher3.popup.PopupContainerWithArrow;
43 import com.android.launcher3.touch.ItemLongClickListener;
44 import com.android.launcher3.util.IntArray;
45 import com.android.launcher3.util.ShortcutUtil;
46 import com.android.launcher3.util.Thunk;
47 import com.android.launcher3.widget.LauncherAppWidgetHostView;
48 
49 import java.util.ArrayList;
50 
51 public class LauncherAccessibilityDelegate extends AccessibilityDelegate implements DragListener {
52 
53     private static final String TAG = "LauncherAccessibilityDelegate";
54 
55     public static final int REMOVE = R.id.action_remove;
56     public static final int UNINSTALL = R.id.action_uninstall;
57     public static final int RECONFIGURE = R.id.action_reconfigure;
58     protected static final int ADD_TO_WORKSPACE = R.id.action_add_to_workspace;
59     protected static final int MOVE = R.id.action_move;
60     protected static final int MOVE_TO_WORKSPACE = R.id.action_move_to_workspace;
61     protected static final int RESIZE = R.id.action_resize;
62     public static final int DEEP_SHORTCUTS = R.id.action_deep_shortcuts;
63     public static final int SHORTCUTS_AND_NOTIFICATIONS = R.id.action_shortcuts_and_notifications;
64 
65     public enum DragType {
66         ICON,
67         FOLDER,
68         WIDGET
69     }
70 
71     public static class DragInfo {
72         public DragType dragType;
73         public ItemInfo info;
74         public View item;
75     }
76 
77     protected final SparseArray<AccessibilityAction> mActions = new SparseArray<>();
78     @Thunk final Launcher mLauncher;
79 
80     private DragInfo mDragInfo = null;
81 
LauncherAccessibilityDelegate(Launcher launcher)82     public LauncherAccessibilityDelegate(Launcher launcher) {
83         mLauncher = launcher;
84 
85         mActions.put(REMOVE, new AccessibilityAction(REMOVE,
86                 launcher.getText(R.string.remove_drop_target_label)));
87         mActions.put(UNINSTALL, new AccessibilityAction(UNINSTALL,
88                 launcher.getText(R.string.uninstall_drop_target_label)));
89         mActions.put(RECONFIGURE, new AccessibilityAction(RECONFIGURE,
90                 launcher.getText(R.string.gadget_setup_text)));
91         mActions.put(ADD_TO_WORKSPACE, new AccessibilityAction(ADD_TO_WORKSPACE,
92                 launcher.getText(R.string.action_add_to_workspace)));
93         mActions.put(MOVE, new AccessibilityAction(MOVE,
94                 launcher.getText(R.string.action_move)));
95         mActions.put(MOVE_TO_WORKSPACE, new AccessibilityAction(MOVE_TO_WORKSPACE,
96                 launcher.getText(R.string.action_move_to_workspace)));
97         mActions.put(RESIZE, new AccessibilityAction(RESIZE,
98                         launcher.getText(R.string.action_resize)));
99         mActions.put(DEEP_SHORTCUTS, new AccessibilityAction(DEEP_SHORTCUTS,
100                 launcher.getText(R.string.action_deep_shortcut)));
101         mActions.put(SHORTCUTS_AND_NOTIFICATIONS, new AccessibilityAction(DEEP_SHORTCUTS,
102                 launcher.getText(R.string.shortcuts_menu_with_notifications_description)));
103     }
104 
addAccessibilityAction(int action, int actionLabel)105     public void addAccessibilityAction(int action, int actionLabel) {
106         mActions.put(action, new AccessibilityAction(action, mLauncher.getText(actionLabel)));
107     }
108 
109     @Override
onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)110     public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
111         super.onInitializeAccessibilityNodeInfo(host, info);
112         addSupportedActions(host, info, false);
113     }
114 
addSupportedActions(View host, AccessibilityNodeInfo info, boolean fromKeyboard)115     public void addSupportedActions(View host, AccessibilityNodeInfo info, boolean fromKeyboard) {
116         if (!(host.getTag() instanceof ItemInfo)) return;
117         ItemInfo item = (ItemInfo) host.getTag();
118 
119         // If the request came from keyboard, do not add custom shortcuts as that is already
120         // exposed as a direct shortcut
121         if (!fromKeyboard && ShortcutUtil.supportsShortcuts(item)) {
122             info.addAction(mActions.get(NotificationListener.getInstanceIfConnected() != null
123                     ? SHORTCUTS_AND_NOTIFICATIONS : DEEP_SHORTCUTS));
124         }
125 
126         for (ButtonDropTarget target : mLauncher.getDropTargetBar().getDropTargets()) {
127             if (target.supportsAccessibilityDrop(item, host)) {
128                 info.addAction(mActions.get(target.getAccessibilityAction()));
129             }
130         }
131 
132         // Do not add move actions for keyboard request as this uses virtual nodes.
133         if (!fromKeyboard && itemSupportsAccessibleDrag(item)) {
134             info.addAction(mActions.get(MOVE));
135 
136             if (item.container >= 0) {
137                 info.addAction(mActions.get(MOVE_TO_WORKSPACE));
138             } else if (item instanceof LauncherAppWidgetInfo) {
139                 if (!getSupportedResizeActions(host, (LauncherAppWidgetInfo) item).isEmpty()) {
140                     info.addAction(mActions.get(RESIZE));
141                 }
142             }
143         }
144 
145         if ((item instanceof AppInfo) || (item instanceof PendingAddItemInfo)) {
146             info.addAction(mActions.get(ADD_TO_WORKSPACE));
147         }
148     }
149 
itemSupportsAccessibleDrag(ItemInfo item)150     private boolean itemSupportsAccessibleDrag(ItemInfo item) {
151         if (item instanceof WorkspaceItemInfo) {
152             // Support the action unless the item is in a context menu.
153             return item.screenId >= 0;
154         }
155         return (item instanceof LauncherAppWidgetInfo)
156                 || (item instanceof FolderInfo);
157     }
158 
159     @Override
performAccessibilityAction(View host, int action, Bundle args)160     public boolean performAccessibilityAction(View host, int action, Bundle args) {
161         if ((host.getTag() instanceof ItemInfo)
162                 && performAction(host, (ItemInfo) host.getTag(), action)) {
163             return true;
164         }
165         return super.performAccessibilityAction(host, action, args);
166     }
167 
performAction(final View host, final ItemInfo item, int action)168     public boolean performAction(final View host, final ItemInfo item, int action) {
169         if (action == ACTION_LONG_CLICK && ShortcutUtil.isDeepShortcut(item)) {
170             CustomActionsPopup popup = new CustomActionsPopup(mLauncher, host);
171             if (popup.canShow()) {
172                 popup.show();
173                 return true;
174             }
175         }
176         if (action == MOVE) {
177             beginAccessibleDrag(host, item);
178         } else if (action == ADD_TO_WORKSPACE) {
179             final int[] coordinates = new int[2];
180             final int screenId = findSpaceOnWorkspace(item, coordinates);
181             mLauncher.getStateManager().goToState(NORMAL, true, new Runnable() {
182 
183                 @Override
184                 public void run() {
185                     if (item instanceof AppInfo) {
186                         WorkspaceItemInfo info = ((AppInfo) item).makeWorkspaceItem();
187                         mLauncher.getModelWriter().addItemToDatabase(info,
188                                 Favorites.CONTAINER_DESKTOP,
189                                 screenId, coordinates[0], coordinates[1]);
190 
191                         ArrayList<ItemInfo> itemList = new ArrayList<>();
192                         itemList.add(info);
193                         mLauncher.bindItems(itemList, true);
194                     } else if (item instanceof PendingAddItemInfo) {
195                         PendingAddItemInfo info = (PendingAddItemInfo) item;
196                         Workspace workspace = mLauncher.getWorkspace();
197                         workspace.snapToPage(workspace.getPageIndexForScreenId(screenId));
198                         mLauncher.addPendingItem(info, Favorites.CONTAINER_DESKTOP,
199                                 screenId, coordinates, info.spanX, info.spanY);
200                     }
201                     announceConfirmation(R.string.item_added_to_workspace);
202                 }
203             });
204             return true;
205         } else if (action == MOVE_TO_WORKSPACE) {
206             Folder folder = Folder.getOpen(mLauncher);
207             folder.close(true);
208             WorkspaceItemInfo info = (WorkspaceItemInfo) item;
209             folder.getInfo().remove(info, false);
210 
211             final int[] coordinates = new int[2];
212             final int screenId = findSpaceOnWorkspace(item, coordinates);
213             mLauncher.getModelWriter().moveItemInDatabase(info,
214                     LauncherSettings.Favorites.CONTAINER_DESKTOP,
215                     screenId, coordinates[0], coordinates[1]);
216 
217             // Bind the item in next frame so that if a new workspace page was created,
218             // it will get laid out.
219             new Handler().post(new Runnable() {
220 
221                 @Override
222                 public void run() {
223                     ArrayList<ItemInfo> itemList = new ArrayList<>();
224                     itemList.add(item);
225                     mLauncher.bindItems(itemList, true);
226                     announceConfirmation(R.string.item_moved);
227                 }
228             });
229         } else if (action == RESIZE) {
230             final LauncherAppWidgetInfo info = (LauncherAppWidgetInfo) item;
231             final IntArray actions = getSupportedResizeActions(host, info);
232             CharSequence[] labels = new CharSequence[actions.size()];
233             for (int i = 0; i < actions.size(); i++) {
234                 labels[i] = mLauncher.getText(actions.get(i));
235             }
236 
237             new AlertDialog.Builder(mLauncher)
238                 .setTitle(R.string.action_resize)
239                 .setItems(labels, new DialogInterface.OnClickListener() {
240 
241                     @Override
242                     public void onClick(DialogInterface dialog, int which) {
243                         performResizeAction(actions.get(which), host, info);
244                         dialog.dismiss();
245                     }
246                 })
247                 .show();
248             return true;
249         } else if (action == DEEP_SHORTCUTS) {
250             return PopupContainerWithArrow.showForIcon((BubbleTextView) host) != null;
251         } else {
252             for (ButtonDropTarget dropTarget : mLauncher.getDropTargetBar().getDropTargets()) {
253                 if (dropTarget.supportsAccessibilityDrop(item, host) &&
254                         action == dropTarget.getAccessibilityAction()) {
255                     dropTarget.onAccessibilityDrop(host, item);
256                     return true;
257                 }
258             }
259         }
260         return false;
261     }
262 
getSupportedResizeActions(View host, LauncherAppWidgetInfo info)263     private IntArray getSupportedResizeActions(View host, LauncherAppWidgetInfo info) {
264         IntArray actions = new IntArray();
265 
266         AppWidgetProviderInfo providerInfo = ((LauncherAppWidgetHostView) host).getAppWidgetInfo();
267         if (providerInfo == null) {
268             return actions;
269         }
270 
271         CellLayout layout = (CellLayout) host.getParent().getParent();
272         if ((providerInfo.resizeMode & AppWidgetProviderInfo.RESIZE_HORIZONTAL) != 0) {
273             if (layout.isRegionVacant(info.cellX + info.spanX, info.cellY, 1, info.spanY) ||
274                     layout.isRegionVacant(info.cellX - 1, info.cellY, 1, info.spanY)) {
275                 actions.add(R.string.action_increase_width);
276             }
277 
278             if (info.spanX > info.minSpanX && info.spanX > 1) {
279                 actions.add(R.string.action_decrease_width);
280             }
281         }
282 
283         if ((providerInfo.resizeMode & AppWidgetProviderInfo.RESIZE_VERTICAL) != 0) {
284             if (layout.isRegionVacant(info.cellX, info.cellY + info.spanY, info.spanX, 1) ||
285                     layout.isRegionVacant(info.cellX, info.cellY - 1, info.spanX, 1)) {
286                 actions.add(R.string.action_increase_height);
287             }
288 
289             if (info.spanY > info.minSpanY && info.spanY > 1) {
290                 actions.add(R.string.action_decrease_height);
291             }
292         }
293         return actions;
294     }
295 
performResizeAction(int action, View host, LauncherAppWidgetInfo info)296     @Thunk void performResizeAction(int action, View host, LauncherAppWidgetInfo info) {
297         CellLayout.LayoutParams lp = (CellLayout.LayoutParams) host.getLayoutParams();
298         CellLayout layout = (CellLayout) host.getParent().getParent();
299         layout.markCellsAsUnoccupiedForView(host);
300 
301         if (action == R.string.action_increase_width) {
302             if (((host.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL)
303                     && layout.isRegionVacant(info.cellX - 1, info.cellY, 1, info.spanY))
304                     || !layout.isRegionVacant(info.cellX + info.spanX, info.cellY, 1, info.spanY)) {
305                 lp.cellX --;
306                 info.cellX --;
307             }
308             lp.cellHSpan ++;
309             info.spanX ++;
310         } else if (action == R.string.action_decrease_width) {
311             lp.cellHSpan --;
312             info.spanX --;
313         } else if (action == R.string.action_increase_height) {
314             if (!layout.isRegionVacant(info.cellX, info.cellY + info.spanY, info.spanX, 1)) {
315                 lp.cellY --;
316                 info.cellY --;
317             }
318             lp.cellVSpan ++;
319             info.spanY ++;
320         } else if (action == R.string.action_decrease_height) {
321             lp.cellVSpan --;
322             info.spanY --;
323         }
324 
325         layout.markCellsAsOccupiedForView(host);
326         Rect sizeRange = new Rect();
327         AppWidgetResizeFrame.getWidgetSizeRanges(mLauncher, info.spanX, info.spanY, sizeRange);
328         ((LauncherAppWidgetHostView) host).updateAppWidgetSize(null,
329                 sizeRange.left, sizeRange.top, sizeRange.right, sizeRange.bottom);
330         host.requestLayout();
331         mLauncher.getModelWriter().updateItemInDatabase(info);
332         announceConfirmation(mLauncher.getString(R.string.widget_resized, info.spanX, info.spanY));
333     }
334 
announceConfirmation(int resId)335     @Thunk void announceConfirmation(int resId) {
336         announceConfirmation(mLauncher.getResources().getString(resId));
337     }
338 
announceConfirmation(String confirmation)339     @Thunk void announceConfirmation(String confirmation) {
340         mLauncher.getDragLayer().announceForAccessibility(confirmation);
341 
342     }
343 
isInAccessibleDrag()344     public boolean isInAccessibleDrag() {
345         return mDragInfo != null;
346     }
347 
getDragInfo()348     public DragInfo getDragInfo() {
349         return mDragInfo;
350     }
351 
352     /**
353      * @param clickedTarget the actual view that was clicked
354      * @param dropLocation relative to {@param clickedTarget}. If provided, its center is used
355      * as the actual drop location otherwise the views center is used.
356      */
handleAccessibleDrop(View clickedTarget, Rect dropLocation, String confirmation)357     public void handleAccessibleDrop(View clickedTarget, Rect dropLocation,
358             String confirmation) {
359         if (!isInAccessibleDrag()) return;
360 
361         int[] loc = new int[2];
362         if (dropLocation == null) {
363             loc[0] = clickedTarget.getWidth() / 2;
364             loc[1] = clickedTarget.getHeight() / 2;
365         } else {
366             loc[0] = dropLocation.centerX();
367             loc[1] = dropLocation.centerY();
368         }
369 
370         mLauncher.getDragLayer().getDescendantCoordRelativeToSelf(clickedTarget, loc);
371         mLauncher.getDragController().completeAccessibleDrag(loc);
372 
373         if (!TextUtils.isEmpty(confirmation)) {
374             announceConfirmation(confirmation);
375         }
376     }
377 
beginAccessibleDrag(View item, ItemInfo info)378     public void beginAccessibleDrag(View item, ItemInfo info) {
379         mDragInfo = new DragInfo();
380         mDragInfo.info = info;
381         mDragInfo.item = item;
382         mDragInfo.dragType = DragType.ICON;
383         if (info instanceof FolderInfo) {
384             mDragInfo.dragType = DragType.FOLDER;
385         } else if (info instanceof LauncherAppWidgetInfo) {
386             mDragInfo.dragType = DragType.WIDGET;
387         }
388 
389         Rect pos = new Rect();
390         mLauncher.getDragLayer().getDescendantRectRelativeToSelf(item, pos);
391         mLauncher.getDragController().prepareAccessibleDrag(pos.centerX(), pos.centerY());
392         mLauncher.getDragController().addDragListener(this);
393 
394         DragOptions options = new DragOptions();
395         options.isAccessibleDrag = true;
396         ItemLongClickListener.beginDrag(item, mLauncher, info, options);
397     }
398 
399     @Override
onDragStart(DragObject dragObject, DragOptions options)400     public void onDragStart(DragObject dragObject, DragOptions options) {
401         // No-op
402     }
403 
404     @Override
onDragEnd()405     public void onDragEnd() {
406         mLauncher.getDragController().removeDragListener(this);
407         mDragInfo = null;
408     }
409 
410     /**
411      * Find empty space on the workspace and returns the screenId.
412      */
findSpaceOnWorkspace(ItemInfo info, int[] outCoordinates)413     protected int findSpaceOnWorkspace(ItemInfo info, int[] outCoordinates) {
414         Workspace workspace = mLauncher.getWorkspace();
415         IntArray workspaceScreens = workspace.getScreenOrder();
416         int screenId;
417 
418         // First check if there is space on the current screen.
419         int screenIndex = workspace.getCurrentPage();
420         screenId = workspaceScreens.get(screenIndex);
421         CellLayout layout = (CellLayout) workspace.getPageAt(screenIndex);
422 
423         boolean found = layout.findCellForSpan(outCoordinates, info.spanX, info.spanY);
424         screenIndex = 0;
425         while (!found && screenIndex < workspaceScreens.size()) {
426             screenId = workspaceScreens.get(screenIndex);
427             layout = (CellLayout) workspace.getPageAt(screenIndex);
428             found = layout.findCellForSpan(outCoordinates, info.spanX, info.spanY);
429             screenIndex++;
430         }
431 
432         if (found) {
433             return screenId;
434         }
435 
436         workspace.addExtraEmptyScreen();
437         screenId = workspace.commitExtraEmptyScreen();
438         layout = workspace.getScreenWithId(screenId);
439         found = layout.findCellForSpan(outCoordinates, info.spanX, info.spanY);
440 
441         if (!found) {
442             Log.wtf(TAG, "Not enough space on an empty screen");
443         }
444         return screenId;
445     }
446 }
447