1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- 2 * vim: ts=4 sw=4 expandtab: 3 * This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 package org.mozilla.geckoview; 8 9 import android.annotation.TargetApi; 10 import android.app.Activity; 11 import android.content.ActivityNotFoundException; 12 import android.content.Intent; 13 import android.content.pm.PackageManager; 14 import android.graphics.Matrix; 15 import android.graphics.Rect; 16 import android.graphics.RectF; 17 import android.os.Build; 18 import android.os.TransactionTooLargeException; 19 import android.text.TextUtils; 20 import android.util.Log; 21 import android.view.ActionMode; 22 import android.view.Menu; 23 import android.view.MenuItem; 24 import android.view.View; 25 import androidx.annotation.NonNull; 26 import androidx.annotation.Nullable; 27 import androidx.annotation.UiThread; 28 import org.mozilla.gecko.util.ThreadUtils; 29 30 /** 31 * Class that implements a basic SelectionActionDelegate. This class is used by GeckoView by default 32 * if the consumer does not explicitly set a SelectionActionDelegate. 33 * 34 * <p>To provide custom actions, extend this class and override the following methods, 35 * 36 * <p>1) Override {@link #getAllActions} to include custom action IDs in the returned array. This 37 * array must include all actions, available or not, and must not change over the class lifetime. 38 * 39 * <p>2) Override {@link #isActionAvailable} to return whether a custom action is currently 40 * available. 41 * 42 * <p>3) Override {@link #prepareAction} to set custom title and/or icon for a custom action. 43 * 44 * <p>4) Override {@link #performAction} to perform a custom action when used. 45 */ 46 @UiThread 47 public class BasicSelectionActionDelegate 48 implements ActionMode.Callback, GeckoSession.SelectionActionDelegate { 49 private static final String LOGTAG = "BasicSelectionAction"; 50 51 protected static final String ACTION_PROCESS_TEXT = Intent.ACTION_PROCESS_TEXT; 52 53 private static final String[] FLOATING_TOOLBAR_ACTIONS = 54 new String[] { 55 ACTION_CUT, 56 ACTION_COPY, 57 ACTION_PASTE, 58 ACTION_SELECT_ALL, 59 ACTION_PASTE_AS_PLAIN_TEXT, 60 ACTION_PROCESS_TEXT 61 }; 62 private static final String[] FIXED_TOOLBAR_ACTIONS = 63 new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE}; 64 65 // This is limitation of intent text. 66 private static final int MAX_INTENT_TEXT_LENGTH = 100000; 67 68 protected final @NonNull Activity mActivity; 69 protected final boolean mUseFloatingToolbar; 70 protected final @NonNull Matrix mTempMatrix = new Matrix(); 71 protected final @NonNull RectF mTempRect = new RectF(); 72 73 private boolean mExternalActionsEnabled; 74 75 protected @Nullable ActionMode mActionMode; 76 protected @Nullable GeckoSession mSession; 77 protected @Nullable Selection mSelection; 78 protected boolean mRepopulatedMenu; 79 80 @TargetApi(Build.VERSION_CODES.M) 81 private class Callback2Wrapper extends ActionMode.Callback2 { 82 @Override onCreateActionMode(final ActionMode actionMode, final Menu menu)83 public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { 84 return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu); 85 } 86 87 @Override onPrepareActionMode(final ActionMode actionMode, final Menu menu)88 public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { 89 return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu); 90 } 91 92 @Override onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem)93 public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { 94 return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem); 95 } 96 97 @Override onDestroyActionMode(final ActionMode actionMode)98 public void onDestroyActionMode(final ActionMode actionMode) { 99 BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode); 100 } 101 102 @Override onGetContentRect(final ActionMode mode, final View view, final Rect outRect)103 public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) { 104 super.onGetContentRect(mode, view, outRect); 105 BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect); 106 } 107 } 108 109 @SuppressWarnings("checkstyle:javadocmethod") BasicSelectionActionDelegate(final @NonNull Activity activity)110 public BasicSelectionActionDelegate(final @NonNull Activity activity) { 111 this(activity, Build.VERSION.SDK_INT >= 23); 112 } 113 114 @SuppressWarnings("checkstyle:javadocmethod") BasicSelectionActionDelegate( final @NonNull Activity activity, final boolean useFloatingToolbar)115 public BasicSelectionActionDelegate( 116 final @NonNull Activity activity, final boolean useFloatingToolbar) { 117 mActivity = activity; 118 mUseFloatingToolbar = useFloatingToolbar; 119 mExternalActionsEnabled = true; 120 } 121 122 /** 123 * Set whether to include text actions from other apps in the floating toolbar. 124 * 125 * @param enable True if external actions should be enabled. 126 */ enableExternalActions(final boolean enable)127 public void enableExternalActions(final boolean enable) { 128 ThreadUtils.assertOnUiThread(); 129 mExternalActionsEnabled = enable; 130 131 if (mActionMode != null) { 132 mActionMode.invalidate(); 133 } 134 } 135 136 /** 137 * Get whether text actions from other apps are enabled. 138 * 139 * @return True if external actions are enabled. 140 */ areExternalActionsEnabled()141 public boolean areExternalActionsEnabled() { 142 return mExternalActionsEnabled; 143 } 144 145 /** 146 * Return list of all actions in proper order, regardless of their availability at present. 147 * Override to add to or remove from the default set. 148 * 149 * @return Array of action IDs in proper order. 150 */ getAllActions()151 protected @NonNull String[] getAllActions() { 152 return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS; 153 } 154 155 /** 156 * Return whether an action is presently available. Override to indicate availability for custom 157 * actions. 158 * 159 * @param id Action ID. 160 * @return True if the action is presently available. 161 */ isActionAvailable(final @NonNull String id)162 protected boolean isActionAvailable(final @NonNull String id) { 163 if (mSelection == null) { 164 return false; 165 } 166 167 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) { 168 return false; 169 } 170 171 if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) { 172 final PackageManager pm = mActivity.getPackageManager(); 173 return pm.resolveActivity(getProcessTextIntent(), PackageManager.MATCH_DEFAULT_ONLY) != null; 174 } 175 return mSelection.isActionAvailable(id); 176 } 177 178 /** 179 * Provides access to whether there are text selection actions available. Override to indicate 180 * availability for custom actions. 181 * 182 * @return True if there are text selection actions available. 183 */ isActionAvailable()184 public boolean isActionAvailable() { 185 if (mSelection == null) { 186 return false; 187 } 188 189 return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty(); 190 } 191 192 /** 193 * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom 194 * action. 195 * 196 * @param id Action ID. 197 * @param item New menu item to prepare. 198 */ prepareAction(final @NonNull String id, final @NonNull MenuItem item)199 protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) { 200 switch (id) { 201 case ACTION_CUT: 202 item.setTitle(android.R.string.cut); 203 break; 204 case ACTION_COPY: 205 item.setTitle(android.R.string.copy); 206 break; 207 case ACTION_PASTE: 208 item.setTitle(android.R.string.paste); 209 break; 210 case ACTION_PASTE_AS_PLAIN_TEXT: 211 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { 212 throw new IllegalStateException("Unexpected version for action"); 213 } 214 item.setTitle(android.R.string.paste_as_plain_text); 215 break; 216 case ACTION_SELECT_ALL: 217 item.setTitle(android.R.string.selectAll); 218 break; 219 case ACTION_PROCESS_TEXT: 220 throw new IllegalStateException("Unexpected action"); 221 } 222 } 223 224 /** 225 * Perform the specified action. Override to perform custom actions. 226 * 227 * @param id Action ID. 228 * @param item Nenu item for the action. 229 * @return True if the action was performed. 230 */ performAction(final @NonNull String id, final @NonNull MenuItem item)231 protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) { 232 if (ACTION_PROCESS_TEXT.equals(id)) { 233 try { 234 mActivity.startActivity(item.getIntent()); 235 } catch (final ActivityNotFoundException e) { 236 Log.e(LOGTAG, "Cannot perform action", e); 237 return false; 238 } 239 return true; 240 } 241 242 if (mSelection == null) { 243 return false; 244 } 245 mSelection.execute(id); 246 247 // Android behavior is to clear selection on copy. 248 if (ACTION_COPY.equals(id)) { 249 if (mUseFloatingToolbar) { 250 clearSelection(); 251 } else { 252 mActionMode.finish(); 253 } 254 } 255 return true; 256 } 257 258 /** 259 * Get the current selection object. This object should not be stored as it does not update when 260 * the selection becomes invalid. Stale actions are ignored. 261 * 262 * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current 263 * action menu. <code>null</code> if no action menu is active. 264 */ getSelection()265 public @Nullable Selection getSelection() { 266 return mSelection; 267 } 268 269 /** Clear the current selection, if possible. */ clearSelection()270 public void clearSelection() { 271 if (mSelection == null) { 272 return; 273 } 274 275 if (isActionAvailable(ACTION_COLLAPSE_TO_END)) { 276 mSelection.collapseToEnd(); 277 } else if (isActionAvailable(ACTION_UNSELECT)) { 278 mSelection.unselect(); 279 } else { 280 mSelection.hide(); 281 } 282 } 283 getSelectedText(final int maxLength)284 private String getSelectedText(final int maxLength) { 285 if (mSelection == null) { 286 return ""; 287 } 288 289 if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) { 290 return mSelection.text; 291 } 292 293 return mSelection.text.substring(0, maxLength); 294 } 295 getProcessTextIntent()296 private Intent getProcessTextIntent() { 297 final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT); 298 intent.addCategory(Intent.CATEGORY_DEFAULT); 299 intent.setType("text/plain"); 300 // If using large text, anything intent may throw RemoteException. 301 intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH)); 302 // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137). 303 intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true); 304 return intent; 305 } 306 307 @Override onCreateActionMode(final ActionMode actionMode, final Menu menu)308 public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { 309 ThreadUtils.assertOnUiThread(); 310 final String[] allActions = getAllActions(); 311 for (final String actionId : allActions) { 312 if (isActionAvailable(actionId)) { 313 if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) { 314 // Android bug where onPrepareActionMode is not called initially. 315 onPrepareActionMode(actionMode, menu); 316 } 317 return true; 318 } 319 } 320 return false; 321 } 322 323 @Override onPrepareActionMode(final ActionMode actionMode, final Menu menu)324 public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { 325 ThreadUtils.assertOnUiThread(); 326 final String[] allActions = getAllActions(); 327 boolean changed = false; 328 329 // Whether we are repopulating an existing menu. 330 mRepopulatedMenu = menu.size() != 0; 331 332 // For each action, see if it's available at present, and if necessary, 333 // add to or remove from menu. 334 for (int i = 0; i < allActions.length; i++) { 335 final String actionId = allActions[i]; 336 final int menuId = i + Menu.FIRST; 337 338 if (ACTION_PROCESS_TEXT.equals(actionId)) { 339 if (mExternalActionsEnabled && !mSelection.text.isEmpty()) { 340 try { 341 menu.addIntentOptions( 342 menuId, 343 menuId, 344 menuId, 345 mActivity.getComponentName(), 346 /* specifiec */ null, 347 getProcessTextIntent(), 348 /* flags */ 0, /* items */ 349 null); 350 changed = true; 351 } catch (final RuntimeException e) { 352 if (e.getCause() instanceof TransactionTooLargeException) { 353 // Binder size error. MAX_INTENT_TEXT_LENGTH is still large? 354 Log.e(LOGTAG, "Cannot add intent option", e); 355 } else { 356 throw e; 357 } 358 } 359 } else if (menu.findItem(menuId) != null) { 360 menu.removeGroup(menuId); 361 changed = true; 362 } 363 continue; 364 } 365 366 if (isActionAvailable(actionId)) { 367 if (menu.findItem(menuId) == null) { 368 prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ "")); 369 changed = true; 370 } 371 } else if (menu.findItem(menuId) != null) { 372 menu.removeItem(menuId); 373 changed = true; 374 } 375 } 376 return changed; 377 } 378 379 @Override onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem)380 public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { 381 ThreadUtils.assertOnUiThread(); 382 MenuItem realMenuItem = null; 383 if (mRepopulatedMenu) { 384 // When we repopulate an existing menu, Android can sometimes give us an old, 385 // deleted MenuItem. Find the current MenuItem that corresponds to the old one. 386 final Menu menu = actionMode.getMenu(); 387 final int size = menu.size(); 388 for (int i = 0; i < size; i++) { 389 final MenuItem item = menu.getItem(i); 390 if (item == menuItem 391 || (item.getItemId() == menuItem.getItemId() 392 && item.getTitle().equals(menuItem.getTitle()))) { 393 realMenuItem = item; 394 break; 395 } 396 } 397 } else { 398 realMenuItem = menuItem; 399 } 400 401 if (realMenuItem == null) { 402 return false; 403 } 404 final String[] allActions = getAllActions(); 405 return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem); 406 } 407 408 @Override onDestroyActionMode(final ActionMode actionMode)409 public void onDestroyActionMode(final ActionMode actionMode) { 410 ThreadUtils.assertOnUiThread(); 411 if (!mUseFloatingToolbar) { 412 clearSelection(); 413 } 414 mSession = null; 415 mSelection = null; 416 mActionMode = null; 417 } 418 419 @SuppressWarnings("checkstyle:javadocmethod") onGetContentRect( final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect)420 public void onGetContentRect( 421 final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) { 422 ThreadUtils.assertOnUiThread(); 423 if (mSelection == null || mSelection.clientRect == null) { 424 return; 425 } 426 mSession.getClientToScreenMatrix(mTempMatrix); 427 mTempMatrix.mapRect(mTempRect, mSelection.clientRect); 428 mTempRect.roundOut(outRect); 429 } 430 431 @TargetApi(Build.VERSION_CODES.M) 432 @Override onShowActionRequest(final GeckoSession session, final Selection selection)433 public void onShowActionRequest(final GeckoSession session, final Selection selection) { 434 ThreadUtils.assertOnUiThread(); 435 mSession = session; 436 mSelection = selection; 437 438 if (mActionMode != null) { 439 if (isActionAvailable()) { 440 mActionMode.invalidate(); 441 } else { 442 mActionMode.finish(); 443 } 444 return; 445 } 446 447 if (mUseFloatingToolbar) { 448 mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING); 449 } else { 450 mActionMode = mActivity.startActionMode(this); 451 } 452 } 453 454 @Override onHideAction(final GeckoSession session, final int reason)455 public void onHideAction(final GeckoSession session, final int reason) { 456 ThreadUtils.assertOnUiThread(); 457 if (mActionMode == null) { 458 return; 459 } 460 461 switch (reason) { 462 case HIDE_REASON_ACTIVE_SCROLL: 463 case HIDE_REASON_ACTIVE_SELECTION: 464 case HIDE_REASON_INVISIBLE_SELECTION: 465 if (mUseFloatingToolbar) { 466 // Hide the floating toolbar when scrolling/selecting. 467 mActionMode.finish(); 468 } 469 break; 470 471 case HIDE_REASON_NO_SELECTION: 472 mActionMode.finish(); 473 break; 474 } 475 } 476 } 477