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