Bug 1171110 - Add support for floating text selection toolbar for Android 6.0+. r?capella draft
authorSebastian Kaspari <s.kaspari@gmail.com>
Mon, 14 Mar 2016 11:57:05 +0100
changeset 347594 9817e4858cc9e4ca8f608195424053947db85757
parent 347300 7909460567c8f6c05c82d2675ddf6ee970ef1a21
child 517666 c70e92bd0341c88480718469ce621f692cd977b8
push id14619
push users.kaspari@gmail.com
push dateTue, 05 Apr 2016 10:45:01 +0000
reviewerscapella
bugs1171110
milestone48.0a1
Bug 1171110 - Add support for floating text selection toolbar for Android 6.0+. r?capella MozReview-Commit-ID: 7FLGgrT5gDg
mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
mobile/android/base/java/org/mozilla/gecko/TextSelection.java
mobile/android/base/java/org/mozilla/gecko/gfx/LayerView.java
mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java
mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
mobile/android/base/java/org/mozilla/gecko/text/TextAction.java
mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java
mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java
mobile/android/base/moz.build
mobile/android/chrome/content/ActionBarHandler.js
rename from mobile/android/base/java/org/mozilla/gecko/TextSelection.java
rename to mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
--- a/mobile/android/base/java/org/mozilla/gecko/TextSelection.java
+++ b/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
@@ -1,48 +1,44 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
-import android.content.res.Resources;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.gfx.BitmapUtils.BitmapLoader;
 import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
 import org.mozilla.gecko.gfx.Layer;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.gfx.LayerView.DrawListener;
-import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.GeckoMenuItem;
-import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.text.TextSelection;
 import org.mozilla.gecko.util.FloatUtils;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.ActionModeCompat.Callback;
 import org.mozilla.gecko.AppConstants.Versions;
 
 import android.content.Context;
-import android.app.Activity;
 import android.graphics.drawable.Drawable;
 import android.view.Menu;
 import android.view.MenuItem;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import java.util.Timer;
 import java.util.TimerTask;
 
 import android.util.Log;
 import android.view.View;
 
-class TextSelection extends Layer implements GeckoEventListener,
-                                             LayerView.DynamicToolbarListener {
+class ActionBarTextSelection extends Layer implements TextSelection, GeckoEventListener, LayerView.DynamicToolbarListener {
     private static final String LOGTAG = "GeckoTextSelection";
     private static final int SHUTDOWN_DELAY_MS = 250;
 
     private final TextSelectionHandle anchorHandle;
     private final TextSelectionHandle caretHandle;
     private final TextSelectionHandle focusHandle;
 
     private final DrawListener mDrawListener;
@@ -69,49 +65,59 @@ class TextSelection extends Layer implem
                 public void run() {
                     endActionMode();
                 }
             });
         }
     };
     private ActionModeTimerTask mActionModeTimerTask;
 
-    TextSelection(TextSelectionHandle anchorHandle,
-                  TextSelectionHandle caretHandle,
-                  TextSelectionHandle focusHandle) {
+    ActionBarTextSelection(TextSelectionHandle anchorHandle,
+                           TextSelectionHandle caretHandle,
+                           TextSelectionHandle focusHandle) {
         this.anchorHandle = anchorHandle;
         this.caretHandle = caretHandle;
         this.focusHandle = focusHandle;
 
         mDrawListener = new DrawListener() {
             @Override
             public void drawFinished() {
                 if (!mDraggingHandles) {
                     GeckoAppShell.notifyObservers("TextSelection:LayerReflow", "");
                 }
             }
         };
+    }
 
+    @Override
+    public void create() {
         // Only register listeners if we have valid start/middle/end handles
         if (anchorHandle == null || caretHandle == null || focusHandle == null) {
             Log.e(LOGTAG, "Failed to initialize text selection because at least one handle is null");
         } else {
             EventDispatcher.getInstance().registerGeckoThreadListener(this,
                 "TextSelection:ActionbarInit",
                 "TextSelection:ActionbarStatus",
                 "TextSelection:ActionbarUninit",
                 "TextSelection:ShowHandles",
                 "TextSelection:HideHandles",
                 "TextSelection:PositionHandles",
                 "TextSelection:Update",
                 "TextSelection:DraggingHandle");
         }
     }
 
-    void destroy() {
+    @Override
+    public boolean dismiss() {
+        // We do not call endActionMode() here because this is already handled by the activity.
+        return false;
+    }
+
+    @Override
+    public void destroy() {
         EventDispatcher.getInstance().unregisterGeckoThreadListener(this,
             "TextSelection:ActionbarInit",
             "TextSelection:ActionbarStatus",
             "TextSelection:ActionbarUninit",
             "TextSelection:ShowHandles",
             "TextSelection:HideHandles",
             "TextSelection:PositionHandles",
             "TextSelection:Update",
@@ -154,33 +160,33 @@ class TextSelection extends Layer implem
                         mViewLeft = 0.0f;
                         mViewTop = 0.0f;
                         mViewZoom = 0.0f;
 
                         // Create text selection layer and add draw-listener for positioning on reflows
                         LayerView layerView = GeckoAppShell.getLayerView();
                         if (layerView != null) {
                             layerView.addDrawListener(mDrawListener);
-                            layerView.addLayer(TextSelection.this);
-                            layerView.getDynamicToolbarAnimator().addTranslationListener(TextSelection.this);
+                            layerView.addLayer(ActionBarTextSelection.this);
+                            layerView.getDynamicToolbarAnimator().addTranslationListener(ActionBarTextSelection.this);
                         }
 
                         if (handles.length() > 1)
                             GeckoAppShell.performHapticFeedback(true);
                     } else if (event.equals("TextSelection:Update")) {
                         if (mActionModeTimerTask != null)
                             mActionModeTimerTask.cancel();
                         showActionMode(message.getJSONArray("actions"));
                     } else if (event.equals("TextSelection:HideHandles")) {
                         // Remove draw-listener and text selection layer
                         LayerView layerView = GeckoAppShell.getLayerView();
                         if (layerView != null) {
                             layerView.removeDrawListener(mDrawListener);
-                            layerView.removeLayer(TextSelection.this);
-                            layerView.getDynamicToolbarAnimator().removeTranslationListener(TextSelection.this);
+                            layerView.removeLayer(ActionBarTextSelection.this);
+                            layerView.getDynamicToolbarAnimator().removeTranslationListener(ActionBarTextSelection.this);
                         }
 
                         mActionModeTimerTask = new ActionModeTimerTask();
                         mActionModeTimer.schedule(mActionModeTimerTask, SHUTDOWN_DELAY_MS);
 
                         anchorHandle.setVisibility(View.GONE);
                         caretHandle.setVisibility(View.GONE);
                         focusHandle.setVisibility(View.GONE);
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -609,16 +609,30 @@ public class BrowserApp extends GeckoApp
 
         initSwitchboard(intent);
 
         mBrowserChrome = (ViewGroup) findViewById(R.id.browser_chrome);
         mActionBarFlipper = (ViewFlipper) findViewById(R.id.browser_actionbar);
         mActionBar = (ActionModeCompatView) findViewById(R.id.actionbar);
 
         mBrowserToolbar = (BrowserToolbar) findViewById(R.id.browser_toolbar);
+        mBrowserToolbar.setTouchEventInterceptor(new TouchEventInterceptor() {
+            @Override
+            public boolean onInterceptTouchEvent(View view, MotionEvent event) {
+                // Manually dismiss text selection bar if it's not overlaying the toolbar.
+                mTextSelection.dismiss();
+                return false;
+            }
+
+            @Override
+            public boolean onTouch(View v, MotionEvent event) {
+                return false;
+            }
+        });
+
         mProgressView = (ToolbarProgressView) findViewById(R.id.progress);
         mBrowserToolbar.setProgressBar(mProgressView);
 
         // Initialize Tab History Controller.
         tabHistoryController = new TabHistoryController(new OnShowTabHistory() {
             @Override
             public void onShowHistory(final List<TabHistoryPage> historyPageList, final int toIndex) {
                 runOnUiThread(new Runnable() {
@@ -946,16 +960,20 @@ public class BrowserApp extends GeckoApp
             }
         }
 
         return null;
     }
 
     @Override
     public void onBackPressed() {
+        if (mTextSelection.dismiss()) {
+            return;
+        }
+
         if (getSupportFragmentManager().getBackStackEntryCount() > 0) {
             super.onBackPressed();
             return;
         }
 
         if (mBrowserToolbar.onBackPressed()) {
             return;
         }
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
@@ -1,18 +1,15 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
-import android.content.ContentResolver;
-import android.widget.AdapterView;
-import android.widget.Button;
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.URLMetadataTable;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.gfx.FullScreenState;
@@ -29,34 +26,36 @@ import org.mozilla.gecko.mozglue.Context
 import org.mozilla.gecko.mozglue.ContextUtils.SafeIntent;
 import org.mozilla.gecko.mozglue.GeckoLoader;
 import org.mozilla.gecko.permissions.Permissions;
 import org.mozilla.gecko.preferences.ClearOnShutdownPref;
 import org.mozilla.gecko.preferences.GeckoPreferences;
 import org.mozilla.gecko.prompts.PromptService;
 import org.mozilla.gecko.restrictions.Restrictions;
 import org.mozilla.gecko.tabqueue.TabQueueHelper;
+import org.mozilla.gecko.text.FloatingToolbarTextSelection;
+import org.mozilla.gecko.text.TextSelection;
 import org.mozilla.gecko.updater.UpdateServiceHelper;
 import org.mozilla.gecko.util.ActivityResultHandler;
 import org.mozilla.gecko.util.ActivityUtils;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.FileUtils;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.GeckoRequest;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.NativeEventListener;
 import org.mozilla.gecko.util.NativeJSObject;
 import org.mozilla.gecko.util.PrefUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
-
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.app.AlertDialog;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
@@ -91,16 +90,18 @@ import android.view.MotionEvent;
 import android.view.OrientationEventListener;
 import android.view.SurfaceHolder;
 import android.view.SurfaceView;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewTreeObserver;
 import android.view.Window;
 import android.widget.AbsoluteLayout;
+import android.widget.AdapterView;
+import android.widget.Button;
 import android.widget.FrameLayout;
 import android.widget.ListView;
 import android.widget.RelativeLayout;
 import android.widget.SimpleAdapter;
 import android.widget.TextView;
 import android.widget.Toast;
 
 import org.json.JSONArray;
@@ -184,17 +185,17 @@ public abstract class GeckoApp
     public List<GeckoAppShell.AppStateListener> mAppStateListeners = new LinkedList<GeckoAppShell.AppStateListener>();
     protected MenuPanel mMenuPanel;
     protected Menu mMenu;
     protected GeckoProfile mProfile;
     protected boolean mIsRestoringActivity;
 
     private ContactService mContactService;
     private PromptService mPromptService;
-    private TextSelection mTextSelection;
+    protected TextSelection mTextSelection;
 
     protected DoorHangerPopup mDoorHangerPopup;
     protected FormAssistPopup mFormAssistPopup;
 
 
     protected LayerView mLayerView;
     private AbsoluteLayout mPluginContainer;
 
@@ -1280,16 +1281,26 @@ public abstract class GeckoApp
         mRootLayout = (RelativeLayout) findViewById(R.id.root_layout);
         mGeckoLayout = (RelativeLayout) findViewById(R.id.gecko_layout);
         mMainLayout = (RelativeLayout) findViewById(R.id.main_layout);
         mLayerView = (LayerView) findViewById(R.id.layer_view);
 
         // Use global layout state change to kick off additional initialization
         mMainLayout.getViewTreeObserver().addOnGlobalLayoutListener(this);
 
+        if (Versions.preMarshmallow || !AppConstants.NIGHTLY_BUILD) {
+            mTextSelection = new ActionBarTextSelection(
+                    (TextSelectionHandle) findViewById(R.id.anchor_handle),
+                    (TextSelectionHandle) findViewById(R.id.caret_handle),
+                    (TextSelectionHandle) findViewById(R.id.focus_handle));
+        } else {
+            mTextSelection = new FloatingToolbarTextSelection(this, mLayerView);
+        }
+        mTextSelection.create();
+
         // Determine whether we should restore tabs.
         mShouldRestore = getSessionRestoreState(savedInstanceState);
         if (mShouldRestore && savedInstanceState != null) {
             boolean wasInBackground =
                 savedInstanceState.getBoolean(SAVED_STATE_IN_BACKGROUND, false);
 
             // Don't log OOM-kills if only one activity was destroyed. (For example
             // from "Don't keep activities" on ICS)
@@ -1554,20 +1565,16 @@ public abstract class GeckoApp
         if (SmsManager.isEnabled()) {
             SmsManager.getInstance().start();
         }
 
         mContactService = new ContactService(EventDispatcher.getInstance(), this);
 
         mPromptService = new PromptService(this);
 
-        mTextSelection = new TextSelection((TextSelectionHandle) findViewById(R.id.anchor_handle),
-                                           (TextSelectionHandle) findViewById(R.id.caret_handle),
-                                           (TextSelectionHandle) findViewById(R.id.focus_handle));
-
         // Trigger the completion of the telemetry timer that wraps activity startup,
         // then grab the duration to give to FHR.
         mJavaUiStartupTimer.stop();
         final long javaDuration = mJavaUiStartupTimer.getElapsed();
 
         ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
             @Override
             public void run() {
--- a/mobile/android/base/java/org/mozilla/gecko/gfx/LayerView.java
+++ b/mobile/android/base/java/org/mozilla/gecko/gfx/LayerView.java
@@ -657,16 +657,20 @@ public class LayerView extends ScrollVie
     public int getOverScrollMode() {
         if (mPanZoomController != null) {
             return mPanZoomController.getOverScrollMode();
         }
 
         return super.getOverScrollMode();
     }
 
+    public float getZoomFactor() {
+        return getLayerClient().getViewportMetrics().zoomFactor;
+    }
+
     @Override
     public void onFocusChanged (boolean gainFocus, int direction, Rect previouslyFocusedRect) {
         super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
         GeckoAccessibility.onLayerViewFocusChanged(this, gainFocus);
     }
 
     public void setFullScreenState(FullScreenState state) {
         mFullScreenState = state;
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.os.Build;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+
+import org.mozilla.gecko.GeckoAppShell;
+
+import java.util.List;
+
+@TargetApi(Build.VERSION_CODES.M)
+public class FloatingActionModeCallback extends ActionMode.Callback2 {
+    private FloatingToolbarTextSelection textSelection;
+    private List<TextAction> actions;
+
+    public FloatingActionModeCallback(FloatingToolbarTextSelection textSelection, List<TextAction> actions) {
+        this.textSelection = textSelection;
+        this.actions = actions;
+    }
+
+    public void updateActions(List<TextAction> actions) {
+        this.actions = actions;
+    }
+
+    @Override
+    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+        return true;
+    }
+
+    @Override
+    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+        menu.clear();
+
+        for (int i = 0; i < actions.size(); i++) {
+            final TextAction action = actions.get(i);
+            menu.add(Menu.NONE, i, action.getFloatingOrder(), action.getLabel());
+        }
+
+        return true;
+    }
+
+    @Override
+    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+        final TextAction action = actions.get(item.getItemId());
+
+        GeckoAppShell.notifyObservers("TextSelection:Action", action.getId());
+
+        return true;
+    }
+
+    @Override
+    public void onDestroyActionMode(ActionMode mode) {}
+
+    @Override
+    public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
+        final Rect contentRect = textSelection.contentRect;
+        if (contentRect != null) {
+            outRect.set(contentRect);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.graphics.Rect;
+import android.os.Build;
+import android.util.Log;
+import android.view.ActionMode;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.List;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Floating toolbar for text selection actions. Only on Android 6+.
+ */
+@TargetApi(Build.VERSION_CODES.M)
+public class FloatingToolbarTextSelection implements TextSelection, GeckoEventListener {
+    private static final String LOGTAG = "GeckoFloatTextSelection";
+
+    private Activity activity;
+    private ActionMode actionMode;
+    private FloatingActionModeCallback actionModeCallback;
+    private LayerView layerView;
+    private int[] locationInWindow;
+
+    private String selectionID;
+    /* package-private */ Rect contentRect;
+
+    public FloatingToolbarTextSelection(Activity activity, LayerView layerView) {
+        this.activity = activity;
+        this.layerView = layerView;
+        this.locationInWindow = new int[2];
+    }
+
+    @Override
+    public boolean dismiss() {
+        if (finishActionMode()) {
+            endTextSelection();
+            return true;
+        }
+
+        return false;
+    }
+
+    private void endTextSelection() {
+        if (TextUtils.isEmpty(selectionID)) {
+            return;
+        }
+
+        final JSONObject args = new JSONObject();
+        try {
+            args.put("selectionID", selectionID);
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "Error building JSON arguments for TextSelection:End", e);
+            return;
+        }
+
+        GeckoAppShell.notifyObservers("TextSelection:End", args.toString());
+    }
+
+    @Override
+    public void create() {
+        registerForEvents();
+    }
+
+    @Override
+    public void destroy() {
+        unregisterFromEvents();
+    }
+
+    private void registerForEvents() {
+        EventDispatcher.getInstance().registerGeckoThreadListener(this,
+                "TextSelection:ActionbarInit",
+                "TextSelection:ActionbarStatus",
+                "TextSelection:ActionbarUninit",
+                "TextSelection:Update",
+                "TextSelection:Visibility");
+    }
+
+    private void unregisterFromEvents() {
+        EventDispatcher.getInstance().unregisterGeckoThreadListener(this,
+                "TextSelection:ActionbarInit",
+                "TextSelection:ActionbarStatus",
+                "TextSelection:ActionbarUninit",
+                "TextSelection:Update",
+                "TextSelection:Visibility");
+    }
+
+    @Override
+    public void handleMessage(final String event, final JSONObject message) {
+        Log.w("SKDBG", "Received event " + event + " with message: " + message.toString());
+
+        ThreadUtils.postToUiThread(new Runnable() {
+            @Override
+            public void run() {
+                handleOnMainThread(event, message);
+            }
+        });
+    }
+
+    private void handleOnMainThread(final String event, final JSONObject message) {
+        if ("TextSelection:ActionbarInit".equals(event)) {
+            selectionID = message.optString("selectionID");
+        } else if ("TextSelection:ActionbarStatus".equals(event)) {
+            updateRect(message);
+
+            if (isContentRectPoint()) {
+                finishActionMode();
+            } else {
+                startActionMode(TextAction.fromEventMessage(message));
+            }
+        } else if ("TextSelection:ActionbarUninit".equals(event)) {
+            finishActionMode();
+        } else if ("TextSelection:Update".equals(event)) {
+            startActionMode(TextAction.fromEventMessage(message));
+        } else if ("TextSelection:Visibility".equals(event)) {
+            finishActionMode();
+        }
+    }
+
+    private void startActionMode(List<TextAction> actions) {
+        if (actionMode != null) {
+            actionModeCallback.updateActions(actions);
+            actionMode.invalidate();
+            return;
+        }
+
+        actionModeCallback = new FloatingActionModeCallback(this, actions);
+        actionMode = activity.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING);
+    }
+
+    private boolean finishActionMode() {
+        if (actionMode != null) {
+            actionMode.finish();
+            actionMode = null;
+            actionModeCallback = null;
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * If the content rect is a point (left == right and top == bottom) then this means that the
+     * content rect is not in the currently visible part.
+     */
+    private boolean isContentRectPoint() {
+        return contentRect.left == contentRect.right && contentRect.top == contentRect.bottom;
+    }
+
+    private void updateRect(JSONObject message) {
+        try {
+            final double x = message.getDouble("x");
+            final double y = (int) message.getDouble("y");
+            final double width = (int) message.getDouble("width");
+            final double height = (int) message.getDouble("height");
+
+            final float zoomFactor = layerView.getZoomFactor();
+            layerView.getLocationInWindow(locationInWindow);
+
+            contentRect = new Rect(
+                    (int) (x * zoomFactor + locationInWindow[0]),
+                    (int) (y * zoomFactor + locationInWindow[1]  + layerView.getSurfaceTranslation()),
+                    (int) ((x + width) * zoomFactor + locationInWindow[0]),
+                    (int) ((y + height) * zoomFactor + locationInWindow[1] + layerView.getSurfaceTranslation()));
+        } catch (JSONException e) {
+            Log.w(LOGTAG, "Could not calculate content rect", e);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Text selection action like "copy", "paste", ..
+ */
+public class TextAction {
+    private static final String LOGTAG = "GeckoTextAction";
+
+    private String id;
+    private String label;
+    private int order;
+    private int floatingOrder;
+
+    private TextAction() {}
+
+    public static List<TextAction> fromEventMessage(JSONObject message) {
+        final List<TextAction> actions = new ArrayList<>();
+
+        try {
+            final JSONArray array = message.getJSONArray("actions");
+
+            for (int i = 0; i < array.length(); i++) {
+                final JSONObject object = array.getJSONObject(i);
+
+                final TextAction action = new TextAction();
+                action.id = object.getString("id");
+                action.label = object.getString("label");
+                action.order = object.getInt("order");
+                action.floatingOrder = object.optInt("floatingOrder", i);
+
+                actions.add(action);
+            }
+        } catch (JSONException e) {
+            Log.w(LOGTAG, "Could not parse text actions", e);
+        }
+
+        return actions;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public int getOrder() {
+        return order;
+    }
+
+    public int getFloatingOrder() {
+        return floatingOrder;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+public interface TextSelection {
+    void create();
+
+    boolean dismiss();
+
+    void destroy();
+}
--- a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java
@@ -13,16 +13,17 @@ import android.support.v4.content.Contex
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.BrowserApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TouchEventInterceptor;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener;
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.lwt.LightweightThemeDrawable;
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.MenuPopup;
 import org.mozilla.gecko.tabs.TabHistoryController;
@@ -123,16 +124,17 @@ public abstract class BrowserToolbar ext
     protected final ThemedImageView menuIcon;
     private MenuPopup menuPopup;
     protected final List<View> focusOrder;
 
     private OnActivateListener activateListener;
     private OnFocusChangeListener focusChangeListener;
     private OnStartEditingListener startEditingListener;
     private OnStopEditingListener stopEditingListener;
+    private TouchEventInterceptor mTouchEventInterceptor;
 
     protected final BrowserApp activity;
 
     protected UIMode uiMode;
     protected TabHistoryController tabHistoryController;
 
     private final Paint shadowPaint;
     private final int shadowColor;
@@ -888,16 +890,28 @@ public abstract class BrowserToolbar ext
 
         final StateListDrawable stateList = new StateListDrawable();
         stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.tabs_tray_grey_pressed));
         stateList.addState(EMPTY_STATE_SET, drawable);
 
         setBackgroundDrawable(stateList);
     }
 
+    public void setTouchEventInterceptor(TouchEventInterceptor interceptor) {
+        mTouchEventInterceptor = interceptor;
+    }
+
+    @Override
+    public boolean onInterceptTouchEvent(MotionEvent event) {
+        if (mTouchEventInterceptor != null && mTouchEventInterceptor.onInterceptTouchEvent(this, event)) {
+            return true;
+        }
+        return super.onInterceptTouchEvent(event);
+    }
+
     @Override
     public void onLightweightThemeReset() {
         setBackgroundResource(R.drawable.url_bar_bg);
     }
 
     public static LightweightThemeDrawable getLightweightThemeDrawable(final View view,
             final LightweightTheme theme, final int colorResID) {
         final int color = ContextCompat.getColor(view.getContext(), colorResID);
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -183,16 +183,17 @@ if CONFIG['MOZ_WEBRTC']:
         'gecko-mozglue.jar',
     ]
     wrjar.javac_flags += ['-Xlint:all,-deprecation,-cast']
 
 gbjar = add_java_jar('gecko-browser')
 gbjar.sources += ['java/org/mozilla/gecko/' + x for x in [
     'AboutPages.java',
     'AccountsHelper.java',
+    'ActionBarTextSelection.java',
     'ActionModeCompat.java',
     'ActionModeCompatView.java',
     'ActivityHandlerHelper.java',
     'AlarmReceiver.java',
     'AndroidGamepadManager.java',
     'animation/AnimationUtils.java',
     'animation/AnimatorProxy.java',
     'animation/HeightChangeAnimation.java',
@@ -570,17 +571,20 @@ gbjar.sources += ['java/org/mozilla/geck
     'tabs/TabsPanel.java',
     'tabs/TabsPanelThumbnailView.java',
     'Telemetry.java',
     'telemetry/TelemetryConstants.java',
     'telemetry/TelemetryPing.java',
     'telemetry/TelemetryPingGenerator.java',
     'telemetry/TelemetryUploadService.java',
     'TelemetryContract.java',
-    'TextSelection.java',
+    'text/FloatingActionModeCallback.java',
+    'text/FloatingToolbarTextSelection.java',
+    'text/TextAction.java',
+    'text/TextSelection.java',
     'TextSelectionHandle.java',
     'ThumbnailHelper.java',
     'toolbar/AutocompleteHandler.java',
     'toolbar/BackButton.java',
     'toolbar/BrowserToolbar.java',
     'toolbar/BrowserToolbarPhone.java',
     'toolbar/BrowserToolbarPhoneBase.java',
     'toolbar/BrowserToolbarTablet.java',
--- a/mobile/android/chrome/content/ActionBarHandler.js
+++ b/mobile/android/chrome/content/ActionBarHandler.js
@@ -36,48 +36,50 @@ var ActionBarHandler = {
     }
 
     if (!this._selectionID && e.collapsed) {
       switch (e.reason) {
         case 'longpressonemptycontent':
         case 'taponcaret':
           // Show ActionBar when long pressing on an empty input or single
           // tapping on the caret.
-          this._init();
+          this._init(e.boundingClientRect);
           break;
 
         case 'updateposition':
           // Do not show ActionBar when single tapping on an non-empty editable
           // input.
           break;
 
         default:
           break;
       }
       return;
     }
 
     // Open a closed ActionBar if carets actually visible.
     if (!this._selectionID && e.caretVisuallyVisible) {
-      this._init();
+      this._init(e.boundingClientRect);
       return;
     }
 
     // Else, update an open ActionBar.
     if (this._selectionID) {
       let [element, win] = this._getSelectionTargets();
-      if (this._targetElement === element &&
-          this._contentWindow === win) {
-        // We have the same focused window/element as before. Trigger "TextSelection:ActionbarStatus"
-        // message only if available actions differ from when last we checked.
-        this._sendActionBarActions();
+      if (this._targetElement === element && this._contentWindow === win) {
+        if (e.reason == 'visibilitychange' || e.reason == 'presscaret') {
+          this._updateVisibility();
+        } else {
+          let forceUpdate = e.reason == 'updateposition' || e.reason == 'releasecaret';
+          this._sendActionBarActions(forceUpdate, e.boundingClientRect);
+        }
       } else {
         // We have a new focused window/element pair.
         this._uninit(false);
-        this._init();
+        this._init(e.boundingClientRect);
       }
     }
   },
 
   /**
    * ActionBarHandler notification observers.
    */
   observe: function(subject, topic, data) {
@@ -118,37 +120,47 @@ var ActionBarHandler = {
         break;
       }
     }
   },
 
   /**
    * Called when Gecko AccessibleCaret becomes visible.
    */
-  _init: function() {
+  _init: function(boundingClientRect) {
     let [element, win] = this._getSelectionTargets();
     if (!win) {
       return this.START_TOUCH_ERROR.NO_CONTENT_WINDOW;
     }
 
     // Hold the ActionBar ID provided by Gecko.
     this._selectionID = this._nextSelectionID++;
     [this._targetElement, this._contentWindow] = [element, win];
 
     // Open the ActionBar, send it's actions list.
     Messaging.sendRequest({
       type: "TextSelection:ActionbarInit",
       selectionID: this._selectionID,
     });
-    this._sendActionBarActions(true);
+    this._sendActionBarActions(true, boundingClientRect);
 
     return this.START_TOUCH_ERROR.NONE;
   },
 
   /**
+   * Called when content is scrolled and handles are hidden.
+   */
+  _updateVisibility: function() {
+    Messaging.sendRequest({
+      type: "TextSelection:Visibility",
+      selectionID: this._selectionID,
+    });
+  },
+
+  /**
    * Determines the window containing the selection, and its
    * editable element if present.
    */
   _getSelectionTargets: function() {
     let [element, win] = [Services.focus.focusedElement, Services.focus.focusedWindow];
     if (!element) {
       // No focused editable.
       return [null, win];
@@ -219,30 +231,34 @@ var ActionBarHandler = {
    * Called to determine current ActionBar actions and send to TextSelection
    * handler. By default we only send if current action state differs from
    * the previous.
    * @param By default we only send an ActionBarStatus update message if
    *        there is a change from the previous state. sendAlways can be
    *        set by init() for example, where we want to always send the
    *        current state.
    */
-  _sendActionBarActions: function(sendAlways) {
+  _sendActionBarActions: function(sendAlways, boundingClientRect) {
     let actions = this._getActionBarActions();
     let actionCountUnchanged = this._actionBarActions &&
       actions.length === this._actionBarActions.length;
     let actionsMatch = actionCountUnchanged &&
       this._actionBarActions.every((e,i) => {
         return e.id === actions[i].id;
       });
 
     if (sendAlways || !actionsMatch) {
       Messaging.sendRequest({
         type: "TextSelection:ActionbarStatus",
         actions: actions,
-      });
+        x: boundingClientRect.x,
+        y: boundingClientRect.y,
+        width: boundingClientRect.width,
+        height: boundingClientRect.height
+      });;
     }
 
     this._actionBarActions = actions;
   },
 
   /**
    * Determine and return current ActionBar state.
    */
@@ -252,16 +268,17 @@ var ActionBarHandler = {
     for (let type in this.actions) {
       let action = this.actions[type];
       if (action.selector.matches(element, win)) {
         let a = {
           id: action.id,
           label: this._getActionValue(action, "label", "", element),
           icon: this._getActionValue(action, "icon", "drawable://ic_status_logo", element),
           order: this._getActionValue(action, "order", 0, element),
+          floatingOrder: this._getActionValue(action, "floatingOrder", 9, element),
           showAsAction: this._getActionValue(action, "showAsAction", true, element),
         };
         actions.push(a);
       }
     }
     actions.sort((a, b) => b.order - a.order);
 
     return actions;
@@ -287,16 +304,17 @@ var ActionBarHandler = {
    */
   actions: {
 
     SELECT_ALL: {
       id: "selectall_action",
       label: Strings.browser.GetStringFromName("contextmenu.selectAll"),
       icon: "drawable://ab_select_all",
       order: 5,
+      floatingOrder: 5,
 
       selector: {
         matches: function(element, win) {
           // For editable, check its length. For default contentWindow, assume
           // true, else there'd been nothing to long-press to open ActionBar.
           return (element) ? element.textLength != 0 : true;
         },
       },
@@ -321,16 +339,17 @@ var ActionBarHandler = {
       },
     },
 
     CUT: {
       id: "cut_action",
       label: Strings.browser.GetStringFromName("contextmenu.cut"),
       icon: "drawable://ab_cut",
       order: 4,
+      floatingOrder: 1,
 
       selector: {
         matches: function(element, win) {
           // Can't cut from non-editable.
           if (!element) {
             return false;
           }
           // Don't allow "cut" from password fields.
@@ -365,16 +384,17 @@ var ActionBarHandler = {
       },
     },
 
     COPY: {
       id: "copy_action",
       label: Strings.browser.GetStringFromName("contextmenu.copy"),
       icon: "drawable://ab_copy",
       order: 3,
+      floatingOrder: 2,
 
       selector: {
         matches: function(element, win) {
           // Don't allow "copy" from password fields.
           if (element instanceof Ci.nsIDOMHTMLInputElement &&
               !element.mozIsTextField(true)) {
             return false;
           }
@@ -397,16 +417,17 @@ var ActionBarHandler = {
       },
     },
 
     PASTE: {
       id: "paste_action",
       label: Strings.browser.GetStringFromName("contextmenu.paste"),
       icon: "drawable://ab_paste",
       order: 2,
+      floatingOrder: 3,
 
       selector: {
         matches: function(element, win) {
           // Can't paste into non-editable.
           if (!element) {
             return false;
           }
           // Can't paste into disabled/readonly fields.
@@ -429,16 +450,17 @@ var ActionBarHandler = {
       },
     },
 
     CALL: {
       id: "call_action",
       label: Strings.browser.GetStringFromName("contextmenu.call"),
       icon: "drawable://phone",
       order: 1,
+      floatingOrder: 0,
 
       selector: {
         matches: function(element, win) {
           return (ActionBarHandler._getSelectedPhoneNumber() != null);
         },
       },
 
       action: function(element, win) {
@@ -451,22 +473,23 @@ var ActionBarHandler = {
     },
 
     SEARCH: {
       id: "search_action",
       label: Strings.browser.formatStringFromName("contextmenu.search",
         [Services.search.defaultEngine.name], 1),
       icon: "drawable://ab_search",
       order: 1,
+      floatingOrder: 6,
 
       selector: {
         matches: function(element, win) {
           // Allow if selected text exists.
           return (ActionBarHandler._getSelectedText().length > 0);
-        },  
+        },
       },
 
       action: function(element, win) {
         let selectedText = ActionBarHandler._getSelectedText();
         ActionBarHandler._uninit();
 
         // Set current tab as parent of new tab,
         // and set new tab as private if the parent is.
@@ -484,16 +507,17 @@ var ActionBarHandler = {
       },
     },
 
     SEARCH_ADD: {
       id: "search_add_action",
       label: Strings.browser.GetStringFromName("contextmenu.addSearchEngine2"),
       icon: "drawable://ab_add_search_engine",
       order: 0,
+      floatingOrder: 8,
 
       selector: {
         matches: function(element, win) {
           if(!(element instanceof HTMLInputElement)) {
             return false;
           }
           let form = element.form;
           if (!form || element.type == "password") {
@@ -511,16 +535,17 @@ var ActionBarHandler = {
       },
     },
 
     SHARE: {
       id: "share_action",
       label: Strings.browser.GetStringFromName("contextmenu.share"),
       icon: "drawable://ic_menu_share",
       order: 0,
+      floatingOrder: 4,
 
       selector: {
         matches: function(element, win) {
           if (!ParentalControls.isAllowed(ParentalControls.SHARE)) {
             return false;
           }
           // Allow if selected text exists.
           return (ActionBarHandler._getSelectedText().length > 0);