diff --git a/dom/quota/StorageManager.cpp b/dom/quota/StorageManager.cpp index 77c3f0824e33..3f4e5997129a 100644 --- a/dom/quota/StorageManager.cpp +++ b/dom/quota/StorageManager.cpp @@ -609,10 +609,18 @@ 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); } diff --git a/mobile/android/app/geckoview-prefs.js b/mobile/android/app/geckoview-prefs.js index b8d451f505dc..0375bad9b23d 100644 --- a/mobile/android/app/geckoview-prefs.js +++ b/mobile/android/app/geckoview-prefs.js @@ -29,6 +29,9 @@ pref("geckoview.logging", "Debug"); // 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); diff --git a/mobile/android/geckoview/api.txt b/mobile/android/geckoview/api.txt index f5917ee6ccab..19732a3aebdd 100644 --- a/mobile/android/geckoview/api.txt +++ b/mobile/android/geckoview/api.txt @@ -546,6 +546,7 @@ package org.mozilla.geckoview { 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 { diff --git 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 index a7062ff8f869..206ebc1c0a11 100644 --- 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 @@ -41,11 +41,11 @@ class PermissionDelegateTest : BaseSessionTest() { @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. @@ -58,9 +58,9 @@ class PermissionDelegateTest : BaseSessionTest() { "window.navigator.mediaDevices.enumerateDevices()") assertThat("Device list should contain camera device", - devices.asJSList(), hasItem(hasEntry("kind", "videoinput"))) + devices.asJSList(), hasItem(hasEntry("kind", "videoinput"))) assertThat("Device list should contain microphone device", - devices.asJSList(), hasItem(hasEntry("kind", "audioinput"))) + devices.asJSList(), hasItem(hasEntry("kind", "audioinput"))) mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate { @@ -74,10 +74,10 @@ class PermissionDelegateTest : BaseSessionTest() { 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]) } } }) @@ -85,11 +85,11 @@ class PermissionDelegateTest : BaseSessionTest() { // 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 })""" @@ -97,9 +97,9 @@ class PermissionDelegateTest : BaseSessionTest() { 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( @@ -129,15 +129,15 @@ class PermissionDelegateTest : BaseSessionTest() { 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() @@ -150,7 +150,7 @@ class PermissionDelegateTest : BaseSessionTest() { 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() } @@ -159,7 +159,7 @@ class PermissionDelegateTest : BaseSessionTest() { session: GeckoSession, permissions: Array?, 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() } }) @@ -168,9 +168,9 @@ class PermissionDelegateTest : BaseSessionTest() { 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 @@ -197,7 +197,7 @@ class PermissionDelegateTest : BaseSessionTest() { 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 @@ -212,7 +212,7 @@ class PermissionDelegateTest : BaseSessionTest() { 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() } }) @@ -220,7 +220,7 @@ class PermissionDelegateTest : BaseSessionTest() { val result = mainSession.waitForJS("Notification.requestPermission()") assertThat("Permission should be granted", - result as String, equalTo("granted")) + result as String, equalTo("granted")) } @WithDevToolsAPI @@ -240,6 +240,62 @@ class PermissionDelegateTest : BaseSessionTest() { 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)) } } diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java index 85869799a2f1..f834c150da2e 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java @@ -757,6 +757,8 @@ public class GeckoSession implements Parcelable { 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); } @@ -4268,6 +4270,12 @@ public class GeckoSession implements Parcelable { */ 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. */ @@ -4308,11 +4316,17 @@ public class GeckoSession implements Parcelable { /** * 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 diff --git 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 index 10b47da8d34f..a4e3f5b4790e 100644 --- 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 @@ -98,6 +98,10 @@ exclude: true [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 @@ -304,4 +308,4 @@ exclude: true [65.24]: ../CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String- [65.25]: ../GeckoResult.html -[api-version]: 3fbf9d92418d270558cefad65cfe00599aeae263 +[api-version]: fb98a878c61a487c5e9af358682b54375957d88d diff --git 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 index e3c24c003c82..28c01f195981 100644 --- 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 @@ -47,6 +47,7 @@ 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; @@ -73,6 +74,7 @@ public class GeckoViewActivity extends AppCompatActivity { private boolean mKillProcessOnDestroy; private boolean mShowNotificationsRejected; + private ArrayList mAcceptedPersistentStorage = new ArrayList(); private LocationView mLocationView; private String mCurrentUri; @@ -621,6 +623,26 @@ public class GeckoViewActivity extends AppCompatActivity { } } + 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) { @@ -666,6 +688,14 @@ public class GeckoViewActivity extends AppCompatActivity { } 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(); diff --git a/mobile/android/geckoview_example/src/main/res/values/strings.xml b/mobile/android/geckoview_example/src/main/res/values/strings.xml index 4e94eb9a2f65..f37a507298de 100644 --- a/mobile/android/geckoview_example/src/main/res/values/strings.xml +++ b/mobile/android/geckoview_example/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Username Password Clear + Allow access to device storage for "%1$s"? Share location with "%1$s"? Allow notifications for "%1$s"? Share video with "%1$s"