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