Bug 1494713 - Add `HistoryDelegate` and wire up the Java history handlers. r=jchen,snorp
authorLina Cambridge <lina@yakshaving.ninja>
Fri, 16 Nov 2018 17:32:04 +0000
changeset 446800 900cd4bf995f4b6a9651da7b9245f66b324e5847
parent 446799 a1fe5c7791a7fec71e6c3a6b7c362e5ec00ba49f
child 446801 32b7fb77f82ca4389cb4746d7962f93865891c1d
push id35052
push userapavel@mozilla.com
push dateSat, 17 Nov 2018 11:25:40 +0000
treeherdermozilla-central@efc1da42132b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjchen, snorp
bugs1494713
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1494713 - Add `HistoryDelegate` and wire up the Java history handlers. r=jchen,snorp Differential Revision: https://phabricator.services.mozilla.com/D11291
mobile/android/geckoview/src/androidTest/assets/www/links.html
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/links.html
@@ -0,0 +1,23 @@
+<html>
+<head>
+<title>Links</title>
+<style>
+:link {
+  color: rgb(0, 0, 255);
+}
+
+:visited {
+  color: rgb(255, 0, 0);
+}
+</style>
+</head>
+<body>
+<ul>
+  <li><a id="mozilla" href="https://mozilla.org">Mozilla</a></li>
+  <li><a id="firefox" href="https://getfirefox.com">Get Firefox!</a></li>
+  <li><a id="bugzilla" href="https://bugzilla.mozilla.org">Bugzilla</a></li>
+  <li><a id="testpilot" href="https://testpilot.firefox.com">Test Pilot</a></li>
+  <li><a id="fxa" href="https://accounts.firefox.com">Firefox Accounts</a></li>
+</ul>
+</body>
+</html>
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
@@ -28,16 +28,17 @@ open class BaseSessionTest(noErrorCollec
         const val CLICK_TO_RELOAD_HTML_PATH = "/assets/www/clickToReload.html"
         const val CONTENT_CRASH_URL = "about:crashcontent"
         const val DOWNLOAD_HTML_PATH = "/assets/www/download.html"
         const val FORMS_HTML_PATH = "/assets/www/forms.html"
         const val HELLO_HTML_PATH = "/assets/www/hello.html"
         const val HELLO2_HTML_PATH = "/assets/www/hello2.html"
         const val INPUTS_PATH = "/assets/www/inputs.html"
         const val INVALID_URI = "not a valid uri"
+        const val LINKS_HTML_PATH = "/assets/www/links.html"
         const val LOREM_IPSUM_HTML_PATH = "/assets/www/loremIpsum.html"
         const val NEW_SESSION_CHILD_HTML_PATH = "/assets/www/newSession_child.html"
         const val NEW_SESSION_HTML_PATH = "/assets/www/newSession.html"
         const val POPUP_HTML_PATH = "/assets/www/popup.html"
         const val SAVE_STATE_PATH = "/assets/www/saveState.html"
         const val TITLE_CHANGE_HTML_PATH = "/assets/www/titleChange.html"
         const val TRACKERS_PATH = "/assets/www/trackers.html"
         const val UNKNOWN_HOST_URI = "http://www.test.invalid/"
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt
@@ -0,0 +1,132 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDevToolsAPI
+
+import android.support.test.filters.MediumTest
+import android.support.test.runner.AndroidJUnit4
+import org.hamcrest.Matchers.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class HistoryDelegateTest : BaseSessionTest() {
+    companion object {
+        // Keep in sync with the styles in `LINKS_HTML_PATH`.
+        const val UNVISITED_COLOR = "rgb(0, 0, 255)"
+        const val VISITED_COLOR = "rgb(255, 0, 0)"
+    }
+
+    @WithDevToolsAPI
+    @Test fun getVisited() {
+        val testUri = createTestUrl(LINKS_HTML_PATH)
+        sessionRule.delegateDuringNextWait(object : GeckoSession.HistoryDelegate {
+            @AssertCalled(count = 1)
+            override fun onVisited(session: GeckoSession, url: String,
+                                   lastVisitedURL: String?,
+                                   flags: Int): GeckoResult<Boolean>? {
+                assertThat("Should pass visited URL", url, equalTo(testUri))
+                assertThat("Should not pass last visited URL", lastVisitedURL, nullValue())
+                assertThat("Should set visit flags", flags,
+                    equalTo(GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL))
+                return GeckoResult.fromValue(true)
+            }
+
+            @AssertCalled(count = 1)
+            override fun getVisited(session: GeckoSession,
+                                    urls: Array<String>) : GeckoResult<BooleanArray>? {
+                val expected = arrayOf(
+                    "https://mozilla.org/",
+                    "https://getfirefox.com/",
+                    "https://bugzilla.mozilla.org/",
+                    "https://testpilot.firefox.com/",
+                    "https://accounts.firefox.com/"
+                )
+                assertThat("Should pass URLs to check", urls.sorted(),
+                    equalTo(expected.sorted()))
+
+                val visits = BooleanArray(urls.size, {
+                    when (urls[it]) {
+                        "https://mozilla.org/", "https://testpilot.firefox.com/" -> true
+                        else -> false
+                    }
+                })
+                return GeckoResult.fromValue(visits)
+            }
+        })
+
+        // Since `getVisited` is called asynchronously after the page loads, we
+        // can't use `waitForPageStop` here.
+        sessionRule.session.loadUri(testUri)
+        sessionRule.session.waitUntilCalled(GeckoSession.HistoryDelegate::class,
+                                            "onVisited", "getVisited")
+
+        // Inject a frame script to query the `:visited` style of a link, using
+        // a special chrome-only method. Note that we can't use the current
+        // browsers s
+        val frameScriptDataUri = GeckoSession.createDataUri(String.format("""
+            addMessageListener("HistoryDelegateTest:GetLinkColor", function onMessage(message) {
+                if (content.document.documentURI != "%s") {
+                    return;
+                }
+                let { selector } = message.data;
+                let element = content.document.querySelector(selector);
+                if (!element) {
+                    sendAsyncMessage("HistoryDelegateTest:GetLinkColor", {
+                        ok: false,
+                        error: "No element for " + selector,
+                    });
+                    return;
+                }
+                let color = content.windowUtils.getVisitedDependentComputedStyle(element, "", "color");
+                sendAsyncMessage("HistoryDelegateTest:GetLinkColor", { ok: true, color });
+            });
+        """, testUri).toByteArray(), null)
+
+        // Note that we can't send the message directly to the current browser,
+        // because `gBrowser` might not refer to the correct window. Instead,
+        // we broadcast the message using the global message manager, and have
+        // the frame script check the document URI.
+        sessionRule.evaluateChromeJS(String.format("""
+            Services.mm.loadFrameScript("%s", true);
+            function getLinkColor(selector) {
+                return new Promise((resolve, reject) => {
+                    Services.mm.addMessageListener("HistoryDelegateTest:GetLinkColor", function onMessage(message) {
+                        Services.mm.removeMessageListener("HistoryDelegateTest:GetLinkColor", onMessage);
+                        if (message.data.ok) {
+                            resolve(message.data.color);
+                        } else {
+                            reject(message.data.error);
+                        }
+                    });
+                    Services.mm.broadcastAsyncMessage('HistoryDelegateTest:GetLinkColor', { selector });
+                });
+            }
+        """, frameScriptDataUri))
+
+        assertThat(
+            "Mozilla should be visited",
+            sessionRule.waitForChromeJS("getLinkColor('#mozilla')") as String,
+            equalTo(VISITED_COLOR)
+        )
+
+        assertThat(
+            "Test Pilot should be visited",
+            sessionRule.waitForChromeJS("getLinkColor('#testpilot')") as String,
+            equalTo(VISITED_COLOR)
+        )
+
+        assertThat(
+            "Bugzilla should be unvisited",
+            sessionRule.waitForChromeJS("getLinkColor('#bugzilla')") as String,
+            equalTo(UNVISITED_COLOR)
+        )
+    }
+}
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
@@ -15,19 +15,20 @@ import org.mozilla.geckoview.WebRequestE
 
 import android.view.inputmethod.CursorAnchorInfo
 import android.view.inputmethod.ExtractedText
 import android.view.inputmethod.ExtractedTextRequest
 
 class Callbacks private constructor() {
     object Default : All
 
-    interface All : ContentDelegate, NavigationDelegate, PermissionDelegate, ProgressDelegate,
-                    PromptDelegate, ScrollDelegate, SelectionActionDelegate, TextInputDelegate,
-                    TrackingProtectionDelegate, MediaDelegate
+    interface All : ContentDelegate, GeckoSession.HistoryDelegate, MediaDelegate,
+                    NavigationDelegate, PermissionDelegate, ProgressDelegate,
+                    PromptDelegate, ScrollDelegate, SelectionActionDelegate,
+                    TextInputDelegate, TrackingProtectionDelegate
 
     interface ContentDelegate : GeckoSession.ContentDelegate {
         override fun onTitleChange(session: GeckoSession, title: String) {
         }
 
         override fun onFocusRequest(session: GeckoSession) {
         }
 
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -7,16 +7,17 @@
 package org.mozilla.geckoview;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.ref.WeakReference;
 import java.net.URLConnection;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.List;
 import java.util.UUID;
 
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEditableChild;
 import org.mozilla.gecko.GeckoThread;
 import org.mozilla.gecko.IGeckoEditableParent;
@@ -269,16 +270,81 @@ public class GeckoSession implements Par
         @Override
         public void sendError(Object response) {
             completeExceptionally(response != null ?
                     new Exception(response.toString()) :
                     new UnknownError());
         }
     }
 
+    private final GeckoSessionHandler<HistoryDelegate> mHistoryHandler =
+        new GeckoSessionHandler<HistoryDelegate>(
+            "GeckoViewHistory", this,
+            new String[]{
+                "GeckoView:OnVisited",
+                "GeckoView:GetVisited",
+            }
+        ) {
+            @Override
+            public void handleMessage(final HistoryDelegate delegate,
+                                      final String event,
+                                      final GeckoBundle message,
+                                      final EventCallback callback) {
+                if ("GeckoView:OnVisited".equals(event)) {
+                    final GeckoResult<Boolean> result =
+                        delegate.onVisited(GeckoSession.this, message.getString("url"),
+                                           message.getString("lastVisitedURL"),
+                                           message.getInt("flags"));
+
+                    if (result == null) {
+                        callback.sendSuccess(false);
+                        return;
+                    }
+
+                    result.then(new GeckoResult.OnValueListener<Boolean, Void>() {
+                        @Override
+                        public GeckoResult<Void> onValue(Boolean visited) throws Throwable {
+                            callback.sendSuccess(visited.booleanValue());
+                            return null;
+                        }
+                    }, new GeckoResult.OnExceptionListener<Void>() {
+                        @Override
+                        public GeckoResult<Void> onException(Throwable exception) throws Throwable {
+                            callback.sendSuccess(false);
+                            return null;
+                        }
+                    });
+                } else if ("GeckoView:GetVisited".equals(event)) {
+                    final String[] urls = message.getStringArray("urls");
+
+                    final GeckoResult<boolean[]> result =
+                        delegate.getVisited(GeckoSession.this, urls);
+
+                    if (result == null) {
+                        callback.sendSuccess(null);
+                        return;
+                    }
+
+                    result.then(new GeckoResult.OnValueListener<boolean[], Void>() {
+                        @Override
+                        public GeckoResult<Void> onValue(final boolean[] visited) throws Throwable {
+                            callback.sendSuccess(visited);
+                            return null;
+                        }
+                    }, new GeckoResult.OnExceptionListener<Void>() {
+                        @Override
+                        public GeckoResult<Void> onException(Throwable exception) throws Throwable {
+                            callback.sendError("Failed to fetch visited statuses for URIs");
+                            return null;
+                        }
+                    });
+                }
+            }
+        };
+
     private final GeckoSessionHandler<ContentDelegate> mContentHandler =
         new GeckoSessionHandler<ContentDelegate>(
             "GeckoViewContent", this,
             new String[]{
                 "GeckoView:ContentCrash",
                 "GeckoView:ContextMenu",
                 "GeckoView:DOMTitleChanged",
                 "GeckoView:DOMWindowFocus",
@@ -750,18 +816,19 @@ public class GeckoSession implements Par
                     }
                 }
             };
 
 
     /* package */ int handlersCount;
 
     private final GeckoSessionHandler<?>[] mSessionHandlers = new GeckoSessionHandler<?>[] {
-        mContentHandler, mMediaHandler, mNavigationHandler, mProgressHandler, mScrollHandler,
-        mTrackingProtectionHandler, mPermissionHandler, mSelectionActionDelegate
+        mContentHandler, mHistoryHandler, mMediaHandler, mNavigationHandler,
+        mPermissionHandler, mProgressHandler, mScrollHandler, mSelectionActionDelegate,
+        mTrackingProtectionHandler
     };
 
     private static class PermissionCallback implements
         PermissionDelegate.Callback, PermissionDelegate.MediaCallback {
 
         private final String mType;
         private EventCallback mCallback;
 
@@ -1775,16 +1842,31 @@ public class GeckoSession implements Par
         mScrollHandler.setDelegate(delegate, this);
     }
 
     public ScrollDelegate getScrollDelegate() {
         return mScrollHandler.getDelegate();
     }
 
     /**
+     * Set the history tracking delegate for this session, replacing the
+     * current delegate if one is set.
+     *
+     * @param delegate The history tracking delegate, or {@code null} to unset.
+     */
+    public void setHistoryDelegate(@Nullable HistoryDelegate delegate) {
+        mHistoryHandler.setDelegate(delegate, this);
+    }
+
+    /** @return The history tracking delegate for this session. */
+    public @Nullable HistoryDelegate getHistoryDelegate() {
+        return mHistoryHandler.getDelegate();
+    }
+
+    /**
     * Set the tracking protection callback handler.
     * This will replace the current handler.
     * @param delegate An implementation of TrackingProtectionDelegate.
     */
     public void setTrackingProtectionDelegate(TrackingProtectionDelegate delegate) {
         mTrackingProtectionHandler.setDelegate(delegate, this);
     }
 
@@ -4171,9 +4253,77 @@ public class GeckoSession implements Par
         void onMediaAdd(@NonNull GeckoSession session, @NonNull MediaElement element);
         /**
          * An HTMLMediaElement has been unloaded.
          * @param session Session instance.
          * @param element The media element that was unloaded.
          */
         void onMediaRemove(@NonNull GeckoSession session, @NonNull MediaElement element);
     }
+
+    /**
+     * An interface for recording new history visits and fetching the visited
+     * status for links.
+     */
+    public interface HistoryDelegate {
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(flag = true,
+                value = { VISIT_TOP_LEVEL,
+                          VISIT_REDIRECT_TEMPORARY, VISIT_REDIRECT_PERMANENT,
+                          VISIT_REDIRECT_SOURCE, VISIT_REDIRECT_SOURCE_PERMANENT,
+                          VISIT_UNRECOVERABLE_ERROR })
+        /* package */ @interface VisitFlags {}
+
+        // These flags are similar to those in `IHistory::LoadFlags`, but we use
+        // different values to decouple GeckoView from Gecko changes. These
+        // should be kept in sync with `GeckoViewHistory::GeckoViewVisitFlags`.
+
+        /** The URL was visited a top-level window. */
+        final int VISIT_TOP_LEVEL = 1 << 0;
+        /** The URL is the target of a temporary redirect. */
+        final int VISIT_REDIRECT_TEMPORARY = 1 << 1;
+        /** The URL is the target of a permanent redirect. */
+        final int VISIT_REDIRECT_PERMANENT = 1 << 2;
+        /** The URL is temporarily redirected to another URL. */
+        final int VISIT_REDIRECT_SOURCE = 1 << 3;
+        /** The URL is permanently redirected to another URL. */
+        final int VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4;
+        /** The URL failed to load due to a client or server error. */
+        final int VISIT_UNRECOVERABLE_ERROR = 1 << 5;
+
+        /**
+         * Records a visit to a page.
+         *
+         * @param session The session where the URL was visited.
+         * @param url The visited URL.
+         * @param lastVisitedURL The last visited URL in this session, to detect
+         *                       redirects and reloads.
+         * @param flags Additional flags for this visit, including redirect and
+         *              error statuses. This is a bitmask of one or more
+         *              {@link VisitFlags}, OR-ed together.
+         * @return A {@link GeckoResult} completed with a boolean indicating
+         *         whether to highlight links for the new URL as visited
+         *         ({@code true}) or unvisited ({@code false}).
+         */
+        default @Nullable GeckoResult<Boolean> onVisited(@NonNull GeckoSession session,
+                                                         @NonNull String url,
+                                                         @Nullable String lastVisitedURL,
+                                                         @VisitFlags int flags) {
+            return null;
+        }
+
+        /**
+         * Returns the visited statuses for links on a page. This is used to
+         * highlight links as visited or unvisited, for example.
+         *
+         * @param session The session requesting the visited statuses.
+         * @param urls A list of URLs to check.
+         * @return A {@link GeckoResult} completed with a list of booleans
+         *         corresponding to the URLs in {@code urls}, and indicating
+         *         whether to highlight links for each URL as visited
+         *         ({@code true}) or unvisited ({@code false}).
+         */
+        default @Nullable GeckoResult<boolean[]> getVisited(@NonNull GeckoSession session,
+                                                            @NonNull String[] urls) {
+            return null;
+        }
+    }
 }
--- a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
+++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
@@ -38,16 +38,18 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.WindowManager;
 import android.widget.ProgressBar;
 
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.util.Arrays;
+import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.Locale;
 
 public class GeckoViewActivity extends AppCompatActivity {
     private static final String LOGTAG = "GeckoViewActivity";
     private static final String DEFAULT_URL = "https://mozilla.org";
     private static final String USE_MULTIPROCESS_EXTRA = "use_multiprocess";
     private static final String FULL_ACCESSIBILITY_TREE_EXTRA = "full_accessibility_tree";
@@ -166,16 +168,17 @@ public class GeckoViewActivity extends A
 
         connectSession(session);
 
         return session;
     }
 
     private void connectSession(GeckoSession session) {
         session.setContentDelegate(new ExampleContentDelegate());
+        session.setHistoryDelegate(new ExampleHistoryDelegate());
         final ExampleTrackingProtectionDelegate tp = new ExampleTrackingProtectionDelegate();
         session.setTrackingProtectionDelegate(tp);
         session.setProgressDelegate(new ExampleProgressDelegate(tp));
         session.setNavigationDelegate(new ExampleNavigationDelegate());
 
         final BasicGeckoViewPrompt prompt = new BasicGeckoViewPrompt(this);
         prompt.filePickerRequestCode = REQUEST_FILE_PICKER;
         session.setPromptDelegate(prompt);
@@ -411,16 +414,42 @@ public class GeckoViewActivity extends A
                     }
                 }
             }
         }
 
         return mErrorTemplate.replace("$ERROR", error);
     }
 
+    private class ExampleHistoryDelegate implements GeckoSession.HistoryDelegate {
+        private final HashSet<String> mVisitedURLs;
+
+        private ExampleHistoryDelegate() {
+            mVisitedURLs = new HashSet<String>();
+        }
+
+        @Override
+        public GeckoResult<Boolean> onVisited(GeckoSession session, String url,
+                                              String lastVisitedURL, int flags) {
+            Log.i(LOGTAG, "Visited URL: " + url);
+
+            mVisitedURLs.add(url);
+            return GeckoResult.fromValue(true);
+        }
+
+        @Override
+        public GeckoResult<boolean[]> getVisited(GeckoSession session, String[] urls) {
+            boolean[] visited = new boolean[urls.length];
+            for (int i = 0; i < urls.length; i++) {
+                visited[i] = mVisitedURLs.contains(urls[i]);
+            }
+            return GeckoResult.fromValue(visited);
+        }
+    }
+
     private class ExampleContentDelegate implements GeckoSession.ContentDelegate {
         @Override
         public void onTitleChange(GeckoSession session, String title) {
             Log.i(LOGTAG, "Content title changed to " + title);
         }
 
         @Override
         public void onFullScreen(final GeckoSession session, final boolean fullScreen) {