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