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