Bug 1463878 - Update GeckoView API to reflect new session storage and remove old API and associated dead code. r=snorp
authorDylan Roeh <droeh@mozilla.com>
Fri, 22 Mar 2019 00:51:13 +0000
changeset 465571 f857b15c8d3c48cda12f50db07445b4a04ce1743
parent 465570 51b9ea3597634038f0b5df7ac79f3476d3287785
child 465572 ecb3fee216580948044845803338c60e3a283ffe
push id35744
push userapavel@mozilla.com
push dateFri, 22 Mar 2019 16:44:08 +0000
treeherdermozilla-central@e66a2b59914d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssnorp
bugs1463878
milestone68.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 1463878 - Update GeckoView API to reflect new session storage and remove old API and associated dead code. r=snorp Differential Revision: https://phabricator.services.mozilla.com/D23697
mobile/android/chrome/geckoview/GeckoViewContentChild.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
mobile/android/modules/geckoview/GeckoViewContent.jsm
--- a/mobile/android/chrome/geckoview/GeckoViewContentChild.js
+++ b/mobile/android/chrome/geckoview/GeckoViewContentChild.js
@@ -36,17 +36,16 @@ class GeckoViewContentChild extends Geck
       Utils: "resource://gre/modules/sessionstore/Utils.jsm",
     });
 
     this.messageManager.addMessageListener("GeckoView:DOMFullscreenEntered",
                                            this);
     this.messageManager.addMessageListener("GeckoView:DOMFullscreenExited",
                                            this);
     this.messageManager.addMessageListener("GeckoView:RestoreState", this);
-    this.messageManager.addMessageListener("GeckoView:SaveState", this);
     this.messageManager.addMessageListener("GeckoView:SetActive", this);
     this.messageManager.addMessageListener("GeckoView:UpdateInitData", this);
     this.messageManager.addMessageListener("GeckoView:ZoomToInput", this);
     this.messageManager.addMessageListener("GeckoView:ScrollBy", this);
     this.messageManager.addMessageListener("GeckoView:ScrollTo", this);
 
     const options = {
         mozSystemGroup: true,
@@ -204,38 +203,18 @@ class GeckoViewContentChild extends Geck
           removeEventListener("resize", onResize, { capture: true });
           if (!gotResize) {
             onResize();
           }
         }, 500);
         break;
       }
 
-      case "GeckoView:SaveState":
-        if (this._savedState) {
-          // Short circuit and return the pending state if we're in the process of restoring
-          sendAsyncMessage("GeckoView:SaveStateFinish", {state: JSON.stringify(this._savedState), id: aMsg.data.id});
-        } else {
-          try {
-            let state = this.collectSessionState();
-            sendAsyncMessage("GeckoView:SaveStateFinish", {
-              state: state ? JSON.stringify(state) : null,
-              id: aMsg.data.id,
-            });
-          } catch (e) {
-            sendAsyncMessage("GeckoView:SaveStateFinish", {
-              error: e.message,
-              id: aMsg.data.id,
-            });
-          }
-        }
-        break;
-
       case "GeckoView:RestoreState":
-        this._savedState = JSON.parse(aMsg.data.state);
+        this._savedState = aMsg.data;
 
         if (this._savedState.history) {
           let restoredHistory = SessionHistory.restore(docShell, this._savedState.history);
 
           addEventListener("load", _ => {
             const formdata = this._savedState.formdata;
             if (formdata) {
               this.Utils.restoreFrameTreeData(content, formdata, (frame, data) => {
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -308,17 +308,16 @@ package org.mozilla.geckoview {
     method @android.support.annotation.AnyThread public void loadUri(@android.support.annotation.NonNull android.net.Uri);
     method @android.support.annotation.AnyThread public void loadUri(@android.support.annotation.NonNull android.net.Uri, int);
     method @android.support.annotation.AnyThread public void loadUri(@android.support.annotation.NonNull android.net.Uri, @android.support.annotation.Nullable android.net.Uri, int);
     method @android.support.annotation.UiThread public void open(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoRuntime);
     method @android.support.annotation.AnyThread public void readFromParcel(@android.support.annotation.NonNull android.os.Parcel);
     method @android.support.annotation.UiThread public void releaseDisplay(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoDisplay);
     method @android.support.annotation.AnyThread public void reload();
     method @android.support.annotation.AnyThread public void restoreState(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession.SessionState);
-    method @android.support.annotation.AnyThread @android.support.annotation.NonNull public org.mozilla.geckoview.GeckoResult<org.mozilla.geckoview.GeckoSession.SessionState> saveState();
     method @android.support.annotation.AnyThread public void setActive(boolean);
     method @android.support.annotation.AnyThread public void setContentBlockingDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.ContentBlocking.Delegate);
     method @android.support.annotation.UiThread public void setContentDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoSession.ContentDelegate);
     method @android.support.annotation.AnyThread public void setFocused(boolean);
     method @android.support.annotation.AnyThread public void setHistoryDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoSession.HistoryDelegate);
     method @android.support.annotation.AnyThread public void setMediaDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoSession.MediaDelegate);
     method @android.support.annotation.UiThread public void setNavigationDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoSession.NavigationDelegate);
     method @android.support.annotation.UiThread public void setPermissionDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoSession.PermissionDelegate);
@@ -467,16 +466,17 @@ package org.mozilla.geckoview {
   public static interface GeckoSession.PermissionDelegate.Permission implements java.lang.annotation.Annotation {
   }
 
   public static interface GeckoSession.ProgressDelegate {
     method @android.support.annotation.UiThread default public void onPageStart(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, @android.support.annotation.NonNull java.lang.String);
     method @android.support.annotation.UiThread default public void onPageStop(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, boolean);
     method @android.support.annotation.UiThread default public void onProgressChange(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, int);
     method @android.support.annotation.UiThread default public void onSecurityChange(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, @android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession.ProgressDelegate.SecurityInformation);
+    method @android.support.annotation.UiThread default public void onSessionStateChange(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, @android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession.SessionState);
   }
 
   public static class GeckoSession.ProgressDelegate.SecurityInformation {
     ctor protected SecurityInformation();
     field public static final int CONTENT_BLOCKED = 1;
     field public static final int CONTENT_LOADED = 2;
     field public static final int CONTENT_UNKNOWN = 0;
     field public static final int SECURITY_MODE_IDENTIFIED = 1;
@@ -624,18 +624,19 @@ package org.mozilla.geckoview {
   public static class GeckoSession.SelectionActionDelegate.Selection {
     ctor protected Selection();
     field public final android.graphics.RectF clientRect;
     field public final int flags;
     field public final java.lang.String text;
   }
 
   @android.support.annotation.AnyThread public static class GeckoSession.SessionState implements android.os.Parcelable {
-    ctor public SessionState(java.lang.String);
+    ctor public SessionState(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession.SessionState);
     method public void readFromParcel(@android.support.annotation.NonNull android.os.Parcel);
+    method public void updateSessionState(@android.support.annotation.NonNull org.mozilla.gecko.util.GeckoBundle);
     field public static final android.os.Parcelable.Creator<org.mozilla.geckoview.GeckoSession.SessionState> CREATOR;
   }
 
   public static interface GeckoSession.TextInputDelegate {
     method @android.support.annotation.UiThread default public void hideSoftInput(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession);
     method @android.support.annotation.UiThread default public void notifyAutoFill(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, int, int);
     method @android.support.annotation.UiThread default public void restartInput(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession, int);
     method @android.support.annotation.UiThread default public void showSoftInput(@android.support.annotation.NonNull org.mozilla.geckoview.GeckoSession);
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
@@ -170,76 +170,16 @@ class ContentDelegateTest : BaseSessionT
                 @AssertCalled(count = 1)
                 override fun onCrash(session: GeckoSession) {
                     remainingSessions.remove(session)
                 }
             })
         }
     }
 
-    @WithDevToolsAPI
-    @WithDisplay(width = 400, height = 400)
-    @Test fun saveAndRestoreState() {
-        val startUri = createTestUrl(SAVE_STATE_PATH)
-        mainSession.loadUri(startUri)
-        sessionRule.waitForPageStop()
-
-        mainSession.evaluateJS("$('#name').value = 'the name'; window.setTimeout(() => window.scrollBy(0, 100),0);")
-        sessionRule.waitUntilCalled(Callbacks.ScrollDelegate::class, "onScrollChanged")
-
-        val state = sessionRule.waitForResult(mainSession.saveState())
-        assertThat("State should not be null", state, notNullValue())
-
-        mainSession.loadUri("about:blank")
-        sessionRule.waitForPageStop()
-
-        mainSession.restoreState(state)
-        sessionRule.waitForPageStop()
-
-        sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
-            @AssertCalled
-            override fun onLocationChange(session: GeckoSession, url: String?) {
-                assertThat("URI should match", url, equalTo(startUri))
-            }
-        })
-
-        assertThat("'name' field should match",
-                mainSession.evaluateJS("$('#name').value").toString(),
-                equalTo("the name"))
-
-        assertThat("Scroll position should match",
-                mainSession.evaluateJS("window.visualViewport.pageTop") as Double,
-                closeTo(100.0, .5))
-    }
-
-    @Test fun saveStateSync() {
-        val startUri = createTestUrl(SAVE_STATE_PATH)
-        mainSession.loadUri(startUri)
-        sessionRule.waitForPageStop()
-
-        var worker = thread {
-            Looper.prepare()
-
-            var thread = Thread.currentThread()
-            mainSession.saveState().then<Void> { _: GeckoSession.SessionState? ->
-                assertThat("We should be on the worker thread", Thread.currentThread(),
-                        equalTo(thread))
-                Looper.myLooper().quit()
-                null
-            }
-
-            Looper.loop()
-        }
-
-        worker.join(sessionRule.timeoutMillis)
-        if (worker.isAlive) {
-            throw UiThreadUtils.TimeoutException("Timed out")
-        }
-    }
-
     val ViewNode by lazy {
         AssistStructure.ViewNode::class.java.getDeclaredConstructor().apply { isAccessible = true }
     }
 
     val ViewNodeBuilder by lazy {
         Class.forName("android.app.assist.AssistStructure\$ViewNodeBuilder")
                 .getDeclaredConstructor(AssistStructure::class.java,
                                         AssistStructure.ViewNode::class.java,
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt
@@ -57,20 +57,26 @@ class GeckoSessionTestRuleTest : BaseSes
                    sessionRule.session.settings.useTrackingProtection,
                    equalTo(true))
         assertThat("ALLOW_JAVASCRIPT should be set",
                 sessionRule.session.settings.allowJavascript,
                 equalTo(false))
     }
 
     @Test(expected = UiThreadUtils.TimeoutException::class)
-    @TimeoutMillis(1000)
+    @TimeoutMillis(2000)
     fun noPendingCallbacks() {
         // Make sure we don't have unexpected pending callbacks at the start of a test.
-        sessionRule.waitUntilCalled(object : Callbacks.All {})
+        sessionRule.waitUntilCalled(object : Callbacks.All {
+            // There may be an extraneous onSessionStateChange call after a test,
+            // so ignore the first received.
+            @AssertCalled(count=2)
+            override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+            }
+        })
     }
 
     @Test fun includesAllCallbacks() {
         for (ifce in GeckoSession::class.java.classes) {
             if (!ifce.isInterface || !ifce.simpleName.endsWith("Delegate")) {
                 continue
             }
             assertThat("Callbacks.All should include interface " + ifce.simpleName,
@@ -175,16 +181,20 @@ class GeckoSessionTestRuleTest : BaseSes
             override fun onSecurityChange(session: GeckoSession,
                                           securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
                 counter++
             }
 
             override fun onProgressChange(session: GeckoSession, progress: Int) {
                 counter++
             }
+
+            override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+                counter++
+            }
         })
 
         assertThat("Callback count should be correct", counter, equalTo(1))
     }
 
     @Test fun waitUntilCalled_specificInterfaceMethod() {
         sessionRule.session.loadTestPath(HELLO_HTML_PATH)
         sessionRule.waitUntilCalled(GeckoSession.ProgressDelegate::class,
@@ -246,16 +256,20 @@ class GeckoSessionTestRuleTest : BaseSes
             override fun onSecurityChange(session: GeckoSession,
                                           securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
                 counter++
             }
 
             override fun onProgressChange(session: GeckoSession, progress: Int) {
                 counter++
             }
+
+            override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+                counter++
+            }
         })
 
         assertThat("Callback count should be correct", counter, equalTo(1))
     }
 
     @Test fun waitUntilCalled_specificObjectMethod() {
         sessionRule.session.loadTestPath(HELLO_HTML_PATH)
 
@@ -953,22 +967,28 @@ class GeckoSessionTestRuleTest : BaseSes
     @Test fun createClosedSession_withSettings() {
         val settings = GeckoSessionSettings.Builder(sessionRule.session.settings).usePrivateMode(true).build()
 
         val newSession = sessionRule.createClosedSession(settings)
         assertThat("New session has same settings", newSession.settings, equalTo(settings))
     }
 
     @Test(expected = UiThreadUtils.TimeoutException::class)
-    @TimeoutMillis(1000)
+    @TimeoutMillis(2000)
     @ClosedSessionAtStart
     fun noPendingCallbacks_withSpecificSession() {
         sessionRule.createOpenSession()
         // Make sure we don't have unexpected pending callbacks after opening a session.
-        sessionRule.waitUntilCalled(object : Callbacks.All {})
+        sessionRule.waitUntilCalled(object : Callbacks.All {
+            // There may be an extraneous onSessionStateChange call after a test,
+            // so ignore the first received.
+            @AssertCalled(count=2)
+            override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+            }
+        })
     }
 
     @Test fun waitForPageStop_withSpecificSession() {
         val newSession = sessionRule.createOpenSession()
         newSession.loadTestPath(HELLO_HTML_PATH)
         newSession.waitForPageStop()
     }
 
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt
@@ -2,16 +2,18 @@
  * 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 org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay
 import org.mozilla.geckoview.test.util.Callbacks
 
 import android.support.test.filters.MediumTest
 import android.support.test.filters.LargeTest
 import android.support.test.runner.AndroidJUnit4
 
 import org.hamcrest.Matchers.*
 import org.junit.Assume.assumeThat
@@ -277,9 +279,54 @@ class ProgressDelegateTest : BaseSession
             }
 
             @AssertCalled(false)
             override fun onSecurityChange(session: GeckoSession,
                                           securityInfo: GeckoSession.ProgressDelegate.SecurityInformation) {
             }
         })
     }
+
+    @WithDevToolsAPI
+    @WithDisplay(width = 400, height = 400)
+    @Test fun saveAndRestoreState() {
+        val startUri = createTestUrl(SAVE_STATE_PATH)
+        mainSession.loadUri(startUri)
+        sessionRule.waitForPageStop()
+
+        mainSession.evaluateJS("$('#name').value = 'the name'; window.setTimeout(() => window.scrollBy(0, 100),0);")
+        mainSession.evaluateJS("$('#name').dispatchEvent(new Event('input'));")
+        sessionRule.waitUntilCalled(Callbacks.ScrollDelegate::class, "onScrollChanged")
+
+        var savedState : GeckoSession.SessionState? = null
+        sessionRule.waitUntilCalled(object : Callbacks.ProgressDelegate {
+            @AssertCalled(count=1)
+            override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) {
+                savedState = state
+            }
+        })
+
+        assertThat("State should not be null", savedState, notNullValue())
+
+        mainSession.loadUri("about:blank")
+        sessionRule.waitForPageStop()
+
+        mainSession.restoreState(savedState!!)
+        sessionRule.waitForPageStop()
+
+        sessionRule.forCallbacksDuringWait(object : Callbacks.NavigationDelegate {
+            @AssertCalled
+            override fun onLocationChange(session: GeckoSession, url: String?) {
+                assertThat("URI should match", url, equalTo(startUri))
+            }
+        })
+
+        /* TODO: Reenable when we have a workaround for ContentSessionStore not
+                 saving in response to JS-driven formdata changes.
+        assertThat("'name' field should match",
+                mainSession.evaluateJS("$('#name').value").toString(),
+                equalTo("the name"))*/
+
+        assertThat("Scroll position should match",
+                mainSession.evaluateJS("window.visualViewport.pageTop") as Double,
+                closeTo(100.0, .5))
+    }
 }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -595,16 +595,20 @@ public class GeckoSession implements Par
                 } else if ("GeckoView:ProgressChanged".equals(event)) {
                     delegate.onProgressChange(GeckoSession.this,
                                               message.getInt("progress"));
                 } else if ("GeckoView:SecurityChanged".equals(event)) {
                     final GeckoBundle identity = message.getBundle("identity");
                     delegate.onSecurityChange(GeckoSession.this, new ProgressDelegate.SecurityInformation(identity));
                 } else if ("GeckoView:StateUpdated".equals(event)) {
                     final GeckoBundle update = message.getBundle("data");
+                    if (update != null) {
+                        mStateCache.updateSessionState(update);
+                        delegate.onSessionStateChange(GeckoSession.this, new SessionState(mStateCache));
+                    }
                 }
             }
         };
 
     private final GeckoSessionHandler<ScrollDelegate> mScrollHandler =
         new GeckoSessionHandler<ScrollDelegate>(
             "GeckoViewScroll", this,
             new String[]{ "GeckoView:ScrollChanged" }
@@ -1723,94 +1727,135 @@ public class GeckoSession implements Par
         mEventDispatcher.dispatch("GeckoView:SetFocused", msg);
     }
 
     /**
      * Class representing a saved session state.
      */
     @AnyThread
     public static class SessionState implements Parcelable {
-        private String mState;
-
-        /**
-         * Construct a SessionState from a String.
-         *
-         * @param state A String representing a SessionState; should originate as output
-         *              of SessionState.toString().
-         */
-        public SessionState(final String state) {
-            mState = state;
+        private GeckoBundle mState;
+
+        private SessionState() {
+            mState = new GeckoBundle(3);
+        }
+
+        private SessionState(final @NonNull GeckoBundle state) {
+            mState = new GeckoBundle(state);
+        }
+
+        public SessionState(final @NonNull SessionState state) {
+            mState = new GeckoBundle(state.mState);
+        }
+
+        public void updateSessionState(final @NonNull GeckoBundle updateData) {
+            if (updateData == null) {
+                Log.w(LOGTAG, "Session state update has no data field.");
+                return;
+            }
+
+            final GeckoBundle history = updateData.getBundle("historychange");
+            final GeckoBundle scroll = updateData.getBundle("scroll");
+            final GeckoBundle formdata = updateData.getBundle("formdata");
+
+            if (history != null) {
+                mState.putBundle("history", history);
+            }
+
+            if (scroll != null) {
+                mState.putBundle("scrolldata", scroll);
+            }
+
+            if (formdata != null) {
+                mState.putBundle("formdata", formdata);
+            }
+
+            return;
         }
 
         @Override
         public String toString() {
-            return mState;
+            if (mState == null) {
+                Log.w(LOGTAG, "Can't convert SessionState with null state to string");
+                return null;
+            }
+
+            String res;
+            try {
+                res = mState.toJSONObject().toString();
+            } catch (JSONException e) {
+                Log.e(LOGTAG, "Could not convert session state to string.");
+                res = null;
+            }
+
+            return res;
         }
 
         @Override // Parcelable
         public int describeContents() {
             return 0;
         }
 
         @Override // Parcelable
         public void writeToParcel(final Parcel dest, final int flags) {
-            dest.writeString(mState);
+            dest.writeString(toString());
         }
 
         // AIDL code may call readFromParcel even though it's not part of Parcelable.
         public void readFromParcel(final @NonNull Parcel source) {
-            mState = source.readString();
+            if (source.readString() == null) {
+                Log.w(LOGTAG, "Can't reproduce session state from Parcel");
+            }
+
+            try {
+                mState = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+            } catch (JSONException e) {
+                Log.e(LOGTAG, "Could not convert string to session state.");
+                mState = null;
+            }
         }
 
         public static final Parcelable.Creator<SessionState> CREATOR =
                 new Parcelable.Creator<SessionState>() {
             @Override
             public SessionState createFromParcel(final Parcel source) {
-                return new SessionState(source.readString());
+                if (source.readString() == null) {
+                    Log.w(LOGTAG, "Can't create session state from Parcel");
+                }
+
+                GeckoBundle res;
+                try {
+                    res = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+                } catch (JSONException e) {
+                    Log.e(LOGTAG, "Could not convert parcel to session state.");
+                    res = null;
+                }
+
+                return new SessionState(res);
             }
 
             @Override
             public SessionState[] newArray(final int size) {
                 return new SessionState[size];
             }
         };
     }
 
-    /**
-     * Save the current browsing session state of this GeckoSession. This session state
-     * includes the history, scroll position, zoom, and any form data that has been entered,
-     * but does not include information pertaining to the GeckoSession itself (for example,
-     * this does not include settings on the GeckoSession).
-     *
-     * @return A {@link GeckoResult} containing the {@link SessionState}
-     */
-    @AnyThread
-    public @NonNull GeckoResult<SessionState> saveState() {
-        CallbackResult<SessionState> result = new CallbackResult<SessionState>() {
-            @Override
-            public void sendSuccess(final Object value) {
-                complete(new SessionState((String)value));
-            }
-        };
-        mEventDispatcher.dispatch("GeckoView:SaveState", null, result);
-        return result;
-    }
+    private SessionState mStateCache = new SessionState();
 
     /**
      * Restore a saved state to this GeckoSession; only data that is saved (history, scroll
      * position, zoom, and form data) will be restored. These will overwrite the corresponding
      * state of this GeckoSession.
      *
-     * @param state A saved session state; this should originate from GeckoSession.saveState().
+     * @param state A saved session state; this should originate from onSessionStateChange().
      */
     @AnyThread
     public void restoreState(final @NonNull SessionState state) {
-        final GeckoBundle msg = new GeckoBundle(1);
-        msg.putString("state", state.toString());
-        mEventDispatcher.dispatch("GeckoView:RestoreState", msg);
+        mEventDispatcher.dispatch("GeckoView:RestoreState", state.mState);
     }
 
     // This is the GeckoDisplay acquired via acquireDisplay(), if any.
     private GeckoDisplay mDisplay;
     /* package */ GeckoDisplay getDisplay() {
         return mDisplay;
     }
 
@@ -2578,16 +2623,27 @@ public class GeckoSession implements Par
         /**
         * The security status has been updated.
         * @param session GeckoSession that initiated the callback.
         * @param securityInfo The new security information.
         */
         @UiThread
         default void onSecurityChange(@NonNull GeckoSession session,
                                       @NonNull SecurityInformation securityInfo) {}
+
+        /**
+        * The browser session state has changed. This can happen in response to
+        * navigation, scrolling, or form data changes; the session state passed
+        * includes the most up to date information on all of these.
+        * @param session GeckoSession that initiated the callback.
+        * @param sessionState SessionState representing the latest browser state.
+        */
+        @UiThread
+        default void onSessionStateChange(@NonNull GeckoSession session,
+                                          @NonNull SessionState sessionState) {}
     }
 
     /**
      * WebResponseInfo contains information about a single web response.
      */
     @AnyThread
     static public class WebResponseInfo {
         /**
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -9,16 +9,20 @@ exclude: true
 <h1> GeckoView API Changelog. </h1>
 
 ## v68
 - Added [`GeckoRuntime#configurationChanged`][68.1] to notify the device
   configuration has changed.
 
 [68.1]: ../GeckoRuntime.html#configurationChanged
 
+- Added `onSessionStateChange` to [`ProgressDelegate`][68.2] and removed `saveState`.
+
+[68.2]: ../GeckoSession.ProgressDelegate.html
+
 ## v67
 - Added [`setAutomaticFontSizeAdjustment`][67.2] to
   [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings
   depending on the OS-level font size setting.
 
 [67.2]: ../GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment-boolean-
 [67.3]: ../GeckoRuntimeSettings.html
 
@@ -215,9 +219,9 @@ exclude: true
 [65.23]: ../GeckoSession.FinderResult.html
 
 - Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
   [`GeckoResult<String>`][65.25].
 
 [65.24]: ../CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
 [65.25]: ../GeckoResult.html
 
-[api-version]: 07af02921c277f9461d7532f2a6a78c527c9cb47
+[api-version]: e8fa4ed799e78f64fddd3f2c463202942811de11
--- 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
@@ -576,16 +576,21 @@ public class GeckoViewActivity extends A
                 mProgressView.setVisibility(View.GONE);
             }
         }
 
         @Override
         public void onSecurityChange(GeckoSession session, SecurityInformation securityInfo) {
             Log.i(LOGTAG, "Security status changed to " + securityInfo.securityMode);
         }
+
+        @Override
+        public void onSessionStateChange(GeckoSession session, GeckoSession.SessionState state) {
+            Log.i(LOGTAG, "New Session state: " + state.toString());
+        }
     }
 
     private class ExamplePermissionDelegate implements GeckoSession.PermissionDelegate {
 
         public int androidPermissionRequestCode = 1;
         private Callback mCallback;
 
         class ExampleNotificationCallback implements GeckoSession.PermissionDelegate.Callback {
--- a/mobile/android/modules/geckoview/GeckoViewContent.jsm
+++ b/mobile/android/modules/geckoview/GeckoViewContent.jsm
@@ -16,25 +16,22 @@ XPCOMUtils.defineLazyModuleGetters(this,
 class GeckoViewContent extends GeckoViewModule {
   onInit() {
     this.registerListener([
         "GeckoViewContent:ExitFullScreen",
         "GeckoView:ClearMatches",
         "GeckoView:DisplayMatches",
         "GeckoView:FindInPage",
         "GeckoView:RestoreState",
-        "GeckoView:SaveState",
         "GeckoView:SetActive",
         "GeckoView:SetFocused",
         "GeckoView:ZoomToInput",
         "GeckoView:ScrollBy",
         "GeckoView:ScrollTo",
     ]);
-
-    this.messageManager.addMessageListener("GeckoView:SaveStateFinish", this);
   }
 
   onEnable() {
     this.window.addEventListener("MozDOMFullscreen:Entered", this,
                                  /* capture */ true, /* untrusted */ false);
     this.window.addEventListener("MozDOMFullscreen:Exited", this,
                                  /* capture */ true, /* untrusted */ false);
 
@@ -101,27 +98,18 @@ class GeckoViewContent extends GeckoView
         if (aData.focused) {
           this.browser.focus();
           this.browser.setAttribute("primary", "true");
         } else {
           this.browser.removeAttribute("primary");
           this.browser.blur();
         }
         break;
-      case "GeckoView:SaveState":
-        if (!this._saveStateCallbacks) {
-          this._saveStateCallbacks = new Map();
-          this._saveStateNextId = 0;
-        }
-        this._saveStateCallbacks.set(this._saveStateNextId, aCallback);
-        this.messageManager.sendAsyncMessage("GeckoView:SaveState", {id: this._saveStateNextId});
-        this._saveStateNextId++;
-        break;
       case "GeckoView:RestoreState":
-        this.messageManager.sendAsyncMessage("GeckoView:RestoreState", {state: aData.state});
+        this.messageManager.sendAsyncMessage("GeckoView:RestoreState", aData);
         break;
     }
   }
 
   // DOM event handler
   handleEvent(aEvent) {
     debug `handleEvent: ${aEvent.type}`;
 
@@ -146,30 +134,16 @@ class GeckoViewContent extends GeckoView
       case "GeckoView:DOMFullscreenExit":
         this.window.windowUtils
                    .remoteFrameFullscreenReverted();
         break;
       case "GeckoView:DOMFullscreenRequest":
         this.window.windowUtils
                    .remoteFrameFullscreenChanged(aMsg.target);
         break;
-      case "GeckoView:SaveStateFinish":
-        if (!this._saveStateCallbacks || !this._saveStateCallbacks.has(aMsg.data.id)) {
-          warn `Failed to save state due to missing callback`;
-          return;
-        }
-
-        const callback = this._saveStateCallbacks.get(aMsg.data.id);
-        if (aMsg.data.error) {
-          callback.onError(aMsg.data.error);
-        } else {
-          callback.onSuccess(aMsg.data.state);
-        }
-        this._saveStateCallbacks.delete(aMsg.data.id);
-        break;
     }
   }
 
   // nsIObserver event handler
   observe(aSubject, aTopic, aData) {
     debug `observe: ${aTopic}`;
 
     switch (aTopic) {