From e73b7956dba4653369751e2d7a1a90f3fad0c99b Mon Sep 17 00:00:00 2001 From: Makoto Kato Date: Mon, 12 Sep 2022 07:36:52 +0000 Subject: [PATCH] Bug 1776829 - Implement "Paste" permission action for clipboard.readText. r=geckoview-reviewers,owlish When calling `clipboard.readText` on content script, Gecko dispatches `MozClipboardReadPaste` event. On Desktop Firefox uses XUL pop up window to handle it, then it shows "Paste" button whether user can allow to read clipboard data. But GeckoView doesn't have XUL pop up. To implement this feature, we show "Paste" pop up using action mode as default. Also, browser side can override delegated methods if it wants another permission pop up or to support Android L (Android L doesn't have action mode). Differential Revision: https://phabricator.services.mozilla.com/D151102 --- .../GeckoViewClipboardPermissionChild.jsm | 100 +++++++++++ .../GeckoViewClipboardPermissionParent.jsm | 49 ++++++ mobile/android/actors/moz.build | 2 + .../components/geckoview/GeckoViewStartup.jsm | 16 ++ mobile/android/geckoview/api.txt | 14 ++ .../assets/www/clipboard_read.html | 19 ++ .../mozilla/geckoview/test/BaseSessionTest.kt | 1 + .../test/SelectionActionDelegateTest.kt | 115 ++++++++++++ .../org/mozilla/gecko/util/GeckoBundle.java | 16 ++ .../BasicSelectionActionDelegate.java | 163 ++++++++++++++++++ .../org/mozilla/geckoview/GeckoSession.java | 86 +++++++++ .../mozilla/geckoview/doc-files/CHANGELOG.md | 18 +- 12 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 mobile/android/actors/GeckoViewClipboardPermissionChild.jsm create mode 100644 mobile/android/actors/GeckoViewClipboardPermissionParent.jsm create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html diff --git a/mobile/android/actors/GeckoViewClipboardPermissionChild.jsm b/mobile/android/actors/GeckoViewClipboardPermissionChild.jsm new file mode 100644 index 000000000000..d89dd9445326 --- /dev/null +++ b/mobile/android/actors/GeckoViewClipboardPermissionChild.jsm @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { GeckoViewActorChild } = ChromeUtils.import( + "resource://gre/modules/GeckoViewActorChild.jsm" +); + +const EXPORTED_SYMBOLS = ["GeckoViewClipboardPermissionChild"]; + +class GeckoViewClipboardPermissionChild extends GeckoViewActorChild { + constructor() { + super(); + this._pendingPromise = null; + } + + async promptPermissionForClipboardRead() { + const uri = this.contentWindow.location.href; + + const { x, y } = await this.sendQuery( + "ClipboardReadTextPaste:GetLastPointerLocation" + ); + + const promise = this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:ClipboardPermissionRequest", + uri, + screenPoint: { + x, + y, + }, + }); + + this._pendingPromise = promise; + + try { + const allowOrDeny = await promise; + if (this._pendingPromise !== promise) { + // Current pending promise is newer. So it means that this promise + // is already resolved or rejected. Do nothing. + return; + } + this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup( + allowOrDeny + ); + this._pendingPromise = null; + } catch (error) { + debug`Permission error: ${error}`; + + if (this._pendingPromise !== promise) { + // Current pending promise is newer. So it means that this promise + // is already resolved or rejected. Do nothing. + return; + } + + this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup( + false + ); + this._pendingPromise = null; + } + } + + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "MozClipboardReadPaste": + if (aEvent.isTrusted) { + this.promptPermissionForClipboardRead(); + } + break; + + // page hide or deactivate cancel clipboard permission. + case "pagehide": + // fallthrough for the next three events. + case "deactivate": + case "mousedown": + case "mozvisualscroll": + // Gecko desktop uses XUL popup to show clipboard permission prompt. + // So it will be closed automatically by scroll and other user + // activation. So GeckoView has to close permission prompt by some user + // activations, too. + + this.eventDispatcher.sendRequest({ + type: "GeckoView:DismissClipboardPermissionRequest", + }); + if (this._pendingPromise) { + this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup( + false + ); + this._pendingPromise = null; + } + break; + } + } +} + +const { debug, warn } = GeckoViewClipboardPermissionChild.initLogging( + "GeckoViewClipboardPermissionChild" +); diff --git a/mobile/android/actors/GeckoViewClipboardPermissionParent.jsm b/mobile/android/actors/GeckoViewClipboardPermissionParent.jsm new file mode 100644 index 000000000000..1fd293505797 --- /dev/null +++ b/mobile/android/actors/GeckoViewClipboardPermissionParent.jsm @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { GeckoViewActorParent } = ChromeUtils.import( + "resource://gre/modules/GeckoViewActorParent.jsm" +); + +const EXPORTED_SYMBOLS = ["GeckoViewClipboardPermissionParent"]; + +class GeckoViewClipboardPermissionParent extends GeckoViewActorParent { + getLastOverWindowPointerLocation() { + const mouseXInCSSPixels = {}; + const mouseYInCSSPixels = {}; + const windowUtils = this.window.windowUtils; + windowUtils.getLastOverWindowPointerLocationInCSSPixels( + mouseXInCSSPixels, + mouseYInCSSPixels + ); + const screenRect = windowUtils.toScreenRect( + mouseXInCSSPixels.value, + mouseYInCSSPixels.value, + 0, + 0 + ); + + return { + x: screenRect.x, + y: screenRect.y, + }; + } + + receiveMessage(aMessage) { + debug`receiveMessage: ${aMessage.name}`; + + switch (aMessage.name) { + case "ClipboardReadTextPaste:GetLastPointerLocation": + return this.getLastOverWindowPointerLocation(); + + default: + return super.receiveMessage(aMessage); + } + } +} + +const { debug, warn } = GeckoViewClipboardPermissionParent.initLogging( + "GeckoViewClipboardPermissionParent" +); diff --git a/mobile/android/actors/moz.build b/mobile/android/actors/moz.build index 859e3e17a25c..4ed7fae41e64 100644 --- a/mobile/android/actors/moz.build +++ b/mobile/android/actors/moz.build @@ -10,6 +10,8 @@ FINAL_TARGET_FILES.actors += [ "ContentDelegateParent.jsm", "GeckoViewAutoFillChild.jsm", "GeckoViewAutoFillParent.jsm", + "GeckoViewClipboardPermissionChild.jsm", + "GeckoViewClipboardPermissionParent.jsm", "GeckoViewContentChild.jsm", "GeckoViewContentParent.jsm", "GeckoViewFormValidationChild.jsm", diff --git a/mobile/android/components/geckoview/GeckoViewStartup.jsm b/mobile/android/components/geckoview/GeckoViewStartup.jsm index e290303d5eda..2652f4874b70 100644 --- a/mobile/android/components/geckoview/GeckoViewStartup.jsm +++ b/mobile/android/components/geckoview/GeckoViewStartup.jsm @@ -92,6 +92,22 @@ const JSWINDOWACTORS = { allFrames: true, messageManagerGroups: ["browsers"], }, + GeckoViewClipboardPermission: { + parent: { + moduleURI: "resource:///actors/GeckoViewClipboardPermissionParent.jsm", + }, + child: { + moduleURI: "resource:///actors/GeckoViewClipboardPermissionChild.jsm", + events: { + MozClipboardReadPaste: {}, + deactivate: { mozSystemGroup: true }, + mousedown: { capture: true, mozSystemGroup: true }, + mozvisualscroll: { mozSystemGroup: true }, + pagehide: { capture: true, mozSystemGroup: true }, + }, + }, + allFrames: true, + }, }; class GeckoViewStartup { diff --git a/mobile/android/geckoview/api.txt b/mobile/android/geckoview/api.txt index 9cce6841654d..a1abc2383eb2 100644 --- a/mobile/android/geckoview/api.txt +++ b/mobile/android/geckoview/api.txt @@ -6,6 +6,7 @@ import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Matrix; +import android.graphics.Point; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Region; @@ -952,6 +953,9 @@ package org.mozilla.geckoview { field @Nullable protected GeckoSession.Window mWindow; } + @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.ClipboardPermissionType { + } + public static interface GeckoSession.ContentDelegate { method @UiThread default public void onCloseRequest(@NonNull GeckoSession); method @UiThread default public void onContextMenu(@NonNull GeckoSession, int, int, @NonNull GeckoSession.ContentDelegate.ContextElement); @@ -1446,8 +1450,10 @@ package org.mozilla.geckoview { } public static interface GeckoSession.SelectionActionDelegate { + method @UiThread default public void onDismissClipboardPermissionRequest(@NonNull GeckoSession); method @UiThread default public void onHideAction(@NonNull GeckoSession, int); method @UiThread default public void onShowActionRequest(@NonNull GeckoSession, @NonNull GeckoSession.SelectionActionDelegate.Selection); + method @Nullable @UiThread default public GeckoResult onShowClipboardPermissionRequest(@NonNull GeckoSession, @NonNull GeckoSession.SelectionActionDelegate.ClipboardPermission); field public static final String ACTION_COLLAPSE_TO_END = "org.mozilla.geckoview.COLLAPSE_TO_END"; field public static final String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START"; field public static final String ACTION_COPY = "org.mozilla.geckoview.COPY"; @@ -1465,6 +1471,14 @@ package org.mozilla.geckoview { field public static final int HIDE_REASON_ACTIVE_SELECTION = 2; field public static final int HIDE_REASON_INVISIBLE_SELECTION = 1; field public static final int HIDE_REASON_NO_SELECTION = 0; + field public static final int PERMISSION_CLIPBOARD_READ = 1; + } + + public static class GeckoSession.SelectionActionDelegate.ClipboardPermission { + ctor protected ClipboardPermission(); + field @Nullable public final Point screenPoint; + field public final int type; + field @NonNull public final String uri; } public static class GeckoSession.SelectionActionDelegate.Selection { diff --git a/mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html b/mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html new file mode 100644 index 000000000000..ee82a7f4a58c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html @@ -0,0 +1,19 @@ + + + + Hello, world! + + + +

Hello, world!

+ + + diff --git 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 index aa6b0729f6bf..df063e9673b4 100644 --- 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 @@ -35,6 +35,7 @@ open class BaseSessionTest(noErrorCollector: Boolean = false) { const val RESUBMIT_CONFIRM = "/assets/www/resubmit.html" const val BEFORE_UNLOAD = "/assets/www/beforeunload.html" const val CLICK_TO_RELOAD_HTML_PATH = "/assets/www/clickToReload.html" + const val CLIPBOARD_READ_HTML_PATH = "/assets/www/clipboard_read.html" const val CONTENT_CRASH_URL = "about:crashcontent" const val DOWNLOAD_HTML_PATH = "/assets/www/download.html" const val FORM_BLANK_HTML_PATH = "/assets/www/form_blank.html" diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt index b58f271fc203..15ab730682f6 100644 --- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt @@ -4,6 +4,9 @@ package org.mozilla.geckoview.test +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession.PromptDelegate import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.* import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled @@ -13,6 +16,7 @@ import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.graphics.Point; import android.graphics.RectF; import android.os.Build import androidx.test.ext.junit.rules.ActivityScenarioRule @@ -254,6 +258,117 @@ class SelectionActionDelegateTest : BaseSessionTest() { testClientRect(selectedContent, jsCssReset, jsBorder10pxPadding10px, expectedDiff) } + @WithDisplay(width = 100, height = 100) + @Test fun clipboardReadAllow() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")); + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + // Select allow + val result = GeckoResult() + mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate { + @AssertCalled(count = 1) + override fun onShowClipboardPermissionRequest( + session: GeckoSession, perm: ClipboardPermission): + GeckoResult { + assertThat("URI should match", perm.uri, startsWith(url)) + assertThat("Type should match", perm.type, + equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ)) + assertThat("screenPoint should match", perm.screenPoint, equalTo(Point(50, 50))) + return GeckoResult.allow() + } + + @AssertCalled(count = 1, order = [2]) + override fun onAlertPrompt( + session: GeckoSession, prompt: PromptDelegate.AlertPrompt): + GeckoResult { + assertThat("Message should match", "allow", equalTo(prompt.message)) + result.complete(null) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + } + + @WithDisplay(width = 100, height = 100) + @Test fun clipboardReadDeny() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")); + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + // Select deny + val result = GeckoResult() + mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onShowClipboardPermissionRequest( + session: GeckoSession, perm: ClipboardPermission): + GeckoResult? { + assertThat("URI should match", perm.uri, startsWith(url)) + assertThat("Type should match", perm.type, + equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ)) + return GeckoResult.deny() + } + + @AssertCalled(count = 1, order = [2]) + override fun onAlertPrompt( + session: GeckoSession, prompt: PromptDelegate.AlertPrompt): + GeckoResult { + assertThat("Message should match", "deny", equalTo(prompt.message)) + result.complete(null) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + } + + @WithDisplay(width = 100, height = 100) + @Test fun clipboardReadDeactivate() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")); + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + val result = GeckoResult() + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onShowClipboardPermissionRequest( + session: GeckoSession, perm: ClipboardPermission): + GeckoResult? { + assertThat("Type should match", perm.type, + equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ)) + result.complete(null) + return GeckoResult() + } + }); + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + @AssertCalled + override fun onDismissClipboardPermissionRequest(session: GeckoSession) { + } + }); + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + /** Interface that defines behavior for a particular type of content */ private interface SelectedContent { fun focus() {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java index b83ee5f26539..7647bb393dd0 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java @@ -5,6 +5,7 @@ package org.mozilla.gecko.util; +import android.graphics.Point; import android.graphics.RectF; import android.os.Build; import android.os.Bundle; @@ -398,6 +399,21 @@ public final class GeckoBundle implements Parcelable { (float) rectBundle.getDouble("bottom")); } + /** + * Returns the value associated with a Point mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Point value + */ + public Point getPoint(final String key) { + final GeckoBundle ptBundle = getBundle(key); + if (ptBundle == null) { + return null; + } + + return new Point(ptBundle.getInt("x"), ptBundle.getInt("y")); + } + /** * Returns the value associated with a GeckoBundle mapping, or null if the mapping does not exist. * diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java index c8d3e4221a58..feef54ceaa8b 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java @@ -12,6 +12,7 @@ import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Matrix; +import android.graphics.Point; import android.graphics.Rect; import android.graphics.RectF; import android.os.Build; @@ -77,6 +78,8 @@ public class BasicSelectionActionDelegate protected @Nullable Selection mSelection; protected boolean mRepopulatedMenu; + private @Nullable ActionMode mActionModeForClipboardPermission; + @TargetApi(Build.VERSION_CODES.M) private class Callback2Wrapper extends ActionMode.Callback2 { @Override @@ -444,6 +447,11 @@ public class BasicSelectionActionDelegate return; } + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + return; + } + if (mUseFloatingToolbar) { mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING); } else { @@ -473,4 +481,159 @@ public class BasicSelectionActionDelegate break; } } + + /** Callback class of clipboard permission. This is used on pre-M only */ + private class ClipboardPermissionCallback implements ActionMode.Callback { + private GeckoResult mResult; + + public ClipboardPermissionCallback(final GeckoResult result) { + mResult = result; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission( + actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + mResult.complete(AllowOrDeny.ALLOW); + mResult = null; + actionMode.finish(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + if (mResult != null) { + mResult.complete(AllowOrDeny.DENY); + } + BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode); + } + } + + /** Callback class of clipboard permission for Android M+ */ + @TargetApi(Build.VERSION_CODES.M) + private class ClipboardPermissionCallbackM extends ActionMode.Callback2 { + private @Nullable GeckoResult mResult; + private final @NonNull GeckoSession mSession; + private final @Nullable Point mPoint; + + public ClipboardPermissionCallbackM( + final @NonNull GeckoSession session, + final @Nullable Point screenPoint, + final @NonNull GeckoResult result) { + mSession = session; + mPoint = screenPoint; + mResult = result; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission( + actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + mResult.complete(AllowOrDeny.ALLOW); + mResult = null; + actionMode.finish(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + if (mResult != null) { + mResult.complete(AllowOrDeny.DENY); + } + BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode); + } + + @Override + public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) { + super.onGetContentRect(mode, view, outRect); + + if (mPoint == null) { + return; + } + + outRect.set(mPoint.x, mPoint.y, mPoint.x + 1, mPoint.y + 1); + } + } + + /** + * Show action mode bar to request clipboard permission + * + * @param session The GeckoSession that initiated the callback. + * @param permission An {@link ClipboardPermission} describing the permission being requested. + * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the + * permission request for this site. + */ + @TargetApi(Build.VERSION_CODES.M) + @Override + public GeckoResult onShowClipboardPermissionRequest( + final GeckoSession session, final ClipboardPermission permission) { + ThreadUtils.assertOnUiThread(); + + final GeckoResult result = new GeckoResult<>(); + + if (mActionMode != null) { + mActionMode.finish(); + mActionMode = null; + } + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + mActionModeForClipboardPermission = null; + } + + if (mUseFloatingToolbar) { + mActionModeForClipboardPermission = + mActivity.startActionMode( + new ClipboardPermissionCallbackM(session, permission.screenPoint, result), + ActionMode.TYPE_FLOATING); + } else { + mActionModeForClipboardPermission = + mActivity.startActionMode(new ClipboardPermissionCallback(result)); + } + + return result; + } + + /** + * Dismiss action mode for requesting clipboard permission popup or model. + * + * @param session The GeckoSession that initiated the callback. + */ + @Override + public void onDismissClipboardPermissionRequest(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + mActionModeForClipboardPermission = null; + } + } + + /* package */ boolean onCreateActionModeForClipboardPermission( + final ActionMode actionMode, final Menu menu) { + final MenuItem item = menu.add(/* group */ Menu.NONE, Menu.FIRST, Menu.FIRST, /* title */ ""); + item.setTitle(android.R.string.paste); + return true; + } + + /* package */ void onDestroyActionModeForClipboardPermission(final ActionMode actionMode) { + mActionModeForClipboardPermission = null; + } } 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 a8a0a1200c98..c5f832fea243 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 @@ -13,6 +13,7 @@ import android.content.Context; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Matrix; +import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; @@ -895,6 +896,8 @@ public class GeckoSession { "GeckoView:ShowSelectionAction", "GeckoView:HideMagnifier", "GeckoView:ShowMagnifier", + "GeckoView:ClipboardPermissionRequest", + "GeckoView:DismissClipboardPermissionRequest", }) { @Override public void handleMessage( @@ -902,6 +905,7 @@ public class GeckoSession { final String event, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleMessage: " + event); if ("GeckoView:ShowSelectionAction".equals(event)) { final @SelectionActionDelegateAction HashSet actionsSet = new HashSet<>(Arrays.asList(message.getStringArray("actions"))); @@ -941,6 +945,25 @@ public class GeckoSession { GeckoSession.this.getMagnifier().show(new PointF(origin[0], origin[1])); } else if ("GeckoView:HideMagnifier".equals(event)) { GeckoSession.this.getMagnifier().dismiss(); + } else if ("GeckoView:ClipboardPermissionRequest".equals(event)) { + final SelectionActionDelegate.ClipboardPermission permission = + new SelectionActionDelegate.ClipboardPermission(message); + + final GeckoResult result = + delegate.onShowClipboardPermissionRequest(GeckoSession.this, permission); + callback.resolveTo( + result.map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return true; + } + if (value == AllowOrDeny.DENY) { + return false; + } + throw new IllegalArgumentException("Invalid response"); + })); + } else if ("GeckoView:DismissClipboardPermissionRequest".equals(event)) { + delegate.onDismissClipboardPermissionRequest(GeckoSession.this); } } }; @@ -3671,6 +3694,63 @@ public class GeckoSession { @UiThread default void onHideAction( @NonNull final GeckoSession session, @SelectionActionDelegateHideReason final int reason) {} + + /** + * Permission for reading clipboard data. See: Clipboard.readText() + */ + int PERMISSION_CLIPBOARD_READ = 1; + + /** Represents attributes of a clipboard permission. */ + public class ClipboardPermission { + /** The URI associated with this content permission. */ + public final @NonNull String uri; + + /** + * The type of this permission; one of {@link #PERMISSION_CLIPBOARD_READ + * PERMISSION_CLIPBOARD_*}. + */ + public final @ClipboardPermissionType int type; + /** + * The last mouse or touch location in screen coordinates when the permission is requested. + */ + public final @Nullable Point screenPoint; + + /** Empty constructor for tests */ + protected ClipboardPermission() { + this.uri = ""; + this.type = PERMISSION_CLIPBOARD_READ; + this.screenPoint = null; + } + + private ClipboardPermission(final @NonNull GeckoBundle bundle) { + this.uri = bundle.getString("uri"); + this.type = PERMISSION_CLIPBOARD_READ; + this.screenPoint = bundle.getPoint("screenPoint"); + } + } + + /** + * Request clipboard permission. + * + * @param session The GeckoSession that initiated the callback. + * @param permission An {@link ClipboardPermission} describing the permission being requested. + * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the + * permission request for this site. + */ + @UiThread + default @Nullable GeckoResult onShowClipboardPermissionRequest( + @NonNull final GeckoSession session, @NonNull ClipboardPermission permission) { + return GeckoResult.deny(); + } + + /** + * Dismiss requesting clipboard permission popup or model. + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onDismissClipboardPermissionRequest(@NonNull final GeckoSession session) {} } @Retention(RetentionPolicy.SOURCE) @@ -3707,6 +3787,12 @@ public class GeckoSession { }) public @interface SelectionActionDelegateHideReason {} + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SelectionActionDelegate.PERMISSION_CLIPBOARD_READ, + }) + public @interface ClipboardPermissionType {} + public interface NavigationDelegate { /** * A view has started loading content from the network. 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 49d8ba866277..56a76fe32a5e 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 @@ -13,6 +13,22 @@ exclude: true ⚠️ breaking change and deprecation notices +## v106 +- Added [`SelectionActionDelegate.onShowClipboardPermissionRequest`][106.1], + [`SelectionActionDelegate.onDismissClipboardPermissionRequest`][106.2], + [`BasicSelectionActionDelegate.onShowClipboardPermissionRequest`][106.3], + [`BasicSelectionActionDelegate.onDismissCancelClipboardPermissionRequest`][106.4] and + [`SelectionActionDelegate.ClipboardPermission`][106.5] to handle permission + request for reading clipboard data by [`clipboard.readText`][106.6]. + ([bug 1776829]({{bugzilla}}1776829)) + +[106.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission) +[106.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onDismissClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession) +[106.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission) +[106.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onDismissClipboardPermission(org.mozilla.geckoview.GeckoSession) +[106.5]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.ClipboardPermission.html +[106.6]: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText + ## v104 - Removed deprecated Autofill.Delegate `onAutofill`, Autofill.Node `fillViewStructure`, `getFocused`, `getId`, `getValue`, `getVisible`, Autofill.NodeData `Autofill.Notify`, Autofill.Session `surfaceChanged`. ([bug 1781180]({{bugzilla}}1781180)) @@ -1224,4 +1240,4 @@ to allow adding gecko profiler markers. [65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,android.os.Bundle,java.lang.String) [65.25]: {{javadoc_uri}}/GeckoResult.html -[api-version]: 771802b68452c32d672df605e3a26d0eebd89b84 +[api-version]: 5dd4f92b49dec51709788671173c5a03b303287b