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