Bug 1527074 - Expose storage manager API to GeckoView r=geckoview-reviewers,snorp
authorEmily Toop <etoop@mozilla.com>
Thu, 25 Apr 2019 16:20:48 +0000
changeset 530152 8fce6b3574a7d1f5d0c74998c7ba0742bc448e24
parent 530151 ae2f7d5c145e23057a3ada819d57231f4687fa73
child 530153 765b4140cde753ee6ac95d3cbc0c84003a007a1c
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgeckoview-reviewers, snorp
bugs1527074
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 1527074 - Expose storage manager API to GeckoView r=geckoview-reviewers,snorp Differential Revision: https://phabricator.services.mozilla.com/D25408
dom/quota/StorageManager.cpp
mobile/android/app/geckoview-prefs.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.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/geckoview_example/src/main/res/values/strings.xml
--- a/dom/quota/StorageManager.cpp
+++ b/dom/quota/StorageManager.cpp
@@ -604,20 +604,28 @@ bool PersistedWorkerMainThreadRunnable::
 
   return true;
 }
 
 nsresult PersistentStoragePermissionRequest::Start() {
   MOZ_ASSERT(NS_IsMainThread());
 
   PromptResult pr;
+  #ifdef MOZ_WIDGET_ANDROID
+  // on Android calling `ShowPrompt` here calls `nsContentPermissionUtils::AskPermission` 
+  // once, and a response of `PromptResult::Pending` calls it again. This results in 
+  // multiple requests for storage access, so we check the prompt prefs only to ensure we
+  // only request it once.
+  pr = CheckPromptPrefs();
+  #else
   nsresult rv = ShowPrompt(pr);
   if (NS_WARN_IF(NS_FAILED(rv))) {
     return rv;
   }
+  #endif
   if (pr == PromptResult::Granted) {
     return Allow(JS::UndefinedHandleValue);
   }
   if (pr == PromptResult::Denied) {
     return Cancel();
   }
 
   return nsContentPermissionUtils::AskPermission(this, mWindow);
--- a/mobile/android/app/geckoview-prefs.js
+++ b/mobile/android/app/geckoview-prefs.js
@@ -24,16 +24,19 @@ pref("geckoview.console.enabled", false)
 pref("geckoview.logging", "Warn");
 #else
 pref("geckoview.logging", "Debug");
 #endif
 
 // Disable Web Push until we get it working
 pref("dom.push.enabled", false);
 
+// enable external storage API
+pref("dom.storageManager.enabled", true);
+
 // Use containerless scrolling.
 pref("layout.scroll.root-frame-containers", 0);
 
 // Inherit locale from the OS, used for multi-locale builds
 pref("intl.locale.requested", "");
 
 // Enable Safe Browsing blocklist updates
 pref("browser.safebrowsing.features.phishing.update", true);
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -541,16 +541,17 @@ package org.mozilla.geckoview {
   }
 
   public static interface GeckoSession.PermissionDelegate {
     method @UiThread default public void onAndroidPermissionsRequest(@NonNull GeckoSession, @Nullable String[], @NonNull GeckoSession.PermissionDelegate.Callback);
     method @UiThread default public void onContentPermissionRequest(@NonNull GeckoSession, @Nullable String, int, @NonNull GeckoSession.PermissionDelegate.Callback);
     method @UiThread default public void onMediaPermissionRequest(@NonNull GeckoSession, @NonNull String, @Nullable GeckoSession.PermissionDelegate.MediaSource[], @Nullable GeckoSession.PermissionDelegate.MediaSource[], @NonNull GeckoSession.PermissionDelegate.MediaCallback);
     field public static final int PERMISSION_DESKTOP_NOTIFICATION = 1;
     field public static final int PERMISSION_GEOLOCATION = 0;
+    field public static final int PERMISSION_PERSISTENT_STORAGE = 2;
   }
 
   public static interface GeckoSession.PermissionDelegate.Callback {
     method @UiThread default public void grant();
     method @UiThread default public void reject();
   }
 
   public static interface GeckoSession.PermissionDelegate.MediaCallback {
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt
@@ -36,75 +36,75 @@ class PermissionDelegateTest : BaseSessi
 
     private fun isEmulator(): Boolean {
         return "generic".equals(Build.DEVICE) || Build.DEVICE.startsWith("generic_")
     }
 
     @WithDevToolsAPI
     @Test fun media() {
         assertInAutomationThat("Should have camera permission",
-                               hasPermission(Manifest.permission.CAMERA), equalTo(true))
+                hasPermission(Manifest.permission.CAMERA), equalTo(true))
 
         assertInAutomationThat("Should have microphone permission",
-                               hasPermission(Manifest.permission.RECORD_AUDIO),
-                               equalTo(true))
+                hasPermission(Manifest.permission.RECORD_AUDIO),
+                equalTo(true))
 
         // Media test is relatively resource-intensive. Clean up resources from previous tests
         // first to improve the stability of this test.
         sessionRule.forceGarbageCollection()
 
         mainSession.loadTestPath(HELLO_HTML_PATH)
         mainSession.waitForPageStop()
 
         val devices = mainSession.waitForJS(
                 "window.navigator.mediaDevices.enumerateDevices()")
 
         assertThat("Device list should contain camera device",
-                   devices.asJSList<Any>(), hasItem(hasEntry("kind", "videoinput")))
+                devices.asJSList<Any>(), hasItem(hasEntry("kind", "videoinput")))
         assertThat("Device list should contain microphone device",
-                   devices.asJSList<Any>(), hasItem(hasEntry("kind", "audioinput")))
+                devices.asJSList<Any>(), hasItem(hasEntry("kind", "audioinput")))
 
 
         mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
             @AssertCalled(count = 1)
             override fun onMediaPermissionRequest(
                     session: GeckoSession, uri: String,
                     video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
                     audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
                     callback: GeckoSession.PermissionDelegate.MediaCallback) {
                 assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
                 assertThat("Video source should be valid", video, not(emptyArray()))
 
                 if (isEmulator()) {
-                  callback.grant(video!![0], null)
+                    callback.grant(video!![0], null)
                 } else {
-                  assertThat("Audio source should be valid", audio, not(emptyArray()))
-                  callback.grant(video!![0], audio!![0])
+                    assertThat("Audio source should be valid", audio, not(emptyArray()))
+                    callback.grant(video!![0], audio!![0])
                 }
             }
         })
 
         // Start a video stream, with audio if on a real device.
         var code: String?
         if (isEmulator()) {
-          code = """window.navigator.mediaDevices.getUserMedia({
+            code = """window.navigator.mediaDevices.getUserMedia({
                        video: { width: 320, height: 240, frameRate: 10 },
                    })"""
         } else {
-          code = """window.navigator.mediaDevices.getUserMedia({
+            code = """window.navigator.mediaDevices.getUserMedia({
                        video: { width: 320, height: 240, frameRate: 10 },
                        audio: true
                    })"""
         }
         val stream = mainSession.waitForJS(code)
 
         assertThat("Stream should be active", stream.asJSMap(),
-                   hasEntry("active", true))
+                hasEntry("active", true))
         assertThat("Stream should have ID", stream.asJSMap(),
-                   hasEntry(equalTo("id"), not(isEmptyString())))
+                hasEntry(equalTo("id"), not(isEmptyString())))
 
         // Stop the stream.
         mainSession.waitForJS(
                 "\$_.then(stream => stream.getTracks().forEach(track => track.stop()))")
 
 
         // Now test rejecting the request.
         mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
@@ -124,58 +124,58 @@ class PermissionDelegateTest : BaseSessi
                         window.navigator.mediaDevices.getUserMedia({ video: true })""")
             } else {
                 mainSession.waitForJS("""
                         window.navigator.mediaDevices.getUserMedia({ audio: true: video: true })""")
             }
             fail("Request should have failed")
         } catch (e: RejectedPromiseException) {
             assertThat("Error should be correct",
-                       e.reason.asJSMap(), hasEntry("name", "NotAllowedError"))
+                    e.reason.asJSMap(), hasEntry("name", "NotAllowedError"))
         }
     }
 
     @WithDevToolsAPI
     @Test fun geolocation() {
         assertInAutomationThat("Should have location permission",
-                               hasPermission(Manifest.permission.ACCESS_FINE_LOCATION),
-                               equalTo(true))
+                hasPermission(Manifest.permission.ACCESS_FINE_LOCATION),
+                equalTo(true))
 
         mainSession.loadTestPath(HELLO_HTML_PATH)
         mainSession.waitForPageStop()
 
         mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
             // Ensure the content permission is asked first, before the Android permission.
             @AssertCalled(count = 1, order = [1])
             override fun onContentPermissionRequest(
                     session: GeckoSession, uri: String?, type: Int,
                     callback: GeckoSession.PermissionDelegate.Callback) {
                 assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
                 assertThat("Type should match", type,
-                           equalTo(GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION))
+                        equalTo(GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION))
                 callback.grant()
             }
 
             @AssertCalled(count = 1, order = [2])
             override fun onAndroidPermissionsRequest(
                     session: GeckoSession, permissions: Array<out String>?,
                     callback: GeckoSession.PermissionDelegate.Callback) {
                 assertThat("Permissions list should be correct",
-                           listOf(*permissions!!), hasItems(Manifest.permission.ACCESS_FINE_LOCATION))
+                        listOf(*permissions!!), hasItems(Manifest.permission.ACCESS_FINE_LOCATION))
                 callback.grant()
             }
         })
 
         val position = mainSession.waitForJS("""new Promise((resolve, reject) =>
                 window.navigator.geolocation.getCurrentPosition(resolve, reject))""")
 
         assertThat("Request should succeed",
-                   position.asJSMap(),
-                   hasEntry(equalTo("coords"),
-                            both(hasKey("longitude")).and(hasKey("latitude"))))
+                position.asJSMap(),
+                hasEntry(equalTo("coords"),
+                        both(hasKey("longitude")).and(hasKey("latitude"))))
     }
 
     @WithDevToolsAPI
     @Test fun geolocation_reject() {
         mainSession.loadTestPath(HELLO_HTML_PATH)
         mainSession.waitForPageStop()
 
         mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
@@ -192,40 +192,40 @@ class PermissionDelegateTest : BaseSessi
                     callback: GeckoSession.PermissionDelegate.Callback) {
             }
         })
 
         val error = mainSession.waitForJS("""new Promise((resolve, reject) =>
                 window.navigator.geolocation.getCurrentPosition(reject, resolve))""")
 
         assertThat("Request should fail",
-                   error.asJSMap(), hasEntry("code", 1.0)) // Error code 1 means permission denied.
+                error.asJSMap(), hasEntry("code", 1.0)) // Error code 1 means permission denied.
     }
 
     @WithDevToolsAPI
     @Test fun notification() {
         mainSession.loadTestPath(HELLO_HTML_PATH)
         mainSession.waitForPageStop()
 
         mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
             @AssertCalled(count = 1)
             override fun onContentPermissionRequest(
                     session: GeckoSession, uri: String?, type: Int,
                     callback: GeckoSession.PermissionDelegate.Callback) {
                 assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
                 assertThat("Type should match", type,
-                           equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+                        equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
                 callback.grant()
             }
         })
 
         val result = mainSession.waitForJS("Notification.requestPermission()")
 
         assertThat("Permission should be granted",
-                   result as String, equalTo("granted"))
+                result as String, equalTo("granted"))
     }
 
     @WithDevToolsAPI
     @Test fun notification_reject() {
         mainSession.loadTestPath(HELLO_HTML_PATH)
         mainSession.waitForPageStop()
 
         mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
@@ -235,11 +235,67 @@ class PermissionDelegateTest : BaseSessi
                     callback: GeckoSession.PermissionDelegate.Callback) {
                 callback.reject()
             }
         })
 
         val result = mainSession.waitForJS("Notification.requestPermission()")
 
         assertThat("Permission should not be granted",
-                   result as String, equalTo("default"))
+                result as String, equalTo("default"))
+    }
+
+    @WithDevToolsAPI
+    @Test fun persistentStorage() {
+        mainSession.loadTestPath(HELLO_HTML_PATH)
+        mainSession.waitForPageStop()
+
+        // Persistent storage can be rejected
+        mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+            @AssertCalled(count = 1)
+            override fun onContentPermissionRequest(
+                    session: GeckoSession, uri: String?, type: Int,
+                    callback: GeckoSession.PermissionDelegate.Callback) {
+                callback.reject()
+            }
+        })
+
+        var success = mainSession.waitForJS("""window.navigator.storage.persist()""")
+
+        assertThat("Request should fail",
+                success as Boolean, equalTo(false))
+
+        // Persistent storage can be granted
+        mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+            // Ensure the content permission is asked first, before the Android permission.
+            @AssertCalled(count = 1, order = [1])
+            override fun onContentPermissionRequest(
+                    session: GeckoSession, uri: String?, type: Int,
+                    callback: GeckoSession.PermissionDelegate.Callback) {
+                assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH))
+                assertThat("Type should match", type,
+                        equalTo(GeckoSession.PermissionDelegate.PERMISSION_PERSISTENT_STORAGE))
+                callback.grant()
+            }
+        })
+
+        success = mainSession.waitForJS("""window.navigator.storage.persist()""")
+
+        assertThat("Request should succeed",
+                success as Boolean,
+                equalTo(true))
+
+        // after permission granted further requests will always return true, regardless of response
+        mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+            @AssertCalled(count = 1)
+            override fun onContentPermissionRequest(
+                    session: GeckoSession, uri: String?, type: Int,
+                    callback: GeckoSession.PermissionDelegate.Callback) {
+                callback.reject()
+            }
+        })
+
+        success = mainSession.waitForJS("""window.navigator.storage.persist()""")
+
+        assertThat("Request should succeed",
+                success as Boolean, equalTo(true))
     }
 }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -752,16 +752,18 @@ public class GeckoSession implements Par
                             new PermissionCallback("android", callback));
                 } else if ("GeckoView:ContentPermission".equals(event)) {
                     final String typeString = message.getString("perm");
                     final int type;
                     if ("geolocation".equals(typeString)) {
                         type = PermissionDelegate.PERMISSION_GEOLOCATION;
                     } else if ("desktop-notification".equals(typeString)) {
                         type = PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION;
+                    } else if ("persistent-storage".equals(typeString)) {
+                        type = PermissionDelegate.PERMISSION_PERSISTENT_STORAGE;
                     } else {
                         throw new IllegalArgumentException("Unknown permission request: " + typeString);
                     }
                     delegate.onContentPermissionRequest(
                             GeckoSession.this, message.getString("uri"),
                             type, new PermissionCallback(typeString, callback));
                 } else if ("GeckoView:MediaPermission".equals(event)) {
                     GeckoBundle[] videoBundles = message.getBundleArray("video");
@@ -4264,16 +4266,22 @@ public class GeckoSession implements Par
 
         /**
          * Permission for using the notifications API.
          * See: https://developer.mozilla.org/en-US/docs/Web/API/notification
          */
         int PERMISSION_DESKTOP_NOTIFICATION = 1;
 
         /**
+         * Permission for using the storage API.
+         * See: https://developer.mozilla.org/en-US/docs/Web/API/Storage_API
+         */
+        int PERMISSION_PERSISTENT_STORAGE = 2;
+
+        /**
          * Callback interface for notifying the result of a permission request.
          */
         interface Callback {
             /**
              * Called by the implementation after permissions are granted; the
              * implementation must call either grant() or reject() for every request.
              */
             @UiThread
@@ -4303,21 +4311,27 @@ public class GeckoSession implements Par
                                                  @Nullable String[] permissions,
                                                  @NonNull Callback callback) {
             callback.reject();
         }
 
         /**
          * Request content permission.
          *
+         * Note, that in the case of PERMISSION_PERSISTENT_STORAGE, once permission has been granted
+         * for a site, it cannot be revoked. If the permission has previously been granted, it is
+         * the responsibility of the consuming app to remember the permission and prevent the prompt
+         * from being redisplayed to the user.
+         *
          * @param session GeckoSession instance requesting the permission.
          * @param uri The URI of the content requesting the permission.
          * @param type The type of the requested permission; possible values are,
          *             PERMISSION_GEOLOCATION
          *             PERMISSION_DESKTOP_NOTIFICATION
+         *             PERMISSION_PERSISTENT_STORAGE
          * @param callback Callback interface.
          */
         @UiThread
         default void onContentPermissionRequest(@NonNull GeckoSession session, @Nullable String uri,
                                                 @Permission int type, @NonNull Callback callback) {
             callback.reject();
         }
 
--- 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
@@ -93,16 +93,20 @@ exclude: true
 - Expose the following prefs in [`GeckoRuntimeSettings`][67.3]:
   [`setAutoZoomEnabled`][68.20], [`setDoubleTapZoomingEnabled`][68.21],
   [`setGlMsaaLevel`][68.22].
 
 [68.20]: ./GeckoRuntimeSettings.html#setAutoZoomEnabled-boolean-
 [68.21]: ./GeckoRuntimeSettings.html#setDoubleTapZoomingEnabled-boolean-
 [68.22]: ./GeckoRuntimeSettings.html#setGlMsaaLevel-int-
 
+- Added new constant for requesting external storage Android permissions, [`PERMISSION_PERSISTENT_STORAGE`][68.23]
+
+[68.23]: ../GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE
+
 ## 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
 
@@ -299,9 +303,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]: 3fbf9d92418d270558cefad65cfe00599aeae263
+[api-version]: fb98a878c61a487c5e9af358682b54375957d88d
--- 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
@@ -42,16 +42,17 @@ 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.ArrayList;
 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 = "about:blank";
     private static final String USE_MULTIPROCESS_EXTRA = "use_multiprocess";
@@ -68,16 +69,17 @@ public class GeckoViewActivity extends A
     private boolean mUseMultiprocess;
     private boolean mFullAccessibilityTree;
     private boolean mUseTrackingProtection;
     private boolean mUsePrivateBrowsing;
     private boolean mEnableRemoteDebugging;
     private boolean mKillProcessOnDestroy;
 
     private boolean mShowNotificationsRejected;
+    private ArrayList<String> mAcceptedPersistentStorage = new ArrayList<String>();
 
     private LocationView mLocationView;
     private String mCurrentUri;
     private boolean mCanGoBack;
     private boolean mCanGoForward;
     private boolean mFullScreen;
 
     private ProgressBar mProgressView;
@@ -616,16 +618,36 @@ public class GeckoViewActivity extends A
 
             @Override
             public void grant() {
                 mShowNotificationsRejected = false;
                 mCallback.grant();
             }
         }
 
+        class ExamplePersistentStorageCallback implements GeckoSession.PermissionDelegate.Callback {
+            private final GeckoSession.PermissionDelegate.Callback mCallback;
+            private final String mUri;
+            ExamplePersistentStorageCallback(final GeckoSession.PermissionDelegate.Callback callback, String uri) {
+                mCallback = callback;
+                mUri = uri;
+            }
+
+            @Override
+            public void reject() {
+                mCallback.reject();
+            }
+
+            @Override
+            public void grant() {
+                mAcceptedPersistentStorage.add(mUri);
+                mCallback.grant();
+            }
+        }
+
         public void onRequestPermissionsResult(final String[] permissions,
                                                final int[] grantResults) {
             if (mCallback == null) {
                 return;
             }
 
             final Callback cb = mCallback;
             mCallback = null;
@@ -661,16 +683,24 @@ public class GeckoViewActivity extends A
             } else if (PERMISSION_DESKTOP_NOTIFICATION == type) {
                 if (mShowNotificationsRejected) {
                     Log.w(LOGTAG, "Desktop notifications already denied by user.");
                     callback.reject();
                     return;
                 }
                 resId = R.string.request_notification;
                 contentPermissionCallback = new ExampleNotificationCallback(callback);
+            } else if (PERMISSION_PERSISTENT_STORAGE == type) {
+                if (mAcceptedPersistentStorage.contains(uri)) {
+                    Log.w(LOGTAG, "Persistent Storage for "+ uri +" already granted by user.");
+                    callback.grant();
+                    return;
+                }
+                resId = R.string.request_storage;
+                contentPermissionCallback = new ExamplePersistentStorageCallback(callback, uri);
             } else {
                 Log.w(LOGTAG, "Unknown permission: " + type);
                 callback.reject();
                 return;
             }
 
             final String title = getString(resId, Uri.parse(uri).getAuthority());
             final BasicGeckoViewPrompt prompt = (BasicGeckoViewPrompt)
--- a/mobile/android/geckoview_example/src/main/res/values/strings.xml
+++ b/mobile/android/geckoview_example/src/main/res/values/strings.xml
@@ -1,15 +1,16 @@
 <resources>
     <string name="app_name">GeckoView Example</string>
     <string name="activity_label">GeckoView Example</string>
     <string name="location_hint">Enter URL or search keywords...</string>
     <string name="username">Username</string>
     <string name="password">Password</string>
     <string name="clear_field">Clear</string>
+    <string name="request_storage">Allow access to device storage for "%1$s"?</string>
     <string name="request_geolocation">Share location with "%1$s"?</string>
     <string name="request_notification">Allow notifications for "%1$s"?</string>
     <string name="request_video">Share video with "%1$s"</string>
     <string name="request_audio">Share audio with "%1$s"</string>
     <string name="request_media">Share video and audio with "%1$s"</string>
     <string name="media_back_camera">Back camera</string>
     <string name="media_front_camera">Front camera</string>
     <string name="media_microphone">Microphone</string>