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