Bug 1463484 - 1. Add SessionFinder; r?snorp r?droeh draft
authorJim Chen <nchen@mozilla.com>
Tue, 10 Jul 2018 13:12:55 -0400
changeset 816178 ecb6e9d8c4e9678c3f7d2d7c4157b2a4384c62dd
parent 815592 3d20b0701781731e0f9b08e1cd40ac842f385e03
child 816179 e971b9ece928d5d55ace661f0f371c145704df02
push id115766
push userbmo:nchen@mozilla.com
push dateTue, 10 Jul 2018 17:13:53 +0000
reviewerssnorp, droeh
bugs1463484
milestone63.0a1
Bug 1463484 - 1. Add SessionFinder; r?snorp r?droeh Add a SessionFinder API for find-in-page functionality. MozReview-Commit-ID: KB7dOPUC6s2
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
mobile/android/modules/geckoview/GeckoViewContent.jsm
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -11,17 +11,16 @@ import java.lang.annotation.RetentionPol
 import java.net.URLConnection;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.UUID;
 
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.gfx.LayerSession;
-import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEditableChild;
 import org.mozilla.gecko.GeckoThread;
 import org.mozilla.gecko.IGeckoEditableParent;
 import org.mozilla.gecko.mozglue.JNIObject;
 import org.mozilla.gecko.NativeQueue;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GeckoBundle;
@@ -29,17 +28,16 @@ import org.mozilla.gecko.util.ThreadUtil
 
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Resources;
 import android.database.Cursor;
 import android.graphics.RectF;
 import android.net.Uri;
 import android.os.Binder;
-import android.os.Bundle;
 import android.os.IBinder;
 import android.os.IInterface;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.os.SystemClock;
 import android.support.annotation.IntDef;
 import android.support.annotation.Nullable;
 import android.support.annotation.NonNull;
@@ -89,26 +87,24 @@ public class GeckoSession extends LayerS
 
     private final NativeQueue mNativeQueue =
         new NativeQueue(State.INITIAL, State.READY);
 
     private final EventDispatcher mEventDispatcher =
         new EventDispatcher(mNativeQueue);
 
     private final SessionTextInput mTextInput = new SessionTextInput(this, mNativeQueue);
-
-    private SessionAccessibility mSessionAccessibility;
+    private SessionAccessibility mAccessibility;
+    private SessionFinder mFinder;
 
     private String mId = UUID.randomUUID().toString().replace("-", "");
     /* package */ String getId() { return mId; }
 
-    private static abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback {
-        @Override
-        public abstract void sendSuccess(Object response);
-
+    /* package */ static abstract class CallbackResult<T> extends GeckoResult<T>
+                                                          implements EventCallback {
         @Override
         public void sendError(Object response) {
             completeExceptionally(response != null ?
                     new Exception(response.toString()) :
                     new UnknownError());
         }
     }
 
@@ -866,20 +862,20 @@ public class GeckoSession extends LayerS
     }
 
     /**
       * Get the SessionAccessibility instance for this session.
       *
       * @return SessionAccessibility instance.
       */
     public @NonNull SessionAccessibility getAccessibility() {
-        if (mSessionAccessibility == null) {
-            mSessionAccessibility = new SessionAccessibility(this);
+        if (mAccessibility == null) {
+            mAccessibility = new SessionAccessibility(this);
         }
-        return mSessionAccessibility;
+        return mAccessibility;
     }
 
     @IntDef(flag = true,
             value = { LOAD_FLAGS_NONE, LOAD_FLAGS_BYPASS_CACHE, LOAD_FLAGS_BYPASS_PROXY,
                       LOAD_FLAGS_EXTERNAL, LOAD_FLAGS_ALLOW_POPUPS })
     public @interface LoadFlags {}
 
     // These flags follow similarly named ones in Gecko's nsIWebNavigation.idl
@@ -1086,16 +1082,99 @@ public class GeckoSession extends LayerS
 
     /**
     * Go forward in history.
     */
     public void goForward() {
         mEventDispatcher.dispatch("GeckoView:GoForward", null);
     }
 
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = true,
+            value = {FINDER_FIND_BACKWARDS, FINDER_FIND_LINKS_ONLY,
+                    FINDER_FIND_MATCH_CASE, FINDER_FIND_WHOLE_WORD})
+    /* package */ @interface FinderFindFlags {}
+
+    /** Go backwards when finding the next match. */
+    public static final int FINDER_FIND_BACKWARDS = 1;
+    /** Perform case-sensitive match; default is to perform a case-insensitive match. */
+    public static final int FINDER_FIND_MATCH_CASE = 1 << 1;
+    /** Must match entire words; default is to allow matching partial words. */
+    public static final int FINDER_FIND_WHOLE_WORD = 1 << 2;
+    /** Limit matches to links on the page. */
+    public static final int FINDER_FIND_LINKS_ONLY = 1 << 3;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = true,
+            value = {FINDER_DISPLAY_HIGHLIGHT_ALL, FINDER_DISPLAY_DIM_PAGE,
+                    FINDER_DISPLAY_DRAW_LINK_OUTLINE})
+    /* package */ @interface FinderDisplayFlags {}
+
+    /** Highlight all find-in-page matches. */
+    public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1;
+    /** Dim the rest of the page when showing a find-in-page match. */
+    public static final int FINDER_DISPLAY_DIM_PAGE = 1 << 1;
+    /** Draw outlines around matching links. */
+    public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 1 << 2;
+
+    /**
+     * Represent the result of a find-in-page operation.
+     */
+    public static final class FinderResult {
+        /** Whether a match was found. */
+        public final boolean found;
+        /** Whether the search wrapped around the top or bottom of the page. */
+        public final boolean wrapped;
+        /** Ordinal number of the current match starting from 1, or 0 if no match. */
+        public final int current;
+        /** Total number of matches found so far, or -1 if unknown. */
+        public final int total;
+        /** Search string. */
+        @NonNull public final String searchString;
+        /** Flags used for the search; either 0 or a combination of {@link #FINDER_FIND_BACKWARDS
+         * FINDER_FIND_*} flags. */
+        @FinderFindFlags public final int flags;
+        /** URI of the link, if the current match is a link, or null otherwise. */
+        @Nullable public final String linkUri;
+        /** Bounds of the current match in client coordinates, or null if unknown. */
+        @Nullable public final RectF clientRect;
+
+        /* package */ FinderResult(@NonNull final GeckoBundle bundle) {
+            found = bundle.getBoolean("found");
+            wrapped = bundle.getBoolean("wrapped");
+            current = bundle.getInt("current", 0);
+            total = bundle.getInt("total", -1);
+            searchString = bundle.getString("searchString");
+            flags = SessionFinder.getFlagsFromBundle(bundle.getBundle("flags"));
+            linkUri = bundle.getString("linkURL");
+
+            final GeckoBundle rectBundle = bundle.getBundle("clientRect");
+            if (rectBundle == null) {
+                clientRect = null;
+            } else {
+                clientRect = new RectF((float) rectBundle.getDouble("left"),
+                                       (float) rectBundle.getDouble("top"),
+                                       (float) rectBundle.getDouble("right"),
+                                       (float) rectBundle.getDouble("bottom"));
+            }
+        }
+    }
+
+    /**
+     * Get the SessionFinder instance for this session, to perform find-in-page operations.
+     *
+     * @return SessionFinder instance.
+     */
+    public SessionFinder getFinder() {
+        if (mFinder == null) {
+            mFinder = new SessionFinder(getEventDispatcher());
+        }
+        return mFinder;
+    }
+
     /**
     * Set this GeckoSession as active or inactive. Setting a GeckoSession to inactive will
     * significantly reduce its memory footprint, but should only be done if the
     * GeckoSession is not currently visible.
     * @param active A boolean determining whether the GeckoSession is active
     */
     public void setActive(boolean active) {
         final GeckoBundle msg = new GeckoBundle();
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
@@ -0,0 +1,139 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.geckoview;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.FinderFindFlags;
+import org.mozilla.geckoview.GeckoSession.FinderDisplayFlags;
+import org.mozilla.geckoview.GeckoSession.FinderResult;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.Pair;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * {@code SessionFinder} instances returned by {@link GeckoSession#getFinder()} performs
+ * find-in-page operations.
+ */
+public final class SessionFinder {
+    private static final String LOGTAG = "GeckoSessionFinder";
+
+    private static final List<Pair<Integer, String>> sFlagNames = Arrays.asList(
+            new Pair<>(GeckoSession.FINDER_FIND_BACKWARDS, "backwards"),
+            new Pair<>(GeckoSession.FINDER_FIND_LINKS_ONLY, "linksOnly"),
+            new Pair<>(GeckoSession.FINDER_FIND_MATCH_CASE, "matchCase"),
+            new Pair<>(GeckoSession.FINDER_FIND_WHOLE_WORD, "wholeWord")
+    );
+
+    private static void addFlagsToBundle(@FinderFindFlags final int flags,
+                                         @NonNull final GeckoBundle bundle) {
+        for (final Pair<Integer, String> name : sFlagNames) {
+            if ((flags & name.first) != 0) {
+                bundle.putBoolean(name.second, true);
+            }
+        }
+    }
+
+    /* package */ static int getFlagsFromBundle(@Nullable final GeckoBundle bundle) {
+        if (bundle == null) {
+            return 0;
+        }
+
+        int flags = 0;
+        for (final Pair<Integer, String> name : sFlagNames) {
+            if (bundle.getBoolean(name.second)) {
+                flags |= name.first;
+            }
+        }
+        return flags;
+    }
+
+    private final EventDispatcher mDispatcher;
+    @FinderDisplayFlags private int mDisplayFlags;
+
+    /* package */ SessionFinder(@NonNull final EventDispatcher dispatcher) {
+        mDispatcher = dispatcher;
+        setDisplayFlags(0);
+    }
+
+    /**
+     * Find and select a string on the current page, starting from the current selection or the
+     * start of the page if there is no selection. Optionally return results related to the
+     * search in a {@link FinderResult} object. If {@code searchString} is null, search
+     * is performed using the previous search string.
+     *
+     * @param searchString String to search, or null to find again using the previous string.
+     * @param flags Flags for performing the search; either 0 or a combination of {@link
+     *              GeckoSession#FINDER_FIND_BACKWARDS FINDER_FIND_*} constants.
+     * @return Result of the search operation as a {@link GeckoResult} object.
+     * @see #clear
+     * @see #setDisplayFlags
+     */
+    @NonNull
+    public GeckoResult<FinderResult> find(@Nullable final String searchString,
+                                          @FinderFindFlags final int flags) {
+        final GeckoBundle bundle = new GeckoBundle(sFlagNames.size() + 1);
+        bundle.putString("searchString", searchString);
+        addFlagsToBundle(flags, bundle);
+
+        final GeckoSession.CallbackResult<FinderResult> result =
+                new GeckoSession.CallbackResult<FinderResult>() {
+            @Override
+            public void sendSuccess(Object response) {
+                complete(new FinderResult((GeckoBundle) response));
+            }
+        };
+        mDispatcher.dispatch("GeckoView:FindInPage", bundle, result);
+        return result;
+    }
+
+    /**
+     * Clear any highlighted find-in-page matches.
+     *
+     * @see #find
+     * @see #setDisplayFlags
+     */
+    public void clear() {
+        mDispatcher.dispatch("GeckoView:ClearMatches", null);
+    }
+
+    /**
+     * Return flags for displaying find-in-page matches.
+     *
+     * @return Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+     *         FINDER_DISPLAY_*} constants.
+     * @see #setDisplayFlags
+     * @see #find
+     */
+    @FinderDisplayFlags public int getDisplayFlags() {
+        return mDisplayFlags;
+    }
+
+    /**
+     * Set flags for displaying find-in-page matches.
+     *
+     * @param flags Display flags as a combination of {@link
+     *              GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL FINDER_DISPLAY_*} constants.
+     * @see #getDisplayFlags
+     * @see #find
+     */
+    public void setDisplayFlags(@FinderDisplayFlags final int flags) {
+        mDisplayFlags = flags;
+
+        final GeckoBundle bundle = new GeckoBundle(3);
+        bundle.putBoolean("highlightAll",
+                          (flags & GeckoSession.FINDER_DISPLAY_HIGHLIGHT_ALL) != 0);
+        bundle.putBoolean("dimPage",
+                          (flags & GeckoSession.FINDER_DISPLAY_DIM_PAGE) != 0);
+        bundle.putBoolean("drawOutline",
+                          (flags & GeckoSession.FINDER_DISPLAY_DRAW_LINK_OUTLINE) != 0);
+        mDispatcher.dispatch("GeckoView:DisplayMatches", bundle);
+    }
+}
--- a/mobile/android/modules/geckoview/GeckoViewContent.jsm
+++ b/mobile/android/modules/geckoview/GeckoViewContent.jsm
@@ -12,16 +12,19 @@ ChromeUtils.import("resource://gre/modul
 XPCOMUtils.defineLazyModuleGetters(this, {
   Services: "resource://gre/modules/Services.jsm",
 });
 
 class GeckoViewContent extends GeckoViewModule {
   onInit() {
     this.registerListener([
         "GeckoViewContent:ExitFullScreen",
+        "GeckoView:ClearMatches",
+        "GeckoView:DisplayMatches",
+        "GeckoView:FindInPage",
         "GeckoView:RestoreState",
         "GeckoView:SaveState",
         "GeckoView:SetActive",
         "GeckoView:ZoomToInput",
     ]);
 
     this.messageManager.addMessageListener("GeckoView:SaveStateFinish", this);
   }
@@ -53,16 +56,28 @@ class GeckoViewContent extends GeckoView
   // Bundle event handler.
   onEvent(aEvent, aData, aCallback) {
     debug `onEvent: event=${aEvent}, data=${aData}`;
 
     switch (aEvent) {
       case "GeckoViewContent:ExitFullScreen":
         this.messageManager.sendAsyncMessage("GeckoView:DOMFullscreenExited");
         break;
+      case "GeckoView:ClearMatches": {
+        this._clearMatches();
+        break;
+      }
+      case "GeckoView:DisplayMatches": {
+        this._displayMatches(aData);
+        break;
+      }
+      case "GeckoView:FindInPage": {
+        this._findInPage(aData, aCallback);
+        break;
+      }
       case "GeckoView:ZoomToInput":
         this.messageManager.sendAsyncMessage(aEvent);
         break;
       case "GeckoView:SetActive":
         if (aData.active) {
           this.browser.setAttribute("primary", "true");
           this.browser.focus();
           this.browser.docShellIsActive = true;
@@ -143,9 +158,160 @@ class GeckoViewContent extends GeckoView
 
         this.eventDispatcher.sendRequest({
           type: "GeckoView:ContentCrash"
         });
       }
       break;
     }
   }
+
+  _findInPage(aData, aCallback) {
+    debug `findInPage: data=${aData} callback=${aCallback && "non-null"}`;
+
+    let finder;
+    try {
+      finder = this.browser.finder;
+    } catch (e) {
+      if (aCallback) {
+        aCallback.onError(`No finder: ${e}`);
+      }
+      return;
+    }
+
+    if (this._finderListener) {
+      finder.removeResultListener(this._finderListener);
+    }
+
+    this._finderListener = {
+      response: {
+        found: false,
+        wrapped: false,
+        current: 0,
+        total: -1,
+        searchString: aData.searchString || finder.searchString,
+        linkURL: null,
+        clientRect: null,
+        flags: {
+          backwards: !!aData.backwards,
+          linksOnly: !!aData.linksOnly,
+          matchCase: !!aData.matchCase,
+          wholeWord: !!aData.wholeWord,
+        },
+      },
+
+      onFindResult(aOptions) {
+        if (!aCallback || aOptions.searchString !== aData.searchString) {
+          // Result from a previous search.
+          return;
+        }
+
+        Object.assign(this.response, {
+          found: aOptions.result !== Ci.nsITypeAheadFind.FIND_NOTFOUND,
+          wrapped: aOptions.result !== Ci.nsITypeAheadFind.FIND_FOUND,
+          linkURL: aOptions.linkURL,
+          clientRect: aOptions.rect && {
+            left: aOptions.rect.left,
+            top: aOptions.rect.top,
+            right: aOptions.rect.right,
+            bottom: aOptions.rect.bottom,
+          },
+          flags: {
+            backwards: aOptions.findBackwards,
+            linksOnly: aOptions.linksOnly,
+            matchCase: this.response.flags.matchCase,
+            wholeWord: this.response.flags.wholeWord,
+          },
+        });
+
+        if (!this.response.found) {
+          this.response.current = 0;
+          this.response.total = 0;
+        }
+
+        // Only send response if we have a count.
+        if (!this.response.found || this.response.current !== 0) {
+          debug `onFindResult: ${this.response}`;
+          aCallback.onSuccess(this.response);
+          aCallback = undefined;
+        }
+      },
+
+      onMatchesCountResult(aResult) {
+        if (!aCallback || finder.searchString !== aData.searchString) {
+          // Result from a previous search.
+          return;
+        }
+
+        Object.assign(this.response, {
+          current: aResult.current,
+          total: aResult.total,
+        });
+
+        // Only send response if we have a result. `found` and `wrapped` are
+        // both false only when we haven't received a result yet.
+        if (this.response.found || this.response.wrapped) {
+          debug `onMatchesCountResult: ${this.response}`;
+          aCallback.onSuccess(this.response);
+          aCallback = undefined;
+        }
+      },
+    };
+
+    finder.caseSensitive = !!aData.matchCase;
+    finder.entireWord = !!aData.wholeWord;
+
+    if (aCallback) {
+      finder.addResultListener(this._finderListener);
+    }
+
+    const drawOutline = this._matchDisplayOptions &&
+                        !!this._matchDisplayOptions.drawOutline;
+
+    if (!aData.searchString || aData.searchString === finder.searchString) {
+      // Search again.
+      aData.searchString = finder.searchString;
+      finder.findAgain(!!aData.backwards,
+                       !!aData.linksOnly,
+                       drawOutline);
+    } else {
+      finder.fastFind(aData.searchString,
+                      !!aData.linksOnly,
+                      drawOutline);
+    }
+  }
+
+  _clearMatches() {
+    try {
+      this.browser.finder.removeSelection();
+    } catch (e) {
+    }
+  }
+
+  _displayMatches(aData) {
+    debug `displayMatches: data=${aData}`;
+
+    let finder;
+    try {
+      finder = this.browser.finder;
+    } catch (e) {
+      return;
+    }
+
+    this._matchDisplayOptions = aData;
+    finder.onHighlightAllChange(!!aData.highlightAll);
+    finder.onModalHighlightChange(!!aData.dimPage);
+
+    if (!finder.searchString) {
+      return;
+    }
+    if (!aData.highlightAll && !aData.dimPage && !aData.drawOutline) {
+      finder.highlighter.highlight(false);
+      return;
+    }
+    const linksOnly = this._finderListener &&
+                      this._finderListener.response.linksOnly;
+    finder.highlighter.highlight(true,
+                                 finder.searchString,
+                                 linksOnly,
+                                 !!aData.drawOutline);
+  }
 }